Skip to content

Foundation Platform Specification

Status: Specified — ready to build Must be built and verified before any module implementation begins.

This document specifies everything that must exist before a single module can be built: the API foundation, database layer, authentication, infrastructure, frontend skeleton, observability, and test harness.


Build Order

Build in this order. Each layer depends on the previous.

Layer Area Location
1 Local dev environment docker-compose.yml, .env.example (session-mode PgBouncer)
2 Database foundation core/db.py, core/models.py, Alembic + tenant migration runner
3 Public schema tables Alembic 0001 (tenants, identities, memberships, user_project_access, tenant_migrations)
4 Tenant context core/context.py, core/tenant.py, core/middleware.py (contextvar + per-tenant role)
5 Authentication core/auth.py, core/security.py, core/access.py, Cognito Lambda, auth endpoints
5a Field encryption core/encryption.py (per-tenant KMS DEK)
6 FastAPI application main.py, core/exceptions.py, core/pagination.py
7 Observability Logging, Sentry, request tracing
8 Test harness tests/conftest.py, tests/factories.py
9 Frontend skeleton React app, auth flow (HttpOnly cookie), API client, PowerSync
10 Infrastructure Terraform modules, CI/CD (3-stage migration gate)

Layer 1 — Local Development Environment

docker-compose.yml

File: docker-compose.yml (repo root)

version: "3.9"

services:
  postgres:
    image: postgres:16-alpine
    container_name: construo-postgres
    environment:
      POSTGRES_DB: construo
      POSTGRES_USER: construo
      POSTGRES_PASSWORD: construo
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U construo"]
      interval: 5s
      timeout: 5s
      retries: 5

  pgbouncer:
    image: pgbouncer/pgbouncer:latest
    container_name: construo-pgbouncer
    environment:
      DATABASES_HOST: postgres
      DATABASES_PORT: 5432
      DATABASES_DBNAME: construo
      DATABASES_USER: construo
      DATABASES_PASSWORD: construo
      PGBOUNCER_POOL_MODE: session        # MUST be session, not transaction.
      PGBOUNCER_MAX_CLIENT_CONN: 100      # SET LOCAL ROLE and SET LOCAL search_path
      PGBOUNCER_DEFAULT_POOL_SIZE: 10     # are transaction-scoped in Postgres; in
    ports:                                # transaction mode PgBouncer discards them
      - "6432:5432"                       # after each commit. Silent tenant isolation
    depends_on:                           # failure. Session mode keeps the settings
      postgres:                           # for the lifetime of the client connection.
        condition: service_healthy        # See ADR-021.

  redis:
    image: redis:7-alpine
    container_name: construo-redis
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

PgBouncer runs in session mode (PGBOUNCER_POOL_MODE: session). Do not change this to transaction mode. SET LOCAL ROLE and SET LOCAL search_path — the mechanisms that enforce per-tenant isolation — are transaction-scoped in PostgreSQL. In transaction mode, PgBouncer returns the connection to the pool after each commit, discarding those settings. The next transaction on that connection may run under a different tenant's search_path. This is a silent data leak, not an error. Session mode keeps the settings for the full lifetime of the client connection. See ADR-021.

The API connects via PgBouncer on port 6432, not directly to Postgres on 5432. Direct Postgres on 5432 is for Alembic migrations and admin access only.

apps/api/.env.example

# Database — connect via PgBouncer for application, direct for migrations
DATABASE_URL=postgresql+asyncpg://construo:construo@localhost:6432/construo
DATABASE_URL_DIRECT=postgresql+asyncpg://construo:construo@localhost:5432/construo

# Redis
REDIS_URL=redis://localhost:6379/0

# Cognito — use dev pool values from 1Password
COGNITO_USER_POOL_ID=eu-west-2_XXXXXXXXX
COGNITO_REGION=eu-west-2
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx

# Application
ENVIRONMENT=dev
DB_ECHO=false
CORS_ORIGINS=["http://localhost:5173","http://acme.local.test:5173","http://beta.local.test:5173"]

# Sentry — leave empty for local dev
SENTRY_DSN=

apps/web/.env.example

VITE_API_BASE_URL=http://localhost:8000
VITE_COGNITO_USER_POOL_ID=eu-west-2_XXXXXXXXX
VITE_COGNITO_REGION=eu-west-2
VITE_COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_COGNITO_DOMAIN=construo-dev.auth.eu-west-2.amazoncognito.com
VITE_POWERSYNC_URL=https://your-instance.powersync.journeyapps.com
VITE_ENVIRONMENT=dev

Layer 2 — Database Foundation

SQLAlchemy and connection

File: apps/api/src/core/db.py

from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)
from sqlalchemy.pool import NullPool
from .config import settings

# NullPool: SQLAlchemy must not maintain its own connection pool on top of PgBouncer.
# PgBouncer is the pool manager. SQLAlchemy acquires a connection, uses it, returns it.
engine = create_async_engine(
    settings.database_url,
    poolclass=NullPool,
    echo=settings.db_echo,
)

# Alembic uses direct connection (bypasses PgBouncer) for DDL operations
# that require session-level settings. Configured separately in alembic/env.py.

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
    autoflush=False,
)

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    """
    Dependency that yields a tenant-scoped database session.

    Reads tenant and user from contextvars (set by middleware — never from
    request.state, which is mutable and not available in background jobs).

    SET LOCAL ROLE: assumes the per-tenant Postgres role (r_{slug}), which has
    GRANT on t_{slug} schema only. A missed SET LOCAL becomes a permission error
    rather than a silent cross-tenant fallback. Defence-in-depth for ADR-007.

    SET LOCAL search_path: scopes unqualified table names to the tenant schema.

    Both are SET LOCAL (transaction-scoped). PgBouncer must be in session mode
    for these to survive across multiple transactions on the same connection.
    See ADR-021 and Layer 1 docker-compose notes.
    """
    from .context import current_tenant, current_user, current_request_id

    tenant = current_tenant.get()
    if tenant is None:
        raise RuntimeError("No tenant context — TenantMiddleware did not set it")

    validate_schema_name(tenant.schema_name)   # guards against injection — ADR-007 M1
    role_name = f"r_{tenant.slug}"
    schema_name = tenant.schema_name

    async with AsyncSessionLocal() as session:
        # ROLE first, then search_path — both in one round trip
        await session.execute(text(
            f'SET LOCAL ROLE "{role_name}"; '
            f'SET LOCAL search_path TO "{schema_name}", public'
        ))
        # Bind session-level audit context (read by audit trigger) — ADR-010
        user = current_user.get()
        request_id = current_request_id.get()
        if user is not None:
            await session.execute(
                text("SET LOCAL app.user_id = :uid; SET LOCAL app.identity_id = :iid"),
                {"uid": str(user.id), "iid": str(user.identity_id)},
            )
        if request_id is not None:
            await session.execute(
                text("SET LOCAL app.request_id = :rid"),
                {"rid": str(request_id)},
            )
        await session.execute(
            text("SET LOCAL app.actor_type = :at"),
            {"at": "user" if user else "system"},
        )
        yield session

Base models

File: apps/api/src/core/models.py

import uuid
from datetime import datetime
from sqlalchemy import DateTime, text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class PublicBase(DeclarativeBase):
    """Base for public schema tables: tenants, tenant_labels, api_keys, field_definitions."""
    pass

class TenantBase(DeclarativeBase):
    """Base for all tenant-schema entity tables."""
    pass

class TimestampMixin:
    """created_at and updated_at. updated_at maintained by DB trigger, not SQLAlchemy."""
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=text("NOW()"),
        nullable=False,
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=text("NOW()"),
        nullable=False,
    )

class EntityMixin(TimestampMixin):
    """Universal columns for every tenant entity table."""
    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        primary_key=True,
        default=uuid.uuid4,
    )
    created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
    updated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
    deleted_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, default=None
    )
    custom_fields: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)

Note: updated_at is set by the DB trigger (not SQLAlchemy onupdate) because onupdate is unreliable with async sessions and connection pools.

Alembic configuration

File: apps/api/alembic/env.py

env.py handles only the public schema. Tenant schema migrations are handled by the standalone runner (see below). Running both in env.py was the source of all seven failure modes in ADR-001.

from sqlalchemy import text
from alembic import context

# Alembic uses DIRECT database URL (not PgBouncer) for DDL
DATABASE_URL_DIRECT = os.environ["DATABASE_URL_DIRECT"]

async def run_migrations_online() -> None:
    engine = create_async_engine(DATABASE_URL_DIRECT)

    # Public schema only — tenant schemas are handled by migrate_tenants.py
    async with engine.begin() as conn:
        await conn.execute(text("SET search_path TO public"))
        await conn.run_sync(lambda sync_conn: context.run_migrations())

    await engine.dispose()

Migration file naming and target comment:

alembic/versions/
  0001_public_schema_foundation.py     # Target: PUBLIC
  0002_db_triggers.py                  # Target: PUBLIC (functions) + TENANT (triggers)
  0003_tenant_users_table.py           # Target: TENANT
  0004_tenant_projects_table.py        # Target: TENANT

Every migration file begins with a comment: # Target: PUBLIC or # Target: TENANT. A CI lint check enforces this.


Standalone tenant migration runner (ADR-001)

File: apps/api/scripts/migrate_tenants.py

Replaces the loop that was in env.py. Continues past per-tenant failures, records outcomes, and exits non-zero if any tenant is not at target.

from dataclasses import dataclass, field
from typing import Literal
import asyncio, argparse, sys
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text

@dataclass
class MigrationReport:
    succeeded: list = field(default_factory=list)
    failed: list = field(default_factory=list)
    skipped: list = field(default_factory=list)

async def migrate_all_tenants(
    target_revision: str | None = None,
    only: list[str] | None = None,
    from_state: Literal["all", "failed"] = "all",
    dry_run: bool = False,
) -> MigrationReport:
    """
    Migrate every active tenant schema to target_revision (default: head).
    Continues past failures, records outcomes in public.tenant_migrations.
    Exits 0 only if every tenant reaches target_revision.
    """
    target = target_revision or get_head_revision()
    tenants = await fetch_active_tenants()
    if only:
        tenants = [t for t in tenants if t.slug in only]
    if from_state == "failed":
        tenants = [t for t in tenants if await last_attempt_failed(t.id, target)]

    report = MigrationReport()

    for tenant in tenants:
        validate_schema_name(tenant.schema_name)
        current = await get_current_revision(tenant.schema_name)
        if current == target:
            report.skipped.append(tenant)
            continue
        if dry_run:
            print(f"[dry-run] Would migrate {tenant.slug}: {current}{target}")
            continue

        attempt_id = await record_attempt(tenant.id, target)
        try:
            await run_alembic_for_schema(tenant.schema_name, target, timeout_seconds=60)
            await record_success(attempt_id)
            report.succeeded.append(tenant)
        except Exception as e:
            await record_failure(attempt_id, str(e))
            report.failed.append((tenant, e))
            # Continue to next tenant — do NOT abort the whole run

    return report

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--only", help="Comma-separated tenant slugs")
    parser.add_argument("--target", help="Target revision (default: head)")
    parser.add_argument("--from-state", choices=["all", "failed"], default="all")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    report = asyncio.run(migrate_all_tenants(
        target_revision=args.target,
        only=args.only.split(",") if args.only else None,
        from_state=args.from_state,
        dry_run=args.dry_run,
    ))

    print(f"Succeeded: {len(report.succeeded)}, Failed: {len(report.failed)}, Skipped: {len(report.skipped)}")

    if report.failed:
        for tenant, err in report.failed:
            print(f"  FAILED: {tenant.slug}{err}", file=sys.stderr)
        sys.exit(1)   # 1 = one or more tenants not at target
    sys.exit(0)       # 0 = all active tenants at target
    # Exit 2 is reserved for runner-itself failures (config error, DB unreachable)

Required CLI flags: - --only acme,beta — restrict to a subset of tenants (for retry) - --dry-run — print what would run without executing - --target REV — migrate to a specific revision (default: head) - --from-state failed — only attempt tenants whose last attempt failed

Verification script: apps/api/scripts/verify_tenant_versions.py — queries public.tenant_migrations and exits 1 if any active tenant is below head.


Migration authoring rules (ADR-001 M5)

All tenant migrations must follow these rules. Enforced in PR review.

  • Backfills run in batches: UPDATE ... WHERE id IN (SELECT ... LIMIT 1000) — never whole-table. Keeps transactions small and avoids lock pressure.
  • Index creation uses CREATE INDEX CONCURRENTLY where possible. Incompatible with Alembic's default transaction mode. Use:
    op.execute("COMMIT")
    op.execute("CREATE INDEX CONCURRENTLY idx_... ON ...")
    
    or with op.batch_alter_table(..., transactional_ddl=False).
  • Enum changes (ALTER TYPE ... ADD VALUE) cannot run inside a transaction in older Postgres. Use standalone op.execute() outside a transaction block.
  • Every migration must declare # Target: PUBLIC or # Target: TENANT as its first comment. CI lint check enforces this.

Expand/contract deploy discipline (ADR-001 M4)

Every schema change ships in two passes.

Pass 1 — expand. Migration adds the new column/table/index. New column is nullable with a sensible default (or has a batched backfill safe to run concurrently with old-app reads). Old application code keeps working because the change is additive. New application code is not yet shipped.

Pass 2 — contract. Once Pass 1 has succeeded for every tenant (verified via public.tenant_migrations + verify_tenant_versions.py), ship application code that uses the new column. A later migration may tighten the constraint (NOT NULL, drop old column) — only after the application change has soaked for one release.

PR review checklist for migrations: - [ ] If this PR contains a migration, is it safe to run while the previous app version is still serving? - [ ] If this PR contains app code using a new column, has the migration been deployed and verified for every tenant? - [ ] Is there a # Target: PUBLIC or # Target: TENANT comment at the top?

Database triggers

Migration: 0002_db_triggers.py — Target: PUBLIC (creates functions), then 0003_tenant_audit_log.py creates the audit_log table per tenant schema.

-- updated_at trigger function — created once in public schema, visible to all schemas
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Unified audit trigger function (ADR-006 + ADR-010)
-- Writes to a single audit_log table per tenant schema, partitioned monthly.
-- Reads actor from session settings (set by get_db) — NOT from updated_by.
-- This correctly captures bulk ORM updates, background jobs, and system actors
-- that do not set updated_by. See ADR-010.
CREATE OR REPLACE FUNCTION public.audit_entity_change()
RETURNS TRIGGER AS $$
DECLARE
    v_actor_id    TEXT := current_setting('app.user_id', true);
    v_identity_id TEXT := current_setting('app.identity_id', true);
    v_request_id  TEXT := current_setting('app.request_id', true);
    v_actor_type  TEXT := coalesce(current_setting('app.actor_type', true), 'unknown');
    v_entity_id   UUID;
    v_before      JSONB;
    v_after       JSONB;
BEGIN
    IF TG_OP = 'DELETE' THEN
        v_entity_id := OLD.id;
        v_before := to_jsonb(OLD);
        v_after := NULL;
    ELSIF TG_OP = 'INSERT' THEN
        v_entity_id := NEW.id;
        v_before := NULL;
        v_after := to_jsonb(NEW);
    ELSE
        v_entity_id := NEW.id;
        v_before := to_jsonb(OLD);
        v_after := to_jsonb(NEW);
    END IF;

    INSERT INTO audit_log (
        entity_type, entity_id, operation,
        actor_id, identity_id, request_id, actor_type,
        before_data, after_data, changed_at
    ) VALUES (
        TG_TABLE_NAME, v_entity_id, TG_OP,
        v_actor_id::UUID, v_identity_id::UUID, v_request_id::UUID, v_actor_type,
        v_before, v_after, NOW()
    );

    IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END;
$$ LANGUAGE plpgsql;

Migration: 0003_tenant_audit_log.py — Target: TENANT

Creates the unified audit_log table in each tenant schema, partitioned monthly. No per-entity _audit tables. All entities write to this one table.

-- Parent table (partitioned by changed_at, monthly range)
CREATE TABLE audit_log (
    id           UUID NOT NULL DEFAULT gen_random_uuid(),
    entity_type  VARCHAR(100) NOT NULL,
    entity_id    UUID NOT NULL,
    operation    VARCHAR(10) NOT NULL,   -- INSERT | UPDATE | DELETE
    actor_id     UUID NULL,              -- user_id from session (NULL for system)
    identity_id  UUID NULL,              -- identity_id from session
    request_id   UUID NULL,
    actor_type   VARCHAR(20) NOT NULL DEFAULT 'unknown',
    before_data  JSONB NULL,
    after_data   JSONB NULL,
    changed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (changed_at);

-- Bootstrap: create current and next month's partitions at migration time.
-- pg_cron job (see below) creates future partitions monthly.
CREATE TABLE audit_log_2026_05 PARTITION OF audit_log
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE audit_log_2026_06 PARTITION OF audit_log
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id, changed_at);
CREATE INDEX idx_audit_log_actor  ON audit_log(actor_id, changed_at);

manage_audit_partitions() pg_cron job (runs monthly, creates next month's partition and drops partitions older than tenant.retention_days). Detached partitions are archived to s3://construo-audit-archive/{tenant_id}/.

Each module migration creates the entity table and attaches both triggers. Pattern (applied in every tenant module migration — no _audit table created):

-- Attach updated_at trigger
CREATE TRIGGER trg_{table}_updated_at
    BEFORE UPDATE ON {table}
    FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();

-- Attach audit trigger (writes to audit_log, not a per-entity table)
CREATE TRIGGER trg_{table}_audit
    AFTER INSERT OR UPDATE OR DELETE ON {table}
    FOR EACH ROW EXECUTE FUNCTION public.audit_entity_change();

Layer 3 — Public Schema Tables

Migration: 0001_public_schema_foundation.py — Target: PUBLIC

public.tenants

CREATE TABLE public.tenants (
    id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    slug                  VARCHAR(63) UNIQUE NOT NULL,
    name                  VARCHAR(255) NOT NULL,
    schema_name           VARCHAR(63) UNIQUE NOT NULL,
    region                VARCHAR(20) NOT NULL DEFAULT 'eu-west-2',
    plan                  VARCHAR(20) NOT NULL DEFAULT 'starter',
    status                VARCHAR(20) NOT NULL DEFAULT 'trial',
    idp_type              VARCHAR(20) NULL,
    idp_config_encrypted  BYTEA NULL,          -- KMS envelope-encrypted (ADR-009)
    idp_config_key_id     VARCHAR(100) NULL,   -- KMS key ARN used to wrap
    retention_days        INTEGER NOT NULL DEFAULT 2555,
    modules_enabled       TEXT[] NOT NULL DEFAULT '{}',
    encrypted_dek         BYTEA NULL,          -- per-tenant DEK, KMS-wrapped (ADR-020)
    dek_key_id            VARCHAR(100) NULL,   -- KMS CMK ARN that wraps the DEK
    created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at            TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Schema naming convention: t_{slug} — prefix avoids PostgreSQL reserved word collisions (e.g. a tenant called "default" or "public" would break without prefix).

idp_config is stored encrypted (ADR-009). Never store SAML signing certificates or client secrets in plaintext. Use lookup_tenant() SECURITY DEFINER function (below) for middleware lookups rather than direct table reads.

public.identities (ADR-004)

One row per human. Decoupled from Cognito sub — supports SAML, OIDC, future federation. A person who belongs to two tenants has one identity and two tenant_memberships rows.

CREATE TABLE public.identities (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    cognito_sub     VARCHAR(255) UNIQUE NOT NULL,
    email           VARCHAR(255) NOT NULL,
    display_name    VARCHAR(255) NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_identities_email ON public.identities(email);

public.tenant_memberships (ADR-004)

Maps identities to tenants. Replaces the implicit "one user, one tenant" assumption.

CREATE TABLE public.tenant_memberships (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    identity_id UUID NOT NULL REFERENCES public.identities(id),
    tenant_id   UUID NOT NULL REFERENCES public.tenants(id),
    roles       TEXT[] NOT NULL DEFAULT '{}',
    status      VARCHAR(20) NOT NULL DEFAULT 'active',  -- active | suspended | offboarded
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (identity_id, tenant_id)
);
CREATE INDEX idx_memberships_tenant ON public.tenant_memberships(tenant_id)
    WHERE status = 'active';
CREATE INDEX idx_memberships_identity ON public.tenant_memberships(identity_id);

public.user_project_access (ADR-003)

Runtime source of truth for project-level access. Replaces project_access JWT claim. Checked live (Redis-cached, 60s TTL) on every request to a project-scoped route.

CREATE TABLE public.user_project_access (
    user_id     UUID NOT NULL,
    tenant_id   UUID NOT NULL REFERENCES public.tenants(id),
    project_id  UUID NOT NULL,
    role        VARCHAR(50) NOT NULL,
    granted_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    granted_by  UUID NOT NULL,
    revoked_at  TIMESTAMPTZ NULL,
    PRIMARY KEY (user_id, tenant_id, project_id)
);
CREATE INDEX idx_upa_user_active
    ON public.user_project_access(user_id, tenant_id)
    WHERE revoked_at IS NULL;

Note: project_id is a foreign key to the tenant-schema projects table. Cross-schema FKs are not supported in Postgres. Enforce in application code.

Every write to this table must go through grant_project_access() or revoke_project_access() in core/access.py — never directly — to ensure cache invalidation. See Layer 5.

public.tenant_migrations (ADR-001)

Operations-side source of truth for per-tenant migration state. Separate from Alembic's per-schema alembic_version. The latter says "what schema version is this schema at"; this table says "what did we try, when, and what happened."

CREATE TABLE public.tenant_migrations (
    tenant_id       UUID NOT NULL REFERENCES public.tenants(id),
    revision        VARCHAR(40) NOT NULL,
    direction       VARCHAR(10) NOT NULL,   -- 'upgrade' | 'downgrade'
    started_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    completed_at    TIMESTAMPTZ NULL,
    error_message   TEXT NULL,
    attempt         INTEGER NOT NULL DEFAULT 1,
    PRIMARY KEY (tenant_id, revision, attempt)
);
CREATE INDEX idx_tenant_migrations_pending
    ON public.tenant_migrations(tenant_id)
    WHERE completed_at IS NULL;

public.api_key_index (ADR-009)

Fast lookup path for API key authentication. Detail in tenant schema.

CREATE TABLE public.api_key_index (
    key_hash    VARCHAR(64) PRIMARY KEY,  -- SHA-256
    tenant_id   UUID NOT NULL REFERENCES public.tenants(id),
    revoked_at  TIMESTAMPTZ NULL
);
CREATE INDEX idx_api_key_index_active ON public.api_key_index(key_hash)
    WHERE revoked_at IS NULL;

public.platform_admin_actions (ADR-007 M6)

Audit log for cross-tenant operations performed by platform admins.

CREATE TABLE public.platform_admin_actions (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    actor_id      UUID NOT NULL,
    action        VARCHAR(100) NOT NULL,
    target_tenant_id UUID NULL REFERENCES public.tenants(id),
    details       JSONB NOT NULL DEFAULT '{}',
    performed_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_platform_admin_actor ON public.platform_admin_actions(actor_id);

Tenant context lookup function (ADR-009)

Middleware does not query public.tenants directly. It calls this SECURITY DEFINER function, which returns only the non-sensitive columns needed for request routing.

CREATE OR REPLACE FUNCTION public.lookup_tenant(p_slug VARCHAR)
RETURNS TABLE (
    id UUID, slug VARCHAR, schema_name VARCHAR, status VARCHAR,
    plan VARCHAR, modules_enabled TEXT[], region VARCHAR
)
LANGUAGE sql SECURITY DEFINER STABLE AS $$
    SELECT id, slug, schema_name, status, plan, modules_enabled, region
    FROM public.tenants
    WHERE slug = p_slug AND status != 'offboarded';
$$;

Per-tenant tables in tenant schema (ADR-009)

labels and field_definitions tables live in the tenant schema, not public. They have no tenant_id column — isolation comes from schema, not discriminator.

Migration: 0003_tenant_base_tables.py — Target: TENANT

-- Tenant-specific display label overrides
CREATE TABLE labels (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    label_key   VARCHAR(100) UNIQUE NOT NULL,
    label_value VARCHAR(255) NOT NULL
);

-- Tenant-defined custom fields for any entity type
CREATE TABLE field_definitions (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    entity_type    VARCHAR(50) NOT NULL,
    field_key      VARCHAR(100) NOT NULL,
    label          VARCHAR(255) NOT NULL,
    field_type     VARCHAR(20) NOT NULL,
    is_required    BOOLEAN NOT NULL DEFAULT false,
    options        JSONB NULL,
    display_order  INTEGER NOT NULL DEFAULT 0,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (entity_type, field_key)
);
CREATE INDEX idx_field_defs_entity ON field_definitions(entity_type);

Redis cache keys for labels and field definitions must use tenant_id UUID (from current_tenant.get().id), never the URL slug. ADR-009.

Tenant provisioning

File: apps/api/src/core/provisioning.py

Creates schema, per-tenant Postgres role, DEK, seeds defaults, runs migrations. Must complete atomically — if any step fails, the whole provisioning fails.

import boto3
from .tenant import validate_schema_name

async def provision_tenant(
    db: AsyncSession,
    slug: str,
    name: str,
    plan: str = "starter",
    region: str = "eu-west-2",
) -> Tenant:
    schema_name = f"t_{slug}"
    role_name = f"r_{slug}"
    validate_schema_name(schema_name)  # ADR-007 M1

    # Generate per-tenant DEK (ADR-020)
    kms = boto3.client("kms", region_name=region)
    dek_response = kms.generate_data_key(
        KeyId=settings.kms_tenant_key_arn,
        KeySpec="AES_256",
    )
    encrypted_dek = dek_response["CiphertextBlob"]
    dek_key_id = settings.kms_tenant_key_arn

    async with db.begin():
        tenant = Tenant(
            slug=slug, name=name, schema_name=schema_name,
            plan=plan, region=region,
            encrypted_dek=encrypted_dek, dek_key_id=dek_key_id,
        )
        db.add(tenant)
        await db.flush()

        # Create tenant schema
        await db.execute(text(f'CREATE SCHEMA "{schema_name}"'))

        # Create per-tenant Postgres role (ADR-007 M2)
        # NOLOGIN — assumed via SET LOCAL ROLE by the application, never used directly
        await db.execute(text(f'CREATE ROLE "{role_name}" NOLOGIN'))
        await db.execute(text(
            f'GRANT USAGE ON SCHEMA "{schema_name}" TO "{role_name}"'
        ))
        await db.execute(text(
            f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{schema_name}" '
            f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "{role_name}"'
        ))
        # Allow the app's main DB user to assume this role via SET LOCAL ROLE
        await db.execute(text(
            f'GRANT "{role_name}" TO "{settings.app_db_user}"'
        ))

        # Per-tenant resource limits (ADR-008)
        # These apply when connected as the tenant role
        timeouts = PLAN_TIMEOUTS[plan]  # e.g. {"statement": "10s", "idle": "30s", "lock": "5s"}
        await db.execute(text(
            f'ALTER ROLE "{role_name}" '
            f'SET statement_timeout = \'{timeouts["statement"]}\' '
            f'SET idle_in_transaction_session_timeout = \'{timeouts["idle"]}\' '
            f'SET lock_timeout = \'{timeouts["lock"]}\''
        ))

        # Run tenant migrations via standalone runner (ADR-001)
        await _run_tenant_migrations(schema_name, role_name)
        # Seed defaults into tenant schema
        await _seed_default_labels(db, tenant.id)
        await _seed_field_definitions(db, tenant.id)

    return tenant

PLAN_TIMEOUTS: dict[str, dict[str, str]] = {
    "trial":        {"statement": "10s",  "idle": "30s", "lock": "5s"},
    "starter":      {"statement": "10s",  "idle": "30s", "lock": "5s"},
    "professional": {"statement": "30s",  "idle": "60s", "lock": "10s"},
    "enterprise":   {"statement": "60s",  "idle": "120s","lock": "15s"},
}

Layer 4 — Tenant Context

Context variables (ADR-007 M4)

File: apps/api/src/core/context.py

Tenant, user, and request identity are propagated via Python contextvars.ContextVar, not request.state. Reasons: request.state is mutable (any code can overwrite it), not available to background jobs or Lambdas, and cannot be reset safely.

from contextvars import ContextVar
from uuid import UUID

# Type annotations use forward references to avoid circular imports
current_tenant: ContextVar["Tenant | None"] = ContextVar("current_tenant", default=None)
current_user: ContextVar["CurrentUser | None"] = ContextVar("current_user", default=None)
current_request_id: ContextVar["UUID | None"] = ContextVar("current_request_id", default=None)

Reset pattern (mandatory): Always use token = var.set(value) and var.reset(token) in a try/finally block. Never use var.set(None) to clear — that changes the value but doesn't restore the previous binding.

Schema name validator (ADR-007 M1)

File: apps/api/src/core/tenant.py

import re

_SCHEMA_NAME_RE = re.compile(r"^t_[a-z0-9_]{1,40}$")

def validate_schema_name(name: str) -> str:
    """
    Validates a tenant schema name against the allowed pattern.
    Called at every boundary where a schema name is used in dynamic SQL.
    Guards against injection if a slug ever reaches provisioning with bad characters.
    """
    if not _SCHEMA_NAME_RE.match(name):
        raise ValueError(f"Invalid schema name: {name!r}")
    return name

Tenant middleware

File: apps/api/src/core/middleware.py

from starlette.middleware.base import BaseHTTPMiddleware
from .context import current_tenant, current_request_id
from .tenant import validate_schema_name
import uuid

class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        request_id = uuid.uuid4()
        token = current_request_id.set(request_id)
        structlog.contextvars.bind_contextvars(
            request_id=str(request_id),
            path=request.url.path,
            method=request.method,
        )
        try:
            response = await call_next(request)
            response.headers["X-Request-ID"] = str(request_id)
            return response
        finally:
            current_request_id.reset(token)
            structlog.contextvars.clear_contextvars()

class TenantMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.url.path == "/health":
            return await call_next(request)

        host = request.headers.get("host", "").split(":")[0]
        slug = _extract_slug(host)
        if slug is None:
            return JSONResponse(
                {"error": "not_found", "message": "Unknown host"}, status_code=404
            )

        tenant = await _load_tenant(slug, request.app.state.redis)
        if tenant is None:
            return JSONResponse(
                {"error": "not_found", "message": "Tenant not found"}, status_code=404
            )

        # Validate schema name at the boundary (ADR-007 M1)
        try:
            validate_schema_name(tenant.schema_name)
        except ValueError:
            return JSONResponse(
                {"error": "internal_error", "message": "Invalid tenant configuration"},
                status_code=500,
            )

        token = current_tenant.set(tenant)
        structlog.contextvars.bind_contextvars(tenant_slug=tenant.slug)
        try:
            return await call_next(request)
        finally:
            current_tenant.reset(token)

def _extract_slug(host: str) -> str | None:
    """
    'acme.construo.build' → 'acme'
    'acme.local.test'     → 'acme'   (local dev)
    'localhost'           → None
    'construo.build'      → None     (no subdomain)
    """
    parts = host.split(".")
    if len(parts) < 2:
        return None
    slug = parts[0]
    return slug if slug else None

async def _load_tenant(slug: str, redis: Redis) -> Tenant | None:
    cached = await redis.get(f"tenant:{slug}")
    if cached:
        return Tenant.model_validate_json(cached)

    # Use lookup_tenant() SECURITY DEFINER function — not direct table read (ADR-009)
    async with AsyncSessionLocal() as db:
        result = await db.execute(
            text("SELECT * FROM public.lookup_tenant(:slug)"), {"slug": slug}
        )
        row = result.mappings().one_or_none()

    if row:
        tenant = Tenant(**row)
        await redis.setex(
            f"tenant:{tenant.id}:{slug}", 300, tenant.model_dump_json()
        )
        return tenant
    return None

Assertion middleware (ADR-007 M5)

Best-effort post-response check. The real safety is the Postgres role — but this catches accidental context mutation in application code.

class TenantAssertionMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        if request.url.path != "/health":
            # If we set a tenant at the start, it should still be set at the end.
            # If it's None here, middleware failed to reset — context leaked.
            tenant = current_tenant.get()
            if tenant is None:
                logger.error("tenant_context_missing_after_request",
                             path=request.url.path)
        return response

Background job tenant context (ADR-007 M7)

Background jobs and Lambdas that need tenant context use this helper. Never bypass this — always set context before acquiring a DB session.

async def run_with_tenant(tenant_id: UUID, fn: "Callable") -> None:
    """Sets tenant context for a background job, then calls fn."""
    tenant = await load_tenant_by_id(tenant_id)
    token_t = current_tenant.set(tenant)
    token_r = current_request_id.set(uuid.uuid4())
    try:
        await fn()
    finally:
        current_tenant.reset(token_t)
        current_request_id.reset(token_r)

async def run_as_system(tenant_id: UUID, reason: str, fn: "Callable") -> None:
    """
    Like run_with_tenant but sets actor_type='system' for audit.
    Use for background jobs, scanners, and auto-actions (ADR-010).
    """
    from .context import current_user
    # current_user left as None — get_db will set actor_type='system'
    await run_with_tenant(tenant_id, fn)

Platform admin escape hatch (ADR-007 M6)

A small number of operations need cross-tenant or superuser access. These must use this factory — never get_db — and must provide a reason string for the audit.

async def get_platform_admin_db(
    user: "CurrentUser",
    action: str,
    target_tenant_id: UUID | None,
    details: dict,
) -> "AsyncGenerator[AsyncSession, None]":
    """
    Yields a session connected as the platform superuser (bypasses per-tenant role).
    Requires caller to have platform_admin role. Writes audit row before yielding.
    """
    if "platform_admin" not in user.roles:
        raise ForbiddenError("platform_admin role required")

    async with PlatformAdminSessionLocal() as session:
        # Pre-write audit before the operation (ADR-007 M6)
        await session.execute(
            text("""
                INSERT INTO public.platform_admin_actions
                    (actor_id, action, target_tenant_id, details)
                VALUES (:aid, :action, :tid, :details::jsonb)
            """),
            {"aid": str(user.id), "action": action,
             "tid": str(target_tenant_id) if target_tenant_id else None,
             "details": json.dumps(details)},
        )
        yield session

Redis client

File: apps/api/src/core/redis.py

from redis.asyncio import Redis, from_url

async def init_redis() -> Redis:
    return await from_url(settings.redis_url, encoding="utf-8", decode_responses=False)

def get_redis(request: Request) -> Redis:
    return request.app.state.redis

Tenant labels helper

File: apps/api/src/core/labels.py

Labels and field_definitions now live in the tenant schema. Cache key uses tenant_id UUID from the contextvar — never from the URL slug (ADR-009).

async def get_label(key: str, redis: Redis, db: AsyncSession) -> str:
    """Returns tenant label override or falls back to DEFAULT_LABELS."""
    tenant = current_tenant.get()
    cache_key = f"labels:{tenant.id}:{key}"   # UUID key, not slug
    cached = await redis.get(cache_key)
    if cached:
        return cached.decode()

    # Labels table is in the tenant schema — get_db already set search_path
    result = await db.execute(
        select(Label.label_value).where(Label.label_key == key)
    )
    value = result.scalar_one_or_none()
    if value:
        await redis.setex(cache_key, 300, value)
        return value

    return DEFAULT_LABELS.get(key, key)

File: apps/api/src/core/defaults.py

Contains DEFAULT_LABELS dict with all known label keys and their English defaults, and DEFAULT_FIELD_DEFINITIONS for seeding new tenants.


Layer 5 — Authentication

Cognito Lambda trigger (ADR-003, ADR-004, ADR-013)

The Lambda trigger runs on every Cognito Pre-Token-Generation event and adds custom claims to the JWT.

What it does: - Looks up the tenant from the Cognito app client ID (one client per tenant) - Verifies the identity has an active membership for that tenant - Adds custom:tenant_id, custom:tenant_slug, custom:identity_id - Adds custom:platform_roles as a native Cognito list claim (not JSON string)

What it does NOT do (ADR-003): - Does not embed project_access — checked live via Redis cache instead - Does not call get_project_access() — removed entirely

File: infra/lambda/cognito_pre_token/handler.py

import boto3
from construo_shared import (
    get_tenant_for_client_id,
    get_membership,
    get_user_roles,
)
# construo_shared is a Lambda layer containing DB query utilities.
# Lambda connects to RDS via VPC ENI using IAM role — no hardcoded credentials.
# Results are cached in Lambda memory with a short TTL to reduce DB load (ADR-013).

_tenant_cache: dict = {}

def handler(event: dict, context) -> dict:
    """
    Cognito Pre-Token-Generation V2 trigger.
    Adds custom claims to the JWT before it is issued.
    """
    client_id = event["callerContext"]["clientId"]
    user_sub = event["userName"]

    # Resolve tenant from client ID (cached) — ADR-004, ADR-013
    tenant = _tenant_cache.get(client_id) or get_tenant_for_client_id(client_id)
    _tenant_cache[client_id] = tenant

    # Verify active membership
    membership = get_membership(user_sub, tenant.id)
    if not membership or membership.status != "active":
        raise Exception("No active membership for this tenant")

    # platform_roles as a native Cognito list claim (not JSON string) — ADR-003 P3
    roles = get_user_roles(user_sub, tenant.id)

    event["response"]["claimsOverrideDetails"] = {
        "claimsToAddOrOverride": {
            "custom:tenant_id":    str(tenant.id),
            "custom:tenant_slug":  tenant.slug,
            "custom:identity_id":  str(membership.identity_id),
        },
        # claimsToAddOrOverrideList is Cognito's native list-claim shape (2021+)
        "claimsToAddOrOverrideList": {
            "custom:platform_roles": roles,
        },
    }
    return event

The Lambda is deployed by Terraform alongside the Cognito User Pool. It connects to RDS via a VPC-attached ENI using an IAM role — no hardcoded credentials.

JWT validation

File: apps/api/src/core/auth.py

import httpx
from jose import jwt, JWTError

class CognitoJWTValidator:
    def __init__(self, user_pool_id: str, region: str, client_id: str):
        self.jwks_url = (
            f"https://cognito-idp.{region}.amazonaws.com"
            f"/{user_pool_id}/.well-known/jwks.json"
        )
        self.client_id = client_id
        self._jwks: dict | None = None

    async def fetch_jwks(self) -> None:
        async with httpx.AsyncClient() as client:
            r = await client.get(self.jwks_url)
            r.raise_for_status()
            self._jwks = r.json()

    async def validate(self, token: str) -> dict:
        if self._jwks is None:
            await self.fetch_jwks()
        try:
            return jwt.decode(
                token, self._jwks, algorithms=["RS256"], audience=self.client_id
            )
        except JWTError as e:
            raise UnauthorisedError(str(e))

JWKS is fetched once on startup (in lifespan) and reused. If validation fails due to key rotation, re-fetch and retry once before raising.

CurrentUser and dependencies

File: apps/api/src/core/security.py

project_access is NOT on CurrentUser (ADR-003). It is checked live via require_project_access(). CurrentUser carries identity and roles only.

from pydantic import BaseModel
from .context import current_user

class CurrentUser(BaseModel):
    id: UUID           # user_id within tenant schema
    identity_id: UUID  # global identity (ADR-004)
    tenant_id: UUID
    tenant_slug: str
    roles: list[str]
    # project_access is NOT here — fetched live via core/access.py (ADR-003)

async def get_current_user(
    request: Request,
    authorization: str = Header(..., alias="Authorization"),
) -> CurrentUser:
    tenant = current_tenant.get()
    if tenant is None:
        raise UnauthorisedError("No tenant context")

    if authorization.startswith("Bearer "):
        token = authorization[7:]
        claims = await request.app.state.jwt_validator.validate(token)
        token_tenant_id = claims.get("custom:tenant_id")
        if str(tenant.id) != token_tenant_id:
            raise UnauthorisedError("Token/tenant mismatch")
        user = CurrentUser(
            id=UUID(claims["sub"]),
            identity_id=UUID(claims["custom:identity_id"]),
            tenant_id=UUID(token_tenant_id),
            tenant_slug=claims["custom:tenant_slug"],
            # platform_roles is now a native Cognito list claim (ADR-003 P3)
            roles=claims.get("custom:platform_roles", []),
        )
        # Bind to contextvar so get_db can set app.user_id in session (ADR-007, ADR-010)
        token_cv = current_user.set(user)
        structlog.contextvars.bind_contextvars(user_id=str(user.id))
        request.state._current_user_token = token_cv  # reset in middleware
        return user

    if authorization.startswith("ApiKey "):
        key = authorization[7:]
        return await _validate_api_key(key, request)

    raise UnauthorisedError("Bearer token or ApiKey required")

def require_permission(permission: str):
    async def _check(
        user: CurrentUser = Depends(get_current_user)
    ) -> CurrentUser:
        if not _has_permission(user.roles, permission):
            raise ForbiddenError(f"Requires {permission}")
        return user
    return _check

Project access service (ADR-003)

File: apps/api/src/core/access.py

from typing import Literal
from uuid import UUID
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession

async def get_project_access(
    user_id: UUID,
    tenant_id: UUID,
    redis: Redis,
    db: AsyncSession,
) -> list[UUID] | Literal["all"]:
    """
    Returns the project UUIDs the user can access, or sentinel 'all'
    if the user is a tenant admin (implicit access to all projects).

    Redis-cached with 60-second TTL. Explicitly invalidated on write to
    user_project_access. ADR-003.
    """
    cache_key = f"upa:{user_id}:{tenant_id}"
    cached = await redis.get(cache_key)
    if cached is not None:
        return _decode_access(cached)

    # Tenant admins implicitly access all projects
    user = await fetch_user(user_id, tenant_id, db)
    if "tenant_admin" in user.roles or "platform_admin" in user.roles:
        await redis.setex(cache_key, 60, "ALL")
        return "all"

    result = await db.execute(
        select(UserProjectAccess.project_id)
        .where(UserProjectAccess.user_id == user_id)
        .where(UserProjectAccess.tenant_id == tenant_id)
        .where(UserProjectAccess.revoked_at.is_(None))
    )
    projects = [row.project_id for row in result.fetchall()]
    await redis.setex(cache_key, 60, _encode_access(projects))
    return projects

async def grant_project_access(
    user_id: UUID, tenant_id: UUID, project_id: UUID,
    role: str, granted_by: UUID, redis: Redis, db: AsyncSession,
) -> None:
    """Insert into user_project_access and invalidate cache. Never write directly."""
    await db.execute(
        insert(UserProjectAccess).values(
            user_id=user_id, tenant_id=tenant_id, project_id=project_id,
            role=role, granted_by=granted_by,
        )
    )
    await redis.delete(f"upa:{user_id}:{tenant_id}")

async def revoke_project_access(
    user_id: UUID, tenant_id: UUID, project_id: UUID,
    redis: Redis, db: AsyncSession,
) -> None:
    """Mark revoked_at and invalidate cache. Never write directly."""
    await db.execute(
        update(UserProjectAccess)
        .where(UserProjectAccess.user_id == user_id)
        .where(UserProjectAccess.tenant_id == tenant_id)
        .where(UserProjectAccess.project_id == project_id)
        .values(revoked_at=func.now())
    )
    await redis.delete(f"upa:{user_id}:{tenant_id}")

def require_project_access(project_id_param: str = "projectId"):
    """
    FastAPI dependency for project-scoped routes.
    Use in place of (or in addition to) require_permission() on any route
    whose URL contains a project or site ID.
    """
    async def _check(
        request: Request,
        user: CurrentUser = Depends(get_current_user),
        redis: Redis = Depends(get_redis),
        db: AsyncSession = Depends(get_db),
    ) -> CurrentUser:
        project_id = UUID(request.path_params[project_id_param])
        access = await get_project_access(user.id, user.tenant_id, redis, db)
        if access != "all" and project_id not in access:
            raise ForbiddenError("No access to this project")
        return user
    return _check

Auth session endpoints (ADR-005)

File: apps/api/src/core/auth_endpoints.py

Refresh token is stored in an HttpOnly Secure SameSite=Strict cookie on api.construo.build. Cookie domain .construo.build is shared across subdomains.

@router.post("/auth/exchange")
async def exchange_code(code: str, redirect_uri: str) -> dict:
    """
    Exchange Cognito authorisation code for tokens.
    Access token returned in response body (stored in memory by React).
    Refresh token set as HttpOnly cookie — never accessible to JavaScript.
    """
    tokens = await cognito_exchange_code(code, redirect_uri)
    response = JSONResponse({"access_token": tokens.access_token})
    response.set_cookie(
        key="refresh_token",
        value=tokens.refresh_token,
        httponly=True,
        secure=True,
        samesite="strict",
        domain=".construo.build",
        max_age=30 * 24 * 3600,  # 30 days
    )
    return response

@router.post("/auth/refresh")
async def refresh_session(
    request: Request,
    x_csrf_token: str = Header(...),  # double-submit CSRF protection
) -> dict:
    """Silent token refresh. Refresh token read from cookie, not request body."""
    refresh_token = request.cookies.get("refresh_token")
    if not refresh_token:
        raise UnauthorisedError("No refresh token")
    tokens = await cognito_refresh(refresh_token)
    return {"access_token": tokens.access_token}

@router.post("/auth/logout")
async def logout(request: Request) -> dict:
    response = JSONResponse({"ok": True})
    response.delete_cookie("refresh_token", domain=".construo.build")
    return response

@router.post("/auth/offline-session")
async def issue_offline_session(
    user: CurrentUser = Depends(get_current_user),
) -> dict:
    """
    Issues a 12-hour offline session token for use when network is unavailable.
    Token is stored in IndexedDB by the React app (not in memory or localStorage).
    API accepts 'Authorization: Offline <token>' header.
    Available operations offline: attendance write, site diary, incidents, photo queue.
    NOT available offline: personnel CRUD, NI/share code read.
    """
    token = generate_offline_session_token(user, ttl_hours=12)
    return {"offline_token": token, "expires_in": 12 * 3600}

File: apps/api/src/core/permissions.py

ROLE_PERMISSIONS: dict[str, set[str]] = {
    "platform_admin":  {"*"},
    "tenant_admin":    {
        "projects:read", "projects:write", "projects:admin",
        "sites:read", "sites:write", "sites:admin",
        "personnel:read", "personnel:write", "personnel:admin",
        # ... full set
    },
    "project_manager": {
        "projects:read", "projects:write",
        "sites:read", "sites:write",
        "personnel:read",
        # ... scoped set
    },
    "site_manager":    {
        "projects:read",
        "sites:read", "sites:write",
        "personnel:read",
        # ... minimal set
    },
    "site_operative":  {"projects:read", "sites:read"},
    "viewer":          {"projects:read", "sites:read"},
    "integration":     {"projects:read", "sites:read"},  # further scoped by API key scopes
}

def _has_permission(roles: list[str], permission: str) -> bool:
    for role in roles:
        perms = ROLE_PERMISSIONS.get(role, set())
        if "*" in perms or permission in perms:
            return True
    return False

Encrypted field type (ADR-020)

File: apps/api/src/core/encryption.py

Per-tenant envelope encryption. Each tenant has a DEK (Data Encryption Key) stored KMS-encrypted in public.tenants.encrypted_dek. The DEK is fetched at startup and cached in process memory — never in Redis. Ciphertext is version-tagged so rotation can re-encrypt lazily on read.

import base64, struct
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import boto3

# In-process DEK cache: {tenant_id: (key_version, dek_bytes)}
_dek_cache: dict[str, tuple[int, bytes]] = {}

def get_tenant_dek(tenant_id: str) -> tuple[int, bytes]:
    if tenant_id in _dek_cache:
        return _dek_cache[tenant_id]
    # Fetch encrypted_dek from DB (one query at startup per tenant)
    tenant = load_tenant_by_id(tenant_id)
    kms = boto3.client("kms")
    dek = kms.decrypt(CiphertextBlob=tenant.encrypted_dek)["Plaintext"]
    version = 1  # increment on key rotation
    _dek_cache[tenant_id] = (version, dek)
    return version, dek

def encrypt_field(tenant_id: str, plaintext: str) -> str:
    """
    Returns version-tagged ciphertext: '{key_version}:{base64_ciphertext}'
    AES-256-GCM with random 96-bit nonce prepended to ciphertext.
    """
    version, dek = get_tenant_dek(tenant_id)
    aesgcm = AESGCM(dek)
    nonce = os.urandom(12)
    ct = aesgcm.encrypt(nonce, plaintext.encode(), None)
    payload = base64.b64encode(nonce + ct).decode()
    return f"{version}:{payload}"

def decrypt_field(tenant_id: str, ciphertext: str) -> str:
    version_str, payload = ciphertext.split(":", 1)
    _, dek = get_tenant_dek(tenant_id)  # use current DEK (rotation re-encrypts lazily)
    raw = base64.b64decode(payload)
    nonce, ct = raw[:12], raw[12:]
    return AESGCM(dek).decrypt(nonce, ct, None).decode()

SQLAlchemy TypeDecorator wraps encrypt_field/decrypt_field and is applied to: - personnel.ni_number - personnel.share_code - (Future: bank sort codes, DOB for RTW verification)

Encrypted fields are never included in PowerSync sync rules and never appear in API response bodies. ADR-020.


Layer 6 — FastAPI Application

Application factory

File: apps/api/src/main.py

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.redis = await init_redis()
    app.state.jwt_validator = CognitoJWTValidator(
        settings.cognito_user_pool_id,
        settings.cognito_region,
        settings.cognito_client_id,
    )
    await app.state.jwt_validator.fetch_jwks()

    if settings.sentry_dsn:
        sentry_sdk.init(dsn=settings.sentry_dsn, environment=settings.environment)

    yield

    await app.state.redis.aclose()

app = FastAPI(title="Construo API", version="1.0.0", lifespan=lifespan)

# Middleware registration order — outermost executes first on request, last on response.
# add_middleware() inserts at the outermost position, so register innermost first.
# Execution order on request: CORS → APIVersion → RequestID → Tenant → Assertion
# Execution order on response: Assertion → Tenant → RequestID → APIVersion → CORS
app.add_middleware(TenantAssertionMiddleware)  # innermost — runs after route handler
app.add_middleware(TenantMiddleware)           # sets current_tenant contextvar
app.add_middleware(RequestIDMiddleware)        # sets current_request_id contextvar
app.add_middleware(APIVersionMiddleware)
app.add_middleware(CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_methods=["*"],
    allow_headers=["*", "API-Version"],
    expose_headers=["API-Version"],
)
# Note: auth endpoints (/auth/exchange, /auth/refresh, /auth/logout, /auth/offline-session)
# are registered at Layer 5. Include the router here:
app.include_router(auth_router, prefix="")

register_exception_handlers(app)

# Routers registered here as modules are built
app.include_router(health_router)

API version middleware

File: apps/api/src/core/middleware.py

class APIVersionMiddleware(BaseHTTPMiddleware):
    CURRENT = 1
    SUPPORTED = {1}

    async def dispatch(self, request: Request, call_next):
        raw = request.headers.get("api-version")
        if raw is None:
            logger.warning("missing_api_version_header", path=request.url.path)
            version = self.CURRENT
        else:
            try:
                version = int(raw)
            except ValueError:
                return JSONResponse(
                    {"error": "validation_failed", "message": "Invalid API-Version"},
                    status_code=400,
                )
            if version not in self.SUPPORTED:
                return JSONResponse(
                    {"error": "validation_failed",
                     "message": f"Unsupported API version: {version}"},
                    status_code=400,
                )

        request.state.api_version = version
        response = await call_next(request)
        response.headers["API-Version"] = str(version)
        return response

Exception hierarchy

File: apps/api/src/core/exceptions.py

class ConstruoError(Exception):
    status_code: int = 500
    error_code: str = "internal_error"

class NotFoundError(ConstruoError):
    status_code = 404; error_code = "not_found"

class UnauthorisedError(ConstruoError):
    status_code = 401; error_code = "unauthorised"

class ForbiddenError(ConstruoError):
    status_code = 403; error_code = "forbidden"

class ConflictError(ConstruoError):
    status_code = 409; error_code = "conflict"

class ValidationError(ConstruoError):
    status_code = 422; error_code = "validation_failed"
    def __init__(self, message: str, field: str | None = None):
        super().__init__(message)
        self.field = field

class RateLimitedError(ConstruoError):
    status_code = 429; error_code = "rate_limited"

def register_exception_handlers(app: FastAPI) -> None:
    @app.exception_handler(ConstruoError)
    async def handle_construo(request, exc: ConstruoError):
        body = {"error": exc.error_code, "message": str(exc)}
        if isinstance(exc, ValidationError) and exc.field:
            body["field"] = exc.field
        return JSONResponse(body, status_code=exc.status_code)

    @app.exception_handler(RequestValidationError)
    async def handle_pydantic(request, exc: RequestValidationError):
        err = exc.errors()[0]
        field = ".".join(str(l) for l in err["loc"] if l != "body")
        return JSONResponse(
            {"error": "validation_failed", "message": err["msg"], "field": field},
            status_code=422,
        )

Pagination

File: apps/api/src/core/pagination.py

import base64, json
from typing import TypeVar, Generic
from pydantic import BaseModel, Field

T = TypeVar("T")

class PaginationParams(BaseModel):
    limit: int = Field(default=20, ge=1, le=100)
    cursor: str | None = None
    sort: str = "-created_at"

class PaginationMeta(BaseModel):
    total: int
    cursor: str | None
    has_more: bool

class PaginatedResponse(BaseModel, Generic[T]):
    data: list[T]
    pagination: PaginationMeta

def encode_cursor(values: dict) -> str:
    return base64.urlsafe_b64encode(json.dumps(values).encode()).decode()

def decode_cursor(cursor: str) -> dict:
    try:
        return json.loads(base64.urlsafe_b64decode(cursor.encode()))
    except Exception:
        raise ValidationError("Invalid cursor")

Configuration

File: apps/api/src/core/config.py

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    database_url_direct: str
    redis_url: str
    cognito_user_pool_id: str
    cognito_region: str
    cognito_client_id: str
    cors_origins: list[str]
    environment: str = "dev"
    db_echo: bool = False
    sentry_dsn: str | None = None

    class Config:
        env_file = ".env"

settings = Settings()

ECS task definition — Secrets Manager injection

In production, environment variables come from AWS Secrets Manager via the ECS task definition. No .env file exists on the container.

// In Terraform ecs_service module — task definition secrets
"secrets": [
  {"name": "DATABASE_URL",            "valueFrom": "arn:aws:secretsmanager:...:construo/prod/database_url"},
  {"name": "DATABASE_URL_DIRECT",     "valueFrom": "arn:aws:secretsmanager:...:construo/prod/database_url_direct"},
  {"name": "REDIS_URL",               "valueFrom": "arn:aws:secretsmanager:...:construo/prod/redis_url"},
  {"name": "COGNITO_USER_POOL_ID",    "valueFrom": "arn:aws:secretsmanager:...:construo/prod/cognito_user_pool_id"},
  {"name": "COGNITO_CLIENT_ID",       "valueFrom": "arn:aws:secretsmanager:...:construo/prod/cognito_client_id"}
]

The ECS execution role must have secretsmanager:GetSecretValue permission on the specific secret ARNs.


Layer 7 — Observability

Structured logging

File: apps/api/src/core/logging.py

Use structlog for structured JSON logging. Every log entry includes: timestamp, level, event, environment, tenant_id, user_id, request_id, path, method.

import structlog
import uuid

def configure_logging() -> None:
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,
            structlog.stdlib.add_log_level,
            structlog.stdlib.add_logger_name,
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.JSONRenderer(),
        ],
        wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
        context_class=dict,
        logger_factory=structlog.PrintLoggerFactory(),
    )

logger = structlog.get_logger()

Request ID middleware — adds a UUID to every request and binds it to the structlog context so all log entries within a request share the same request_id:

class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        request_id = str(uuid.uuid4())
        structlog.contextvars.bind_contextvars(
            request_id=request_id,
            path=request.url.path,
            method=request.method,
        )
        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id
        structlog.contextvars.clear_contextvars()
        return response

After tenant and auth middleware run, bind tenant and user to context:

# In TenantMiddleware after tenant is resolved:
structlog.contextvars.bind_contextvars(tenant_slug=tenant.slug)

# In get_current_user after token is validated:
structlog.contextvars.bind_contextvars(user_id=str(user.id))

Sentry

Initialised in lifespan when SENTRY_DSN is set. FastAPI integration captures unhandled exceptions automatically. Custom errors that inherit from ConstruoError are handled exceptions — do not send to Sentry unless they are 500-level.

sentry_sdk.init(
    dsn=settings.sentry_dsn,
    environment=settings.environment,
    traces_sample_rate=0.1,   # 10% of requests traced
    profiles_sample_rate=0.1,
    integrations=[FastApiIntegration()],
    before_send=lambda event, hint: (
        None if event.get("level") != "error" else event
    ),
)

Add to pyproject.toml dependencies: structlog>=24.0.0


Layer 8 — Test Harness

conftest.py

File: apps/api/tests/conftest.py

import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.pool import NullPool

TEST_DB_URL = "postgresql+asyncpg://construo:construo@localhost:5432/construo_test"

# Fixed UUIDs for deterministic tests
ACME_TENANT_ID = UUID("10000000-0000-0000-0000-000000000001")
BETA_TENANT_ID = UUID("10000000-0000-0000-0000-000000000002")

TEST_USERS = {
    "acme": {
        "tenant_admin":    UUID("00000001-0000-0000-0000-000000000001"),
        "project_manager": UUID("00000001-0000-0000-0000-000000000002"),
        "site_manager":    UUID("00000001-0000-0000-0000-000000000003"),
        "site_operative":  UUID("00000001-0000-0000-0000-000000000004"),
        "viewer":          UUID("00000001-0000-0000-0000-000000000005"),
    },
    "beta": {
        "tenant_admin":    UUID("00000002-0000-0000-0000-000000000001"),
        "project_manager": UUID("00000002-0000-0000-0000-000000000002"),
    },
}

@pytest_asyncio.fixture(scope="session")
async def test_tenants():
    """Provisions acme and beta test tenants. Session-scoped — created once."""
    engine = create_async_engine(TEST_DB_URL, poolclass=NullPool)
    async with async_sessionmaker(engine)() as db:
        acme = await provision_tenant(db, "acme", "Acme Construction")
        beta = await provision_tenant(db, "beta", "Beta Construction")
    await engine.dispose()
    return {"acme": acme, "beta": beta}

@pytest_asyncio.fixture
async def client():
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://testserver",
    ) as c:
        yield c

@pytest.fixture
def auth_headers(test_tenants):
    """
    Factory fixture. Usage: auth_headers("acme", "project_manager")
    Returns headers dict with Authorization, Host, and API-Version.
    """
    def _make(tenant_slug: str, role: str) -> dict[str, str]:
        tenant = test_tenants[tenant_slug]
        user_id = TEST_USERS[tenant_slug][role]
        token = _build_test_jwt(
            sub=str(user_id),
            tenant_id=str(tenant.id),
            tenant_slug=tenant_slug,
            roles=[role],
        )
        return {
            "Authorization": f"Bearer {token}",
            "Host": f"{tenant_slug}.local.test",
            "API-Version": "1",
        }
    return _make

JWTs in tests are signed with a test RSA key pair, not issued by Cognito. The validator in tests is configured to accept this test key. Test RSA key pair is generated once and committed to tests/fixtures/. Never use real Cognito in tests.

Cross-tenant user fixture (ADR-004):

# A user with active membership in both acme and beta.
# Used to verify that an acme-scoped session cannot read beta data even when
# the underlying identity has beta membership.
CROSS_TENANT_IDENTITY_ID = UUID("00000003-0000-0000-0000-000000000001")

@pytest_asyncio.fixture(scope="session")
async def cross_tenant_user(test_tenants):
    """Identity with memberships in both acme and beta."""
    async with AsyncSessionLocal() as db:
        identity = await create_identity(db, CROSS_TENANT_IDENTITY_ID, "shared@example.com")
        await create_membership(db, identity.id, test_tenants["acme"].id, roles=["project_manager"])
        await create_membership(db, identity.id, test_tenants["beta"].id, roles=["viewer"])
        await db.commit()
    return identity

Mandatory test: acme-scoped session for this identity cannot read beta data.

Tenant context assertion fixture (ADR-007 M8):

@pytest.fixture
def assert_tenant(test_tenants):
    """
    Asserts that the current_tenant contextvar matches the expected slug.
    Use in any test that exercises tenant context to confirm the context is set.
    """
    def _check(slug: str) -> None:
        t = current_tenant.get()
        assert t is not None, "No tenant context set"
        assert t.slug == slug, f"Expected tenant {slug!r}, got {t.slug!r}"
    return _check

factories.py

File: apps/api/tests/factories.py

async def create_project(db: AsyncSession, created_by: UUID, **overrides) -> Project:
    data = {
        "name": "Test Project",
        "reference": f"PRJ-{uuid4().hex[:6].upper()}",
        "status": "active",
        "created_by": created_by,
        "updated_by": created_by,
        **overrides,
    }
    obj = Project(**data)
    db.add(obj)
    await db.flush()
    return obj

async def create_site(
    db: AsyncSession, project_id: UUID, created_by: UUID, **overrides
) -> Site:
    data = {
        "project_id": project_id,
        "name": "Test Site",
        "status": "active",
        "created_by": created_by,
        "updated_by": created_by,
        **overrides,
    }
    obj = Site(**data)
    db.add(obj)
    await db.flush()
    return obj

Extend with one factory function per entity as modules are built.

Seed data conventions

Rule: all test data is created programmatically via factory functions. No SQL seed files, no fixture dumps.

Factory function signature

Every factory function follows the same pattern:

async def create_{entity}(
    db: AsyncSession,
    created_by: UUID,
    **overrides,
) -> {Entity}:

**overrides lets any column be overridden in the test without a separate factory variant. Default values in the factory must be valid and self-consistent so tests that don't need a specific value can omit it entirely.

Canonical test scenario

A session-scoped canonical fixture in conftest.py provisions a standard Acme Construction scenario that is reused across all module tests. This avoids recreating the same hierarchy in every test file.

Fixed UUIDs ensure deterministic cross-module assertions:

# Fixed UUIDs — canonical Acme scenario
ACME_PROJECT_ID  = UUID("00000001-0001-0000-0000-000000000001")
ACME_SITE_A_ID   = UUID("00000001-0001-0000-0000-000000000002")
ACME_SITE_B_ID   = UUID("00000001-0001-0000-0000-000000000003")
# Add one UUID per canonical entity as each module is built
# Pattern: 00000001-{module-index:04d}-0000-0000-{entity-index:012d}

@pytest_asyncio.fixture(scope="session")
async def canonical(test_tenants):
    """
    Standard Acme Construction scenario.
    One project, two sites. Extend as modules are built.
    Session-scoped — created once per test run.
    """
    acme = test_tenants["acme"]
    pm_id = TEST_USERS["acme"]["project_manager"]
    async with AsyncSessionLocal() as db:
        project = await create_project(db, pm_id,
            id=ACME_PROJECT_ID, name="Canary Wharf Phase 2")
        site_a = await create_site(db, pm_id,
            id=ACME_SITE_A_ID, project_id=project.id, name="Site A — Tower Block")
        site_b = await create_site(db, pm_id,
            id=ACME_SITE_B_ID, project_id=project.id, name="Site B — Substation")
        await db.commit()
    return {"project": project, "site_a": site_a, "site_b": site_b}

When to use canonical vs a fresh factory call

Use canonical Use factory directly
Test needs a project/site to exist (most tests) Test is specifically about creation behaviour
Test checks a list or filter — needs stable IDs Test needs an entity in a specific non-default state
Test checks cross-tenant isolation — references beta Test needs to verify soft-delete, expiry, conflict

Cross-tenant isolation tests

Every module must include at least one test verifying that data created in t_acme is not accessible from t_beta. Use auth_headers("beta", ...) to make the request and assert 404.

async def test_cross_tenant_isolation(client, canonical, auth_headers):
    response = await client.get(
        f"/projects/{ACME_PROJECT_ID}",
        headers=auth_headers("beta", "project_manager"),
    )
    assert response.status_code == 404

Layer 9 — Frontend Skeleton

Project structure

apps/web/src/
├── main.tsx              Entry point
├── App.tsx               Router setup
├── core/
│   ├── api/
│   │   ├── client.ts     Axios/fetch wrapper with auth headers
│   │   └── index.ts      Re-exports
│   ├── auth/
│   │   ├── CognitoAuth.ts  Cognito hosted UI redirect flow
│   │   ├── AuthContext.tsx  React context: user, token, logout
│   │   └── ProtectedRoute.tsx
│   ├── powersync/
│   │   ├── setup.ts       PowerSync client init
│   │   └── schema.ts      Local SQLite schema (mirrors API entities)
│   ├── layout/
│   │   ├── AppShell.tsx   Sidebar nav, topbar, content area
│   │   └── Sidebar.tsx
│   └── types/
│       └── api.ts         Auto-generated from OpenAPI spec
└── modules/               Empty until module build begins

API client

File: apps/web/src/core/api/client.ts

const BASE_URL = import.meta.env.VITE_API_BASE_URL;

export async function apiRequest<T>(
  path: string,
  options: RequestInit = {}
): Promise<T> {
  const token = getAccessToken();  // from AuthContext
  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      "API-Version": "1",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  if (!response.ok) {
    const error = await response.json();
    throw new ApiError(error.error, error.message, response.status, error.field);
  }

  return response.json();
}

export class ApiError extends Error {
  constructor(
    public code: string,
    message: string,
    public status: number,
    public field?: string,
  ) {
    super(message);
  }
}

Auth flow

Construo uses the Cognito hosted UI (OAuth 2.0 authorisation code flow). No custom login form — Cognito handles it.

User visits acme.construo.build
  → React checks for valid access token in memory (AuthContext)
  → None found → redirect to Cognito hosted UI
  → User authenticates
  → Cognito redirects to acme.construo.build/auth/callback?code=...
  → React calls POST /auth/exchange (Construo API, not Cognito directly)
  → API exchanges code with Cognito, returns access_token in body
    and sets refresh_token in HttpOnly Secure SameSite=Strict cookie (ADR-005)
  → Access token stored in React state (AuthContext) — never localStorage
  → Redirect to original destination

Token storage (ADR-005): - Access token: React state (AuthContext). Lost on page reload — silently refreshed via POST /auth/refresh (reads HttpOnly cookie, never exposed to JS). - Refresh token: HttpOnly Secure SameSite=Strict cookie on .construo.build. Not accessible to JavaScript. XSS cannot exfiltrate it. - Offline token: IndexedDB. 12-hour validity. Used when network is unavailable.

Silent refresh: On page reload or access token expiry, React calls POST /auth/refresh. The browser sends the HttpOnly cookie automatically. The API issues a new access token. If the cookie is absent (logout, expiry), redirect to Cognito login.

PowerSync initialisation

File: apps/web/src/core/powersync/setup.ts

import { PowerSyncDatabase } from "@powersync/web";
import { schema } from "./schema";

export const db = new PowerSyncDatabase({ schema });

export async function connectPowerSync(accessToken: string): Promise<void> {
  // Fetch a PowerSync token from the API (short-lived, tenant-scoped)
  const { token } = await apiRequest<{ token: string }>("/sync/token");

  await db.connect({
    url: import.meta.env.VITE_POWERSYNC_URL,
    fetchCredentials: async () => ({ token }),
  });
}

PowerSync token endpoint (GET /sync/token) is part of the foundation — returns a short-lived token scoped to the current user and tenant.

PoC gate (ADR-002): The PowerSync × schema-per-tenant integration must be validated before any sync-reliant module work begins (Sprint 1, Phase 0 gate). The specific concern is sync rule generation across N schemas — a UNION ALL across 30+ schemas in a sync rule requires a redeploy on every new tenant if hard-coded. The PoC must demonstrate a dynamic or per-tenant sync rule approach that scales to 100 tenants without redeploy. Do not implement per-module offline sync until the PoC passes the cross-tenant leakage test. Leave architecture/offline-sync.md empty until the PoC concludes.

Vite config

File: apps/web/vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
  server: {
    port: 5173,
    // Allow requests from acme.local.test and beta.local.test
    host: "0.0.0.0",
    cors: true,
  },
});

Layer 10 — Infrastructure (Terraform)

Module list

Module Creates
vpc VPC (10.0.0.0/16), 3-tier subnets (public/private/data), IGW, NAT GW, route tables, VPC flow logs
rds RDS PostgreSQL 16, subnet group, SG, parameter group, Secrets Manager password with auto-rotation
redis ElastiCache Redis 7, subnet group, SG, encryption in-transit
ecr ECR repo, lifecycle policy (keep last 10 images), image scanning enabled
ecs_cluster ECS cluster, IAM execution role (with Secrets Manager + ECR permissions), CloudWatch log group
alb ALB, HTTPS listener (port 443), HTTP→HTTPS redirect (port 80), target group, SG
ecs_service Fargate service, task definition (with Secrets Manager env injection), autoscaling
cognito User Pool, User Pool Client, Pre-Token-Generation Lambda trigger
lambda_cognito Lambda function for Cognito trigger, IAM role, VPC attachment
s3_assets Frontend bucket, S3 OAC, bucket policy
s3_files Tenant files bucket, lifecycle rules, versioning
cloudfront Distribution (S3 + ALB origins), WAF association, ACM cert (us-east-1)
waf Web ACL: OWASP Core, Known Bad Inputs, IP Reputation, rate limit (2000 req/5min)
secrets Secrets Manager secrets for all application config
cloudtrail Trail, S3 bucket, 7-year retention, log file validation

Security group rules

SG Inbound Outbound
ALB 80 + 443 from 0.0.0.0/0 Port 8000 to ECS SG
ECS Port 8000 from ALB SG Port 5432 to RDS SG, port 6379 to Redis SG, port 443 to 0.0.0.0/0
RDS Port 5432 from ECS SG only None
Redis Port 6379 from ECS SG only None

Dev environment tfvars

environment       = "dev"
region            = "eu-west-2"
db_instance_class = "db.t3.medium"
db_multi_az       = false
ecs_task_cpu      = 512
ecs_task_memory   = 1024
ecs_desired_count = 1
redis_node_type   = "cache.t3.micro"

IAM — ECS execution role permissions

{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage",
      "ecr:GetAuthorizationToken"], "Resource": "*"},
    {"Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:*"},
    {"Effect": "Allow", "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:eu-west-2:{account}:secret:construo/*"}
  ]
}

GitHub Actions CI/CD

File: .github/workflows/api-ci.yml

name: API CI/CD

on:
  push:
    paths: ["apps/api/**", ".github/workflows/api-ci.yml"]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install uv && uv pip install --system -e "apps/api[dev]"
      - run: cd apps/api && ruff check .
      - run: cd apps/api && mypy --strict src/

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_DB: construo_test, POSTGRES_USER: construo,
               POSTGRES_PASSWORD: construo }
        ports: ["5432:5432"]
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install uv && uv pip install --system -e "apps/api[dev]"
      - run: cd apps/api && pytest --cov=src --cov-report=xml
      - uses: codecov/codecov-action@v4

  build-deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: read }
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: eu-west-2
      - uses: aws-actions/amazon-ecr-login@v2
      - run: |
          IMAGE=${{ secrets.ECR_REGISTRY }}/construo-api:${{ github.sha }}
          docker build -t $IMAGE apps/api/
          docker push $IMAGE
      # Three-stage migration gate (ADR-001 M3)
      # Application deploy is blocked until ALL tenant schemas are at head.
      - name: Migrate public schema
        run: cd apps/api && alembic upgrade head
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL_DIRECT }}

      - name: Migrate tenant schemas
        run: cd apps/api && python scripts/migrate_tenants.py
        timeout-minutes: 30
        env:
          DATABASE_URL_DIRECT: ${{ secrets.DATABASE_URL_DIRECT }}

      - name: Verify all tenants at head
        run: cd apps/api && python scripts/verify_tenant_versions.py
        # Exits 1 if any active tenant is below head revision

      - name: Deploy application
        if: success()  # blocked if any migration step failed
        run: |
          aws ecs update-service \
            --cluster construo-dev \
            --service construo-api \
            --force-new-deployment

No static AWS credentials. Uses OIDC via configure-aws-credentials action.


pyproject.toml

[project]
name = "construo-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.111.0",
    "uvicorn[standard]>=0.29.0",
    "sqlalchemy[asyncio]>=2.0.0",
    "asyncpg>=0.29.0",
    "alembic>=1.13.0",
    "pydantic>=2.7.0",
    "pydantic-settings>=2.2.0",
    "python-jose[cryptography]>=3.3.0",
    "redis[hiredis]>=5.0.0",
    "httpx>=0.27.0",
    "boto3>=1.34.0",
    "cryptography>=42.0.0",   # AES-256-GCM for per-tenant field encryption (ADR-020)
    "sentry-sdk[fastapi]>=2.0.0",
    "structlog>=24.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.2.0",
    "pytest-asyncio>=0.23.0",
    "pytest-cov>=5.0.0",
    "ruff>=0.4.0",
    "mypy>=1.10.0",
]

[tool.ruff]
line-length = 88
target-version = "py312"
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "A", "C4", "T20"]
ignore = ["S101"]

[tool.mypy]
strict = true
python_version = "3.12"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

Dockerfile

FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install uv
COPY pyproject.toml .
RUN uv pip install --system -e .

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages \
                    /usr/local/lib/python3.12/site-packages
COPY src/ ./src/
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

Verification Criteria

Foundation is complete when every item below passes.

Local environment

  • docker compose up -d starts postgres, pgbouncer, redis with no errors
  • PgBouncer pool mode is session — confirmed via SHOW pool_mode in pgbouncer console
  • Direct postgres connection on 5432 succeeds
  • PgBouncer connection on 6432 succeeds
  • Redis connection on 6379 succeeds
  • alembic upgrade head (public schema only) runs cleanly via direct connection
  • python scripts/migrate_tenants.py --dry-run shows both test tenants without error
  • python scripts/migrate_tenants.py migrates t_acme and t_beta to head
  • python scripts/verify_tenant_versions.py exits 0 (both tenants at head)
  • \dn in psql shows public, t_acme, t_beta
  • \du shows roles r_acme and r_beta (NOLOGIN)
  • SET LOCAL ROLE "r_acme" can be executed as the app user
  • SET LOCAL ROLE "r_beta" followed by SELECT * FROM t_acme.projects → permission denied
  • public.tenant_migrations has rows for both tenants at head
  • Data inserted via t_acme search_path is not visible via t_beta search_path

API — local

  • uvicorn src.main:app --reload starts with no errors
  • GET /health → 200
  • GET /health with Host: acme.local.test → 200
  • GET /health with Host: unknown.local.test → 404
  • Request with valid test JWT → current_tenant and current_user contextvars set correctly
  • Request with invalid JWT → 401 with correct error envelope
  • Request where JWT tenant_id ≠ Host tenant → 401 Token/tenant mismatch
  • Request missing API-Version header → 200 with API-Version: 1 in response + warning logged
  • Request with API-Version: 99 → 400
  • Error responses match {"error": "...", "message": "..."} format
  • POST /auth/exchange returns access_token in body and sets refresh_token HttpOnly cookie
  • POST /auth/refresh uses cookie (not body) to issue new access token
  • POST /auth/logout clears the refresh_token cookie
  • POST /auth/offline-session returns offline token for 12h

Tenant isolation (ADR-007)

  • DB session for t_acme request has ROLE = r_acme (verify via SELECT current_role)
  • DB session for t_beta request has ROLE = r_beta
  • search_path for t_acme session is t_acme, public
  • app.user_id session variable is set correctly for authenticated requests
  • Missing tenant contextvar raises RuntimeError before DB session is acquired
  • validate_schema_name rejects slugs with special characters

Migration runner (ADR-001)

  • Runner continues past a forced failure (simulate via constraint on test schema)
  • --only acme migrates only t_acme
  • --from-state failed retries only failed tenants
  • --dry-run prints plan without executing
  • Exit code 0 when all tenants at head; exit code 1 when any tenant not at head

Auth and access (ADR-003)

  • JWT contains no custom:project_access claim
  • custom:platform_roles is a list (not a JSON-encoded string)
  • get_project_access returns from Redis cache on second call
  • revoke_project_access invalidates cache; next request returns 403
  • grant_project_access followed by get_project_access reflects the grant within 60s

Tests

  • pytest passes with no errors
  • Cross-tenant isolation test: write to t_acme, query from t_beta → 404
  • Cross-tenant user test: acme-scoped session for cross-tenant identity cannot read beta data
  • assert_tenant("acme") passes within acme-scoped request context
  • All 5 role auth fixtures produce valid tokens
  • Coverage ≥ 80% on core/

Infrastructure (after AWS setup)

  • terraform plan in environments/dev shows no errors
  • terraform apply completes, all resources created
  • ECS service shows 1 healthy task
  • GET https://acme.construo.build/health → 200 via CloudFront → ALB → ECS
  • RDS not reachable from internet
  • Redis not reachable from internet
  • CloudTrail receiving events
  • CloudWatch log group /ecs/construo-api receiving logs
  • KMS CMK for tenant DEK encryption exists in eu-west-2
  • r_acme Postgres role exists in RDS instance (provisioning ran correctly)

CI/CD

  • Push to feature branch triggers lint + test jobs
  • Failed test blocks build-deploy job
  • Merge to main: migrate public → migrate tenants → verify versions → deploy (in order)
  • Failed tenant migration step blocks application deploy
  • Rollback tested: previous task definition re-deployed successfully

Frontend

  • npm run dev starts with no errors
  • http://acme.local.test:5173 loads React app
  • Unauthenticated visit redirects to Cognito hosted UI
  • Successful Cognito login: access token in memory, refresh token in HttpOnly cookie
  • Page reload: silent refresh via cookie restores session without redirect
  • POST /auth/offline-session succeeds; offline token accepted in subsequent request
  • npm run generate-types produces src/core/types/api.ts from running API