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 CONCURRENTLYwhere possible. Incompatible with Alembic's default transaction mode. Use: orwith op.batch_alter_table(..., transactional_ddl=False). - Enum changes (
ALTER TYPE ... ADD VALUE) cannot run inside a transaction in older Postgres. Use standaloneop.execute()outside a transaction block. - Every migration must declare
# Target: PUBLICor# Target: TENANTas 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:
**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 -dstarts postgres, pgbouncer, redis with no errors - PgBouncer pool mode is
session— confirmed viaSHOW pool_modein 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-runshows both test tenants without error -
python scripts/migrate_tenants.pymigratest_acmeandt_betato head -
python scripts/verify_tenant_versions.pyexits 0 (both tenants at head) -
\dnin psql showspublic,t_acme,t_beta -
\dushows rolesr_acmeandr_beta(NOLOGIN) -
SET LOCAL ROLE "r_acme"can be executed as the app user -
SET LOCAL ROLE "r_beta"followed bySELECT * FROM t_acme.projects→ permission denied -
public.tenant_migrationshas rows for both tenants at head - Data inserted via
t_acmesearch_path is not visible viat_betasearch_path
API — local¶
-
uvicorn src.main:app --reloadstarts with no errors -
GET /health→ 200 -
GET /healthwithHost: acme.local.test→ 200 -
GET /healthwithHost: unknown.local.test→ 404 - Request with valid test JWT →
current_tenantandcurrent_usercontextvars set correctly - Request with invalid JWT → 401 with correct error envelope
- Request where JWT
tenant_id≠ Host tenant → 401Token/tenant mismatch - Request missing
API-Versionheader → 200 withAPI-Version: 1in response + warning logged - Request with
API-Version: 99→ 400 - Error responses match
{"error": "...", "message": "..."}format -
POST /auth/exchangereturnsaccess_tokenin body and setsrefresh_tokenHttpOnly cookie -
POST /auth/refreshuses cookie (not body) to issue new access token -
POST /auth/logoutclears therefresh_tokencookie -
POST /auth/offline-sessionreturns offline token for 12h
Tenant isolation (ADR-007)¶
- DB session for
t_acmerequest hasROLE = r_acme(verify viaSELECT current_role) - DB session for
t_betarequest hasROLE = r_beta -
search_pathfort_acmesession ist_acme, public -
app.user_idsession variable is set correctly for authenticated requests - Missing tenant contextvar raises
RuntimeErrorbefore DB session is acquired -
validate_schema_namerejects slugs with special characters
Migration runner (ADR-001)¶
- Runner continues past a forced failure (simulate via constraint on test schema)
-
--only acmemigrates onlyt_acme -
--from-state failedretries only failed tenants -
--dry-runprints 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_accessclaim -
custom:platform_rolesis a list (not a JSON-encoded string) -
get_project_accessreturns from Redis cache on second call -
revoke_project_accessinvalidates cache; next request returns 403 -
grant_project_accessfollowed byget_project_accessreflects the grant within 60s
Tests¶
-
pytestpasses with no errors - Cross-tenant isolation test: write to
t_acme, query fromt_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 planinenvironments/devshows no errors -
terraform applycompletes, 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-apireceiving logs - KMS CMK for tenant DEK encryption exists in
eu-west-2 -
r_acmePostgres 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 devstarts with no errors -
http://acme.local.test:5173loads 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-sessionsucceeds; offline token accepted in subsequent request -
npm run generate-typesproducessrc/core/types/api.tsfrom running API