Blame
|
1 | --- |
||||||
| 2 | category: reference |
|||||||
| 3 | tags: |
|||||||
| 4 | - phase-2 |
|||||||
| 5 | - multi-tenant |
|||||||
| 6 | - resolver |
|||||||
| 7 | last_updated: 2026-03-13 |
|||||||
| 8 | --- |
|||||||
| 9 | ||||||||
| 10 | # P2-5b+7: Multi-Tenant Resolver + PROXY_HEADER |
|||||||
| 11 | ||||||||
| 12 | **Branch:** `feat/P2-5b-7-resolver` (from `phase-2`) |
|||||||
| 13 | **Status:** Complete, 42 tests passing |
|||||||
| 14 | ||||||||
| 15 | ## What was built |
|||||||
| 16 | ||||||||
| 17 | ### 1. `app/otterwiki/resolver.py` — TenantResolver WSGI middleware |
|||||||
| 18 | ||||||||
| 19 | Core multi-tenant routing layer. For each request: |
|||||||
| 20 | ||||||||
| 21 | 1. Extracts `user_id` from Host subdomain: `{user_id}.wikibot.io` |
|||||||
| 22 | 2. Extracts `wiki_slug` from first URL path segment: `/{slug}/...` |
|||||||
| 23 | 3. Looks up user in DynamoDB (UserModel.get), wiki in DynamoDB (WikiModel.get) |
|||||||
| 24 | 4. Authenticates via one of three paths: |
|||||||
| 25 | - **Platform JWT** (two dots in token) → AuthMiddleware.authenticate → AclEnforcer.check_access |
|||||||
| 26 | - **Bearer token** (opaque, no dots) → AclEnforcer.check_bearer_token |
|||||||
| 27 | - **Anonymous** (no auth header) → AclEnforcer.check_public_access |
|||||||
| 28 | 5. Swaps `otterwiki.server.storage` and `otterwiki.server.githttpserver` per-request (cached by repo path) |
|||||||
| 29 | 6. Updates Flask app config (REPOSITORY, SQLALCHEMY_DATABASE_URI, FAISS_INDEX_DIR) |
|||||||
| 30 | 7. Injects `x-otterwiki-name`, `x-otterwiki-email`, `x-otterwiki-permissions` into WSGI environ |
|||||||
| 31 | 8. Strips `/{slug}` prefix from PATH_INFO |
|||||||
| 32 | 9. Lazy-inits wiki git repo on first access |
|||||||
| 33 | ||||||||
| 34 | Non-tenant requests (dev.wikibot.io, bare wikibot.io, other domains) pass through unchanged. |
|||||||
| 35 | ||||||||
| 36 | ### 2. `app/otterwiki/lambda_init.py` — Conditional multi-tenant wrapping |
|||||||
| 37 | ||||||||
| 38 | - `MULTI_TENANT=true` env var activates TenantResolver |
|||||||
| 39 | - When active: skips single-tenant `_ensure_directories()`, wraps app with resolver |
|||||||
| 40 | - When inactive: preserves existing single-tenant behavior |
|||||||
| 41 | - `_build_tenant_resolver()` constructs all dependencies (PlatformJWT, UserModel, WikiModel, AclModel, AuthMiddleware, AclEnforcer) |
|||||||
| 42 | ||||||||
| 43 | ### 3. `infra/__main__.py` — Infrastructure changes |
|||||||
| 44 | ||||||||
| 45 | - Moved DynamoDB component definition above otterwiki Lambda (dependency ordering) |
|||||||
| 46 | - Added otterwiki Lambda env vars: `AUTH_METHOD`, `PLATFORM_MODE`, `MULTI_TENANT`, `USERS_TABLE`, `WIKIS_TABLE`, `ACLS_TABLE`, `JWT_PUBLIC_KEY` |
|||||||
| 47 | - Removed: `READ_ACCESS`, `WRITE_ACCESS`, `ATTACHMENT_ACCESS` (resolver handles auth now) |
|||||||
| 48 | - Added `otterwiki-dynamodb-policy` IAM role policy (GetItem, PutItem, UpdateItem, DeleteItem, Query, Scan on all 3 tables + GSIs) |
|||||||
| 49 | - Added MCP Lambda env vars: `USERS_TABLE`, `WIKIS_TABLE`, `ACLS_TABLE`, `PLATFORM_DOMAIN` |
|||||||
| 50 | - Added `mcp-dynamodb-policy` IAM role policy (read-only: GetItem, Query, Scan) |
|||||||
| 51 | - Added `jwt_public_key` as pulumi config secret |
|||||||
| 52 | ||||||||
| 53 | ### 4. `app/poc/mcp_server.py` — Multi-tenant MCP |
|||||||
| 54 | ||||||||
| 55 | - `_resolve_tenant_client(token)`: scans DynamoDB for wiki matching bearer token, constructs tenant-specific API URL (`https://{owner_id}.wikibot.io/{slug}`) |
|||||||
| 56 | - `_get_client()` tries tenant resolution first, falls back to static `API_BASE_URL` |
|||||||
| 57 | - WikiClient instances cached by `(owner_id, slug)` tuple |
|||||||
| 58 | ||||||||
| 59 | ### 5. `tests/test_resolver.py` — 42 tests |
|||||||
| 60 | ||||||||
| 61 | - **Host parsing** (8): valid subdomain, UUID, dev passthrough, bare domain, different domain, empty, port stripping, case insensitivity |
|||||||
| 62 | - **Wiki slug parsing** (6): slug+page, trailing slash, slug only, deep path, root, empty |
|||||||
| 63 | - **JWT heuristic** (4): JWT format, bearer token, one dot, three dots |
|||||||
| 64 | - **Error responses** (3): 404, 401, 403 |
|||||||
| 65 | - **Passthrough** (3): dev domain, bare domain, different domain |
|||||||
| 66 | - **User lookup** (1): unknown user → 404 |
|||||||
| 67 | - **Wiki lookup** (2): unknown wiki → 404, missing slug → 400 |
|||||||
| 68 | - **Auth flows** (7): JWT+ACL, bearer token, anonymous public, anonymous private → 403, invalid JWT → 401, invalid bearer → 401, JWT no ACL → 403, malformed auth → 401 |
|||||||
| 69 | - **Path stripping** (5): page, root, slug-only, deep path, original preserved |
|||||||
| 70 | - **Storage swap** (1): _swap_storage called with correct paths |
|||||||
| 71 | - **Proxy headers** (1): headers injected in environ |
|||||||
| 72 | ||||||||
| 73 | ## Design decisions |
|||||||
| 74 | ||||||||
| 75 | - **user_id as subdomain** (not username): avoids needing a new DynamoDB field/GSI. Pretty URLs deferred to Phase 3/4. |
|||||||
| 76 | - **JWT vs bearer heuristic**: JWTs have exactly 2 dots (header.payload.signature), bearer tokens don't. Simple and reliable. |
|||||||
| 77 | - **Storage caching**: GitStorage instances cached by repo path in module-level dict. Safe on Lambda (single concurrency). |
|||||||
| 78 | - **`_swap_storage` mocked in tests**: otterwiki isn't importable in test env, so storage swap is mocked. The swap logic itself is straightforward module attribute assignment. |
|||||||