Blame
|
1 | --- |
||||||
| 2 | category: plan |
|||||||
| 3 | tags: [implementation, permissions, admin, multi-tenancy] |
|||||||
| 4 | last_updated: 2026-03-17 |
|||||||
| 5 | confidence: high |
|||||||
| 6 | --- |
|||||||
| 7 | ||||||||
| 8 | # Permissions Panel Re-enablement: Implementation Plan |
|||||||
| 9 | ||||||||
| 10 | Parent: [[Design/Admin_Panel_Reenablement]] |
|||||||
| 11 | ||||||||
| 12 | ## Summary |
|||||||
| 13 | ||||||||
| 14 | 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. |
|||||||
| 15 | ||||||||
| 16 | ## Repos and Branches |
|||||||
| 17 | ||||||||
| 18 | | Repo | Base branch | Feature branch | |
|||||||
| 19 | |------|------------|----------------| |
|||||||
| 20 | | `otterwiki` (`/Users/sderle/code/otterwiki/otterwiki/`) | `wikibot-io` | `feat/permissions-panel` | |
|||||||
| 21 | | `robot.wtf` (`/Users/sderle/code/otterwiki/robot.wtf/`) | `main` | `feat/permissions-panel` | |
|||||||
| 22 | ||||||||
| 23 | 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. |
|||||||
| 24 | ||||||||
| 25 | ## Change 1: Otterwiki fork — Remove decorator and hide registration fields |
|||||||
| 26 | ||||||||
| 27 | ### 1a. Remove `@platform_mode_disabled` from the route |
|||||||
| 28 | ||||||||
| 29 | **File:** `/Users/sderle/code/otterwiki/otterwiki/otterwiki/views.py`, lines 237–248 |
|||||||
| 30 | ||||||||
| 31 | Current: |
|||||||
| 32 | ```python |
|||||||
| 33 | @app.route( |
|||||||
| 34 | "/-/admin/permissions_and_registration", methods=["POST", "GET"] |
|||||||
| 35 | ) # pyright: ignore -- false positive |
|||||||
| 36 | @login_required |
|||||||
| 37 | @platform_mode_disabled |
|||||||
| 38 | def admin_permissions_and_registration(): |
|||||||
| 39 | ``` |
|||||||
| 40 | ||||||||
| 41 | Change: Remove `@platform_mode_disabled` line (line 241). |
|||||||
| 42 | ||||||||
| 43 | ### 1b. Show sidebar link in PLATFORM_MODE for permissions only |
|||||||
| 44 | ||||||||
| 45 | **File:** `/Users/sderle/code/otterwiki/otterwiki/otterwiki/templates/settings.html`, lines 43–62 |
|||||||
| 46 | ||||||||
| 47 | Currently the entire block (User Management, Permissions, Mail) is gated by `{% if not config.PLATFORM_MODE %}`. Change to: |
|||||||
| 48 | - Keep User Management and Mail inside the `{% if not config.PLATFORM_MODE %}` guard |
|||||||
| 49 | - Move the Permissions link outside it (before the guard, after Repository Management) |
|||||||
| 50 | ||||||||
| 51 | New structure (lines ~42–62): |
|||||||
| 52 | ```jinja |
|||||||
| 53 | <a href="{{ url_for("admin_permissions_and_registration") }}" class="sidebar-link sidebar-link-with-icon"> |
|||||||
| 54 | <span class="sidebar-icon" style="min-width: 3rem;"> |
|||||||
| 55 | <i class="fas fa-users-cog"></i> |
|||||||
| 56 | </span> |
|||||||
| 57 | Permissions{% if not config.PLATFORM_MODE %} and Registration{% endif %} |
|||||||
| 58 | </a> |
|||||||
| 59 | {% if not config.PLATFORM_MODE %} |
|||||||
| 60 | <a href="{{ url_for("admin_user_management") }}" ...> |
|||||||
| 61 | User Management |
|||||||
| 62 | </a> |
|||||||
| 63 | <a href="{{ url_for("admin_mail_preferences") }}" ...> |
|||||||
| 64 | Mail Preferences |
|||||||
| 65 | </a> |
|||||||
| 66 | {% endif %} |
|||||||
| 67 | ``` |
|||||||
| 68 | ||||||||
| 69 | ### 1c. Hide registration fields in PLATFORM_MODE |
|||||||
| 70 | ||||||||
| 71 | **File:** `/Users/sderle/code/otterwiki/otterwiki/otterwiki/templates/admin/permissions_and_registration.html` |
|||||||
| 72 | ||||||||
| 73 | Wrap lines 34–85 (the five registration checkboxes: DISABLE_REGISTRATION, EMAIL_NEEDS_CONFIRMATION, AUTO_APPROVAL, NOTIFY_ADMINS_ON_REGISTER, NOTIFY_USER_ON_APPROVAL) in: |
|||||||
| 74 | ||||||||
| 75 | ```jinja |
|||||||
| 76 | {% if not config.PLATFORM_MODE %} |
|||||||
| 77 | {# ... all five checkbox form groups ... #} |
|||||||
| 78 | {% endif %} |
|||||||
| 79 | ``` |
|||||||
| 80 | ||||||||
| 81 | 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: |
|||||||
| 82 | ||||||||
| 83 | ```jinja |
|||||||
| 84 | {% for permission_option in ["ANONYMOUS","REGISTERED","APPROVED"] + ([] if config.PLATFORM_MODE else ["ADMIN"]) %} |
|||||||
| 85 | ``` |
|||||||
| 86 | ||||||||
| 87 | ### 1d. Hide the per-user note about setting to "Admin" |
|||||||
| 88 | ||||||||
| 89 | 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: |
|||||||
| 90 | ||||||||
| 91 | ```jinja |
|||||||
| 92 | {% if config.PLATFORM_MODE %} |
|||||||
| 93 | <div> |
|||||||
| 94 | <em>Note:</em> These settings control per-wiki access levels. The platform ACL (owner/editor/viewer) |
|||||||
| 95 | grants the maximum permissions. These settings can restrict further but never escalate. |
|||||||
| 96 | "Anonymous" means anyone with a link. "Registered" means authenticated platform users. |
|||||||
| 97 | </div> |
|||||||
| 98 | {% else %} |
|||||||
| 99 | <div> |
|||||||
| 100 | <em>Note:</em> To configure privileges per user, ... |
|||||||
| 101 | </div> |
|||||||
| 102 | {% endif %} |
|||||||
| 103 | ``` |
|||||||
| 104 | ||||||||
| 105 | ### 1e. Handle form submission for PLATFORM_MODE |
|||||||
| 106 | ||||||||
| 107 | **File:** `/Users/sderle/code/otterwiki/otterwiki/otterwiki/preferences.py`, lines 419–436 (`handle_permissions_and_registration`) |
|||||||
| 108 | ||||||||
| 109 | 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")`). |
|||||||
| 110 | ||||||||
| 111 | **Fix:** Guard the registration checkbox loop with a PLATFORM_MODE check: |
|||||||
| 112 | ||||||||
| 113 | ```python |
|||||||
| 114 | def handle_permissions_and_registration(form): |
|||||||
| 115 | for name in ["READ_access", "WRITE_access", "ATTACHMENT_access"]: |
|||||||
| 116 | _update_preference(name.upper(), form.get(name, "ANONYMOUS")) |
|||||||
| 117 | if not app.config.get("PLATFORM_MODE"): |
|||||||
| 118 | for checkbox in [ |
|||||||
| 119 | "disable_registration", |
|||||||
| 120 | "auto_approval", |
|||||||
| 121 | "email_needs_confirmation", |
|||||||
| 122 | "notify_admins_on_register", |
|||||||
| 123 | "notify_user_on_approval", |
|||||||
| 124 | ]: |
|||||||
| 125 | _update_preference(checkbox.upper(), form.get(checkbox, "False")) |
|||||||
| 126 | db.session.commit() |
|||||||
| 127 | update_app_config() |
|||||||
| 128 | toast("Preferences updated.") |
|||||||
| 129 | return redirect(url_for("admin_permissions_and_registration")) |
|||||||
| 130 | ``` |
|||||||
| 131 | ||||||||
| 132 | ## Change 2: robot.wtf resolver — Intersect permissions with per-wiki access levels |
|||||||
| 133 | ||||||||
| 134 | ### 2a. New function: `_apply_wiki_access_restrictions` |
|||||||
| 135 | ||||||||
| 136 | **File:** `/Users/sderle/code/otterwiki/robot.wtf/app/resolver.py` |
|||||||
| 137 | ||||||||
| 138 | Add after `_is_write_request` (around line 273): |
|||||||
| 139 | ||||||||
| 140 | ```python |
|||||||
| 141 | def _apply_wiki_access_restrictions( |
|||||||
| 142 | permissions: tuple[str, ...] | list[str], |
|||||||
| 143 | is_authenticated: bool, |
|||||||
| 144 | ) -> list[str]: |
|||||||
| 145 | """Intersect ACL-granted permissions with per-wiki access preferences. |
|||||||
| 146 | ||||||||
| 147 | After _swap_database() + update_app_config(), the per-wiki |
|||||||
| 148 | READ_ACCESS / WRITE_ACCESS / ATTACHMENT_ACCESS values are in app.config. |
|||||||
| 149 | ||||||||
| 150 | Platform ACL grants the ceiling. Per-wiki preferences can only restrict. |
|||||||
| 151 | ||||||||
| 152 | Access levels: |
|||||||
| 153 | ANONYMOUS — no restriction (anyone gets this permission) |
|||||||
| 154 | REGISTERED — only authenticated users |
|||||||
| 155 | APPROVED — treated same as REGISTERED (no per-user approval tracking yet) |
|||||||
| 156 | ||||||||
| 157 | Args: |
|||||||
| 158 | permissions: ACL-granted permissions (e.g. ("READ", "WRITE", "UPLOAD")) |
|||||||
| 159 | is_authenticated: Whether the request has a resolved identity |
|||||||
| 160 | (not anonymous) |
|||||||
| 161 | ||||||||
| 162 | Returns: |
|||||||
| 163 | Filtered list of permissions. |
|||||||
| 164 | """ |
|||||||
| 165 | import otterwiki.server |
|||||||
| 166 | app = otterwiki.server.app |
|||||||
| 167 | ||||||||
| 168 | result = list(permissions) |
|||||||
| 169 | ||||||||
| 170 | access_map = { |
|||||||
| 171 | "READ": "READ_ACCESS", |
|||||||
| 172 | "WRITE": "WRITE_ACCESS", |
|||||||
| 173 | "UPLOAD": "ATTACHMENT_ACCESS", |
|||||||
| 174 | } |
|||||||
| 175 | ||||||||
| 176 | for perm, config_key in access_map.items(): |
|||||||
| 177 | if perm not in result: |
|||||||
| 178 | continue |
|||||||
| 179 | level = app.config.get(config_key, "ANONYMOUS").upper() |
|||||||
| 180 | if level == "ANONYMOUS": |
|||||||
| 181 | continue |
|||||||
| 182 | # REGISTERED and APPROVED both require authentication |
|||||||
| 183 | # (APPROVED would require per-user tracking; deferred — treat as REGISTERED) |
|||||||
| 184 | if level in ("REGISTERED", "APPROVED", "ADMIN"): |
|||||||
| 185 | if not is_authenticated: |
|||||||
| 186 | result.remove(perm) |
|||||||
| 187 | ||||||||
| 188 | # WRITE requires READ; UPLOAD requires WRITE |
|||||||
| 189 | if "READ" not in result: |
|||||||
| 190 | for dep in ("WRITE", "UPLOAD"): |
|||||||
| 191 | if dep in result: |
|||||||
| 192 | result.remove(dep) |
|||||||
| 193 | if "WRITE" not in result: |
|||||||
| 194 | if "UPLOAD" in result: |
|||||||
| 195 | result.remove("UPLOAD") |
|||||||
| 196 | ||||||||
| 197 | return result |
|||||||
| 198 | ``` |
|||||||
| 199 | ||||||||
| 200 | ### 2b. Call `_apply_wiki_access_restrictions` in `TenantResolver.__call__` |
|||||||
| 201 | ||||||||
| 202 | **File:** `/Users/sderle/code/otterwiki/robot.wtf/app/resolver.py`, lines 396–406 (after `_swap_database`, before injecting headers) |
|||||||
| 203 | ||||||||
| 204 | Insert between `_swap_database(wiki_dir)` (line 399) and the header injection block (line 402): |
|||||||
| 205 | ||||||||
| 206 | ```python |
|||||||
| 207 | # Apply per-wiki access restrictions |
|||||||
| 208 | proxy_headers = auth_result["proxy_headers"] |
|||||||
| 209 | is_authenticated = proxy_headers.get("x-otterwiki-email", "") not in ( |
|||||||
| 210 | "", "@anonymous" |
|||||||
| 211 | ) |
|||||||
| 212 | perms_key = "X-Otterwiki-Permissions" # Note: key casing matches build_proxy_headers |
|||||||
| 213 | # Actually the key is lowercase in build_proxy_headers |
|||||||
| 214 | perms_key = "x-otterwiki-permissions" |
|||||||
| 215 | raw_perms = proxy_headers.get(perms_key, "").split(",") |
|||||||
| 216 | restricted = _apply_wiki_access_restrictions(raw_perms, is_authenticated) |
|||||||
| 217 | proxy_headers[perms_key] = format_permission_header(restricted) |
|||||||
| 218 | ``` |
|||||||
| 219 | ||||||||
| 220 | **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. |
|||||||
| 221 | ||||||||
| 222 | 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. |
|||||||
| 223 | ||||||||
| 224 | ### 2c. Determine `is_authenticated` from auth path |
|||||||
| 225 | ||||||||
| 226 | Looking at the auth resolution paths: |
|||||||
| 227 | - JWT auth → authenticated (email = `@{handle}`) |
|||||||
| 228 | - Cookie auth → authenticated (email = `@{handle}`) |
|||||||
| 229 | - Bearer token → authenticated (email = `mcp@robot.wtf`) |
|||||||
| 230 | - API key → authenticated (email = `@system`) |
|||||||
| 231 | - Anonymous → NOT authenticated (email = `@anonymous`) |
|||||||
| 232 | ||||||||
| 233 | The check `email not in ("", "@anonymous")` correctly identifies unauthenticated requests. |
|||||||
| 234 | ||||||||
| 235 | ## The APPROVED Level Question |
|||||||
| 236 | ||||||||
| 237 | **Can we support APPROVED without per-user tracking?** No — not fully. |
|||||||
| 238 | ||||||||
| 239 | - In vanilla Otterwiki, APPROVED means users must be individually approved by an admin via User Management. |
|||||||
| 240 | - In robot.wtf, there's no per-wiki user table being actively populated (users are transient proxy-header objects). |
|||||||
| 241 | - User Management panel is still disabled. |
|||||||
| 242 | ||||||||
| 243 | **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. |
|||||||
| 244 | ||||||||
| 245 | **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. |
|||||||
| 246 | ||||||||
| 247 | ## What Happens for Each User Type |
|||||||
| 248 | ||||||||
| 249 | | User type | Platform ACL perms | Wiki READ_ACCESS=ANON | Wiki READ_ACCESS=REGISTERED | Wiki WRITE_ACCESS=REGISTERED | |
|||||||
| 250 | |-----------|-------------------|----------------------|---------------------------|------------------------------| |
|||||||
| 251 | | Owner (JWT) | READ,WRITE,UPLOAD,ADMIN | all | all | all | |
|||||||
| 252 | | Editor (JWT/cookie) | READ,WRITE,UPLOAD | all | all | all | |
|||||||
| 253 | | Viewer (ACL) | READ | READ only | READ only | READ only | |
|||||||
| 254 | | Public (anon, wiki is_public) | READ | READ | **none** (stripped) | READ only | |
|||||||
| 255 | | MCP bearer token | READ,WRITE,UPLOAD | all | all | all | |
|||||||
| 256 | | No auth, wiki private | 403 before reaching this code | N/A | N/A | N/A | |
|||||||
| 257 | ||||||||
| 258 | 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. |
|||||||
| 259 | ||||||||
| 260 | ## ADMIN Permission Pass-Through |
|||||||
| 261 | ||||||||
| 262 | 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. |
|||||||
| 263 | ||||||||
| 264 | ## Test Specifications |
|||||||
| 265 | ||||||||
| 266 | ### Otterwiki fork tests |
|||||||
| 267 | ||||||||
| 268 | **File:** New test file or extend existing admin test. |
|||||||
| 269 | ||||||||
| 270 | 1. **Route accessible in PLATFORM_MODE:** With `PLATFORM_MODE=True` and an admin user, GET `/-/admin/permissions_and_registration` returns 200 (not 404). |
|||||||
| 271 | 2. **Registration fields hidden:** Response HTML does not contain `disable_registration` checkbox when `PLATFORM_MODE=True`. |
|||||||
| 272 | 3. **Registration fields shown:** Response HTML contains `disable_registration` when `PLATFORM_MODE=False`. |
|||||||
| 273 | 4. **ADMIN option hidden in PLATFORM_MODE:** The access dropdowns don't include "ADMIN" option when `PLATFORM_MODE=True`. |
|||||||
| 274 | 5. **Form submission saves only access prefs in PLATFORM_MODE:** POST with READ_access=REGISTERED saves READ_ACCESS but does not touch DISABLE_REGISTRATION. |
|||||||
| 275 | 6. **Sidebar link visible in PLATFORM_MODE:** The settings template renders a link to `admin_permissions_and_registration` when `PLATFORM_MODE=True`. |
|||||||
| 276 | ||||||||
| 277 | ### robot.wtf tests |
|||||||
| 278 | ||||||||
| 279 | **File:** `/Users/sderle/code/otterwiki/robot.wtf/tests/test_resolver.py` (extend) |
|||||||
| 280 | ||||||||
| 281 | Unit tests for `_apply_wiki_access_restrictions`: |
|||||||
| 282 | ||||||||
| 283 | 1. **All ANONYMOUS — no restriction:** Permissions pass through unchanged. |
|||||||
| 284 | 2. **READ_ACCESS=REGISTERED, authenticated user:** READ preserved. |
|||||||
| 285 | 3. **READ_ACCESS=REGISTERED, anonymous user:** READ stripped; WRITE and UPLOAD also stripped (dependency). |
|||||||
| 286 | 4. **WRITE_ACCESS=REGISTERED, anonymous user with READ_ACCESS=ANONYMOUS:** READ preserved, WRITE stripped, UPLOAD stripped. |
|||||||
| 287 | 5. **ATTACHMENT_ACCESS=REGISTERED, anonymous user:** READ and WRITE preserved, UPLOAD stripped. |
|||||||
| 288 | 6. **APPROVED treated as REGISTERED:** READ_ACCESS=APPROVED, anonymous → READ stripped. |
|||||||
| 289 | 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). |
|||||||
| 290 | 8. **Dependency chain:** WRITE removed → UPLOAD also removed even if ATTACHMENT_ACCESS=ANONYMOUS. |
|||||||
| 291 | ||||||||
| 292 | Integration-level tests (mock `_swap_database` and `otterwiki.server.app.config`): |
|||||||
| 293 | ||||||||
| 294 | 9. **Full resolver flow:** Authenticated cookie user on wiki with WRITE_ACCESS=REGISTERED gets WRITE permission. |
|||||||
| 295 | 10. **Full resolver flow:** Anonymous user on public wiki with READ_ACCESS=REGISTERED gets no permissions (empty header). |
|||||||
| 296 | 11. **Quota stripping + access restriction compose correctly.** |
|||||||
| 297 | ||||||||
| 298 | ## Deployment and Independence |
|||||||
| 299 | ||||||||
| 300 | The two feature branches can be merged independently: |
|||||||
| 301 | ||||||||
| 302 | - **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. |
|||||||
| 303 | ||||||||
| 304 | - **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. |
|||||||
| 305 | ||||||||
| 306 | **Recommended order:** otterwiki fork first (UI changes, simpler to test), then robot.wtf (logic changes, needs more test coverage). |
|||||||
| 307 | ||||||||
| 308 | ## Open Questions |
|||||||
| 309 | ||||||||
| 310 | 1. **Should we log when wiki-level restrictions strip permissions?** Probably yes, at DEBUG level, for troubleshooting. |
|||||||
| 311 | 2. **Should the platform management UI (robot.wtf dashboard) expose these settings too?** Not now — the otterwiki admin panel is sufficient. |
|||||||
| 312 | 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. |
|||||||
