Route: permissions

Verb-mode ?view=permissions. Owner-only ACL editor for a single resource. Lives in src/routes/permissions.rs and is dispatched from Route: view (dispatch) when the query string says view=permissions.

Permission gate

if let Err(resp) = guard::require(req, &resource_path, Role::Owner).await {
    return resp;
}

Only Role::Owner may read or write the permissions editor. There is no read-only mode — exposing who has what role to a non-owner is itself a leak. Failed authorization returns the standard 403 from sfx::op::forbidden_response.

Methods

  • GETrender_form(req, &resource_path) builds the editor page.
  • POSThandle_post(req, &resource_path) parses action, mutates perms.json, saves, and 302-redirects back to <prefix><resource_path>?view=permissions (so the admin lands back on the editor and immediately sees the updated state).

The redirect preserves the active language via crate::current_prefix(req), identical to the edit-route convention.

Storage

The on-disk format is a sibling perms.json next to the resource (i.e. inside the resource folder, alongside ctx.json). The handler does not touch the file directly — it goes through store::load(resource_path) to read and store::save(resource_path, &perms) to write. If store::save fails it returns 500 Save failed: <err>.

Actions

POST dispatches on the form field action:

actionreadseffect
set_publicrolesets perms.public
set_authenticatedrolesets perms.authenticated (minimum role any logged-in user gets)
set_useruser_id, roleinserts/updates perms.users[user_id] = role; if role parses to Role::None, removes the entry
remove_useruser_idunconditionally removes perms.users[user_id]
set_groupgroup, roleinserts/updates perms.groups[group] = role; Role::None removes
remove_groupgroupunconditionally removes perms.groups[group]

Each branch validates its required fields first (user_id required, group required) and returns 400 on a missing value. Unknown action values return 400 unknown action: <s>.

Role ladder

None < Read < Append < Write < Delete < Owner

Six values, monotonically increasing in capability. Owner includes everything below it; None is the explicit “no access” floor.

parse_role is a thin wrapper over Role::from_str, which accepts unrecognized strings as Role::None. This is intentional and load-bearing: the empty <option value=""> slot of a <select> parses as None, which the set_user / set_group branches treat as “remove this entry.” The same wire convention lets the UI offer a single “no role” option without a separate “remove” action.

Group caveat

Per-group entries (perms.groups) are stored on disk but not yet enforced at runtime. The resolver in store::Perms::allows does not consult groups, so any role you set there is effectively dead data until the group resolver lands. The editor renders a warning banner above the group card:

Groups are not yet wired into resolution — these entries are stored but ignored at runtime. UI shown for forward compatibility.

The card itself is fully functional so existing perms files round-trip through the editor without data loss.

Form rendering

role_select(name, selected) emits one <select> per place a role is chosen:

for r in [Role::None, Role::Read, Role::Append, Role::Write, Role::Delete, Role::Owner] {
    let sel = if r == selected { " selected" } else { "" };
    out.push_str(&format!(
        r#"<option value="{val}"{sel}>{label}</option>"#,
        val = r.as_str(), sel = sel, label = r.as_str(),
    ));
}

Each card pre-selects the current value: the public card pre-selects perms.public, the authenticated card pre-selects perms.authenticated, the user/group “grant” forms pre-select Role::Read (the most common new grant). All six roles are always offered, including Role::None.

HTML escaping

permissions.rs keeps its own html_escape copy — the standard five replacements:

s.replace('&', "&amp;")
 .replace('<', "&lt;")
 .replace('>', "&gt;")
 .replace('"', "&quot;")
 .replace('\'', "&#39;")

Every user-controlled string in the rendered page goes through it: user_id and group in the listing tables, the resource path in the header, and the hidden-input echoes in each per-row Remove form. The role labels themselves come from Role::as_str (a fixed alphabet) and don’t need escaping.

The handler does not import crate::routes::edit::html_escape (and edit.rs keeps its own copy too) — the duplication is deliberate to keep these two route modules self-contained. If either copy gains an escaping requirement, the other must be updated separately.

Breadcrumb

The editor reuses the standard breadcrumb chrome by reading the underlying resource as an Any:

let breadcrumb = crate::content::File::path_value(
    &crate::content::Any::read(resource_path.to_string()),
    &lang,
);

Going through Any::read rather than reading the resource’s concrete type means the path bar still works even on a resource whose ctx.json is malformed or whose type is one the editor doesn’t know — owners need to be able to fix permissions on a broken resource without first fixing the resource itself.

The body is then rendered into edit/general.html (the shared editor chrome) with can_edit = false, can_download = false, can_manage_perms = false — the page hides its own top-bar buttons because the chrome already shows EDIT MODE + a Back-to-view button.

Cross-links

The Header / LangDict machinery that the breadcrumb walks is covered in Common: Header and LangDict. The verb-dispatch entry that routes ?view=permissions here is Route: view (dispatch).