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).
1b. Show sidebar link in PLATFORM_MODE for permissions only
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.
- Route accessible in PLATFORM_MODE: With
PLATFORM_MODE=Trueand an admin user, GET/-/admin/permissions_and_registrationreturns 200 (not 404). - Registration fields hidden: Response HTML does not contain
disable_registrationcheckbox whenPLATFORM_MODE=True. - Registration fields shown: Response HTML contains
disable_registrationwhenPLATFORM_MODE=False. - ADMIN option hidden in PLATFORM_MODE: The access dropdowns don't include "ADMIN" option when
PLATFORM_MODE=True. - Form submission saves only access prefs in PLATFORM_MODE: POST with READ_access=REGISTERED saves READ_ACCESS but does not touch DISABLE_REGISTRATION.
- Sidebar link visible in PLATFORM_MODE: The settings template renders a link to
admin_permissions_and_registrationwhenPLATFORM_MODE=True.
robot.wtf tests
File: /Users/sderle/code/otterwiki/robot.wtf/tests/test_resolver.py (extend)
Unit tests for _apply_wiki_access_restrictions:
- All ANONYMOUS — no restriction: Permissions pass through unchanged.
- READ_ACCESS=REGISTERED, authenticated user: READ preserved.
- READ_ACCESS=REGISTERED, anonymous user: READ stripped; WRITE and UPLOAD also stripped (dependency).
- WRITE_ACCESS=REGISTERED, anonymous user with READ_ACCESS=ANONYMOUS: READ preserved, WRITE stripped, UPLOAD stripped.
- ATTACHMENT_ACCESS=REGISTERED, anonymous user: READ and WRITE preserved, UPLOAD stripped.
- APPROVED treated as REGISTERED: READ_ACCESS=APPROVED, anonymous → READ stripped.
- 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).
- Dependency chain: WRITE removed → UPLOAD also removed even if ATTACHMENT_ACCESS=ANONYMOUS.
Integration-level tests (mock _swap_database and otterwiki.server.app.config):
- Full resolver flow: Authenticated cookie user on wiki with WRITE_ACCESS=REGISTERED gets WRITE permission.
- Full resolver flow: Anonymous user on public wiki with READ_ACCESS=REGISTERED gets no permissions (empty header).
- 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_disabledremoval 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.configvalues for READ_ACCESS etc. If the preferences haven't been set (no admin panel yet), the defaults are all "ANONYMOUS" (fromotterwiki/server.pyline 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
- Should we log when wiki-level restrictions strip permissions? Probably yes, at DEBUG level, for troubleshooting.
- Should the platform management UI (robot.wtf dashboard) expose these settings too? Not now — the otterwiki admin panel is sufficient.
- 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.
