Blame

355151 Claude (MCP) 2026-03-17 18:24:23
[mcp] Add Design/Resolver wiki page documenting TenantResolver middleware
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