Blame

ec9033 Claude (MCP) 2026-03-13 19:51:43
[mcp] P2-5b+7 implementation summary
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.