Security
Security overview
Source of truth: SECURITY.md in the repository. Incident contact: security@example.com (replace before production).
# Security & Operations This document describes the production security posture of AEC Spec Validator: what is enforced in code, what is enforced in the database, and what is an operational procedure. It is the reference for the "prototype → product" boundary. ## Authentication and authorization ### Session model - Browser sessions use Supabase Auth JWTs (access + refresh token in `localStorage`), refreshed automatically 60 s before expiry with a one-shot forced refresh on 401. - **Sign-in and sign-up go through server proxies** (`/api/auth/sign-in`, `/api/auth/sign-up`) which apply shared brute-force throttling **before** the request reaches Supabase Auth: - per client IP and per target account (SHA-256 of the email, never the raw address), - 10 attempts per 15 minutes per key, `Retry-After` on 429, - uniform `401 Email or password is incorrect.` so responses never reveal whether an account exists. - Password recovery and token refresh call Supabase directly; both require possession of a high-entropy token. ### MFA readiness Supabase Auth natively supports TOTP factors (`/auth/v1/factors`). MFA is **not** enabled yet. Preparation checklist when enabling: 1. Enable MFA enrollment in the Supabase dashboard (Auth → MFA). 2. Extend the sign-in proxy to forward the `AAL2` challenge/verify flow. 3. Require `aal2` claims for owner-only operations (token minting, project deletion, webhook management) by checking `auth.jwt()->>'aal'` in the relevant RLS policies. ### Roles and RLS Access control is enforced in PostgreSQL row-level security, not in application code: - `owner` — full control: settings, baseline, members, tokens, webhooks, deletion, audit trail. - `editor` — read everything in the project, request server-side runs, set reviews through the transactional review RPC, and create evidence/specifications. - `viewer` — read-only. - Outsiders (authenticated non-members) and `anon` see **zero rows**, even when they guess valid UUIDs. `public.can_view_project` / `public.can_edit_project` are the single authorization source for all child tables and **exclude soft-deleted projects**. ### Authoritative validation and review writes Validation results are immutable engine snapshots, not user-authored project data. `authenticated` and `service_role` cannot insert, update, or delete `validation_runs` directly. UI, background-job, demo, and machine-API execution paths all call the service-only `persist_authoritative_validation_run` RPC. The function derives `owner_id` from the project, verifies the supplied server-authenticated actor is an owner/editor, verifies referenced model/specification assets belong to the same project, and then inserts the snapshot. Editors set human review decisions through `set_validation_review`; direct writes to `validation_reviews` and `validation_review_history` are denied. The RPC derives the reviewer from `auth.uid()`, locks the current decision, archives it, writes the replacement, and appends `validation.review_set` to `audit_events` in one database transaction. Review history and audit rows cannot be updated or deleted through authenticated or service-role REST clients. **RLS test matrix:** `supabase/tests/rls_test_matrix.sql` seeds owner/editor/viewer/outsider personas and asserts the full matrix (visibility, authoritative-write forgery denial, transactional review identity/history, guessed-ID probes, soft-delete blackout, service-role restrictions, anon lockout). Run it after every migration change: ```bash supabase db reset psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f supabase/tests/rls_test_matrix.sql ``` The script rolls back its fixtures and must print `RLS MATRIX PASSED`. ### Invitations and machine tokens - Invitations expire after 7 days (`expires_at`), are single-project, role-scoped, and accepted through a `security definer` RPC that revalidates recipient email, pending status, and expiry. - Machine API tokens (`aec_…`) are stored as SHA-256 hashes with scoped permissions (`models:read/write`, `specifications:read/write`, `runs:read/write`, `regressions:read`), optional expiry, and owner-only management. ## Upload security All file-accepting endpoints route through `src/security/uploadGuards.ts`: | Control | Implementation | |---|---| | Allowed MIME types | Per-kind allowlist; a declared MIME that mismatches is rejected with 415 | | Magic bytes | Container detection (ZIP `PK`, `%PDF-`, `ISO-10303-21`, JSON, NUL-free text) must match the claimed format | | Max sizes | IFC 10/50/100 MB by plan (20 MB on the public parser), DOCX 15 MB, XLSX/CSV 10 MB, PDF 20 MB, model JSON 5 MB — checked before buffering and again on bytes | | ZIP bomb protection | DOCX/XLSX central-directory inspection: entry caps, uncompressed-size caps, compression-ratio 100 cap, macro/external-link rejection (`specificationDocx.ts`, `specificationXlsx.ts`) | | Parser timeouts | Server IFC imports run in an isolated worker terminated after 30 seconds. Document parsers enforce input/expansion bounds and cooperative deadlines; a Promise deadline alone does not interrupt CPU-bound work. | | Filename sanitization | Path components, control characters, and shell metacharacters stripped; length capped; sanitized name is what gets persisted | ### Malware scanning strategy Documents are parsed, normalized, and only derived data is served back — original binaries are never re-served to other users, which removes the classic stored-malware distribution path. When raw-file retention lands in object storage the pipeline is: 1. Upload into a **private quarantine bucket** (no public URLs, short-lived signed URLs only). 2. Asynchronous scan (ClamAV container or a scanning API) before the object is marked available. 3. Objects failing the scan are deleted and the event is recorded in `audit_events`. ### Private object storage Evidence attachments are currently stored as size-capped (5 MB), SHA-256-hashed base64 inside RLS-protected rows — there is no publicly addressable storage. Any future move to Supabase Storage must use private buckets with RLS storage policies mirroring `can_view_project`. ## Rate limiting `src/security/rateLimit.ts` provides shared fixed-window limits backed by **Upstash Redis** (`UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`); every instance sees the same counters. Without Redis, local development and CI use per-process counters. Vercel production requires Redis. Outages, timeouts and malformed command results reject protected requests with HTTP 503 and `Retry-After`, and emit `rate_limit.redis_unavailable`; they cannot silently disable protection. | Scope | Limit | Keys | |---|---|---| | `auth` | 10 / 15 min | client IP **and** hashed target account | | `chat` | 20 / min | client IP | | `upload` | 12 / min | client IP (public) or project (machine API) | | `validation` | 6 / min | project | All 429 and protection-unavailable 503 responses carry `Retry-After`. Durable validation jobs use database leases so one worker owns each job phase. The standalone `acquireJobSlot` helper also refuses new slots during Redis outages. ## Data lifecycle | Concern | Behavior | |---|---| | Raw document retention | Original DOCX/XLSX/PDF binaries are parsed in the browser and **not persisted**; only approved fragments/anchors (provenance snapshots) are stored. IFC uploads persist the normalized model + content hash, not raw STEP, except as `project_model_assets` metadata | | Generated artifacts | Validation snapshots, reviews, evidence, and audit history live inside the project tree and follow project deletion | | Soft delete | `DELETE /api/projects/:id` sets `deleted_at`; members lose access instantly (RLS), owner keeps restore visibility | | Restore | `DELETE /api/projects/:id?action=restore` (owner only) | | Permanent deletion | `DELETE /api/projects/:id?action=permanent` (owner) or scheduled `purge_soft_deleted_projects_by_plan()` from the authenticated worker tick; retention is 30/365/2555 days by plan | | Account export | `GET /api/account/export` returns all owned data as JSON under the caller's own JWT (RLS-bounded); binaries referenced by SHA-256 | ### Backup and restore - **Backups:** The current deployment uses Supabase Free; automatic daily backups and PITR must not be assumed. Configure encrypted logical database dumps and separate Storage object backups as described in [production operations](docs/PRODUCTION_OPERATIONS.md). CI verifies a disposable database restore; production backup coverage requires its own recorded evidence. - **Restore test (run quarterly, and after every destructive migration):** 1. Create a scratch Supabase project in the same region. 2. Restore the latest dump: `psql "$SCRATCH_DB_URL" -f backup.sql`. 3. Run `supabase/tests/rls_test_matrix.sql` against the scratch database — it must print `RLS MATRIX PASSED`. 4. Spot-check row counts of `projects`, `validation_runs`, `finding_evidence` against production. 5. Download one audit bundle through the app pointed at the scratch database and verify `CHECKSUMS.sha256`. A backup that has not passed this procedure is treated as nonexistent. ### Region and subprocessors - **EU region:** provision the Supabase project in an EU region (e.g. `eu-central-1`) and pin Vercel functions to `fra1`. Upstash Redis must also be EU-hosted. - **Subprocessor list** (publish to customers; update on change): | Subprocessor | Purpose | Data | Region | |---|---|---|---| | Supabase | Database, auth, RLS | Account data, project data, documents-derived data | EU | | Vercel | Hosting, serverless compute | Request payloads in transit, logs (redacted) | EU (fra1) | | Upstash | Shared rate limiting | Hashed rate-limit keys only, TTL ≤ 15 min | EU | | Google (optional) | AI explanations | Server-verified findings only, never raw documents | Per Google config | | OpenAI (optional) | AI explanations | Server-verified findings only, never raw documents | Per OpenAI config | AI providers are optional; the deterministic fallback requires no third party. ## Observability `src/observability/logger.ts` emits single-line JSON events with automatic redaction: keys matching tokens/passwords/authorization/emails/document-content patterns are replaced with `[redacted]` **before** serialization, long strings are truncated, huge arrays summarized. Secrets and customer documents cannot reach the logs by construction. - **Request IDs**: generated per request, echoed as `X-Request-Id`, attached to every event; machine API responses already carry `requestId`. - **Metrics** (as `metric.*` events): `ifc_import_duration_ms`, `model_import_duration_ms`, `validation_duration_ms`, `parser_failure`, `upload_rejected`, `chat_answer` (with `mode: ai|fallback` for AI-fallback ratio). - **Audit events** (`audit.*` log lines + durable `audit_events` rows via service role): sign-in/sign-up outcomes (hashed account IDs), project soft/permanent deletion, restore, account export. Rows contain identifiers and outcomes only — never content, credentials, or raw emails. Project owners can read their project's audit trail through RLS. - **Error monitoring**: `request.failed` events include route, request ID, duration, and message. Point the platform log drain at your error tracker; no additional SDK is required. ## Acceptance criteria mapping | Criterion | Where verified | |---|---| | All RLS negative tests pass | `supabase/tests/rls_test_matrix.sql` | | Foreign project unreachable even by guessed ID | Matrix (`outsider cannot fetch … by guessed ID`) + API tests returning 404 on RLS-empty reads | | Upload attacks have tests | `src/security/uploadGuards.test.ts`, `app/api/ifc/route.test.ts` (renamed binary, ZIP-as-IFC, flood → 429), existing DOCX/XLSX bomb tests | | Backup restorable | Restore-test runbook above; matrix doubles as post-restore verification | | No secrets or documents in logs | Redaction-by-key in `logger.ts` + `logger.test.ts`; audit rows store hashes/IDs only |