Properties
category: reference tags: - phase-2 - multi-tenant - resolver last_updated: 2026-03-13
P2-5b+7: Multi-Tenant Resolver + PROXY_HEADER
Branch: feat/P2-5b-7-resolver (from phase-2)
Status: Complete, 42 tests passing
What was built
1. app/otterwiki/resolver.py — TenantResolver WSGI middleware
Core multi-tenant routing layer. For each request:
- Extracts
user_idfrom Host subdomain:{user_id}.wikibot.io - Extracts
wiki_slugfrom first URL path segment:/{slug}/... - Looks up user in DynamoDB (UserModel.get), wiki in DynamoDB (WikiModel.get)
- Authenticates via one of three paths:
- Platform JWT (two dots in token) → AuthMiddleware.authenticate → AclEnforcer.check_access
- Bearer token (opaque, no dots) → AclEnforcer.check_bearer_token
- Anonymous (no auth header) → AclEnforcer.check_public_access
- Swaps
otterwiki.server.storageandotterwiki.server.githttpserverper-request (cached by repo path) - Updates Flask app config (REPOSITORY, SQLALCHEMY_DATABASE_URI, FAISS_INDEX_DIR)
- Injects
x-otterwiki-name,x-otterwiki-email,x-otterwiki-permissionsinto WSGI environ - Strips
/{slug}prefix from PATH_INFO - Lazy-inits wiki git repo on first access
Non-tenant requests (dev.wikibot.io, bare wikibot.io, other domains) pass through unchanged.
2. app/otterwiki/lambda_init.py — Conditional multi-tenant wrapping
MULTI_TENANT=trueenv var activates TenantResolver- When active: skips single-tenant
_ensure_directories(), wraps app with resolver - When inactive: preserves existing single-tenant behavior
_build_tenant_resolver()constructs all dependencies (PlatformJWT, UserModel, WikiModel, AclModel, AuthMiddleware, AclEnforcer)
3. infra/__main__.py — Infrastructure changes
- Moved DynamoDB component definition above otterwiki Lambda (dependency ordering)
- Added otterwiki Lambda env vars:
AUTH_METHOD,PLATFORM_MODE,MULTI_TENANT,USERS_TABLE,WIKIS_TABLE,ACLS_TABLE,JWT_PUBLIC_KEY - Removed:
READ_ACCESS,WRITE_ACCESS,ATTACHMENT_ACCESS(resolver handles auth now) - Added
otterwiki-dynamodb-policyIAM role policy (GetItem, PutItem, UpdateItem, DeleteItem, Query, Scan on all 3 tables + GSIs) - Added MCP Lambda env vars:
USERS_TABLE,WIKIS_TABLE,ACLS_TABLE,PLATFORM_DOMAIN - Added
mcp-dynamodb-policyIAM role policy (read-only: GetItem, Query, Scan) - Added
jwt_public_keyas pulumi config secret
4. app/poc/mcp_server.py — Multi-tenant MCP
_resolve_tenant_client(token): scans DynamoDB for wiki matching bearer token, constructs tenant-specific API URL (https://{owner_id}.wikibot.io/{slug})_get_client()tries tenant resolution first, falls back to staticAPI_BASE_URL- WikiClient instances cached by
(owner_id, slug)tuple
5. tests/test_resolver.py — 42 tests
- Host parsing (8): valid subdomain, UUID, dev passthrough, bare domain, different domain, empty, port stripping, case insensitivity
- Wiki slug parsing (6): slug+page, trailing slash, slug only, deep path, root, empty
- JWT heuristic (4): JWT format, bearer token, one dot, three dots
- Error responses (3): 404, 401, 403
- Passthrough (3): dev domain, bare domain, different domain
- User lookup (1): unknown user → 404
- Wiki lookup (2): unknown wiki → 404, missing slug → 400
- 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
- Path stripping (5): page, root, slug-only, deep path, original preserved
- Storage swap (1): _swap_storage called with correct paths
- Proxy headers (1): headers injected in environ
Design decisions
- user_id as subdomain (not username): avoids needing a new DynamoDB field/GSI. Pretty URLs deferred to Phase 3/4.
- JWT vs bearer heuristic: JWTs have exactly 2 dots (header.payload.signature), bearer tokens don't. Simple and reliable.
- Storage caching: GitStorage instances cached by repo path in module-level dict. Safe on Lambda (single concurrency).
_swap_storagemocked in tests: otterwiki isn't importable in test env, so storage swap is mocked. The swap logic itself is straightforward module attribute assignment.