Properties
category: plan
tags: [implementation, permissions, admin, multi-tenancy]
last_updated: 2026-03-17
confidence: high

Permissions Panel Re-enablement: Implementation Plan

Parent: Design/Admin_Panel_Reenablement

Summary

Re-enable the Permissions admin panel (/-/admin/permissions_and_registration) in robot.wtf's multi-tenant platform. Wiki owners can set READ_ACCESS, WRITE_ACCESS, ATTACHMENT_ACCESS per-wiki. The resolver intersects these with platform ACL permissions before injecting proxy headers.

Repos and Branches

Repo Base branch Feature branch
otterwiki (/Users/sderle/code/otterwiki/otterwiki/) wikibot-io feat/permissions-panel
robot.wtf (/Users/sderle/code/otterwiki/robot.wtf/) main feat/permissions-panel

Changes can be developed and tested independently. The otterwiki changes are purely UI (template + decorator removal). The robot.wtf changes are purely resolver logic with no otterwiki import changes.

Change 1: Otterwiki fork — Remove decorator and hide registration fields

1a. Remove @platform_mode_disabled from the route

File: /Users/sderle/code/otterwiki/otterwiki/otterwiki/views.py, lines 237–248

Current:

@app.route(
    "/-/admin/permissions_and_registration", methods=["POST", "GET"]
)  # pyright: ignore -- false positive
@login_required
@platform_mode_disabled
def admin_permissions_and_registration():

Change: Remove @platform_mode_disabled line (line 241).

File: /Users/sderle/code/otterwiki/otterwiki/otterwiki/templates/settings.html, lines 43–62

Currently the entire block (User Management, Permissions, Mail) is gated by {% if not config.PLATFORM_MODE %}. Change to:

  • Keep User Management and Mail inside the {% if not config.PLATFORM_MODE %} guard
  • Move the Permissions link outside it (before the guard, after Repository Management)

New structure (lines ~42–62):

<a href="{{ url_for("admin_permissions_and_registration") }}" class="sidebar-link sidebar-link-with-icon">
    <span class="sidebar-icon" style="min-width: 3rem;">
        <i class="fas fa-users-cog"></i>
    </span>
    Permissions{% if not config.PLATFORM_MODE %} and Registration{% endif %}
</a>
{% if not config.PLATFORM_MODE %}
<a href="{{ url_for("admin_user_management") }}" ...>
    User Management
</a>
<a href="{{ url_for("admin_mail_preferences") }}" ...>
    Mail Preferences
</a>
{% endif %}

1c. Hide registration fields in PLATFORM_MODE

File: /Users/sderle/code/otterwiki/otterwiki/otterwiki/templates/admin/permissions_and_registration.html

Wrap lines 34–85 (the five registration checkboxes: DISABLE_REGISTRATION, EMAIL_NEEDS_CONFIRMATION, AUTO_APPROVAL, NOTIFY_ADMINS_ON_REGISTER, NOTIFY_USER_ON_APPROVAL) in:

{% if not config.PLATFORM_MODE %}
  {# ... all five checkbox form groups ... #}
{% endif %}

Also hide the "ADMIN" option from the access level dropdowns in PLATFORM_MODE (line 24). In platform mode, ADMIN-level access restriction doesn't apply since admin is determined by ACL role, not registration. Change the loop:

{% for permission_option in ["ANONYMOUS","REGISTERED","APPROVED"] + ([] if config.PLATFORM_MODE else ["ADMIN"]) %}

1d. Hide the per-user note about setting to "Admin"

Line 10–12 in the template contains a note about setting access to "Admin" and using User Management. In PLATFORM_MODE, wrap this in {% if not config.PLATFORM_MODE %}...{% endif %} and show an alternative note:

{% if config.PLATFORM_MODE %}
<div>
    <em>Note:</em> These settings control per-wiki access levels. The platform ACL (owner/editor/viewer)
    grants the maximum permissions. These settings can restrict further but never escalate.
    "Anonymous" means anyone with a link. "Registered" means authenticated platform users.
</div>
{% else %}
<div>
    <em>Note:</em> To configure privileges per user, ...
</div>
{% endif %}

1e. Handle form submission for PLATFORM_MODE

File: /Users/sderle/code/otterwiki/otterwiki/otterwiki/preferences.py, lines 419–436 (handle_permissions_and_registration)

No changes needed. The function already saves only the values present in the form. Since registration checkboxes will be absent in PLATFORM_MODE, they won't be updated. However, on first save the missing checkboxes will be saved as "False" (their default from form.get(checkbox, "False")).

Fix: Guard the registration checkbox loop with a PLATFORM_MODE check:

def handle_permissions_and_registration(form):
    for name in ["READ_access", "WRITE_access", "ATTACHMENT_access"]:
        _update_preference(name.upper(), form.get(name, "ANONYMOUS"))
    if not app.config.get("PLATFORM_MODE"):
        for checkbox in [
            "disable_registration",
            "auto_approval",
            "email_needs_confirmation",
            "notify_admins_on_register",
            "notify_user_on_approval",
        ]:
            _update_preference(checkbox.upper(), form.get(checkbox, "False"))
    db.session.commit()
    update_app_config()
    toast("Preferences updated.")
    return redirect(url_for("admin_permissions_and_registration"))

Change 2: robot.wtf resolver — Intersect permissions with per-wiki access levels

2a. New function: _apply_wiki_access_restrictions

File: /Users/sderle/code/otterwiki/robot.wtf/app/resolver.py

Add after _is_write_request (around line 273):

def _apply_wiki_access_restrictions(
    permissions: tuple[str, ...] | list[str],
    is_authenticated: bool,
) -> list[str]:
    """Intersect ACL-granted permissions with per-wiki access preferences.

    After _swap_database() + update_app_config(), the per-wiki
    READ_ACCESS / WRITE_ACCESS / ATTACHMENT_ACCESS values are in app.config.

    Platform ACL grants the ceiling. Per-wiki preferences can only restrict.

    Access levels:
      ANONYMOUS  — no restriction (anyone gets this permission)
      REGISTERED — only authenticated users
      APPROVED   — treated same as REGISTERED (no per-user approval tracking yet)

    Args:
        permissions: ACL-granted permissions (e.g. ("READ", "WRITE", "UPLOAD"))
        is_authenticated: Whether the request has a resolved identity
            (not anonymous)

    Returns:
        Filtered list of permissions.
    """
    import otterwiki.server
    app = otterwiki.server.app

    result = list(permissions)

    access_map = {
        "READ": "READ_ACCESS",
        "WRITE": "WRITE_ACCESS",
        "UPLOAD": "ATTACHMENT_ACCESS",
    }

    for perm, config_key in access_map.items():
        if perm not in result:
            continue
        level = app.config.get(config_key, "ANONYMOUS").upper()
        if level == "ANONYMOUS":
            continue
        # REGISTERED and APPROVED both require authentication
        # (APPROVED would require per-user tracking; deferred — treat as REGISTERED)
        if level in ("REGISTERED", "APPROVED", "ADMIN"):
            if not is_authenticated:
                result.remove(perm)

    # WRITE requires READ; UPLOAD requires WRITE
    if "READ" not in result:
        for dep in ("WRITE", "UPLOAD"):
            if dep in result:
                result.remove(dep)
    if "WRITE" not in result:
        if "UPLOAD" in result:
            result.remove("UPLOAD")

    return result

2b. Call _apply_wiki_access_restrictions in TenantResolver.__call__

File: /Users/sderle/code/otterwiki/robot.wtf/app/resolver.py, lines 396–406 (after _swap_database, before injecting headers)

Insert between _swap_database(wiki_dir) (line 399) and the header injection block (line 402):

        # Apply per-wiki access restrictions
        proxy_headers = auth_result["proxy_headers"]
        is_authenticated = proxy_headers.get("x-otterwiki-email", "") not in (
            "", "@anonymous"
        )
        perms_key = "X-Otterwiki-Permissions"  # Note: key casing matches build_proxy_headers
        # Actually the key is lowercase in build_proxy_headers
        perms_key = "x-otterwiki-permissions"
        raw_perms = proxy_headers.get(perms_key, "").split(",")
        restricted = _apply_wiki_access_restrictions(raw_perms, is_authenticated)
        proxy_headers[perms_key] = format_permission_header(restricted)

Important: This must happen AFTER _swap_database() (which calls update_app_config(), loading per-wiki preferences into app.config) and BEFORE injecting headers into environ.

The existing quota-stripping logic (lines 382–388) should remain and runs on the already-restricted permissions. Reorder: wiki access restrictions first, then quota stripping. Or just let both run — quota stripping is additive removal and safe to apply on already-restricted perms.

2c. Determine is_authenticated from auth path

Looking at the auth resolution paths:

  • JWT auth → authenticated (email = @{handle})
  • Cookie auth → authenticated (email = @{handle})
  • Bearer token → authenticated (email = mcp@robot.wtf)
  • API key → authenticated (email = @system)
  • Anonymous → NOT authenticated (email = @anonymous)

The check email not in ("", "@anonymous") correctly identifies unauthenticated requests.

The APPROVED Level Question

Can we support APPROVED without per-user tracking? No — not fully.

  • In vanilla Otterwiki, APPROVED means users must be individually approved by an admin via User Management.
  • In robot.wtf, there's no per-wiki user table being actively populated (users are transient proxy-header objects).
  • User Management panel is still disabled.

Decision: Treat APPROVED the same as REGISTERED for now. Both mean "authentication required." This is documented in the function docstring. When User Management is re-enabled (Phase 2 per Design/Admin_Panel_Reenablement), APPROVED can be differentiated.

UI consideration: We could hide the APPROVED option in PLATFORM_MODE, but it's better to leave it visible and documented. Wiki owners who set APPROVED get REGISTERED behavior — a reasonable default that becomes stricter (not looser) when user tracking lands.

What Happens for Each User Type

User type Platform ACL perms Wiki READ_ACCESS=ANON Wiki READ_ACCESS=REGISTERED Wiki WRITE_ACCESS=REGISTERED
Owner (JWT) READ,WRITE,UPLOAD,ADMIN all all all
Editor (JWT/cookie) READ,WRITE,UPLOAD all all all
Viewer (ACL) READ READ only READ only READ only
Public (anon, wiki is_public) READ READ none (stripped) READ only
MCP bearer token READ,WRITE,UPLOAD all all all
No auth, wiki private 403 before reaching this code N/A N/A N/A

Key insight: When READ_ACCESS=REGISTERED, anonymous users on a public wiki lose READ. This effectively makes the wiki private despite is_public=true in the platform DB. That's correct — wiki owner's preference is the final word on restrictions.

ADMIN Permission Pass-Through

The _apply_wiki_access_restrictions function does NOT touch the ADMIN permission. ADMIN is purely platform-controlled (ACL role = owner). The wiki-level access preferences only affect READ, WRITE, and UPLOAD.

Test Specifications

Otterwiki fork tests

File: New test file or extend existing admin test.

  1. Route accessible in PLATFORM_MODE: With PLATFORM_MODE=True and an admin user, GET /-/admin/permissions_and_registration returns 200 (not 404).
  2. Registration fields hidden: Response HTML does not contain disable_registration checkbox when PLATFORM_MODE=True.
  3. Registration fields shown: Response HTML contains disable_registration when PLATFORM_MODE=False.
  4. ADMIN option hidden in PLATFORM_MODE: The access dropdowns don't include "ADMIN" option when PLATFORM_MODE=True.
  5. Form submission saves only access prefs in PLATFORM_MODE: POST with READ_access=REGISTERED saves READ_ACCESS but does not touch DISABLE_REGISTRATION.
  6. Sidebar link visible in PLATFORM_MODE: The settings template renders a link to admin_permissions_and_registration when PLATFORM_MODE=True.

robot.wtf tests

File: /Users/sderle/code/otterwiki/robot.wtf/tests/test_resolver.py (extend)

Unit tests for _apply_wiki_access_restrictions:

  1. All ANONYMOUS — no restriction: Permissions pass through unchanged.
  2. READ_ACCESS=REGISTERED, authenticated user: READ preserved.
  3. READ_ACCESS=REGISTERED, anonymous user: READ stripped; WRITE and UPLOAD also stripped (dependency).
  4. WRITE_ACCESS=REGISTERED, anonymous user with READ_ACCESS=ANONYMOUS: READ preserved, WRITE stripped, UPLOAD stripped.
  5. ATTACHMENT_ACCESS=REGISTERED, anonymous user: READ and WRITE preserved, UPLOAD stripped.
  6. APPROVED treated as REGISTERED: READ_ACCESS=APPROVED, anonymous → READ stripped.
  7. ADMIN permission not touched: Owner with ADMIN + READ_ACCESS=REGISTERED, anonymous → ADMIN preserved (though this case shouldn't occur in practice since ADMIN requires auth).
  8. Dependency chain: WRITE removed → UPLOAD also removed even if ATTACHMENT_ACCESS=ANONYMOUS.

Integration-level tests (mock _swap_database and otterwiki.server.app.config):

  1. Full resolver flow: Authenticated cookie user on wiki with WRITE_ACCESS=REGISTERED gets WRITE permission.
  2. Full resolver flow: Anonymous user on public wiki with READ_ACCESS=REGISTERED gets no permissions (empty header).
  3. Quota stripping + access restriction compose correctly.

Deployment and Independence

The two feature branches can be merged independently:

  • otterwiki fork first: Safe to merge. The @platform_mode_disabled removal means the route returns the form, but without resolver-side intersection, the saved preferences have no effect on actual access control. The proxy header permissions still come from the ACL alone. This is harmless — the admin can save preferences that don't do anything yet.

  • robot.wtf first: Safe to merge. The resolver reads app.config values for READ_ACCESS etc. If the preferences haven't been set (no admin panel yet), the defaults are all "ANONYMOUS" (from otterwiki/server.py line 38–40), so no permissions are stripped. No behavioral change.

Recommended order: otterwiki fork first (UI changes, simpler to test), then robot.wtf (logic changes, needs more test coverage).

Open Questions

  1. Should we log when wiki-level restrictions strip permissions? Probably yes, at DEBUG level, for troubleshooting.
  2. Should the platform management UI (robot.wtf dashboard) expose these settings too? Not now — the otterwiki admin panel is sufficient.
  3. When READ_ACCESS=REGISTERED strips READ from anonymous on a public wiki, should the resolver return 403 instead of passing through with empty permissions? Current design passes through with empty permissions; otterwiki will show appropriate "access denied" UI. This is probably fine.