Blame
|
1 | --- |
||||||
| 2 | category: spec |
|||||||
| 3 | tags: [architecture, resolver, multi-tenant, auth] |
|||||||
| 4 | last_updated: 2026-03-17 |
|||||||
| 5 | confidence: high |
|||||||
| 6 | --- |
|||||||
| 7 | ||||||||
| 8 | # Resolver (TenantResolver) |
|||||||
| 9 | ||||||||
| 10 | The resolver is the WSGI middleware that makes robot.wtf multi-tenant. Every request to a wiki subdomain passes through it. It lives in `app/resolver.py`. |
|||||||
| 11 | ||||||||
| 12 | ## What it does, in order |
|||||||
| 13 | ||||||||
| 14 | For every HTTP request to `{slug}.robot.wtf`: |
|||||||
| 15 | ||||||||
| 16 | 1. **Parse host** → extract wiki slug from subdomain |
|||||||
| 17 | 2. **Look up wiki** → query `robot.db` wikis table by slug |
|||||||
| 18 | 3. **Swap storage** → patch otterwiki's module-level `storage` singleton in every module that imported it (yes, really — otterwiki uses module globals, not dependency injection) |
|||||||
| 19 | 4. **Swap database** → replace SQLAlchemy engine with the per-wiki `wiki.db`, reload preferences via `update_app_config()` |
|||||||
| 20 | 5. **Authenticate** → JWT cookie, bearer token, or anonymous |
|||||||
| 21 | 6. **Derive permissions** → owner gets ADMIN; per-wiki user table flags derive READ/WRITE/UPLOAD; fallback is READ for authenticated users |
|||||||
| 22 | 7. **Apply access restrictions** → per-wiki READ_ACCESS/WRITE_ACCESS/ATTACHMENT_ACCESS preferences can strip permissions (ANONYMOUS/REGISTERED/APPROVED levels) |
|||||||
| 23 | 8. **Inject proxy headers** → `x-otterwiki-email`, `x-otterwiki-name`, `x-otterwiki-permissions` |
|||||||
| 24 | 9. **Delegate** → pass modified `environ` to the wrapped otterwiki WSGI app |
|||||||
| 25 | ||||||||
| 26 | ## Why it's complex |
|||||||
| 27 | ||||||||
| 28 | Otterwiki was designed as a single-tenant app. It uses module-level globals for storage and a single SQLAlchemy database. The resolver makes it multi-tenant by swapping these globals on every request. This is inherently fragile and is the source of most platform bugs. |
|||||||
| 29 | ||||||||
| 30 | ### The storage swap problem |
|||||||
| 31 | ||||||||
| 32 | Otterwiki's `storage` (a `GitStorage` instance) is imported by value into ~8 modules at import time. Swapping the module-level variable in `otterwiki.server` doesn't affect the copies in `otterwiki.wiki`, `otterwiki.helper`, etc. So the resolver patches all of them: |
|||||||
| 33 | ||||||||
| 34 | ```python |
|||||||
| 35 | otterwiki.server.storage = storage |
|||||||
| 36 | otterwiki.wiki.storage = storage |
|||||||
| 37 | otterwiki.helper.storage = storage |
|||||||
| 38 | # ... 5 more modules, plus plugin state dicts |
|||||||
| 39 | ``` |
|||||||
| 40 | ||||||||
| 41 | If a new otterwiki module imports `storage` and we don't patch it, that module sees the wrong wiki's data. |
|||||||
| 42 | ||||||||
| 43 | ### The database swap problem |
|||||||
| 44 | ||||||||
| 45 | SQLAlchemy's Flask extension (`flask_sqlalchemy`) creates an engine at app init time and caches it. The resolver reaches into `db._app_engines` to swap the engine directly. This is a private API that could break on any Flask-SQLAlchemy upgrade. |
|||||||
| 46 | ||||||||
| 47 | After swapping the engine, the resolver calls `otterwiki.server.update_app_config()` which does `SELECT * FROM preferences` and writes each row into `app.config`. This is how per-wiki preferences (READ_ACCESS, SITE_NAME, SITE_ICON, etc.) take effect. |
|||||||
| 48 | ||||||||
| 49 | ### The multi-worker problem |
|||||||
| 50 | ||||||||
| 51 | With gunicorn's `preload_app = True` and multiple workers, each worker has its own copy of `app.config`. When one worker handles a preference change (e.g., user changes READ_ACCESS via admin UI), only that worker's `app.config` is updated. Other workers see stale values until they call `update_app_config()`. |
|||||||
| 52 | ||||||||
| 53 | **Fix (2026-03-17):** The fast path in `_swap_database()` now calls `update_app_config()` even when the engine URL already matches. This means every request does a `SELECT * FROM preferences` — a small SQLite read that's fast under WAL mode but worth noting as a performance characteristic. |
|||||||
| 54 | ||||||||
| 55 | ### The default database problem |
|||||||
| 56 | ||||||||
| 57 | Otterwiki creates a default SQLAlchemy database at startup (before any request). The `settings.cfg` used to point this at `/tmp/otterwiki_default.db`, but Otterwiki's own default is `sqlite:///:memory:`. The problem: `update_app_config()` runs at startup against this default DB, and any preference rows in it overwrite `settings.cfg` values. This is why `SITE_ICON` set in `settings.cfg` was silently overridden to empty string. |
|||||||
| 58 | ||||||||
| 59 | **Fix (2026-03-17):** Removed the `SQLALCHEMY_DATABASE_URI` override from `settings.cfg`, letting Otterwiki use its in-memory default. All platform preferences (SITE_ICON, SITE_LOGO, access levels, etc.) are seeded into per-wiki DBs via `_init_wiki_db()`, where `update_app_config()` actually reads them. |
|||||||
| 60 | ||||||||
| 61 | **Rule:** Never use `settings.cfg` for preferences that `update_app_config()` manages. The DB always wins. See also the memory entry `feedback_otterwiki_config_override.md`. |
|||||||
| 62 | ||||||||
| 63 | ## Key data structures |
|||||||
| 64 | ||||||||
| 65 | ### Per-wiki SQLite DB (`/srv/data/wikis/{slug}/wiki.db`) |
|||||||
| 66 | ||||||||
| 67 | Seeded by `_init_wiki_db()` with `INSERT OR IGNORE` (idempotent, never overwrites user changes): |
|||||||
| 68 | ||||||||
| 69 | | Preference | Default | Purpose | |
|||||||
| 70 | |---|---|---| |
|||||||
| 71 | | READ_ACCESS | REGISTERED | Who can read (ANONYMOUS/REGISTERED/APPROVED) | |
|||||||
| 72 | | WRITE_ACCESS | REGISTERED | Who can write | |
|||||||
| 73 | | ATTACHMENT_ACCESS | REGISTERED | Who can upload | |
|||||||
| 74 | | AUTH_METHOD | PROXY_HEADER | Always proxy header in platform mode | |
|||||||
| 75 | | DISABLE_REGISTRATION | True | Platform handles registration | |
|||||||
| 76 | | AUTO_APPROVAL | False | Safety net | |
|||||||
| 77 | | SITE_ICON | `https://robot.wtf/static/robot.wtf.svg` | Default favicon | |
|||||||
| 78 | | SITE_LOGO | `https://robot.wtf/static/robot.wtf.svg` | Default nav icon | |
|||||||
| 79 | ||||||||
| 80 | Also seeds the wiki owner into the `user` table as admin. |
|||||||
| 81 | ||||||||
| 82 | ### Auth result dict |
|||||||
| 83 | ||||||||
| 84 | Returned by `_resolve_auth()`, consumed by the middleware: |
|||||||
| 85 | ||||||||
| 86 | ```python |
|||||||
| 87 | { |
|||||||
| 88 | "proxy_headers": {"x-otterwiki-email": ..., "x-otterwiki-name": ..., "x-otterwiki-permissions": ...}, |
|||||||
| 89 | "is_authenticated": bool, |
|||||||
| 90 | "is_bearer_token": bool, |
|||||||
| 91 | "per_wiki_user": {"is_admin": bool, "is_approved": bool, "allow_read": bool, ...} | None, |
|||||||
| 92 | } |
|||||||
| 93 | ``` |
|||||||
| 94 | ||||||||
| 95 | ## Auth paths |
|||||||
| 96 | ||||||||
| 97 | | Credential | Path | Permissions | |
|||||||
| 98 | |---|---|---| |
|||||||
| 99 | | JWT (Authorization header) | `_resolve_jwt()` | Owner → ADMIN; per-wiki user flags; fallback READ | |
|||||||
| 100 | | JWT (cookie) | `authenticate_from_cookie()` | Same as above | |
|||||||
| 101 | | Bearer token (opaque) | `_resolve_bearer_token()` | Editor role (READ+WRITE+UPLOAD), wiki-scoped | |
|||||||
| 102 | | Internal API key | Direct match | ADMIN (MCP sidecar → REST API) | |
|||||||
| 103 | | Anonymous | No credentials | READ, subject to access restrictions | |
|||||||
| 104 | ||||||||
| 105 | Bearer tokens bypass per-wiki access restrictions (they're already scoped to a specific wiki by the token itself). |
|||||||
| 106 | ||||||||
| 107 | ## Access restriction flow |
|||||||
| 108 | ||||||||
| 109 | After permissions are derived from auth, they're filtered by `_apply_wiki_access_restrictions()`: |
|||||||
| 110 | ||||||||
| 111 | - **ANONYMOUS** → no filter |
|||||||
| 112 | - **REGISTERED** → unauthenticated users lose READ/WRITE/UPLOAD |
|||||||
| 113 | - **APPROVED** → unauthenticated OR non-approved users lose READ/WRITE/UPLOAD |
|||||||
| 114 | - **ADMIN** → never stripped |
|||||||
| 115 | ||||||||
| 116 | If an unauthenticated browser request has no remaining read permissions and READ_ACCESS != ANONYMOUS, the resolver redirects to `/auth/login?return_to={original_url}`. |
|||||||
| 117 | ||||||||
| 118 | ## Known limitations |
|||||||
| 119 | ||||||||
| 120 | 1. **Module patching is fragile.** Any new otterwiki module that imports `storage` at the top level needs to be added to `_swap_storage()`. |
|||||||
| 121 | 2. **`db._app_engines` is a private API.** Flask-SQLAlchemy updates could break the engine swap. |
|||||||
| 122 | 3. **One `SELECT *` per request** for preferences reload. Acceptable for current scale but would need caching for high throughput. |
|||||||
| 123 | 4. **Disk quota enforcement is incomplete.** `disk_usage_bytes` is always 0 (the wiki stats plugin isn't implemented yet), so quota checks are effectively dead code. |
|||||||
| 124 | ||||||||
| 125 | ## Related pages |
|||||||
| 126 | ||||||||
| 127 | - [[Design/Auth]] — Auth architecture (superseded sections, but ATProto OAuth is current) |
|||||||
| 128 | - [[Design/VPS_Architecture]] — Overall platform architecture |
|||||||
| 129 | - [[Design/Admin_Panel_Reenablement]] — Per-wiki admin UI, which the resolver enables |
|||||||
| 130 | - [[Plans/Disk_Usage_Caps]] — Planned fix for the quota dead code |
|||||||
