diff --git a/.env.example b/.env.example index 24f904e..6cebaae 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,14 @@ NC3_APP_DB_PASSWORD=nc3_app # Same raw-interpolation caveat; override PLATFORM_DATABASE_URL instead if the # password needs percent-encoding. NC3_PLATFORM_DB_PASSWORD=app_platform +# Credential-surface role (docs/database-roles.md): only the api service +# connects as nc3_auth. Same bootstrap as the two roles above, once per +# cluster: +# docker compose exec postgres psql -U postgres -d nc3_testing_platform \ +# -c "ALTER ROLE nc3_auth PASSWORD 'nc3_auth'" +# Same raw-interpolation caveat; override AUTH_DATABASE_URL instead if the +# password needs percent-encoding. +NC3_AUTH_DB_PASSWORD=nc3_auth # Redis REDIS_PORT=6379 @@ -59,6 +67,17 @@ SCAN_HEARTBEAT_INTERVAL_SECONDS=5 SCAN_STALE_AFTER_SECONDS=30 SCAN_SWEEP_INTERVAL_SECONDS=15 +# Authentication (B3 / US #79) +# Deployment master key at the root of the envelope hierarchy: 64 hex chars +# (256 bits). DEVELOPMENT VALUE ONLY — a real deployment mounts a secret and +# sets APP_ENCRYPTION_MASTER_KEY_FILE=/run/secrets/app_encryption_master_key +# on the api service alone (workers never unwrap keys). Generate a real one: +# openssl rand -hex 32 +APP_ENCRYPTION_MASTER_KEY=00000000000000000000000000000000000000000000000000000000deadbeef +# Browser origin allowed to make cookie-bearing state changes (CSRF origin +# check). Leave unset in development; set to the public origin in production: +#AUTH_PUBLIC_ORIGIN=https://testing.nc3.lu + # Rauthy (development identity provider) # Values not listed here live in infra/compose/rauthy/config.toml, which Rauthy requires as a file. # The port appears in pub_url, rp_origin, and OIDC_DISCOVERY_URL; changing it changes all three. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c85523..25457f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,8 @@ jobs: run: | PGPASSWORD=postgres psql -h localhost -U postgres -d postgres \ -c "ALTER ROLE nc3_app PASSWORD 'nc3_app'" \ - -c "ALTER ROLE app_platform PASSWORD 'app_platform'" + -c "ALTER ROLE app_platform PASSWORD 'app_platform'" \ + -c "ALTER ROLE nc3_auth PASSWORD 'nc3_auth'" # The standing regression gate for any RLS policy change (US #81): # cross-org, cross-user, guest-arm, worker hint-then-verify, grant @@ -202,7 +203,8 @@ jobs: run: | docker compose exec -T postgres psql -U postgres -d nc3_testing_platform \ -c "ALTER ROLE nc3_app PASSWORD 'nc3_app'" \ - -c "ALTER ROLE app_platform PASSWORD 'app_platform'" + -c "ALTER ROLE app_platform PASSWORD 'app_platform'" \ + -c "ALTER ROLE nc3_auth PASSWORD 'nc3_auth'" - name: Scan round trip lands in PostgreSQL run: | diff --git a/api/openapi.json b/api/openapi.json index d0badfb..7c723be 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -6,6 +6,327 @@ "version": "4.0.1" }, "paths": { + "/api/v1/auth/register": { + "post": { + "tags": [ + "auth" + ], + "summary": "Register a platform-local account", + "description": "Provision the account and its workspace organization (IDR-016).\n\nThe registrant becomes `organization_admin` of a fresh workspace, and the\nconsent receipts for every active account-level statement are recorded\natomically with the account. Registration does not log in — call\n`POST /auth/login` next.", + "operationId": "register_api_v1_auth_register_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationSubmission" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisteredUser" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "headers": { + "RateLimit": { + "description": "Quota-window state, e.g. `limit=100, remaining=42, reset=30`.", + "schema": { + "type": "string" + } + }, + "RateLimit-Policy": { + "description": "Advertised quota policy, e.g. `100;w=60`.", + "schema": { + "type": "string" + } + }, + "Retry-After": { + "description": "Seconds until the quota resets. Sent with `429`.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Log in with email and password", + "description": "Open a server-side session and set the `__Host-session` cookie.\n\nUnknown email, disabled account, and wrong password all answer the same\n`401`. A locked account answers `429` with `Retry-After`.", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginSubmission" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionInfo" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "headers": { + "RateLimit": { + "description": "Quota-window state, e.g. `limit=100, remaining=42, reset=30`.", + "schema": { + "type": "string" + } + }, + "RateLimit-Policy": { + "description": "Advertised quota policy, e.g. `100;w=60`.", + "schema": { + "type": "string" + } + }, + "Retry-After": { + "description": "Seconds until the quota resets. Sent with `429`.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + } + } + }, + "/api/v1/auth/session": { + "get": { + "tags": [ + "auth" + ], + "summary": "The authenticated session", + "description": "The current user, organization, and server-side expiry horizon.", + "operationId": "read_session_api_v1_auth_session_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionInfo" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "SessionCookie": [] + } + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Log out", + "description": "Revoke the session server-side and clear the cookie.", + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "SessionCookie": [] + } + ] + } + }, + "/api/v1/auth/password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Change the password", + "description": "Verify the current password, re-encrypt, and rotate every session.\n\nSessions on other devices are revoked; this one is replaced and the new\ncookie is set on the response (session regeneration on privilege change).", + "operationId": "change_password_api_v1_auth_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeSubmission" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetail" + } + } + } + } + }, + "security": [ + { + "SessionCookie": [] + } + ] + } + }, "/api/v1/scans": { "post": { "tags": [ @@ -5901,6 +6222,30 @@ "title": "InvitationPreview", "description": "What an invitee can see before accepting.\n\nDeliberately thin. Anyone holding the link can read this, so it carries the\norganization's name and nothing that would leak its membership or activity." }, + "LoginSubmission": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 12, + "format": "password", + "title": "Password", + "writeOnly": true + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "LoginSubmission", + "description": "Password login for a platform-local account." + }, "Member": { "properties": { "user_id": { @@ -6391,6 +6736,33 @@ ], "title": "Page[Schedule]" }, + "PasswordChangeSubmission": { + "properties": { + "current_password": { + "type": "string", + "maxLength": 128, + "minLength": 12, + "format": "password", + "title": "Current Password", + "writeOnly": true + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 12, + "format": "password", + "title": "New Password", + "writeOnly": true + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChangeSubmission", + "description": "Authenticated password change; requires the current password." + }, "ProblemDetail": { "properties": { "type": { @@ -6457,6 +6829,93 @@ "title": "ProblemDetail", "description": "RFC 9457 problem detail." }, + "RegisteredUser": { + "properties": { + "user_id": { + "type": "string", + "format": "uuid7", + "title": "User Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole" + } + }, + "type": "object", + "required": [ + "user_id", + "organization_id", + "email", + "organization_role" + ], + "title": "RegisteredUser", + "description": "The provisioned account: the registrant administers a workspace org.\n\nPer IDR-016 the workspace organization is created at registration with\nthe registrant as `organization_admin`; the first successful DNS\nverification later promotes and names it." + }, + "RegistrationSubmission": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 12, + "format": "password", + "title": "Password", + "writeOnly": true + }, + "display_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "statement_responses": { + "items": { + "$ref": "#/components/schemas/StatementResponseSubmission" + }, + "type": "array", + "title": "Statement Responses", + "description": "Answers to every active account-level acceptance statement (`GET /statements`), each named by key and exact version. Registration is refused while any is missing." + } + }, + "type": "object", + "required": [ + "email", + "password", + "statement_responses" + ], + "title": "RegistrationSubmission", + "description": "Lean registration: email, password, optional display name, consent." + }, "Report": { "properties": { "id": { @@ -7837,6 +8296,72 @@ "title": "ScheduleUpdate", "description": "Partial update. Omitted fields are left alone.\n\n`asset_id` is absent: repointing a schedule at another asset would attribute one\nasset's recurring history to a different one." }, + "SessionInfo": { + "properties": { + "user_id": { + "type": "string", + "format": "uuid7", + "title": "User Id" + }, + "organization_id": { + "type": "string", + "format": "uuid7", + "title": "Organization Id" + }, + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "organization_role": { + "$ref": "#/components/schemas/OrganizationRole" + }, + "session_created_at": { + "type": "string", + "format": "date-time", + "title": "Session Created At" + }, + "last_seen_at": { + "type": "string", + "format": "date-time", + "title": "Last Seen At" + }, + "idle_expires_at": { + "type": "string", + "format": "date-time", + "title": "Idle Expires At" + }, + "absolute_expires_at": { + "type": "string", + "format": "date-time", + "title": "Absolute Expires At" + } + }, + "type": "object", + "required": [ + "user_id", + "organization_id", + "email", + "organization_role", + "session_created_at", + "last_seen_at", + "idle_expires_at", + "absolute_expires_at" + ], + "title": "SessionInfo", + "description": "The authenticated session and its server-side expiry horizon.\n\n`idle_expires_at` moves with activity; `absolute_expires_at` never does.\nWhichever passes first ends the session (Non-functional v0.11)." + }, "SeverityCounts": { "properties": { "critical": { @@ -8391,6 +8916,12 @@ } }, "securitySchemes": { + "SessionCookie": { + "type": "apiKey", + "description": "Browser session cookie set by `POST /auth/login`. HttpOnly, Secure, SameSite=Lax; the session record and its idle/absolute timeouts are enforced server-side.", + "in": "cookie", + "name": "__Host-session" + }, "OpenIdConnect": { "type": "openIdConnect", "description": "OpenID Connect token issued by the platform identity provider. Some operations additionally require current MFA assurance, read from the token at request time.", diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index a834cd4..cc139a2 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -47,10 +47,19 @@ services: restart: unless-stopped environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:?}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-nc3_testing_platform} - # Runtime role connection (docs/database-roles.md); unused until the session layer lands (B5). + # Runtime role connection (docs/database-roles.md). # The password is interpolated raw: if it contains URL-reserved characters # (@ : / % ? #), also set APP_DATABASE_URL with it percent-encoded. APP_DATABASE_URL: ${APP_DATABASE_URL:-postgresql+psycopg://nc3_app:${NC3_APP_DB_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-nc3_testing_platform}} + # Credential-surface connection (B3, docs/database-roles.md): the api + # service alone holds nc3_auth; no worker service gets this variable. + AUTH_DATABASE_URL: ${AUTH_DATABASE_URL:-postgresql+psycopg://nc3_auth:${NC3_AUTH_DB_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-nc3_testing_platform}} + # Envelope master key (data-model §1.2) — api only, never the workers. + # Set the value in Dokploy's environment tab, or mount a secret file and + # point APP_ENCRYPTION_MASTER_KEY_FILE at it. + APP_ENCRYPTION_MASTER_KEY: ${APP_ENCRYPTION_MASTER_KEY:?} + # Browser origin for the CSRF origin check (core/csrf.py). + AUTH_PUBLIC_ORIGIN: ${AUTH_PUBLIC_ORIGIN:-} CELERY_BROKER_URL: amqp://${RABBITMQ_USER:?}:${RABBITMQ_PASSWORD:?}@rabbitmq:5672// REDIS_URL: redis://redis:6379/0 OIDC_DISCOVERY_URL: ${OIDC_DISCOVERY_URL:-} diff --git a/docs/database-roles.md b/docs/database-roles.md index 66ccfb5..f195aa7 100644 --- a/docs/database-roles.md +++ b/docs/database-roles.md @@ -1,11 +1,14 @@ # Database roles -Three PostgreSQL roles, three jobs. Migrations run as the **owning role** — -the `postgres` superuser in the local Compose stack, whatever owner the -deployment provisions — and own every object they create. The application -connects as two non-owner runtime roles: **`nc3_app`** for tenant work (the -API and the scan-queue workers) and **`app_platform`** for cross-organization -platform duties (the platform-queue worker and beat). Row-level security +Migrations run as the **owning role** — the `postgres` superuser in the +local Compose stack, whatever owner the deployment provisions — and own every +object they create. The application connects as three non-owner runtime +roles: **`nc3_app`** for tenant work (the API and the scan-queue workers), +**`app_platform`** for cross-organization platform duties (the +platform-queue worker and beat), and **`nc3_auth`** for the credential +surface (the API service alone, B3 / US #79). A fourth, NOLOGIN role — +**`nc3_auth_definer`** — exists only to own the two SECURITY DEFINER auth +lookups. Row-level security binds to both (IDR-012): every application table carries `ENABLE` **and** `FORCE ROW LEVEL SECURITY`, `nc3_app` reaches rows only through the per-transaction context arms below, and `app_platform` only through its @@ -18,6 +21,8 @@ per-duty allowlist policies. Neither role has `BYPASSRLS`; nothing does. | owning role (`postgres` in dev) | Alembic (`make db-*`), operators | Superuser/owner: DDL, grants, everything. The dev superuser bypasses RLS outright; a non-superuser owner is subject to `FORCE` (see [database-migrations.md](database-migrations.md)). | | `nc3_app` | API service, scan-queue workers (`APP_DATABASE_URL`) | `SELECT`/`INSERT`/`UPDATE`/`DELETE` on application tables — minus the append-only exceptions below — scoped per row by the `tenant_rows` policies. No DDL, no role management, no `alembic_version`. | | `app_platform` | platform-queue worker and beat (`APP_DATABASE_URL`, overridden per service in compose) | Duty allowlist only: `SELECT`/`INSERT`/`UPDATE` on `scan_job` and `scan_task` (dispatch, reaper, heartbeat, seed tool), `INSERT` on `audit_event`. Nothing else — adding a platform duty means adding a grant + policy in a revision, never widening one. | +| `nc3_auth` | API service only (`AUTH_DATABASE_URL`) | Credential surface: `SELECT`/`INSERT`/`UPDATE` on `user_credential` and `user_session` (no `DELETE` — revocation is an UPDATE; hard deletion arrives with the erasure story), the registration transaction (`SELECT`/`INSERT` on `organization`, `app_user`, `key_envelope`, `statement_response`; `SELECT` on `statement`), and `EXECUTE` on the two auth lookups. Deliberately **not** granted to `nc3_app`: the scan workers hold that role and GUCs are app-asserted, so any `nc3_app` grant here would let a compromised worker forge a session row (US #79). | +| `nc3_auth_definer` | nobody (NOLOGIN) | Owns `auth_login_lookup` and `auth_session_bootstrap`; its only privilege is an explicit `FOR SELECT USING (true)` policy + `SELECT` grant on `app_user`, `user_credential`, `user_session` — the reviewable "bypass" of IDR-012's session bootstrap, with no BYPASSRLS anywhere. | Exceptions to `nc3_app`'s blanket data grant, from the data model's append-only rules (§5.2, §12.1): **no `UPDATE` or `DELETE` on @@ -67,6 +72,25 @@ the task row *under the policy*. A forged or missing hint loads zero rows and the delivery drops without writing. That is what "RLS revalidated on Celery result write" means mechanically. +## The SECURITY DEFINER auth lookups (IDR-012, B3) + +Before identity is known no RLS arm can open, so exactly two pre-context +reads exist, as `SECURITY DEFINER` SQL functions: `auth_login_lookup(email)` +(login: email → credential row) and `auth_session_bootstrap(token_hash)` +(every authenticated request: cookie hash → session row). Hardening, all +asserted by `tests/test_auth_postgres.py`: + +- owned by `nc3_auth_definer` (NOLOGIN, NOBYPASSRLS) — on a non-superuser + owner `FORCE` binds function owners too, so the functions read through an + explicit `definer_lookup` `FOR SELECT USING (true)` policy on exactly the + three tables they join, in the same allowlist shape as `app_platform`'s + duty policies; +- `SET search_path = ''` with schema-qualified references; +- `EXECUTE` revoked from `PUBLIC`, granted to `nc3_auth` alone — `nc3_app` + and `app_platform` calling either is a permission error; +- read-only (`STABLE`): the lockout increment and the `last_seen_at` touch + are ordinary in-policy writes under the user context, never definer writes. + ## Where the roles come from Both roles are created by hand-written Alembic revisions (`nc3_app role and @@ -80,21 +104,25 @@ membership, and finally validates the role's *effective* privileges **No password appears in any migration.** The revisions create the roles with `LOGIN` but no credential; each environment sets its own: -- **Development**: `NC3_APP_DB_PASSWORD` and `NC3_PLATFORM_DB_PASSWORD` in - `.env` (see `.env.example`), applied once per PostgreSQL cluster — the +- **Development**: `NC3_APP_DB_PASSWORD`, `NC3_PLATFORM_DB_PASSWORD`, and + `NC3_AUTH_DB_PASSWORD` in `.env` (see `.env.example`), applied once per + PostgreSQL cluster — the `ALTER ROLE` must set the same values the compose URLs interpolate: ```bash set -a; . ./.env; set +a docker compose exec postgres psql -U postgres -d nc3_testing_platform \ -c "ALTER ROLE nc3_app PASSWORD '${NC3_APP_DB_PASSWORD}'" \ - -c "ALTER ROLE app_platform PASSWORD '${NC3_PLATFORM_DB_PASSWORD}'" + -c "ALTER ROLE app_platform PASSWORD '${NC3_PLATFORM_DB_PASSWORD}'" \ + -c "ALTER ROLE nc3_auth PASSWORD '${NC3_AUTH_DB_PASSWORD}'" ``` -- **Dokploy**: set both variables in the application's environment tab and - run the same `ALTER ROLE` against the deployment database. + `nc3_auth_definer` is NOLOGIN and never gets a password. + +- **Dokploy**: set all three variables in the application's environment tab + and run the same `ALTER ROLE` against the deployment database. -The API and worker services carry `APP_DATABASE_URL`; `worker/db.py` reads it +The API service additionally carries `AUTH_DATABASE_URL` (`core/api_db.py`; settings field `auth_database_url`) for the `nc3_auth` engine — no worker service gets it, and the deployment master-key secret mounts into the api service only. The API and worker services carry `APP_DATABASE_URL`; `worker/db.py` reads it (via `settings.app_database_url`). Which role the credential names is compose topology: the api and scan-worker services get `nc3_app`, worker-platform and beat get `app_platform`. `PLATFORM_DATABASE_URL` is a **Compose interpolation @@ -116,9 +144,9 @@ percent-encoding. `DATABASE_URL` stays the owning role, for Alembic and explicit grant per table keeps the privilege surface reviewable in the diff. - `app_platform` gets **no** blanket grant, ever. A new platform duty is a new grant + policy pair named after the duty. -- **The isolation suite (`tests/test_org_isolation.py`, `pytest -m - postgres`) is the standing regression gate for any change to roles, grants, - or policies.** It connects as the runtime roles and asserts the cross-org, +- **The isolation suites (`tests/test_org_isolation.py` and + `tests/test_auth_postgres.py`, `pytest -m postgres`) are the standing + regression gate for any change to roles, grants, or policies.** It connects as the runtime roles and asserts the cross-org, cross-user, and guest boundaries, the worker hint-then-verify path, the pool-leak guard, the append-only refusals, and the duty allowlist. CI runs it inside the Migration round trip job. diff --git a/docs/reference/data-model-v4_0_2.md b/docs/reference/data-model-v4_0_2.md index 5f042e4..afb2719 100644 --- a/docs/reference/data-model-v4_0_2.md +++ b/docs/reference/data-model-v4_0_2.md @@ -130,7 +130,8 @@ Namespaced text values, not database enums: `statement_key`, `required_context_t - A registered platform user belongs to exactly one organization. - Platform-administrator status comes from the identity provider and is independent of the organization role. -- The identity provider stays the system of record for identity, credentials, authentication methods, sessions, and MFA enrollment. +- Since B3 (US #79) the platform is its own identity provider: this row is the identity projection (`identity_subject` keys issuer + subject; local accounts use `local:`), while credentials and sessions live in the user-private tables of §3.5-§3.6 — never here, because this row is visible org-wide for member management (IDR-012). +- `email` is unique case-insensitively (expression index `uq_app_user_email_lower`); the application lowercases at the boundary. ### 3.3 `key_envelope` @@ -203,6 +204,34 @@ Account erasure completes within 30 days. The workflow deletes the user-scope `k Organization erasure additionally deletes the organization-scope `key_envelope`, crypto-shredding organization-owned encrypted values (§3.3), before the `organization` row and its owned data are removed. +### 3.5 `user_credential` (B3 / US #79) + +User-private row: RLS user arm only (`user_id = app.current_user`), granted to the `nc3_auth` role alone — the API service's credential-surface connection. The password hash is argon2id, encrypted (AES-256-GCM, `nonce || ct`) under the user-scope KEK of §3.3, so user erasure crypto-shreds it even out of backups. + +| Column | Type | Constraints | +|-----------------------|-------------|--------------------------------------------------------------| +| `id` | UUID | Primary key | +| `user_id` | UUID | Not null; unique; foreign key to `app_user.id` (cascade) | +| `password_ciphertext` | bytea | Not null; argon2id hash under the user-scope KEK | +| `failed_login_count` | integer | Not null; default `0`; per-account brute-force counter | +| `locked_until` | timestamptz | Nullable; lockout horizon after repeated failures | +| `password_updated_at` | timestamptz | Not null | +| `created_at` | timestamptz | Not null | +| `updated_at` | timestamptz | Not null | + +### 3.6 `user_session` (B3 / US #79) + +User-private row, same RLS class and role as §3.5. One row per browser session (IDR-010); the cookie holds the token, the row holds its SHA-256. The pre-context resolution token hash → session is the `auth_session_bootstrap` SECURITY DEFINER lookup (IDR-012); idle/absolute timeouts are enforced by the application against these stamps. §13.6 holds: MFA assurance will live on the session, never as a User boolean. + +| Column | Type | Constraints | +|----------------|-------------|-----------------------------------------------------------| +| `id` | UUID | Primary key | +| `user_id` | UUID | Not null; foreign key to `app_user.id` (cascade); indexed | +| `token_hash` | bytea | Not null; unique; SHA-256 of the cookie token | +| `created_at` | timestamptz | Not null; anchors the absolute timeout | +| `last_seen_at` | timestamptz | Not null; anchors the idle timeout | +| `revoked_at` | timestamptz | Nullable; set by logout and session rotation | + ```mermaid erDiagram ORGANIZATION ||--o| KEY_ENVELOPE: "owns (scope organization)" diff --git a/infra/compose/api.yml b/infra/compose/api.yml index b634972..12bb1b4 100644 --- a/infra/compose/api.yml +++ b/infra/compose/api.yml @@ -17,6 +17,14 @@ services: # The password is interpolated raw: if it contains URL-reserved characters # (@ : / % ? #), set APP_DATABASE_URL directly with it percent-encoded. APP_DATABASE_URL: ${APP_DATABASE_URL:-postgresql+psycopg://nc3_app:${NC3_APP_DB_PASSWORD:-nc3_app}@postgres:5432/${POSTGRES_DB:-nc3_testing_platform}} + # Credential-surface connection (B3, docs/database-roles.md): the api + # service alone holds nc3_auth; no worker service gets this variable. + AUTH_DATABASE_URL: ${AUTH_DATABASE_URL:-postgresql+psycopg://nc3_auth:${NC3_AUTH_DB_PASSWORD:-nc3_auth}@postgres:5432/${POSTGRES_DB:-nc3_testing_platform}} + # Envelope master key (data-model §1.2) — api only, never the workers. + # Development value from .env; production mounts a secret file and sets + # APP_ENCRYPTION_MASTER_KEY_FILE instead. + APP_ENCRYPTION_MASTER_KEY: ${APP_ENCRYPTION_MASTER_KEY:-} + AUTH_PUBLIC_ORIGIN: ${AUTH_PUBLIC_ORIGIN:-} CELERY_BROKER_URL: amqp://${RABBITMQ_USER:-rabbitmq}:${RABBITMQ_PASSWORD:-rabbitmq}@rabbitmq:5672// REDIS_URL: redis://redis:6379/0 # Service hostname, not localhost: the API reaches Rauthy over the diff --git a/migrations/versions/2026_08_18_a9f2c4e6b8d0_seed_v4_0_account_statements.py b/migrations/versions/2026_08_18_a9f2c4e6b8d0_seed_v4_0_account_statements.py new file mode 100644 index 0000000..761548b --- /dev/null +++ b/migrations/versions/2026_08_18_a9f2c4e6b8d0_seed_v4_0_account_statements.py @@ -0,0 +1,62 @@ +"""Seed the v4.0 statements: terms acceptance and scan-target attestation. + +Revision: a9f2c4e6b8d0 +Revises: b3c7a9e2f4d1 + +Registration (B3 / US #79) records a consent receipt against real `statement` +rows, so the reference rows must exist. Ids, versions, hashes, and URIs are +verbatim from the mock `GET /statements` (domains/statements/router.py) — +the mock and the database agree until that operation is realized. The DPO +delivers the final texts (Non-functional → GDPR); a new text is a new version +row, never an edit here. + +`statement` carries FORCE RLS with a read-only policy, so on a non-superuser +owner this DML must lift FORCE for the duration of its own transaction +(docs/database-migrations.md — the table is empty, the ACCESS EXCLUSIVE lock +is momentary). +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "a9f2c4e6b8d0" +down_revision: str | None = "b3c7a9e2f4d1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_TERMS_ID = "019ee1a2-0011-7c22-8d33-4e55f6a77b88" +_ATTESTATION_ID = "019ee1a2-2233-7e44-af55-6a77b899cdaa" + + +def upgrade() -> None: + """Apply this revision.""" + op.execute("ALTER TABLE statement NO FORCE ROW LEVEL SECURITY") + op.execute( + f""" + INSERT INTO statement + (id, statement_key, version, response_kind, required_context_type, + content_hash, content_uri, effective_at, retired_at) + VALUES + ('{_TERMS_ID}', 'terms_and_conditions', '2026-01-15', 'acceptance', + NULL, + 'sha256:2f8a1c9d4e7b0a3f6c5d8e1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6c5d8e1b4a7f', + 'https://testing.nc3.lu/legal/terms/2026-01-15', + '2026-01-15T00:00:00Z', NULL), + ('{_ATTESTATION_ID}', 'scan_target_permission', '2026-01-15', + 'attestation', 'scan_job', + 'sha256:7b0a3f6c5d8e1b4a7f0c3d6e9b2a5f8c1d4e7b0a3f6c5d8e1b4a7f2f8a1c9d4e', + 'https://testing.nc3.lu/legal/scan-permission/2026-01-15', + '2026-01-15T00:00:00Z', NULL) + """ + ) + op.execute("ALTER TABLE statement FORCE ROW LEVEL SECURITY") + + +def downgrade() -> None: + """Revert this revision.""" + op.execute("ALTER TABLE statement NO FORCE ROW LEVEL SECURITY") + op.execute( + f"DELETE FROM statement WHERE id IN ('{_TERMS_ID}', '{_ATTESTATION_ID}')" + ) + op.execute("ALTER TABLE statement FORCE ROW LEVEL SECURITY") diff --git a/migrations/versions/2026_08_18_b3c7a9e2f4d1_auth_tables_nc3_auth_role_and_session_bootstrap.py b/migrations/versions/2026_08_18_b3c7a9e2f4d1_auth_tables_nc3_auth_role_and_session_bootstrap.py new file mode 100644 index 0000000..7cec453 --- /dev/null +++ b/migrations/versions/2026_08_18_b3c7a9e2f4d1_auth_tables_nc3_auth_role_and_session_bootstrap.py @@ -0,0 +1,467 @@ +"""Auth tables, nc3_auth role, and the session-bootstrap lookups. + +Revision: b3c7a9e2f4d1 +Revises: 0d5679b5500d + +Hand-written past the two table creations: autogenerate never sees roles, +grants, policies, or functions (docs/database-roles.md). Implements the B3 +slice of IDR-012: + +* `user_credential` and `user_session` — user-owned RLS rows, granted to a + new **`nc3_auth`** connection role that only the API service holds. The + scan workers share `nc3_app`, and RLS GUCs are application-asserted, so + any `nc3_app` grant here would let a compromised worker forge a session + row or exfiltrate credential ciphertext (US #79 review). +* Two SECURITY DEFINER lookups — `auth_login_lookup` (email → credential) + and `auth_session_bootstrap` (token hash → session) — the only reads that + can happen before an RLS context exists, because identity is what they + resolve. They are owned by **`nc3_auth_definer`**, a NOLOGIN role whose + only privilege is an explicit `FOR SELECT USING (true)` policy on exactly + the three tables the lookups join. No BYPASSRLS anywhere: on a + non-superuser owner FORCE RLS binds even function owners, so the "bypass" + is a reviewable allowlist policy, in the same shape as `app_platform`'s + duty policies. +* The org-arm policies of the registration transaction (organization, + app_user, key_envelope, statement_response; the statement read) are + extended to `nc3_auth` — registration provisions the workspace org + (IDR-016) inside one `nc3_auth` transaction. +* `app_user` gains the case-insensitive unique email index the login + lookup relies on. + +Closing gates re-run the deny-until-classified check (this revision creates +tables after 0d5679b5500d's gate ran) and pin both new roles to their +enumerated privileges. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b3c7a9e2f4d1" +down_revision: str | None = "0d5679b5500d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_USER = "NULLIF(current_setting('app.current_user', true), '')::uuid" + +# The tables of the registration transaction whose existing nc3_app policies +# gain the nc3_auth arm (ALTER POLICY replaces the role list). +_REGISTRATION_TENANT_TABLES = ( + "organization", + "app_user", + "key_envelope", + "statement_response", +) + +# The tables the two SECURITY DEFINER lookups join. +_DEFINER_LOOKUP_TABLES = ("app_user", "user_credential", "user_session") + + +def _create_role_defensively(role: str, *, login: bool) -> None: + """CREATE ROLE with the fa547b13b972 defensive shape. + + Idempotent creation (roles are cluster-level), attribute re-assertion + (NOBYPASSRLS included), and a loud refusal while the role inherits + anything through a membership. + """ + login_sql = "LOGIN" if login else "NOLOGIN" + op.execute( + f""" + DO $$ + BEGIN + CREATE ROLE {role} {login_sql} + NOSUPERUSER NOCREATEDB NOCREATEROLE + NOBYPASSRLS NOREPLICATION; + EXCEPTION WHEN duplicate_object THEN + NULL; -- created by another database's upgrade or a prior run + END + $$; + """ + ) + op.execute( + f"ALTER ROLE {role} {login_sql} " + "NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION" + ) + op.execute( + f""" + DO $$ + DECLARE + memberships text; + BEGIN + SELECT string_agg(r.rolname, ', ') + INTO memberships + FROM pg_auth_members m + JOIN pg_roles r ON r.oid = m.roleid + WHERE m.member = '{role}'::regrole; + IF memberships IS NOT NULL THEN + RAISE EXCEPTION '{role} is a member of: % — inherited ' + 'privileges would bypass this revision''s allowlist; ' + 'revoke those memberships first (docs/database-roles.md)', + memberships; + END IF; + END + $$; + """ + ) + + +def upgrade() -> None: + """Apply this revision.""" + # --- The two user-owned tables (models: domains/auth/models.py). + op.create_table( + "user_credential", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("password_ciphertext", sa.LargeBinary(), nullable=False), + sa.Column( + "failed_login_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "password_updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name=op.f("fk_user_credential_user_id_app_user"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_credential")), + sa.UniqueConstraint("user_id", name=op.f("uq_user_credential_user_id")), + ) + op.create_table( + "user_session", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "last_seen_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name=op.f("fk_user_session_user_id_app_user"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_session")), + sa.UniqueConstraint("token_hash", name=op.f("uq_user_session_token_hash")), + ) + op.create_index( + op.f("ix_user_session_user_id"), "user_session", ["user_id"], unique=False + ) + # One account per email, case-insensitive; also what auth_login_lookup + # scans. The application lowercases at the boundary. + op.create_index( + "uq_app_user_email_lower", + "app_user", + [sa.text("lower(email)")], + unique=True, + ) + + # --- The two roles. + _create_role_defensively("nc3_auth", login=True) + _create_role_defensively("nc3_auth_definer", login=False) + + # --- Grants: duty-minimal, spelled per table (docs/database-roles.md). + op.execute("GRANT USAGE ON SCHEMA public TO nc3_auth") + op.execute("GRANT USAGE ON SCHEMA public TO nc3_auth_definer") + # The credential surface belongs to nc3_auth alone — deliberately NOT + # granted to nc3_app (US #79). No DELETE: logout and rotation revoke, + # hard deletion arrives with the erasure story on its own grant. + op.execute( + "GRANT SELECT, INSERT, UPDATE ON TABLE user_credential, user_session " + "TO nc3_auth" + ) + # The registration transaction (IDR-016): INSERT the workspace org, the + # admin user, both envelopes, and the consent receipts; SELECT is needed + # by the ORM's INSERT..RETURNING of server defaults. + op.execute( + "GRANT SELECT, INSERT ON TABLE organization, app_user, key_envelope, " + "statement_response TO nc3_auth" + ) + op.execute("GRANT SELECT ON TABLE statement TO nc3_auth") + # The definer owner reads exactly what its two functions join. + op.execute( + "GRANT SELECT ON TABLE app_user, user_credential, user_session " + "TO nc3_auth_definer" + ) + + # --- RLS on the new tables: deny-until-classified means classify now. + for table in ("user_credential", "user_session"): + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY tenant_rows ON {table} FOR ALL TO nc3_auth " + f"USING (user_id = {_USER}) WITH CHECK (user_id = {_USER})" + ) + # The definer arm: an explicit, reviewable USING (true) SELECT policy on + # a NOLOGIN role — the IDR-012 "sole bypass", without BYPASSRLS. On a + # non-superuser owner FORCE RLS binds function owners too, so without + # these the lookups would return nothing exactly where it matters. + for table in _DEFINER_LOOKUP_TABLES: + op.execute( + f"CREATE POLICY definer_lookup ON {table} " + "FOR SELECT TO nc3_auth_definer USING (true)" + ) + # Registration runs as nc3_auth: extend the existing arms. ALTER POLICY + # replaces the role list, so both roles are named. + for table in _REGISTRATION_TENANT_TABLES: + op.execute(f"ALTER POLICY tenant_rows ON {table} TO nc3_app, nc3_auth") + op.execute("ALTER POLICY reference_read ON statement TO nc3_app, nc3_auth") + + # --- The SECURITY DEFINER lookups (IDR-012). Hardened: pinned empty + # search_path, schema-qualified references, at most one row out, EXECUTE + # revoked from PUBLIC and granted to nc3_auth alone. + op.execute( + """ + CREATE FUNCTION public.auth_login_lookup(p_email text) + RETURNS TABLE ( + user_id uuid, + organization_id uuid, + disabled_at timestamptz, + password_ciphertext bytea, + failed_login_count integer, + locked_until timestamptz, + observed_at timestamptz + ) + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' + AS $$ + SELECT u.id, u.organization_id, u.disabled_at, + c.password_ciphertext, c.failed_login_count, c.locked_until, + now() + FROM public.app_user u + JOIN public.user_credential c ON c.user_id = u.id + WHERE lower(u.email) = lower(p_email) + $$; + """ + ) + op.execute( + """ + CREATE FUNCTION public.auth_session_bootstrap(p_token_hash bytea) + RETURNS TABLE ( + session_id uuid, + user_id uuid, + organization_id uuid, + session_created_at timestamptz, + last_seen_at timestamptz, + revoked_at timestamptz, + user_disabled_at timestamptz, + observed_at timestamptz + ) + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' + AS $$ + SELECT s.id, s.user_id, u.organization_id, s.created_at, + s.last_seen_at, s.revoked_at, u.disabled_at, now() + FROM public.user_session s + JOIN public.app_user u ON u.id = s.user_id + WHERE s.token_hash = p_token_hash + $$; + """ + ) + # Ownership transfer needs momentary membership on a non-superuser owner + # (a no-op privilege-wise for the dev superuser); revoked right after. + op.execute("GRANT nc3_auth_definer TO CURRENT_USER") + op.execute( + "ALTER FUNCTION public.auth_login_lookup(text) OWNER TO nc3_auth_definer" + ) + op.execute( + "ALTER FUNCTION public.auth_session_bootstrap(bytea) " + "OWNER TO nc3_auth_definer" + ) + op.execute("REVOKE nc3_auth_definer FROM CURRENT_USER") + for signature in ( + "public.auth_login_lookup(text)", + "public.auth_session_bootstrap(bytea)", + ): + op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC") + op.execute(f"GRANT EXECUTE ON FUNCTION {signature} TO nc3_auth") + + # --- Gate 1: deny-until-classified, re-run because this revision created + # tables after 0d5679b5500d's gate ran (same SQL; docs/database-roles.md). + op.execute( + """ + DO $$ + DECLARE + offending text; + BEGIN + SELECT string_agg(c.relname, ', ' ORDER BY c.relname) + INTO offending + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'r' + AND c.relname <> 'alembic_version' + AND (NOT c.relrowsecurity + OR NOT c.relforcerowsecurity + OR NOT EXISTS ( + SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)); + IF offending IS NOT NULL THEN + RAISE EXCEPTION 'tables without forced RLS and a policy: % — ' + 'classify them in a revision before shipping ' + '(docs/database-roles.md)', offending; + END IF; + END + $$; + """ + ) + # --- Gate 2: nc3_auth must hold nothing beyond its enumerated duties — + # in particular nothing on the scan chain or the audit log (audit writes + # are B7's), and no DELETE anywhere. + op.execute( + """ + DO $$ + DECLARE + offending text; + BEGIN + SELECT string_agg(check_name, ', ') + INTO offending + FROM (VALUES + ('scan_job (any)', + has_table_privilege('nc3_auth', 'scan_job', + 'SELECT, INSERT, UPDATE, DELETE')), + ('asset (any)', + has_table_privilege('nc3_auth', 'asset', + 'SELECT, INSERT, UPDATE, DELETE')), + ('api_key (any)', + has_table_privilege('nc3_auth', 'api_key', + 'SELECT, INSERT, UPDATE, DELETE')), + ('audit_event (any)', + has_table_privilege('nc3_auth', 'audit_event', + 'SELECT, INSERT, UPDATE, DELETE')), + ('user_credential DELETE', + has_table_privilege('nc3_auth', 'user_credential', 'DELETE')), + ('user_session DELETE', + has_table_privilege('nc3_auth', 'user_session', 'DELETE')), + ('organization UPDATE/DELETE', + has_table_privilege('nc3_auth', 'organization', + 'UPDATE, DELETE')), + ('app_user UPDATE/DELETE', + has_table_privilege('nc3_auth', 'app_user', 'UPDATE, DELETE')), + ('statement (any write)', + has_table_privilege('nc3_auth', 'statement', + 'INSERT, UPDATE, DELETE')), + ('alembic_version (any)', + has_table_privilege('nc3_auth', 'alembic_version', + 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')), + ('schema public CREATE', + has_schema_privilege('nc3_auth', 'public', 'CREATE')) + ) AS t(check_name, held) + WHERE held; + IF offending IS NOT NULL THEN + RAISE EXCEPTION 'nc3_auth holds privileges beyond its duty ' + 'allowlist (%) — likely granted to PUBLIC out of band; ' + 'revoke them first (docs/database-roles.md)', offending; + END IF; + END + $$; + """ + ) + # --- Gate 3: the definer owner reads its three tables and does nothing + # else, anywhere, ever. + op.execute( + """ + DO $$ + DECLARE + offending text; + BEGIN + SELECT string_agg(check_name, ', ') + INTO offending + FROM (VALUES + ('user_credential (any write)', + has_table_privilege('nc3_auth_definer', 'user_credential', + 'INSERT, UPDATE, DELETE')), + ('user_session (any write)', + has_table_privilege('nc3_auth_definer', 'user_session', + 'INSERT, UPDATE, DELETE')), + ('app_user (any write)', + has_table_privilege('nc3_auth_definer', 'app_user', + 'INSERT, UPDATE, DELETE')), + ('key_envelope (any)', + has_table_privilege('nc3_auth_definer', 'key_envelope', + 'SELECT, INSERT, UPDATE, DELETE')), + ('alembic_version (any)', + has_table_privilege('nc3_auth_definer', 'alembic_version', + 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')), + ('schema public CREATE', + has_schema_privilege('nc3_auth_definer', 'public', 'CREATE')) + ) AS t(check_name, held) + WHERE held; + IF offending IS NOT NULL THEN + RAISE EXCEPTION 'nc3_auth_definer holds privileges beyond its ' + 'lookup allowlist (%) — revoke them first ' + '(docs/database-roles.md)', offending; + END IF; + END + $$; + """ + ) + + +def downgrade() -> None: + """Revert this revision.""" + op.execute("DROP FUNCTION IF EXISTS public.auth_login_lookup(text)") + op.execute("DROP FUNCTION IF EXISTS public.auth_session_bootstrap(bytea)") + op.execute("DROP POLICY IF EXISTS definer_lookup ON app_user") + for table in _REGISTRATION_TENANT_TABLES: + op.execute(f"ALTER POLICY tenant_rows ON {table} TO nc3_app") + op.execute("ALTER POLICY reference_read ON statement TO nc3_app") + op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA public FROM nc3_auth") + op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA public FROM nc3_auth_definer") + op.execute("REVOKE USAGE ON SCHEMA public FROM nc3_auth") + op.execute("REVOKE USAGE ON SCHEMA public FROM nc3_auth_definer") + # Table drops take their policies, FORCE flags, and remaining grants along. + op.drop_index(op.f("ix_user_session_user_id"), table_name="user_session") + op.drop_table("user_session") + op.drop_table("user_credential") + op.drop_index("uq_app_user_email_lower", table_name="app_user") + # Cluster-level roles: dropping fails while another database still holds + # grants to them — surface that actionably (same shape as fa547b13b972). + for role in ("nc3_auth", "nc3_auth_definer"): + op.execute( + f""" + DO $$ + BEGIN + DROP ROLE IF EXISTS {role}; + EXCEPTION WHEN dependent_objects_still_exist THEN + RAISE EXCEPTION '{role} still holds privileges in another ' + 'database of this cluster; revoke them there, then rerun ' + 'the downgrade (docs/database-roles.md)'; + END + $$; + """ + ) diff --git a/pyproject.toml b/pyproject.toml index 9013586..036264c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ license = "GPL-3.0-only" license-files = ["LICENSE"] dependencies = [ "alembic>=1.19.1", + "argon2-cffi>=25.1.0", "celery[gevent,redis]>=5.6.3", + "cryptography>=46.0.3", "fastapi[standard]>=0.141.1", "idna>=3.18", "psycopg[binary]>=3.3.4", diff --git a/src/nc3_testing_platform/core/api_db.py b/src/nc3_testing_platform/core/api_db.py new file mode 100644 index 0000000..7d098b7 --- /dev/null +++ b/src/nc3_testing_platform/core/api_db.py @@ -0,0 +1,111 @@ +"""API-side database access: one lazily built sync engine per runtime role. + +The API connects with two roles (docs/database-roles.md): ``nc3_app`` for +tenant work — the future domain realizations — and ``nc3_auth`` for the +credential surface, held by the API service alone so the scan workers that +share ``nc3_app`` never gain a privilege on `user_credential`/`user_session` +(US #79). Engines are lazy for the same reason as `worker/db.py`: importing +this module costs nothing in a process that never opens a session. + +The request dependencies commit on handler success and roll back on an +escaping exception — deliberately unlike the worker's explicit-commit +contextmanager: the worker orders its commits before event publishes +(Datastore-split ADR), while an API request has no such ordering, and the +session-touch write of the authentication dependency (`core/security.py`) +must persist even under handlers that write nothing themselves. +""" + +import threading +from collections.abc import Iterator +from typing import Annotated + +import sqlalchemy as sa +from fastapi import Depends +from sqlalchemy.orm import Session, sessionmaker + +# The aggregator import is load-bearing: mapper configuration needs every +# referenced table on the metadata, and API code imports only the models it +# touches (same rationale as worker/db.py). +from nc3_testing_platform import models as _models # noqa: F401 +from nc3_testing_platform.core.settings import settings + +# FastAPI runs sync dependencies on threadpool workers, so two cold-start +# requests can race the lazy construction; the losing engine's pool would +# leak for the process lifetime. One lock, double-checked, covers all four +# globals — construction happens once per process and the steady-state path +# never contends. +_init_lock = threading.Lock() + +_app_engine: sa.Engine | None = None +_app_factory: sessionmaker[Session] | None = None +_auth_engine: sa.Engine | None = None +_auth_factory: sessionmaker[Session] | None = None + + +def get_app_engine() -> sa.Engine: + """The process-wide ``nc3_app`` engine, created on first use.""" + global _app_engine + if _app_engine is None: + with _init_lock: + if _app_engine is None: + _app_engine = sa.create_engine( + settings.app_database_url, pool_pre_ping=True + ) + return _app_engine + + +def get_auth_engine() -> sa.Engine: + """The process-wide ``nc3_auth`` engine, created on first use.""" + global _auth_engine + if _auth_engine is None: + with _init_lock: + if _auth_engine is None: + _auth_engine = sa.create_engine( + settings.auth_database_url, pool_pre_ping=True + ) + return _auth_engine + + +def _unit_of_work(factory: sessionmaker[Session]) -> Iterator[Session]: + unit = factory() + try: + yield unit + unit.commit() + except BaseException: + unit.rollback() + raise + finally: + unit.close() + + +def app_session() -> Iterator[Session]: + """Request-scoped tenant session (``nc3_app``); commits on success.""" + global _app_factory + if _app_factory is None: + # The engine is resolved before acquiring: the lock is not reentrant + # and get_app_engine takes it on its own cold path. + engine = get_app_engine() + with _init_lock: + if _app_factory is None: + _app_factory = sessionmaker(bind=engine) + yield from _unit_of_work(_app_factory) + + +def auth_session() -> Iterator[Session]: + """Request-scoped credential-surface session (``nc3_auth``). + + One transaction per request: the RLS context asserted inside it + (`core/rls.py`, ``SET LOCAL``) dies at the commit this dependency issues. + """ + global _auth_factory + if _auth_factory is None: + # Same non-reentrant-lock ordering as app_session. + engine = get_auth_engine() + with _init_lock: + if _auth_factory is None: + _auth_factory = sessionmaker(bind=engine) + yield from _unit_of_work(_auth_factory) + + +AppDbSession = Annotated[Session, Depends(app_session)] +AuthDbSession = Annotated[Session, Depends(auth_session)] diff --git a/src/nc3_testing_platform/core/crypto.py b/src/nc3_testing_platform/core/crypto.py new file mode 100644 index 0000000..0dbbc5c --- /dev/null +++ b/src/nc3_testing_platform/core/crypto.py @@ -0,0 +1,117 @@ +"""Application-layer envelope encryption (IDR-011/IDR-017, data-model §1.2). + +Two levels: the deployment master key — a mounted secret, never stored — wraps +one random KEK per `key_envelope` row; data is encrypted under the unwrapped +scope KEK, or under a per-record DEK itself wrapped by the KEK where a table +stores a `wrapped_dek` column (data-model §3.5, §12.1). AES-256-GCM throughout. + +Blob layout: :func:`encrypt` returns ``nonce || ciphertext`` so a stored blob +is self-contained; `key_envelope` alone keeps its nonce in a column of its own +because the schema says so (§3.3), which is what the :class:`WrappedKey` shape +carries. AAD binds every ciphertext to its purpose — a blob lifted from one +column can never be replayed into another. + +Settings are read at call time, never bound at import, so tests can +monkeypatch ``settings.app_encryption_master_key``. +""" + +import secrets +from dataclasses import dataclass + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from nc3_testing_platform.core.settings import settings + +ENVELOPE_ALGORITHM = "AES-256-GCM" +_NONCE_BYTES = 12 +_KEY_BYTES = 32 + + +class MasterKeyUnavailableError(RuntimeError): + """The deployment master key is not configured. + + Raised instead of any fallback: an operation that needs the key must fail + loudly (a logged 500), never degrade to plaintext. + """ + + +class DecryptionError(RuntimeError): + """Authenticated decryption failed: wrong key, wrong AAD, or tampering.""" + + +def master_key() -> bytes: + """The deployment master key, decoded from settings. + + :raises MasterKeyUnavailableError: When ``APP_ENCRYPTION_MASTER_KEY`` is + unset. Format and length were already validated at process start + (`core/settings.py`), so no re-validation happens here. + """ + if not settings.app_encryption_master_key: + raise MasterKeyUnavailableError( + "APP_ENCRYPTION_MASTER_KEY is not configured; refusing to operate " + "on encrypted identity material." + ) + return bytes.fromhex(settings.app_encryption_master_key) + + +def generate_key() -> bytes: + """A fresh random 256-bit key, for a scope KEK or a per-record DEK.""" + return secrets.token_bytes(_KEY_BYTES) + + +@dataclass(frozen=True) +class WrappedKey: + """A key encrypted under the master key, in `key_envelope` column shape.""" + + ciphertext: bytes + nonce: bytes + algorithm: str + master_key_version: str + + +def wrap_key(key: bytes, *, aad: bytes) -> WrappedKey: + """Encrypt ``key`` under the master key. + + :param key: The KEK (or DEK) to wrap. + :param aad: Purpose tag, e.g. ``b"key_envelope"`` — must match at unwrap. + """ + nonce = secrets.token_bytes(_NONCE_BYTES) + ciphertext = AESGCM(master_key()).encrypt(nonce, key, aad) + return WrappedKey( + ciphertext=ciphertext, + nonce=nonce, + algorithm=ENVELOPE_ALGORITHM, + master_key_version=settings.app_encryption_master_key_version, + ) + + +def unwrap_key(ciphertext: bytes, nonce: bytes, *, aad: bytes) -> bytes: + """Recover a wrapped key. + + :raises DecryptionError: On tampering, a wrong master key, or a wrong AAD. + """ + try: + return AESGCM(master_key()).decrypt(nonce, ciphertext, aad) + except InvalidTag as exc: + raise DecryptionError("Key unwrap failed authentication.") from exc + + +def encrypt(plaintext: bytes, key: bytes, *, aad: bytes) -> bytes: + """Encrypt ``plaintext`` under ``key``; returns ``nonce || ciphertext``.""" + nonce = secrets.token_bytes(_NONCE_BYTES) + return nonce + AESGCM(key).encrypt(nonce, plaintext, aad) + + +def decrypt(blob: bytes, key: bytes, *, aad: bytes) -> bytes: + """Decrypt a ``nonce || ciphertext`` blob produced by :func:`encrypt`. + + :raises DecryptionError: On a truncated blob, tampering, a wrong key, or + a wrong AAD. + """ + if len(blob) <= _NONCE_BYTES: + raise DecryptionError("Ciphertext is shorter than its nonce.") + try: + return AESGCM(key).decrypt(blob[:_NONCE_BYTES], blob[_NONCE_BYTES:], aad) + except InvalidTag as exc: + raise DecryptionError("Decryption failed authentication.") from exc diff --git a/src/nc3_testing_platform/core/csrf.py b/src/nc3_testing_platform/core/csrf.py new file mode 100644 index 0000000..f6f666d --- /dev/null +++ b/src/nc3_testing_platform/core/csrf.py @@ -0,0 +1,67 @@ +"""Origin-check middleware: IDR-010's origin-validation CSRF arm (B3 / US #79). + +A state-changing request that carries the session cookie must come from the +deployment's own browser origin (``AUTH_PUBLIC_ORIGIN``). SameSite=Lax on the +cookie already blocks the classic cross-site POST; this check refuses what +Lax cannot see — sibling subdomains, downgraded agents — and costs one header +comparison. An empty setting disables it (non-browser and development use). + +Pure ASGI on purpose: the middleware never wraps or buffers the response, so +the SSE progress route streams through untouched. Requests without the +session cookie pass unchecked — API keys are machine-to-machine and carry no +cookie, and the anonymous auth operations have nothing to forge yet. +""" + +from http import HTTPStatus + +from starlette.datastructures import Headers +from starlette.types import ASGIApp, Receive, Scope, Send + +from nc3_testing_platform.core.errors import ProblemDetail, ProblemResponse +from nc3_testing_platform.core.security import SESSION_COOKIE_NAME +from nc3_testing_platform.core.settings import settings + +_STATE_CHANGING = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + + +class OriginCheckMiddleware: + """Refuse cookie-bearing state changes from a foreign origin.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Pass the request through, or answer 403 problem+json in place.""" + if scope["type"] != "http" or scope["method"] not in _STATE_CHANGING: + await self.app(scope, receive, send) + return + allowed = settings.auth_public_origin.rstrip("/") + if not allowed: + await self.app(scope, receive, send) + return + headers = Headers(scope=scope) + if f"{SESSION_COOKIE_NAME}=" not in headers.get("cookie", ""): + await self.app(scope, receive, send) + return + origin = headers.get("origin") + if origin is not None: + permitted = origin.rstrip("/") == allowed + else: + # Older agents omit Origin on same-origin POSTs; Referer is the + # fallback signal. Neither header present means no browser + # context to judge — refuse, because the cookie says browser. + referer = headers.get("referer", "") + permitted = referer.startswith(f"{allowed}/") + if permitted: + await self.app(scope, receive, send) + return + problem = ProblemDetail( + title=HTTPStatus.FORBIDDEN.phrase, + status=HTTPStatus.FORBIDDEN, + detail="Cross-origin state-changing request refused (origin check).", + ) + response = ProblemResponse( + status_code=HTTPStatus.FORBIDDEN, + content=problem.model_dump(mode="json", exclude_none=True), + ) + await response(scope, receive, send) diff --git a/src/nc3_testing_platform/core/security.py b/src/nc3_testing_platform/core/security.py index b63a5bd..abfcf2c 100644 --- a/src/nc3_testing_platform/core/security.py +++ b/src/nc3_testing_platform/core/security.py @@ -1,27 +1,39 @@ -"""OpenAPI security schemes and the rate-limit response contract. - -Contract-only, the identity provider itself is external. - -The identity provider owns identity, credentials, authentication methods, -sessions, MFA enrollment, and current assurance; this service only projects -an `app_user` row from a verified subject. A caller therefore presents -either an OIDC token or a platform API key. - -`auto_error` is off throughout. -Dependencies in this module declare requirements into the published contract and enforce nothing. -Credential verification and assurance evaluation are `NotImplementedError` seams, unwired while the application is a live mock. - -Developer note: A frontend may route calls through its own proxy backend ("BFF"). -That changes how the browser authenticates to the BFF (httpOnly session cookie), -not this contract: the BFF calls this API as an ordinary client, presenting an -OIDC bearer token or an API key. +"""Security schemes, the session dependency, and the rate-limit response contract. + +Since B3 (US #79) the platform is its own identity provider (Non-functional +v0.11): registration, argon2id credentials, and sessions are platform-managed +in `domains/auth`. Browser authentication is one server-side `user_session` +row behind one `__Host-` cookie (IDR-010), enforced by :func:`require_session` +— the only live gate in this module. Everything the session needs before an +RLS context exists goes through the `auth_session_bootstrap` SECURITY DEFINER +lookup (IDR-012). + +The OpenID Connect and API-key schemes stay published in the contract: +API keys remain machine-to-machine only and their verification lands with the +API-key story, while the OIDC scheme is the federation seam of a later phase — +v4.0 ships no SSO, so its discovery URL keeps a reserved-invalid default and +`verify_token` stays a `NotImplementedError` seam. + +`auto_error` is off throughout. The declaration-only dependencies publish +requirements into the contract for operations that are still live mocks. + +Developer note: A frontend may route calls through its own proxy backend +("BFF"). That changes how the browser authenticates to the BFF, not this +contract: the BFF calls this API as an ordinary client. """ +import hashlib +from dataclasses import dataclass +from datetime import timedelta from typing import Annotated +from uuid import UUID -from fastapi import Depends -from fastapi.security import APIKeyHeader, OpenIdConnect +import sqlalchemy as sa +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyCookie, APIKeyHeader, OpenIdConnect +from nc3_testing_platform.core import rls +from nc3_testing_platform.core.api_db import AuthDbSession from nc3_testing_platform.core.errors import PROBLEM_MEDIA_TYPE, ProblemDetail from nc3_testing_platform.core.settings import settings @@ -51,8 +63,105 @@ ), ) +SESSION_COOKIE_NAME = "__Host-session" + +session_cookie = APIKeyCookie( + name=SESSION_COOKIE_NAME, + scheme_name="SessionCookie", + auto_error=False, + description=( + "Browser session cookie set by `POST /auth/login`. HttpOnly, Secure, " + "SameSite=Lax; the session record and its idle/absolute timeouts are " + "enforced server-side." + ), +) + OidcAuth = Annotated[str | None, Depends(oidc)] ApiKeyAuth = Annotated[str | None, Depends(api_key)] +SessionAuth = Annotated[str | None, Depends(session_cookie)] + +# Clears the session cookie on the response that refuses it, so a browser +# stops replaying a token the server will never accept again. +SESSION_COOKIE_CLEAR = ( + f'{SESSION_COOKIE_NAME}=""; HttpOnly; Max-Age=0; Path=/; ' + "SameSite=lax; Secure" +) + + +def hash_session_token(token: str) -> bytes: + """The stored form of a session token: its SHA-256 digest. + + A hash, not an encryption: the value must stay an index key for the + pre-context SECURITY DEFINER lookup, and it never needs to be reversed — + session rows are hard-deleted on erasure, so there is nothing to + crypto-shred. + """ + return hashlib.sha256(token.encode("ascii")).digest() + + +@dataclass(frozen=True) +class AuthenticatedSession: + """The request's authenticated identity, resolved from the session cookie.""" + + session_id: UUID + user_id: UUID + organization_id: UUID + + +# The pre-context lookup (IDR-012): runs as the nc3_auth_definer-owned +# SECURITY DEFINER function because before identity is known no RLS arm can +# open. Raw SQL, not the ORM model — core must not import `domains/auth`. +_SESSION_BOOTSTRAP = sa.text( + "SELECT session_id, user_id, organization_id, session_created_at," + " last_seen_at, revoked_at, user_disabled_at, observed_at" + " FROM public.auth_session_bootstrap(:token_hash)" +) +_SESSION_TOUCH = sa.text( + "UPDATE user_session SET last_seen_at = now() WHERE id = :session_id" +) + + +def _session_refused(clear_cookie: bool) -> HTTPException: + headers = {"Set-Cookie": SESSION_COOKIE_CLEAR} if clear_cookie else None + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated: the session is missing, expired, or revoked.", + headers=headers, + ) + + +def require_session(token: SessionAuth, db: AuthDbSession) -> AuthenticatedSession: + """Resolve the session cookie to an identity and open its RLS user arm. + + Timeout policy runs application-side against the database clock returned + by the lookup (idle and absolute caps, Non-functional v0.11); the + `last_seen_at` touch is an in-policy UPDATE under the user context, never + a definer write. Every failure answers `401` and clears the cookie. + """ + if not token: + raise _session_refused(clear_cookie=False) + row = db.execute( + _SESSION_BOOTSTRAP, {"token_hash": hash_session_token(token)} + ).one_or_none() + if row is None or row.revoked_at is not None or row.user_disabled_at is not None: + raise _session_refused(clear_cookie=True) + idle = timedelta(seconds=settings.auth_session_idle_seconds) + absolute = timedelta(seconds=settings.auth_session_absolute_seconds) + if ( + row.observed_at - row.session_created_at >= absolute + or row.observed_at - row.last_seen_at >= idle + ): + raise _session_refused(clear_cookie=True) + rls.set_user_context(db, row.user_id) + db.execute(_SESSION_TOUCH, {"session_id": row.session_id}) + return AuthenticatedSession( + session_id=row.session_id, + user_id=row.user_id, + organization_id=row.organization_id, + ) + + +CurrentSession = Annotated[AuthenticatedSession, Depends(require_session)] def require_authentication(oidc_token: OidcAuth, key: ApiKeyAuth) -> None: diff --git a/src/nc3_testing_platform/core/settings.py b/src/nc3_testing_platform/core/settings.py index 3a69944..1b5f155 100644 --- a/src/nc3_testing_platform/core/settings.py +++ b/src/nc3_testing_platform/core/settings.py @@ -121,6 +121,13 @@ def _blank_means_unset(cls, data: dict[str, Any]) -> dict[str, Any]: app_database_url: str = ( "postgresql+psycopg://nc3_app:nc3_app@localhost:5432/nc3_testing_platform" ) + # The credential-surface connection (core/api_db.py): the `nc3_auth` role, + # held by the API service alone so the scan workers sharing `nc3_app` + # never gain a privilege on the auth tables (docs/database-roles.md, + # US #79). Same raw-interpolation caveat as `app_database_url`. + auth_database_url: str = ( + "postgresql+psycopg://nc3_auth:nc3_auth@localhost:5432/nc3_testing_platform" + ) redis_url: str = "redis://localhost:6379/0" celery_broker_url: str = "amqp://rabbitmq:rabbitmq@localhost:5672//" @@ -161,6 +168,60 @@ def _job_timeout_covers_the_task_limit(self) -> "Settings": # rejects a worker that cannot name its queue. worker_queue: str = "" + # Authentication (domains/auth, B3 / US #79). The master key is the + # deployment secret at the root of the envelope hierarchy (data-model + # §1.2): 64 hex characters (256 bits), normally supplied as + # APP_ENCRYPTION_MASTER_KEY_FILE=/run/secrets/app_encryption_master_key + # and mounted into the api service only. Empty means the operations that + # need it refuse loudly (core/crypto.py) — never a plaintext fallback. + # Only the version string is ever stored in PostgreSQL. + app_encryption_master_key: str = "" + app_encryption_master_key_version: str = "1" + + # Browser origin allowed to make cookie-bearing state changes + # (core/csrf.py, IDR-010's origin-validation arm). Empty disables the + # check, for non-browser and development use. + auth_public_origin: str = "" + + # Server-side session policy (Non-functional v0.11: idle 30 min, + # absolute 8 h) and the login lockout + rate limits of the brute-force + # requirement. Windows and thresholds are per IP for the Redis limits; + # the lockout is per account and lives on the credential row. + auth_session_idle_seconds: int = Field(default=1800, ge=60) + auth_session_absolute_seconds: int = Field(default=28800, ge=300) + auth_lockout_threshold: int = Field(default=10, ge=1) + auth_lockout_seconds: int = Field(default=900, ge=60) + auth_login_rate_limit: int = Field(default=10, ge=1) + auth_login_rate_window_seconds: int = Field(default=60, ge=1) + auth_register_rate_limit: int = Field(default=10, ge=1) + auth_register_rate_window_seconds: int = Field(default=3600, ge=1) + + @model_validator(mode="after") + def _auth_settings_are_coherent(self) -> "Settings": + """Refuse a key that is not 256-bit hex and an absolute cap under idle. + + The key check runs at startup so a truncated or re-encoded secret + fails before the first registration, not during it. + """ + if self.app_encryption_master_key: + try: + raw = bytes.fromhex(self.app_encryption_master_key) + except ValueError: + raise ValueError( + "APP_ENCRYPTION_MASTER_KEY must be hexadecimal." + ) from None + if len(raw) != 32: + raise ValueError( + "APP_ENCRYPTION_MASTER_KEY must be 64 hex characters " + "(256 bits)." + ) + if self.auth_session_absolute_seconds < self.auth_session_idle_seconds: + raise ValueError( + "AUTH_SESSION_ABSOLUTE_SECONDS must be at least " + "AUTH_SESSION_IDLE_SECONDS." + ) + return self + @classmethod def settings_customise_sources( cls, diff --git a/src/nc3_testing_platform/domains/auth/__init__.py b/src/nc3_testing_platform/domains/auth/__init__.py new file mode 100644 index 0000000..ecdf475 --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/__init__.py @@ -0,0 +1 @@ +"""Platform-local authentication: registration, login, sessions (B3 / US #79).""" diff --git a/src/nc3_testing_platform/domains/auth/dependencies.py b/src/nc3_testing_platform/domains/auth/dependencies.py new file mode 100644 index 0000000..1b34657 --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/dependencies.py @@ -0,0 +1,70 @@ +"""Anti-abuse gates on the anonymous auth operations (B3 / US #79). + +Per-IP fixed-window counters on the delivered Redis primitive +(`core/redis_utils.py`). Deliberately fail-open: if Redis is unreachable the +request proceeds with a logged warning — login availability beats one +rate-limit layer, and the durable per-account lockout (`domains/auth/service`) +stands on its own. The adaptive PoW/CAPTCHA escalation on top is B10's. +""" + +import logging + +from fastapi import Depends, HTTPException, Request, status + +from nc3_testing_platform.core import redis_utils +from nc3_testing_platform.core.settings import settings + +logger = logging.getLogger("nc3_testing_platform.domains.auth") + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client is not None else "unknown" + + +async def _consume_or_429(key: str, *, limit: int, window_seconds: int) -> None: + try: + decision = await redis_utils.consume( + key, limit=limit, window_seconds=window_seconds + ) + except Exception: + logger.warning( + "rate-limit backend unavailable; failing open for %s", key, + exc_info=True, + ) + return + if decision.allowed: + return + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests; retry after the window resets.", + headers={ + "RateLimit": ( + f"limit={decision.limit}, remaining={decision.remaining}, " + f"reset={decision.reset_seconds}" + ), + "RateLimit-Policy": f"{decision.limit};w={window_seconds}", + "Retry-After": str(decision.reset_seconds), + }, + ) + + +async def login_rate_limit(request: Request) -> None: + """Per-IP window on `POST /auth/login`.""" + await _consume_or_429( + f"auth:login:{_client_ip(request)}", + limit=settings.auth_login_rate_limit, + window_seconds=settings.auth_login_rate_window_seconds, + ) + + +async def register_rate_limit(request: Request) -> None: + """Per-IP window on `POST /auth/register`.""" + await _consume_or_429( + f"auth:register:{_client_ip(request)}", + limit=settings.auth_register_rate_limit, + window_seconds=settings.auth_register_rate_window_seconds, + ) + + +LoginRateLimited = Depends(login_rate_limit) +RegisterRateLimited = Depends(register_rate_limit) diff --git a/src/nc3_testing_platform/domains/auth/models.py b/src/nc3_testing_platform/domains/auth/models.py new file mode 100644 index 0000000..5597ce1 --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/models.py @@ -0,0 +1,68 @@ +"""SQLAlchemy models for platform-local credentials and sessions (B3 / US #79). + +Both tables are user-owned RLS rows (`user_id = app.current_user`) granted to +the `nc3_auth` role alone: `app_user` rows are visible org-wide for member +management, so credential material cannot live there (IDR-012). The password +hash is stored encrypted under the user-scope KEK (`key_envelope`, +IDR-011/017), so user erasure crypto-shreds it even out of backups. The +session token is stored as a plain SHA-256 hash — it must stay an index key +for the pre-context SECURITY DEFINER bootstrap, is not reversible, and dies +with the account by hard delete. +""" + +import uuid +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from nc3_testing_platform.core.db import Base, uuid_pk + + +class UserCredential(Base): + """argon2id password material and lockout state, one row per user. + + `password_ciphertext` is AES-256-GCM (`nonce || ct`, `core/crypto.py`) + over the argon2id hash string, under the user-scope KEK. The lockout + counter and `locked_until` implement the per-account arm of the + brute-force requirement; the per-IP arm lives in Redis and is + deliberately independent. + """ + + __tablename__ = "user_credential" + + id: Mapped[uuid.UUID] = uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + sa.ForeignKey("app_user.id", ondelete="CASCADE"), unique=True + ) + password_ciphertext: Mapped[bytes] + failed_login_count: Mapped[int] = mapped_column(server_default=sa.text("0")) + locked_until: Mapped[datetime | None] + password_updated_at: Mapped[datetime] = mapped_column( + server_default=sa.func.now() + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + updated_at: Mapped[datetime] = mapped_column( + server_default=sa.func.now(), onupdate=sa.func.now() + ) + + +class UserSession(Base): + """One server-side browser session (IDR-010). + + Timeouts are enforced by the application against these stamps + (`core/security.py`): `created_at` anchors the absolute cap, + `last_seen_at` the idle cap. Logout and password change set + `revoked_at`; rows are never reused after it. + """ + + __tablename__ = "user_session" + + id: Mapped[uuid.UUID] = uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + sa.ForeignKey("app_user.id", ondelete="CASCADE"), index=True + ) + token_hash: Mapped[bytes] = mapped_column(unique=True) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + last_seen_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + revoked_at: Mapped[datetime | None] diff --git a/src/nc3_testing_platform/domains/auth/repository.py b/src/nc3_testing_platform/domains/auth/repository.py new file mode 100644 index 0000000..4be6b02 --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/repository.py @@ -0,0 +1,97 @@ +"""Query layer of the auth domain: every statement it issues, in one place. + +The one pre-context query is `auth_login_lookup` — a SECURITY DEFINER +function (IDR-012), called before any RLS arm can open because identity is +exactly what it resolves. Everything else runs in-policy under the user or +org context the service asserted first (`core/rls.py`). +""" + +import uuid +from collections.abc import Sequence +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from nc3_testing_platform.core import enums +from nc3_testing_platform.domains.auth.models import UserCredential, UserSession +from nc3_testing_platform.domains.org.models import AppUser, KeyEnvelope +from nc3_testing_platform.domains.statements.models import Statement + +_LOGIN_LOOKUP = sa.text( + "SELECT user_id, organization_id, disabled_at, password_ciphertext," + " failed_login_count, locked_until, observed_at" + " FROM public.auth_login_lookup(:email)" +) + + +def login_lookup(db: Session, email: str) -> sa.Row[Any] | None: + """The pre-context credential row for ``email``, or ``None``. + + ``observed_at`` is the database clock at lookup time; every timeout and + lockout comparison uses it so the decision matches the stored stamps. + """ + return db.execute(_LOGIN_LOOKUP, {"email": email}).one_or_none() + + +def active_acceptance_statements(db: Session) -> Sequence[Statement]: + """The account-level acceptance statements registration must collect.""" + return db.scalars( + sa.select(Statement).where( + Statement.response_kind == enums.StatementResponseKind.ACCEPTANCE, + Statement.required_context_type.is_(None), + Statement.effective_at <= sa.func.now(), + Statement.retired_at.is_(None), + ) + ).all() + + +def user_envelope(db: Session, user_id: uuid.UUID) -> KeyEnvelope | None: + """The user-scope key envelope, readable under the user arm.""" + return db.scalars( + sa.select(KeyEnvelope).where( + KeyEnvelope.user_id == user_id, + KeyEnvelope.scope == enums.KeyScope.USER, + ) + ).one_or_none() + + +def credential_for(db: Session, user_id: uuid.UUID) -> UserCredential | None: + """The user's credential row, readable under the user arm.""" + return db.scalars( + sa.select(UserCredential).where(UserCredential.user_id == user_id) + ).one_or_none() + + +def user_by_id(db: Session, user_id: uuid.UUID) -> AppUser | None: + """The user's own `app_user` row, readable under the user arm.""" + return db.get(AppUser, user_id) + + +def session_by_id(db: Session, session_id: uuid.UUID) -> UserSession | None: + """One session row, readable under the user arm.""" + return db.get(UserSession, session_id) + + +def revoke_session(db: Session, session_id: uuid.UUID) -> None: + """Mark one session revoked; a no-op if it already is.""" + db.execute( + sa.update(UserSession) + .where(UserSession.id == session_id, UserSession.revoked_at.is_(None)) + .values(revoked_at=sa.func.now()) + ) + + +def revoke_other_sessions( + db: Session, user_id: uuid.UUID, *, keep_session_id: uuid.UUID +) -> None: + """Revoke every live session of ``user_id`` except ``keep_session_id``.""" + db.execute( + sa.update(UserSession) + .where( + UserSession.user_id == user_id, + UserSession.id != keep_session_id, + UserSession.revoked_at.is_(None), + ) + .values(revoked_at=sa.func.now()) + ) diff --git a/src/nc3_testing_platform/domains/auth/router.py b/src/nc3_testing_platform/domains/auth/router.py new file mode 100644 index 0000000..f2c3b8e --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/router.py @@ -0,0 +1,217 @@ +"""The `/auth` operations: register, login, logout, session, password. + +`register` and `login` are anonymous by construction (they mint the identity +everything else consumes) and sit behind the per-IP rate-limit dependencies. +The other three require the session cookie via `CurrentSession` +(`core/security.py`), which also publishes the `SessionCookie` scheme into +their contract entries. Cookie writes happen here and nowhere else. +""" + +from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi.exceptions import RequestValidationError + +from nc3_testing_platform.core.api_db import AuthDbSession +from nc3_testing_platform.core.errors import problem_responses +from nc3_testing_platform.core.security import ( + SESSION_COOKIE_CLEAR, + SESSION_COOKIE_NAME, + CurrentSession, + rate_limited, +) +from nc3_testing_platform.domains.auth import service +from nc3_testing_platform.domains.auth.dependencies import ( + LoginRateLimited, + RegisterRateLimited, +) +from nc3_testing_platform.domains.auth.models import UserSession +from nc3_testing_platform.domains.auth.schemas import ( + LoginSubmission, + PasswordChangeSubmission, + RegisteredUser, + RegistrationSubmission, + SessionInfo, +) +from nc3_testing_platform.domains.org.models import AppUser + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _set_session_cookie(response: Response, token: str) -> None: + # `__Host-` requires Secure and Path=/ with no Domain; SameSite=Lax plus + # the origin-check middleware are the CSRF countermeasures (IDR-010). + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + + +def _session_info(user: AppUser, session: UserSession) -> SessionInfo: + idle_expires_at, absolute_expires_at = service.session_expiries(session) + return SessionInfo( + user_id=user.id, + organization_id=user.organization_id, + email=user.email, + display_name=user.display_name, + organization_role=user.organization_role, + session_created_at=session.created_at, + last_seen_at=session.last_seen_at, + idle_expires_at=idle_expires_at, + absolute_expires_at=absolute_expires_at, + ) + + +def _consent_validation_error(exc: service.ConsentError) -> RequestValidationError: + """Consent gaps in the shape of every other validation failure.""" + errors = [ + { + "type": "value_error", + "loc": ("body", "statement_responses"), + "msg": f"Missing acceptance of {key!r} version {version!r}.", + "input": None, + } + for key, version in exc.missing + ] + [ + { + "type": "value_error", + "loc": ("body", "statement_responses"), + "msg": f"Unknown statement {key!r} version {version!r}.", + "input": None, + } + for key, version in exc.unknown + ] + return RequestValidationError(errors) + + +@router.post( + "/register", + status_code=status.HTTP_201_CREATED, + summary="Register a platform-local account", + responses={**problem_responses(409, 422, 500), **rate_limited()}, + dependencies=[RegisterRateLimited], +) +def register( + body: RegistrationSubmission, request: Request, db: AuthDbSession +) -> RegisteredUser: + """Provision the account and its workspace organization (IDR-016). + + The registrant becomes `organization_admin` of a fresh workspace, and the + consent receipts for every active account-level statement are recorded + atomically with the account. Registration does not log in — call + `POST /auth/login` next. + """ + try: + user = service.register( + db, + body, + client_ip=request.client.host if request.client else "unknown", + user_agent=request.headers.get("user-agent", "")[:400], + ) + except service.ConsentError as exc: + raise _consent_validation_error(exc) from None + except service.EmailTakenError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An account with this email address already exists.", + ) from None + return RegisteredUser( + user_id=user.id, + organization_id=user.organization_id, + email=user.email, + display_name=user.display_name, + organization_role=user.organization_role, + ) + + +@router.post( + "/login", + summary="Log in with email and password", + responses={**problem_responses(401, 422, 500), **rate_limited()}, + dependencies=[LoginRateLimited], +) +def login( + body: LoginSubmission, response: Response, db: AuthDbSession +) -> SessionInfo: + """Open a server-side session and set the `__Host-session` cookie. + + Unknown email, disabled account, and wrong password all answer the same + `401`. A locked account answers `429` with `Retry-After`. + """ + try: + result = service.login(db, email=body.email, password=body.password) + except service.AccountLockedError as exc: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="The account is temporarily locked after repeated failures.", + headers={"Retry-After": str(exc.retry_after_seconds)}, + ) from None + except service.InvalidCredentialsError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password.", + ) from None + _set_session_cookie(response, result.token) + return _session_info(result.user, result.session) + + +@router.get( + "/session", + summary="The authenticated session", + responses=problem_responses(401), +) +def read_session(current: CurrentSession, db: AuthDbSession) -> SessionInfo: + """The current user, organization, and server-side expiry horizon.""" + user, session = service.session_snapshot( + db, user_id=current.user_id, session_id=current.session_id + ) + return _session_info(user, session) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="Log out", + responses=problem_responses(401), +) +def logout( + current: CurrentSession, response: Response, db: AuthDbSession +) -> None: + """Revoke the session server-side and clear the cookie.""" + service.logout(db, current.session_id) + response.headers["Set-Cookie"] = SESSION_COOKIE_CLEAR + + +@router.post( + "/password", + status_code=status.HTTP_204_NO_CONTENT, + summary="Change the password", + responses=problem_responses(401, 403, 422, 500), +) +def change_password( + body: PasswordChangeSubmission, + current: CurrentSession, + response: Response, + db: AuthDbSession, +) -> None: + """Verify the current password, re-encrypt, and rotate every session. + + Sessions on other devices are revoked; this one is replaced and the new + cookie is set on the response (session regeneration on privilege change). + """ + try: + result = service.change_password( + db, + user_id=current.user_id, + current_session_id=current.session_id, + current_password=body.current_password, + new_password=body.new_password, + ) + except service.WrongCurrentPasswordError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The current password did not verify.", + ) from None + _set_session_cookie(response, result.token) diff --git a/src/nc3_testing_platform/domains/auth/schemas.py b/src/nc3_testing_platform/domains/auth/schemas.py new file mode 100644 index 0000000..8e2ebbe --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/schemas.py @@ -0,0 +1,83 @@ +"""Request and response shapes of the auth operations (B3 / US #79). + +Passwords travel as `SecretStr` so they can never leak through a repr, a log +line, or a validation error echo. The registration body carries the statement +answers inline — consent versioning is part of the registration act +(Non-functional v0.11), not a follow-up call — reusing the exact submission +shape of `POST /statement-responses` so both paths record identical receipts. +""" + +from pydantic import BaseModel, EmailStr, Field, SecretStr + +from nc3_testing_platform.core.enums import OrganizationRole +from nc3_testing_platform.core.schemas import BaseSchema, ResourceId, Timestamp +from nc3_testing_platform.domains.statements.schemas import ( + StatementResponseSubmission, +) + +# Bounds shared by every password field: 12 as the local-account floor +# (password is the only factor until B4 lands MFA), 128 well under argon2's +# practical limits while still refusing megabyte bodies. +_PASSWORD_FIELD = Field(min_length=12, max_length=128) + + +class RegistrationSubmission(BaseModel): + """Lean registration: email, password, optional display name, consent.""" + + email: EmailStr + password: SecretStr = _PASSWORD_FIELD + display_name: str | None = Field(default=None, max_length=200) + statement_responses: list[StatementResponseSubmission] = Field( + description=( + "Answers to every active account-level acceptance statement " + "(`GET /statements`), each named by key and exact version. " + "Registration is refused while any is missing." + ), + ) + + +class RegisteredUser(BaseSchema): + """The provisioned account: the registrant administers a workspace org. + + Per IDR-016 the workspace organization is created at registration with + the registrant as `organization_admin`; the first successful DNS + verification later promotes and names it. + """ + + user_id: ResourceId + organization_id: ResourceId + email: EmailStr + display_name: str | None = None + organization_role: OrganizationRole + + +class LoginSubmission(BaseModel): + """Password login for a platform-local account.""" + + email: EmailStr + password: SecretStr = _PASSWORD_FIELD + + +class SessionInfo(BaseSchema): + """The authenticated session and its server-side expiry horizon. + + `idle_expires_at` moves with activity; `absolute_expires_at` never does. + Whichever passes first ends the session (Non-functional v0.11). + """ + + user_id: ResourceId + organization_id: ResourceId + email: EmailStr + display_name: str | None = None + organization_role: OrganizationRole + session_created_at: Timestamp + last_seen_at: Timestamp + idle_expires_at: Timestamp + absolute_expires_at: Timestamp + + +class PasswordChangeSubmission(BaseModel): + """Authenticated password change; requires the current password.""" + + current_password: SecretStr = _PASSWORD_FIELD + new_password: SecretStr = _PASSWORD_FIELD diff --git a/src/nc3_testing_platform/domains/auth/service.py b/src/nc3_testing_platform/domains/auth/service.py new file mode 100644 index 0000000..e0a0021 --- /dev/null +++ b/src/nc3_testing_platform/domains/auth/service.py @@ -0,0 +1,396 @@ +"""Business logic of platform-local authentication (B3 / US #79). + +Registration is one RLS-context transaction (IDR-016): the organization and +user ids are generated application-side (UUIDv7, like the guest-launch path) +so `set_org_context` can precede the INSERTs, and the workspace organization, +admin user, both key envelopes, the encrypted credential, and the consent +receipts land atomically. Login resolves identity through the +`auth_login_lookup` SECURITY DEFINER function, then does every write +in-policy under the user arm. + +Failure paths that must persist state (the lockout counter) commit +explicitly before raising — the request dependency rolls back on an escaping +exception, and a brute-force counter a failed login rolls back would count +nothing. + +Log lines carry UUIDs only: email addresses are PII and stay out of shared +logs (Non-functional → GDPR); actor evidence goes into the encrypted consent +receipts instead. +""" + +import json +import logging +import secrets +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta +from functools import lru_cache + +import sqlalchemy as sa +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from pydantic import SecretStr +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from uuid6 import uuid7 + +from nc3_testing_platform.core import crypto, rls +from nc3_testing_platform.core.enums import KeyScope, OrganizationRole +from nc3_testing_platform.core.security import hash_session_token +from nc3_testing_platform.core.settings import settings +from nc3_testing_platform.domains.auth import repository +from nc3_testing_platform.domains.auth.models import UserCredential, UserSession +from nc3_testing_platform.domains.auth.schemas import RegistrationSubmission +from nc3_testing_platform.domains.org.models import ( + AppUser, + KeyEnvelope, + Organization, +) +from nc3_testing_platform.domains.statements.models import StatementResponse + +logger = logging.getLogger("nc3_testing_platform.domains.auth") + +# AAD purpose tags (core/crypto.py): a ciphertext can only ever open in the +# column it was written for. +_ENVELOPE_AAD = b"key_envelope.wrapped_kek" +_CREDENTIAL_AAD = b"user_credential.password" +_EVIDENCE_AAD = b"statement_response.evidence" +_DEK_AAD = b"statement_response.wrapped_dek" + +# The local issuer of identity projection (OIDC-ready: issuer + subject). +_LOCAL_SUBJECT_PREFIX = "local:" + +_hasher = PasswordHasher() + + +@lru_cache(maxsize=1) +def _dummy_hash() -> str: + """A hash for constant-work verification when the email is unknown.""" + return _hasher.hash("platform-dummy-password") + + +class EmailTakenError(Exception): + """An account with this email already exists (unique index refusal).""" + + +class ConsentError(Exception): + """The submitted statement answers do not cover the active set.""" + + def __init__( + self, + missing: list[tuple[str, str]], + unknown: list[tuple[str, str]], + ) -> None: + self.missing = missing + self.unknown = unknown + super().__init__(f"missing={missing!r} unknown={unknown!r}") + + +class InvalidCredentialsError(Exception): + """Unknown email, disabled account, or wrong password — one answer.""" + + +class AccountLockedError(Exception): + """The account is locked out after repeated failures.""" + + def __init__(self, retry_after_seconds: int) -> None: + self.retry_after_seconds = retry_after_seconds + super().__init__(f"locked for {retry_after_seconds}s") + + +class WrongCurrentPasswordError(Exception): + """Password change refused: the current password did not verify.""" + + +@dataclass(frozen=True) +class LoginResult: + """A fresh session: the plaintext token exists only in this value.""" + + token: str + user: AppUser + session: UserSession + + +def _encrypt_password(password: SecretStr, user_kek: bytes) -> bytes: + hashed = _hasher.hash(password.get_secret_value()) + return crypto.encrypt(hashed.encode("ascii"), user_kek, aad=_CREDENTIAL_AAD) + + +def _unwrap_user_kek(db: Session, user_id: uuid.UUID) -> bytes: + envelope = repository.user_envelope(db, user_id) + if envelope is None: + # Registration always creates it, so absence is corruption, not input. + raise RuntimeError(f"user {user_id} has no user-scope key envelope") + return crypto.unwrap_key( + envelope.wrapped_kek, envelope.wrapping_nonce, aad=_ENVELOPE_AAD + ) + + +def register( + db: Session, + submission: RegistrationSubmission, + *, + client_ip: str, + user_agent: str, +) -> AppUser: + """Provision the workspace organization and its admin user (IDR-016). + + :raises ConsentError: When the answers do not cover every active + account-level acceptance statement, or name an unknown one. + :raises EmailTakenError: When the email already has an account. + :raises crypto.MasterKeyUnavailableError: When the deployment master key + is unset — surfaced as a 500, never a plaintext fallback. + """ + required = { + (s.statement_key, s.version): s + for s in repository.active_acceptance_statements(db) + } + answered = { + (r.statement_key, r.version) for r in submission.statement_responses + } + missing = sorted(set(required) - answered) + unknown = sorted(answered - set(required)) + if missing or unknown: + raise ConsentError(missing=missing, unknown=unknown) + + org_id, user_id = uuid7(), uuid7() + rls.set_org_context(db, org_id, user_id) + + org_kek, user_kek = crypto.generate_key(), crypto.generate_key() + org_wrapped = crypto.wrap_key(org_kek, aad=_ENVELOPE_AAD) + user_wrapped = crypto.wrap_key(user_kek, aad=_ENVELOPE_AAD) + + user = AppUser( + id=user_id, + organization_id=org_id, + identity_subject=f"{_LOCAL_SUBJECT_PREFIX}{user_id}", + email=submission.email.lower(), + display_name=submission.display_name, + organization_role=OrganizationRole.ORGANIZATION_ADMIN, + ) + user_envelope = KeyEnvelope( + scope=KeyScope.USER, + organization_id=org_id, + user_id=user_id, + wrapped_kek=user_wrapped.ciphertext, + wrapping_nonce=user_wrapped.nonce, + wrapping_algorithm=user_wrapped.algorithm, + master_key_version=user_wrapped.master_key_version, + ) + # Staged flushes: without relationship() constructs the unit of work does + # not order inserts by raw foreign keys, so each parent goes in explicitly + # before the rows that reference it. + # Unnamed org until the first successful DNS verification (IDR-016). + db.add(Organization(id=org_id, name="Workspace")) + db.flush() + db.add(user) + try: + db.flush() + except IntegrityError as exc: + if "uq_app_user_email_lower" in str(exc.orig): + raise EmailTakenError from exc + raise + db.add_all( + [ + KeyEnvelope( + scope=KeyScope.ORGANIZATION, + organization_id=org_id, + wrapped_kek=org_wrapped.ciphertext, + wrapping_nonce=org_wrapped.nonce, + wrapping_algorithm=org_wrapped.algorithm, + master_key_version=org_wrapped.master_key_version, + ), + user_envelope, + UserCredential( + user_id=user_id, + password_ciphertext=_encrypt_password( + submission.password, user_kek + ), + ), + ] + ) + # Populates user_envelope.id (a flush-time column default), which the + # consent receipts reference as their opaque envelope pointer (§3.5). + db.flush() + evidence = json.dumps( + { + "action": "registration", + "actor_email": submission.email.lower(), + "client_ip": client_ip, + "user_agent": user_agent, + } + ).encode() + for response in submission.statement_responses: + dek = crypto.generate_key() + db.add( + StatementResponse( + organization_id=org_id, + statement_id=required[(response.statement_key, response.version)].id, + envelope_id=user_envelope.id, + responded_at=sa.func.now(), + response_evidence_encrypted=crypto.encrypt( + evidence, dek, aad=_EVIDENCE_AAD + ), + wrapped_dek=crypto.encrypt(dek, user_kek, aad=_DEK_AAD), + encryption_metadata={"algorithm": crypto.ENVELOPE_ALGORITHM}, + ) + ) + db.flush() + logger.info( + "registration provisioned organization %s with admin user %s", + org_id, + user_id, + ) + return user + + +def login(db: Session, *, email: str, password: SecretStr) -> LoginResult: + """Verify a password and open a fresh session. + + Unknown email, disabled account, and wrong password are one + indistinguishable :class:`InvalidCredentialsError`, after constant-work + hashing. The lockout counter commits even though the login fails. + + :raises AccountLockedError: While the per-account lockout stands. + """ + row = repository.login_lookup(db, email.lower()) + if row is None or row.disabled_at is not None: + _verify_expecting_mismatch(_dummy_hash(), password) + raise InvalidCredentialsError + if row.locked_until is not None and row.locked_until > row.observed_at: + remaining = row.locked_until - row.observed_at + raise AccountLockedError(int(remaining.total_seconds()) + 1) + + rls.set_user_context(db, row.user_id) + user_kek = _unwrap_user_kek(db, row.user_id) + stored = crypto.decrypt( + row.password_ciphertext, user_kek, aad=_CREDENTIAL_AAD + ).decode("ascii") + + try: + _hasher.verify(stored, password.get_secret_value()) + except VerifyMismatchError: + _record_failure(db, row.user_id, observed_at=row.observed_at) + raise InvalidCredentialsError from None + + credential = repository.credential_for(db, row.user_id) + if credential is not None: + if credential.failed_login_count or credential.locked_until is not None: + credential.failed_login_count = 0 + credential.locked_until = None + if _hasher.check_needs_rehash(stored): + credential.password_ciphertext = _encrypt_password( + password, user_kek + ) + user = repository.user_by_id(db, row.user_id) + if user is None: # pragma: no cover - the lookup just proved it exists + raise InvalidCredentialsError + token, session = _open_session(db, row.user_id) + logger.info("login succeeded for user %s", row.user_id) + return LoginResult(token=token, user=user, session=session) + + +def _verify_expecting_mismatch(hashed: str, password: SecretStr) -> None: + """Constant-work verify whose mismatch is the expected outcome.""" + try: + _hasher.verify(hashed, password.get_secret_value()) + except VerifyMismatchError: + pass + + +def _record_failure( + db: Session, user_id: uuid.UUID, *, observed_at: datetime +) -> None: + """Count one failed login; lock at the threshold; always persist. + + Commits explicitly: the caller raises next, and the request dependency + would otherwise roll the counter back with the failed request. + """ + credential = repository.credential_for(db, user_id) + if credential is None: # pragma: no cover - registration always creates it + return + credential.failed_login_count += 1 + if credential.failed_login_count >= settings.auth_lockout_threshold: + credential.locked_until = observed_at + timedelta( + seconds=settings.auth_lockout_seconds + ) + credential.failed_login_count = 0 + logger.warning("account lockout applied to user %s", user_id) + db.commit() + + +def _open_session(db: Session, user_id: uuid.UUID) -> tuple[str, UserSession]: + token = secrets.token_urlsafe(32) + session = UserSession(user_id=user_id, token_hash=hash_session_token(token)) + db.add(session) + db.flush() + return token, session + + +def logout(db: Session, session_id: uuid.UUID) -> None: + """Revoke the session; the router clears the cookie.""" + repository.revoke_session(db, session_id) + + +def session_snapshot( + db: Session, *, user_id: uuid.UUID, session_id: uuid.UUID +) -> tuple[AppUser, UserSession]: + """The user and session rows behind an authenticated request. + + Both were just proven to exist by the bootstrap in this same transaction + snapshot, so absence is corruption, not input. + """ + user = repository.user_by_id(db, user_id) + session = repository.session_by_id(db, session_id) + if user is None or session is None: # pragma: no cover + raise RuntimeError("authenticated session rows disappeared mid-request") + return user, session + + +def session_expiries(session: UserSession) -> tuple[datetime, datetime]: + """(idle_expires_at, absolute_expires_at) for one session row.""" + return ( + session.last_seen_at + timedelta(seconds=settings.auth_session_idle_seconds), + session.created_at + + timedelta(seconds=settings.auth_session_absolute_seconds), + ) + + +def change_password( + db: Session, + *, + user_id: uuid.UUID, + current_session_id: uuid.UUID, + current_password: SecretStr, + new_password: SecretStr, +) -> LoginResult: + """Re-encrypt the credential and rotate every session (privilege change). + + Other sessions are revoked outright; the calling session is replaced by a + fresh row so the browser gets a new token — session id regeneration on + privilege change, per the US. + + :raises WrongCurrentPasswordError: When the current password does not verify. + """ + credential = repository.credential_for(db, user_id) + if credential is None: # pragma: no cover - registration always creates it + raise WrongCurrentPasswordError + user_kek = _unwrap_user_kek(db, user_id) + stored = crypto.decrypt( + credential.password_ciphertext, user_kek, aad=_CREDENTIAL_AAD + ).decode("ascii") + try: + _hasher.verify(stored, current_password.get_secret_value()) + except VerifyMismatchError: + raise WrongCurrentPasswordError from None + + credential.password_ciphertext = _encrypt_password(new_password, user_kek) + credential.password_updated_at = sa.func.now() + repository.revoke_other_sessions(db, user_id, keep_session_id=current_session_id) + repository.revoke_session(db, current_session_id) + user = repository.user_by_id(db, user_id) + if user is None: # pragma: no cover - the session just proved it exists + raise WrongCurrentPasswordError + token, session = _open_session(db, user_id) + logger.info("password changed for user %s; sessions rotated", user_id) + return LoginResult(token=token, user=user, session=session) diff --git a/src/nc3_testing_platform/domains/org/models.py b/src/nc3_testing_platform/domains/org/models.py index f51b6dc..4d2a39c 100644 --- a/src/nc3_testing_platform/domains/org/models.py +++ b/src/nc3_testing_platform/domains/org/models.py @@ -38,11 +38,20 @@ class Organization(Base): class AppUser(Base): """The single local user entity (§3.2). - The identity provider stays the system of record for identity, credentials, - sessions, and MFA; this row stores platform-owned fields only. + Since B3 (US #79) the platform is the identity provider: this row is the + identity projection (`identity_subject` keys issuer + subject; local + accounts use `local:`), while credentials and sessions live in + the user-private tables of `domains/auth` — never here, because this row + is visible org-wide for member management (IDR-012). """ __tablename__ = "app_user" + __table_args__ = ( + # B3: one account per email, case-insensitive. The application + # lowercases at the boundary; the expression index enforces it at + # the root and backs the `auth_login_lookup` definer function. + sa.Index("uq_app_user_email_lower", sa.text("lower(email)"), unique=True), + ) id: Mapped[uuid.UUID] = uuid_pk() organization_id: Mapped[uuid.UUID] = mapped_column( diff --git a/src/nc3_testing_platform/main.py b/src/nc3_testing_platform/main.py index 84d0af4..917e7c2 100644 --- a/src/nc3_testing_platform/main.py +++ b/src/nc3_testing_platform/main.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, FastAPI +from nc3_testing_platform.core.csrf import OriginCheckMiddleware from nc3_testing_platform.core.errors import ( configure_openapi, register_exception_handlers, @@ -14,6 +15,7 @@ from nc3_testing_platform.domains.api_keys.router import router as api_keys_router from nc3_testing_platform.domains.assets.router import public_feed_router from nc3_testing_platform.domains.assets.router import router as assets_router +from nc3_testing_platform.domains.auth.router import router as auth_router from nc3_testing_platform.domains.findings.router import router as findings_router from nc3_testing_platform.domains.health.router import router as health_router from nc3_testing_platform.domains.notifications.router import account_router @@ -47,6 +49,10 @@ register_exception_handlers(app) configure_openapi(app) +# CSRF origin validation (IDR-010): pure ASGI, so the SSE route streams +# through untouched. Inert until AUTH_PUBLIC_ORIGIN is set. +app.add_middleware(OriginCheckMiddleware) + # Referenced only from handwritten schema — the launch variants from the # media-type-dispatched request body on `POST /scans`, and the event payloads from # the `text/event-stream` response — so FastAPI's own pass never sees them. @@ -62,6 +68,7 @@ ) api_v1 = APIRouter(prefix="/api/v1") +api_v1.include_router(auth_router) api_v1.include_router(scans_router) api_v1.include_router(assets_router) api_v1.include_router(public_feed_router) diff --git a/src/nc3_testing_platform/models.py b/src/nc3_testing_platform/models.py index 9d89965..ec423cc 100644 --- a/src/nc3_testing_platform/models.py +++ b/src/nc3_testing_platform/models.py @@ -10,6 +10,7 @@ from nc3_testing_platform.domains.admin import models as admin_models from nc3_testing_platform.domains.api_keys import models as api_keys_models from nc3_testing_platform.domains.assets import models as assets_models +from nc3_testing_platform.domains.auth import models as auth_models from nc3_testing_platform.domains.findings import models as findings_models from nc3_testing_platform.domains.notifications import models as notifications_models from nc3_testing_platform.domains.org import models as org_models @@ -23,6 +24,7 @@ "admin_models", "api_keys_models", "assets_models", + "auth_models", "findings_models", "notifications_models", "org_models", diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py new file mode 100644 index 0000000..12a0225 --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +1,710 @@ +"""Unit tests for the auth domain. + +Covers the service state machine, the session dependency, the router's +problem mapping and cookies, the CSRF middleware, and the rate-limit gates. +The database layer is mocked at the repository/session boundary; the +live-PostgreSQL counterpart is `tests/test_auth_postgres.py` +(`pytest -m postgres`). +""" + +import uuid +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient +from pydantic import SecretStr +from sqlalchemy.exc import IntegrityError +from uuid6 import uuid7 + +from nc3_testing_platform.core import api_db, redis_utils, security +from nc3_testing_platform.core.enums import OrganizationRole +from nc3_testing_platform.core.settings import settings +from nc3_testing_platform.domains.auth import service +from nc3_testing_platform.domains.auth.schemas import RegistrationSubmission +from nc3_testing_platform.domains.statements.schemas import ( + StatementResponseSubmission, +) +from nc3_testing_platform.main import app + +TEST_KEY_HEX = "ab" * 32 +NOW = datetime(2026, 8, 18, 12, 0, tzinfo=UTC) +PASSWORD = SecretStr("correct horse battery") + + +@pytest.fixture(autouse=True) +def master_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Every auth test runs with a synthetic deployment master key.""" + monkeypatch.setattr(settings, "app_encryption_master_key", TEST_KEY_HEX) + + +@pytest.fixture(autouse=True) +def no_rate_limit(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep every test off any real Redis: the gate allows by default. + + Patching the public `consume` boundary (not the private client) makes the + suite order-independent even when a developer has the compose Redis up; + the rate-limit tests below override this same boundary deliberately. + """ + + async def _allow(key: str, *, limit: int, window_seconds: int, client: Any = None) -> Any: + return redis_utils.RateLimitDecision( + allowed=True, limit=limit, remaining=limit, reset_seconds=window_seconds + ) + + monkeypatch.setattr(redis_utils, "consume", _allow) + + +def _registration(**overrides: Any) -> RegistrationSubmission: + """A valid registration body accepting the seeded terms statement.""" + fields: dict[str, Any] = { + "email": "admin@example.lu", + "password": PASSWORD, + "display_name": "Admin", + "statement_responses": [ + StatementResponseSubmission( + statement_key="terms_and_conditions", version="2026-01-15" + ) + ], + } + fields.update(overrides) + return RegistrationSubmission(**fields) + + +def _terms_statement() -> SimpleNamespace: + """The seeded acceptance statement, as the repository would return it.""" + return SimpleNamespace( + id=uuid7(), statement_key="terms_and_conditions", version="2026-01-15" + ) + + +# --- registration service --------------------------------------------------- + + +def test_register_provisions_workspace_admin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """One transaction: org + admin user + two envelopes + credential + receipt.""" + db = MagicMock() + monkeypatch.setattr( + service.repository, + "active_acceptance_statements", + lambda db: [_terms_statement()], + ) + user = service.register( + db, _registration(), client_ip="203.0.113.7", user_agent="pytest" + ) + assert user.organization_role == OrganizationRole.ORGANIZATION_ADMIN + assert user.email == "admin@example.lu" + assert user.identity_subject == f"local:{user.id}" + added = [ + obj for call in db.add_all.call_args_list for obj in call.args[0] + ] + [call.args[0] for call in db.add.call_args_list] + names = [type(obj).__name__ for obj in added] + assert names.count("KeyEnvelope") == 2 + assert "Organization" in names + assert "UserCredential" in names + assert "StatementResponse" in names + # Staged: org, user, envelope/credential, receipts (FK insert order). + assert db.flush.call_count == 4 + + +def test_register_refuses_missing_consent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Registration without every active acceptance statement is refused.""" + monkeypatch.setattr( + service.repository, + "active_acceptance_statements", + lambda db: [_terms_statement()], + ) + with pytest.raises(service.ConsentError) as excinfo: + service.register( + db=MagicMock(), + submission=_registration(statement_responses=[]), + client_ip="203.0.113.7", + user_agent="pytest", + ) + assert excinfo.value.missing == [("terms_and_conditions", "2026-01-15")] + + +def test_register_refuses_unknown_statement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An answer naming a statement that is not active is refused, not dropped.""" + monkeypatch.setattr( + service.repository, "active_acceptance_statements", lambda db: [] + ) + with pytest.raises(service.ConsentError) as excinfo: + service.register( + db=MagicMock(), + submission=_registration(), + client_ip="203.0.113.7", + user_agent="pytest", + ) + assert excinfo.value.unknown == [("terms_and_conditions", "2026-01-15")] + + +def test_register_maps_duplicate_email(monkeypatch: pytest.MonkeyPatch) -> None: + """The unique-index refusal surfaces as EmailTakenError.""" + db = MagicMock() + # The org flush passes; the app_user flush hits the unique index. + db.flush.side_effect = [ + None, + IntegrityError( + "INSERT", {}, Exception('duplicate key "uq_app_user_email_lower"') + ), + ] + monkeypatch.setattr( + service.repository, + "active_acceptance_statements", + lambda db: [_terms_statement()], + ) + with pytest.raises(service.EmailTakenError): + service.register( + db, _registration(), client_ip="203.0.113.7", user_agent="pytest" + ) + + +# --- login service ---------------------------------------------------------- + + +def _stored_credential(password: str = PASSWORD.get_secret_value()) -> dict[str, Any]: + """A user KEK, its envelope row, and the encrypted argon2 hash.""" + from nc3_testing_platform.core import crypto + + kek = crypto.generate_key() + wrapped = crypto.wrap_key(kek, aad=b"key_envelope.wrapped_kek") + envelope = SimpleNamespace( + wrapped_kek=wrapped.ciphertext, wrapping_nonce=wrapped.nonce + ) + hashed = service._hasher.hash(password) + ciphertext = crypto.encrypt( + hashed.encode("ascii"), kek, aad=b"user_credential.password" + ) + return {"envelope": envelope, "ciphertext": ciphertext} + + +def _login_row(ciphertext: bytes, **overrides: Any) -> SimpleNamespace: + """A row in the shape auth_login_lookup returns.""" + row = SimpleNamespace( + user_id=uuid7(), + organization_id=uuid7(), + disabled_at=None, + password_ciphertext=ciphertext, + failed_login_count=0, + locked_until=None, + observed_at=NOW, + ) + for name, value in overrides.items(): + setattr(row, name, value) + return row + + +def _wire_login( + monkeypatch: pytest.MonkeyPatch, + row: SimpleNamespace | None, + stored: dict[str, Any] | None = None, + credential: SimpleNamespace | None = None, +) -> None: + """Point the repository at the given fake rows.""" + monkeypatch.setattr(service.repository, "login_lookup", lambda db, email: row) + if stored is not None: + monkeypatch.setattr( + service.repository, "user_envelope", lambda db, uid: stored["envelope"] + ) + monkeypatch.setattr( + service.repository, "credential_for", lambda db, uid: credential + ) + if row is not None: + monkeypatch.setattr( + service.repository, + "user_by_id", + lambda db, uid: SimpleNamespace( + id=row.user_id, organization_id=row.organization_id + ), + ) + + +def test_login_unknown_email_is_invalid_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No row answers exactly like a wrong password.""" + _wire_login(monkeypatch, row=None) + with pytest.raises(service.InvalidCredentialsError): + service.login(MagicMock(), email="ghost@example.lu", password=PASSWORD) + + +def test_login_disabled_account_is_invalid_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A disabled account is indistinguishable from a wrong password.""" + stored = _stored_credential() + row = _login_row(stored["ciphertext"], disabled_at=NOW - timedelta(days=1)) + _wire_login(monkeypatch, row, stored) + with pytest.raises(service.InvalidCredentialsError): + service.login(MagicMock(), email="admin@example.lu", password=PASSWORD) + + +def test_login_locked_account_answers_retry_after( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A standing lockout reports the remaining seconds.""" + stored = _stored_credential() + row = _login_row( + stored["ciphertext"], locked_until=NOW + timedelta(seconds=120) + ) + _wire_login(monkeypatch, row, stored) + with pytest.raises(service.AccountLockedError) as excinfo: + service.login(MagicMock(), email="admin@example.lu", password=PASSWORD) + assert excinfo.value.retry_after_seconds == 121 + + +def test_login_wrong_password_increments_and_commits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mismatch counts one failure and persists it despite the raised error.""" + stored = _stored_credential() + credential = SimpleNamespace(failed_login_count=0, locked_until=None) + row = _login_row(stored["ciphertext"]) + _wire_login(monkeypatch, row, stored, credential) + db = MagicMock() + with pytest.raises(service.InvalidCredentialsError): + service.login( + db, email="admin@example.lu", password=SecretStr("wrong-password-12") + ) + assert credential.failed_login_count == 1 + db.commit.assert_called_once() + + +def test_login_lockout_applies_at_threshold( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Nth failure locks the account and resets the counter.""" + stored = _stored_credential() + credential = SimpleNamespace( + failed_login_count=settings.auth_lockout_threshold - 1, locked_until=None + ) + row = _login_row(stored["ciphertext"]) + _wire_login(monkeypatch, row, stored, credential) + with pytest.raises(service.InvalidCredentialsError): + service.login( + MagicMock(), + email="admin@example.lu", + password=SecretStr("wrong-password-12"), + ) + assert credential.locked_until == NOW + timedelta( + seconds=settings.auth_lockout_seconds + ) + assert credential.failed_login_count == 0 + + +def test_login_success_resets_counter_and_opens_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A correct password resets the lockout state and mints a hashed session.""" + stored = _stored_credential() + credential = SimpleNamespace(failed_login_count=3, locked_until=None) + row = _login_row(stored["ciphertext"]) + _wire_login(monkeypatch, row, stored, credential) + result = service.login(MagicMock(), email="Admin@Example.lu", password=PASSWORD) + assert credential.failed_login_count == 0 + assert result.session.token_hash == security.hash_session_token(result.token) + assert result.session.user_id == row.user_id + + +# --- session dependency (core/security.py) ---------------------------------- + + +def _bootstrap_row(**overrides: Any) -> SimpleNamespace: + """A row in the shape auth_session_bootstrap returns.""" + row = SimpleNamespace( + session_id=uuid7(), + user_id=uuid7(), + organization_id=uuid7(), + session_created_at=NOW - timedelta(minutes=5), + last_seen_at=NOW - timedelta(minutes=1), + revoked_at=None, + user_disabled_at=None, + observed_at=NOW, + ) + for name, value in overrides.items(): + setattr(row, name, value) + return row + + +def _db_returning(row: SimpleNamespace | None) -> MagicMock: + """A session mock whose bootstrap query yields the given row.""" + db = MagicMock() + db.execute.return_value.one_or_none.return_value = row + return db + + +def test_require_session_missing_cookie_is_401() -> None: + """No cookie, no session — and nothing to clear.""" + with pytest.raises(HTTPException) as excinfo: + security.require_session(None, MagicMock()) + assert excinfo.value.status_code == 401 + assert excinfo.value.headers is None + + +@pytest.mark.parametrize( + "overrides", + [ + {"revoked_at": NOW - timedelta(minutes=1)}, + {"user_disabled_at": NOW - timedelta(days=1)}, + {"last_seen_at": NOW - timedelta(minutes=31)}, + {"session_created_at": NOW - timedelta(hours=9)}, + ], + ids=["revoked", "disabled", "idle-expired", "absolute-expired"], +) +def test_require_session_refuses_and_clears(overrides: dict[str, Any]) -> None: + """Revocation, disablement, and both timeouts answer 401 + cookie clear.""" + db = _db_returning(_bootstrap_row(**overrides)) + with pytest.raises(HTTPException) as excinfo: + security.require_session("token", db) + assert excinfo.value.status_code == 401 + assert "Max-Age=0" in (excinfo.value.headers or {})["Set-Cookie"] + + +def test_require_session_happy_path_touches_and_returns() -> None: + """A live session opens the user arm and refreshes the idle anchor.""" + row = _bootstrap_row() + db = _db_returning(row) + resolved = security.require_session("token", db) + assert resolved.user_id == row.user_id + assert resolved.organization_id == row.organization_id + statements = [str(call.args[0]) for call in db.execute.call_args_list] + assert any("UPDATE user_session SET last_seen_at" in s for s in statements) + + +# --- router: cookies, problem mapping, CSRF, rate limits --------------------- + + +FAKE_USER: Any = SimpleNamespace( + id=uuid7(), + organization_id=uuid7(), + email="admin@example.lu", + display_name="Admin", + organization_role=OrganizationRole.ORGANIZATION_ADMIN, +) +FAKE_SESSION: Any = SimpleNamespace( + id=uuid7(), + created_at=NOW, + last_seen_at=NOW, +) +FAKE_IDENTITY = security.AuthenticatedSession( + session_id=FAKE_SESSION.id, + user_id=FAKE_USER.id, + organization_id=FAKE_USER.organization_id, +) + + +@pytest.fixture +def client() -> Any: + """A TestClient whose DB dependency yields a mock; overrides cleaned up.""" + app.dependency_overrides[api_db.auth_session] = lambda: MagicMock() + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +@pytest.fixture +def authenticated(client: TestClient) -> TestClient: + """The client with the session dependency resolved to a fixed identity.""" + app.dependency_overrides[security.require_session] = lambda: FAKE_IDENTITY + return client + + +def test_login_sets_host_cookie( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The session cookie carries the full __Host- attribute set.""" + monkeypatch.setattr( + service, + "login", + lambda db, *, email, password: service.LoginResult( + token="test-token", user=FAKE_USER, session=FAKE_SESSION + ), + ) + response = client.post( + "/api/v1/auth/login", + json={"email": "admin@example.lu", "password": "correct horse battery"}, + ) + assert response.status_code == 200 + cookie = response.headers["set-cookie"] + assert cookie.startswith('__Host-session="test-token"') or cookie.startswith( + "__Host-session=test-token" + ) + lowered = cookie.lower() + assert "httponly" in lowered + assert "secure" in lowered + assert "samesite=lax" in lowered + assert "path=/" in lowered + body = response.json() + assert body["idle_expires_at"] > body["last_seen_at"] + assert body["absolute_expires_at"] > body["session_created_at"] + + +def test_login_maps_invalid_credentials_to_401( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The service's single failure answer stays a single 401 problem.""" + + def _raise(db: Any, *, email: str, password: Any) -> Any: + raise service.InvalidCredentialsError + + monkeypatch.setattr(service, "login", _raise) + response = client.post( + "/api/v1/auth/login", + json={"email": "admin@example.lu", "password": "wrong-password-12"}, + ) + assert response.status_code == 401 + assert response.headers["content-type"] == "application/problem+json" + + +def test_login_maps_lockout_to_429_with_retry_after( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A lockout is a quota answer, not a credential answer.""" + + def _raise(db: Any, *, email: str, password: Any) -> Any: + raise service.AccountLockedError(retry_after_seconds=42) + + monkeypatch.setattr(service, "login", _raise) + response = client.post( + "/api/v1/auth/login", + json={"email": "admin@example.lu", "password": "wrong-password-12"}, + ) + assert response.status_code == 429 + assert response.headers["retry-after"] == "42" + + +def test_register_maps_email_taken_to_409( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A duplicate email is a 409 problem, matching the contract idiom.""" + + def _raise(db: Any, submission: Any, *, client_ip: str, user_agent: str) -> Any: + raise service.EmailTakenError + + monkeypatch.setattr(service, "register", _raise) + response = client.post( + "/api/v1/auth/register", + json={ + "email": "admin@example.lu", + "password": "correct horse battery", + "statement_responses": [], + }, + ) + assert response.status_code == 409 + assert response.headers["content-type"] == "application/problem+json" + + +def test_register_maps_consent_gap_to_422_with_field_errors( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Consent gaps come out as field-level validation errors.""" + + def _raise(db: Any, submission: Any, *, client_ip: str, user_agent: str) -> Any: + raise service.ConsentError( + missing=[("terms_and_conditions", "2026-01-15")], unknown=[] + ) + + monkeypatch.setattr(service, "register", _raise) + response = client.post( + "/api/v1/auth/register", + json={ + "email": "admin@example.lu", + "password": "correct horse battery", + "statement_responses": [], + }, + ) + assert response.status_code == 422 + errors = response.json()["errors"] + assert errors[0]["name"] == "body.statement_responses" + assert "terms_and_conditions" in errors[0]["reason"] + + +def test_logout_revokes_and_clears_cookie( + authenticated: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Logout is a 204 whose Set-Cookie retires the browser's copy.""" + revoked: list[uuid.UUID] = [] + monkeypatch.setattr( + service, "logout", lambda db, session_id: revoked.append(session_id) + ) + response = authenticated.post("/api/v1/auth/logout") + assert response.status_code == 204 + assert "Max-Age=0" in response.headers["set-cookie"] + assert revoked == [FAKE_IDENTITY.session_id] + + +def test_session_endpoint_reports_expiries( + authenticated: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET /auth/session projects the user and the expiry horizon.""" + monkeypatch.setattr( + service, + "session_snapshot", + lambda db, *, user_id, session_id: (FAKE_USER, FAKE_SESSION), + ) + response = authenticated.get("/api/v1/auth/session") + assert response.status_code == 200 + body = response.json() + assert body["user_id"] == str(FAKE_USER.id) + assert body["organization_role"] == "organization_admin" + + +def test_password_change_rotates_cookie( + authenticated: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A password change answers 204 and sets a fresh session cookie.""" + monkeypatch.setattr( + service, + "change_password", + lambda db, **kwargs: service.LoginResult( + token="rotated-token", user=FAKE_USER, session=FAKE_SESSION + ), + ) + response = authenticated.post( + "/api/v1/auth/password", + json={ + "current_password": "correct horse battery", + "new_password": "even more correct horse", + }, + ) + assert response.status_code == 204 + assert "rotated-token" in response.headers["set-cookie"] + + +def test_password_change_wrong_current_is_403( + authenticated: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The wrong current password refuses without touching anything.""" + + def _raise(db: Any, **kwargs: Any) -> Any: + raise service.WrongCurrentPasswordError + + monkeypatch.setattr(service, "change_password", _raise) + response = authenticated.post( + "/api/v1/auth/password", + json={ + "current_password": "wrong horse battery!", + "new_password": "even more correct horse", + }, + ) + assert response.status_code == 403 + + +# --- CSRF middleware ---------------------------------------------------------- + + +def test_csrf_refuses_foreign_origin( + authenticated: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A cookie-bearing POST from another origin dies in the middleware.""" + monkeypatch.setattr(settings, "auth_public_origin", "https://testing.nc3.lu") + response = authenticated.post( + "/api/v1/auth/logout", + headers={ + "Cookie": "__Host-session=whatever", + "Origin": "https://evil.example", + }, + ) + assert response.status_code == 403 + assert response.headers["content-type"] == "application/problem+json" + + +def test_csrf_allows_own_origin( + authenticated: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The deployment's own origin passes through to the handler.""" + monkeypatch.setattr(settings, "auth_public_origin", "https://testing.nc3.lu") + monkeypatch.setattr(service, "logout", lambda db, session_id: None) + response = authenticated.post( + "/api/v1/auth/logout", + headers={ + "Cookie": "__Host-session=whatever", + "Origin": "https://testing.nc3.lu", + }, + ) + assert response.status_code == 204 + + +def test_csrf_ignores_cookieless_requests( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Machine-to-machine calls carry no cookie and are never origin-checked.""" + monkeypatch.setattr(settings, "auth_public_origin", "https://testing.nc3.lu") + + def _raise(db: Any, *, email: str, password: Any) -> Any: + raise service.InvalidCredentialsError + + monkeypatch.setattr(service, "login", _raise) + response = client.post( + "/api/v1/auth/login", + json={"email": "admin@example.lu", "password": "wrong-password-12"}, + headers={"Origin": "https://evil.example"}, + ) + assert response.status_code == 401 # reached the handler, not the middleware + + +# --- rate limits --------------------------------------------------------------- + + +def test_login_rate_limit_answers_429( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Past the per-IP window the login answers 429 with quota headers.""" + calls = {"count": 0} + + async def _counting( + key: str, *, limit: int, window_seconds: int, client: Any = None + ) -> Any: + calls["count"] += 1 + return redis_utils.RateLimitDecision( + allowed=calls["count"] <= limit, + limit=limit, + remaining=max(0, limit - calls["count"]), + reset_seconds=30, + ) + + monkeypatch.setattr(redis_utils, "consume", _counting) + monkeypatch.setattr(settings, "auth_login_rate_limit", 2) + + def _raise(db: Any, *, email: str, password: Any) -> Any: + raise service.InvalidCredentialsError + + monkeypatch.setattr(service, "login", _raise) + body = {"email": "admin@example.lu", "password": "wrong-password-12"} + for _ in range(2): + assert client.post("/api/v1/auth/login", json=body).status_code == 401 + response = client.post("/api/v1/auth/login", json=body) + assert response.status_code == 429 + assert "retry-after" in response.headers + assert response.headers["ratelimit"].startswith("limit=2") + + +def test_rate_limit_fails_open_without_redis( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreachable Redis never blocks login; the DB lockout still stands.""" + + async def _broken(*args: Any, **kwargs: Any) -> Any: + raise ConnectionError("redis down") + + monkeypatch.setattr(redis_utils, "consume", _broken) + + def _raise(db: Any, *, email: str, password: Any) -> Any: + raise service.InvalidCredentialsError + + monkeypatch.setattr(service, "login", _raise) + response = client.post( + "/api/v1/auth/login", + json={"email": "admin@example.lu", "password": "wrong-password-12"}, + ) + assert response.status_code == 401 # processed, not 429/500 diff --git a/tests/test_auth_postgres.py b/tests/test_auth_postgres.py new file mode 100644 index 0000000..8f22271 --- /dev/null +++ b/tests/test_auth_postgres.py @@ -0,0 +1,306 @@ +"""Live-PostgreSQL integration for the auth vertical (B3 / US #79). + +Runs the real thing end to end: the FastAPI handlers over a real `nc3_auth` +engine against a database carrying the migrations — registration provisioning, +login and lockout, cookie sessions, and the SECURITY DEFINER lookups. Also +asserts the decision-13 privilege boundary: `nc3_app` (the role the scan +workers hold) has no reach into the credential surface, and the definer +functions have the hardened shape the revision promises. + +Marked `postgres` (deselected by default); CI runs it inside the Migration +round trip job after `alembic upgrade head` and the role-credential +bootstrap. Role URLs derive from `DATABASE_URL` with the dev-default +passwords unless `APP_DATABASE_URL` / `AUTH_DATABASE_URL` say otherwise. +""" + +import os +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest +import sqlalchemy as sa +from fastapi.testclient import TestClient +from sqlalchemy.exc import ProgrammingError +from sqlalchemy.orm import Session, sessionmaker +from uuid6 import uuid7 + +from nc3_testing_platform.core import api_db, rls +from nc3_testing_platform.core.settings import settings +from nc3_testing_platform.main import app + +pytestmark = pytest.mark.postgres + +TEST_KEY_HEX = "cd" * 32 +PASSWORD = "correct horse battery" +_OWNER_URL = os.getenv("DATABASE_URL") + + +def _role_url(role: str, env_name: str) -> str: + """The role's connection URL: explicit env, or derived dev defaults.""" + explicit = os.getenv(env_name) + if explicit: + return explicit + if not _OWNER_URL: + pytest.skip("DATABASE_URL not set") + derived = sa.engine.make_url(_OWNER_URL).set(username=role, password=role) + return derived.render_as_string(hide_password=False) + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """The app over a real nc3_auth engine, with a synthetic master key. + + The base URL is https so the client's jar accepts and replays the + Secure session cookie. The lazy engine global is reset around the test + so the monkeypatched URL is the one that builds it. + """ + if not _OWNER_URL: + pytest.skip("DATABASE_URL not set") + monkeypatch.setattr(settings, "app_encryption_master_key", TEST_KEY_HEX) + monkeypatch.setattr( + settings, "auth_database_url", _role_url("nc3_auth", "AUTH_DATABASE_URL") + ) + # Plain assignment, not monkeypatch: monkeypatch's teardown runs after + # this fixture's post-yield body and would restore (possibly disposed) + # prior engines over the explicit reset below. + stale = api_db._auth_engine + if stale is not None: + stale.dispose() + api_db._auth_engine = None + api_db._auth_factory = None + with TestClient(app, base_url="https://testserver") as test_client: + yield test_client + engine = api_db._auth_engine + if engine is not None: + engine.dispose() + api_db._auth_engine = None + api_db._auth_factory = None + + +def _registration_body(email: str) -> dict[str, Any]: + """A valid registration payload accepting the seeded terms statement.""" + return { + "email": email, + "password": PASSWORD, + "display_name": "Integration", + "statement_responses": [ + {"statement_key": "terms_and_conditions", "version": "2026-01-15"} + ], + } + + +def _fresh_email() -> str: + """A collision-free address; live databases keep rows between runs.""" + return f"user-{uuid7().hex[:12]}@example.lu" + + +def _register(client: TestClient, email: str) -> dict[str, Any]: + """Register and return the provisioning result.""" + response = client.post("/api/v1/auth/register", json=_registration_body(email)) + assert response.status_code == 201, response.text + return response.json() + + +def test_register_login_session_logout_flow(client: TestClient) -> None: + """The whole vertical: provision, authenticate, introspect, revoke.""" + email = _fresh_email() + registered = _register(client, email) + assert registered["organization_role"] == "organization_admin" + assert registered["email"] == email + + # Wrong password: uniform 401, no cookie. + refused = client.post( + "/api/v1/auth/login", + json={"email": email, "password": "wrong horse battery"}, + ) + assert refused.status_code == 401 + assert "set-cookie" not in refused.headers + + # Right password: cookie lands in the jar and authenticates the session. + logged_in = client.post( + "/api/v1/auth/login", json={"email": email, "password": PASSWORD} + ) + assert logged_in.status_code == 200, logged_in.text + assert logged_in.headers["set-cookie"].startswith("__Host-session=") + + session = client.get("/api/v1/auth/session") + assert session.status_code == 200, session.text + assert session.json()["user_id"] == registered["user_id"] + + assert client.post("/api/v1/auth/logout").status_code == 204 + assert client.get("/api/v1/auth/session").status_code == 401 + + +def test_duplicate_email_answers_409(client: TestClient) -> None: + """The case-insensitive unique index refuses a second registration.""" + email = _fresh_email() + _register(client, email) + response = client.post( + "/api/v1/auth/register", json=_registration_body(email.upper()) + ) + assert response.status_code == 409 + + +def test_registration_without_consent_answers_422(client: TestClient) -> None: + """The seeded terms statement must be accepted.""" + body = _registration_body(_fresh_email()) + body["statement_responses"] = [] + response = client.post("/api/v1/auth/register", json=body) + assert response.status_code == 422 + assert "terms_and_conditions" in response.text + + +def test_lockout_after_repeated_failures( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The durable per-account lockout stands even for the right password.""" + monkeypatch.setattr(settings, "auth_lockout_threshold", 3) + email = _fresh_email() + _register(client, email) + for _ in range(3): + assert ( + client.post( + "/api/v1/auth/login", + json={"email": email, "password": "wrong horse battery"}, + ).status_code + == 401 + ) + locked = client.post( + "/api/v1/auth/login", json={"email": email, "password": PASSWORD} + ) + assert locked.status_code == 429 + assert int(locked.headers["retry-after"]) > 0 + + +def test_password_change_rotates_sessions(client: TestClient) -> None: + """Old cookies die with the change; the rotated one keeps working.""" + email = _fresh_email() + _register(client, email) + assert ( + client.post( + "/api/v1/auth/login", json={"email": email, "password": PASSWORD} + ).status_code + == 200 + ) + old_cookie = client.cookies["__Host-session"] + changed = client.post( + "/api/v1/auth/password", + json={ + "current_password": PASSWORD, + "new_password": "an even better horse", + }, + ) + assert changed.status_code == 204, changed.text + # The rotated cookie authenticates; the pre-change one does not. The + # stale check clears the jar and sends the old token as a raw header — + # httpx deprecated per-request cookies. + assert client.get("/api/v1/auth/session").status_code == 200 + client.cookies.clear() + stale = client.get( + "/api/v1/auth/session", + headers={"Cookie": f"__Host-session={old_cookie}"}, + ) + assert stale.status_code == 401 + + +# --- decision-13 boundary: nc3_app never reaches the credential surface ----- + + +@pytest.fixture +def app_role_session() -> Iterator[Session]: + """A session as nc3_app — the role the scan workers hold.""" + engine = sa.create_engine(_role_url("nc3_app", "APP_DATABASE_URL")) + factory = sessionmaker(bind=engine) + session = factory() + try: + yield session + finally: + session.close() + engine.dispose() + + +def test_nc3_app_has_no_privilege_on_auth_tables( + app_role_session: Session, +) -> None: + """SELECT on the credential tables is refused at the grant layer.""" + for table in ("user_credential", "user_session"): + with pytest.raises(ProgrammingError) as excinfo: + app_role_session.execute(sa.text(f"SELECT count(*) FROM {table}")) + assert "permission denied" in str(excinfo.value) + app_role_session.rollback() + + +def test_nc3_app_cannot_execute_the_definer_lookups( + app_role_session: Session, +) -> None: + """A compromised worker gets no account-enumeration oracle.""" + with pytest.raises(ProgrammingError) as excinfo: + app_role_session.execute( + sa.text("SELECT * FROM public.auth_login_lookup('a@b.lu')") + ) + assert "permission denied" in str(excinfo.value) + app_role_session.rollback() + + +def test_cross_user_isolation_on_credential_rows(client: TestClient) -> None: + """Under user A's RLS arm, user B's credential rows vanish.""" + email_a, email_b = _fresh_email(), _fresh_email() + user_a = uuid.UUID(_register(client, email_a)["user_id"]) + user_b = uuid.UUID(_register(client, email_b)["user_id"]) + engine = sa.create_engine(_role_url("nc3_auth", "AUTH_DATABASE_URL")) + factory = sessionmaker(bind=engine) + session = factory() + try: + rls.set_user_context(session, user_a) + rows = session.execute( + sa.text( + "SELECT count(*) FROM user_credential WHERE user_id = :other" + ), + {"other": user_b}, + ).scalar_one() + assert rows == 0 + own = session.execute( + sa.text("SELECT count(*) FROM user_credential WHERE user_id = :own"), + {"own": user_a}, + ).scalar_one() + assert own == 1 + finally: + session.close() + engine.dispose() + + +def test_definer_functions_have_the_hardened_shape() -> None: + """Owner, pinned search_path, and no PUBLIC or nc3_app EXECUTE.""" + if not _OWNER_URL: + pytest.skip("DATABASE_URL not set") + engine = sa.create_engine(_OWNER_URL) + try: + with engine.connect() as connection: + for signature in ( + "public.auth_login_lookup(text)", + "public.auth_session_bootstrap(bytea)", + ): + owner, is_definer, config = connection.execute( + sa.text( + "SELECT pg_get_userbyid(proowner), prosecdef, proconfig " + "FROM pg_proc WHERE oid = CAST(:sig AS regprocedure)" + ), + {"sig": signature}, + ).one() + assert owner == "nc3_auth_definer", signature + assert is_definer, signature + assert any( + entry.startswith("search_path=") for entry in config or [] + ), signature + for role in ("nc3_app", "app_platform"): + granted = connection.execute( + sa.text( + "SELECT has_function_privilege(:role, " + "CAST(:sig AS regprocedure), 'EXECUTE')" + ), + {"role": role, "sig": signature}, + ).scalar_one() + assert not granted, f"{role} can execute {signature}" + finally: + engine.dispose() diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..72c6ccd --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,68 @@ +"""Unit tests for the envelope-encryption primitives (core/crypto.py).""" + +import pytest + +from nc3_testing_platform.core import crypto +from nc3_testing_platform.core.settings import settings + +TEST_KEY_HEX = "ab" * 32 + + +@pytest.fixture +def master_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Configure a synthetic 256-bit master key for the test.""" + monkeypatch.setattr(settings, "app_encryption_master_key", TEST_KEY_HEX) + monkeypatch.setattr(settings, "app_encryption_master_key_version", "test-1") + + +def test_wrap_unwrap_round_trip(master_key: None) -> None: + """A wrapped KEK unwraps to itself under the same AAD.""" + kek = crypto.generate_key() + wrapped = crypto.wrap_key(kek, aad=b"key_envelope.wrapped_kek") + assert wrapped.algorithm == crypto.ENVELOPE_ALGORITHM + assert wrapped.master_key_version == "test-1" + assert ( + crypto.unwrap_key( + wrapped.ciphertext, wrapped.nonce, aad=b"key_envelope.wrapped_kek" + ) + == kek + ) + + +def test_unwrap_refuses_wrong_aad(master_key: None) -> None: + """A ciphertext never opens under another purpose tag.""" + kek = crypto.generate_key() + wrapped = crypto.wrap_key(kek, aad=b"purpose-a") + with pytest.raises(crypto.DecryptionError): + crypto.unwrap_key(wrapped.ciphertext, wrapped.nonce, aad=b"purpose-b") + + +def test_encrypt_decrypt_round_trip(master_key: None) -> None: + """nonce||ct blobs decrypt to the original plaintext.""" + key = crypto.generate_key() + blob = crypto.encrypt(b"argon2id$...", key, aad=b"user_credential.password") + assert crypto.decrypt(blob, key, aad=b"user_credential.password") == b"argon2id$..." + + +def test_decrypt_refuses_tampering(master_key: None) -> None: + """One flipped bit fails GCM authentication.""" + key = crypto.generate_key() + blob = bytearray(crypto.encrypt(b"secret", key, aad=b"t")) + blob[-1] ^= 0x01 + with pytest.raises(crypto.DecryptionError): + crypto.decrypt(bytes(blob), key, aad=b"t") + + +def test_decrypt_refuses_truncated_blob(master_key: None) -> None: + """A blob shorter than its nonce is refused before AESGCM sees it.""" + with pytest.raises(crypto.DecryptionError): + crypto.decrypt(b"short", crypto.generate_key(), aad=b"t") + + +def test_missing_master_key_refuses_loudly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No key means a raised error — never a plaintext or derived fallback.""" + monkeypatch.setattr(settings, "app_encryption_master_key", "") + with pytest.raises(crypto.MasterKeyUnavailableError): + crypto.wrap_key(crypto.generate_key(), aad=b"t") diff --git a/tests/test_models.py b/tests/test_models.py index 9906f4e..ae1893d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -19,6 +19,8 @@ EXPECTED_TABLES = { "organization", "app_user", + "user_credential", + "user_session", "key_envelope", "organization_invitation", "asset", diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py index 81818fa..932a0a5 100644 --- a/tests/test_openapi_export.py +++ b/tests/test_openapi_export.py @@ -20,6 +20,9 @@ ) ANONYMOUS_OPERATIONS = { + # B3: the operations that mint an identity are anonymous by construction. + ("/api/v1/auth/register", "post"), + ("/api/v1/auth/login", "post"), ("/api/v1/scans", "post"), ("/api/v1/scans/{scan_id}", "get"), ("/api/v1/scans/{scan_id}/results", "get"), @@ -76,7 +79,12 @@ def _accepts_anonymous(operation: dict[str, Any]) -> bool: def test_anonymous_operations_are_exactly_the_documented_set( spec: dict[str, Any], ) -> None: - """Only the seven operations named in api-design §1 accept an anonymous caller.""" + """Exactly nine operations accept an anonymous caller. + + The seven of api-design §1 plus the two B3 auth operations that mint the + identity (register/login) — the contract extension under review with the + contract owner. + """ anonymous = { (path, method) for path, item in spec["paths"].items() diff --git a/tests/test_smoke_surface.py b/tests/test_smoke_surface.py index 83c3bd1..796dead 100644 --- a/tests/test_smoke_surface.py +++ b/tests/test_smoke_surface.py @@ -38,6 +38,18 @@ _METHODS = ("get", "post", "put", "patch", "delete") +# Operations realized against the database (B3 / US #79): this module smokes +# the live mock in-process with no PostgreSQL, so the real auth handlers are +# exempt here. Their coverage lives in tests/test_auth_flow.py (unit, mocked +# session layer) and tests/test_auth_postgres.py (live, `pytest -m postgres`). +REALIZED_OPERATIONS = { + ("post", "/api/v1/auth/register"), + ("post", "/api/v1/auth/login"), + ("get", "/api/v1/auth/session"), + ("post", "/api/v1/auth/logout"), + ("post", "/api/v1/auth/password"), +} + _CLAIM_TOKEN = "9xK2mQ7pL4vR8nT1jH5gF3dS6aW0zYbUcElOnAiKrXs" # Size sanity for the buffered stream body; the module timeout bounds a hang. @@ -300,14 +312,14 @@ def test_cases_cover_every_operation() -> None: if method in _METHODS } covered = {(case.method, case.path) for case in CASES} - assert covered == inventory + assert covered == inventory - REALIZED_OPERATIONS def test_cases_cover_every_request_media_type() -> None: """Every declared request media type of every operation has a case sending it.""" for path, item in SPEC["paths"].items(): for method, operation in item.items(): - if method not in _METHODS: + if method not in _METHODS or (method, path) in REALIZED_OPERATIONS: continue declared = set(operation.get("requestBody", {}).get("content", {})) sent = { diff --git a/uv.lock b/uv.lock index 715d28c..9bfb65d 100644 --- a/uv.lock +++ b/uv.lock @@ -62,6 +62,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -1035,7 +1078,9 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "celery", extra = ["gevent", "redis"] }, + { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, { name = "idna" }, { name = "psycopg", extra = ["binary"] }, @@ -1072,8 +1117,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.19.1" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "celery", extras = ["gevent", "redis"], specifier = ">=5.6.3" }, { name = "chainvalidator", marker = "extra == 'modules'", git = "https://github.com/NC3-TestingPlatform/chainvalidator.git?rev=v0.1.6" }, + { name = "cryptography", specifier = ">=46.0.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, { name = "idna", specifier = ">=3.18" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },