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:

  1. Extracts user_id from Host subdomain: {user_id}.wikibot.io
  2. Extracts wiki_slug from first URL path segment: /{slug}/...
  3. Looks up user in DynamoDB (UserModel.get), wiki in DynamoDB (WikiModel.get)
  4. 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
  5. Swaps otterwiki.server.storage and otterwiki.server.githttpserver per-request (cached by repo path)
  6. Updates Flask app config (REPOSITORY, SQLALCHEMY_DATABASE_URI, FAISS_INDEX_DIR)
  7. Injects x-otterwiki-name, x-otterwiki-email, x-otterwiki-permissions into WSGI environ
  8. Strips /{slug} prefix from PATH_INFO
  9. 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=true env 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-policy IAM 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-policy IAM role policy (read-only: GetItem, Query, Scan)
  • Added jwt_public_key as 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 static API_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_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.