From 41071eaf6252cccf0c389e604ab06743b8071c4a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 22 Oct 2026 15:21:22 -0400 Subject: [PATCH 1/4] feat: stage, publish, roll back and dry-run auth policy changes Every change to the policy keys is now a numbered version in policy_versions, whether it comes from a publish, a rollback, a whole-config PATCH (config_patch) or the first boot (bootstrap). A single draft can be saved, dry-run against recent sign-ins and published atomically. Publish and rollback need admin:write and a fresh step-up, and answer 409 when expectedVersion is stale. The dry run reports refused sign-ins and lockouts per account, caps at 10,000 accounts, and counts BREAK_GLASS_EMAILS accounts without listing them. Closes #402. Closes #403. --- .changeset/staged-policy-changes.md | 10 + README.md | 2 +- adapter-manifest.json | 7 + docs/admin-operations.md | 77 + docs/configuration.md | 19 + openapi.json | 1871 +++++++++++++++++ src/controllers/policy.ts | 172 ++ src/controllers/systemConfig.ts | 18 +- src/generated/api.ts | 1821 ++++++++++++++++ src/lib/authEventCategories.ts | 1 + .../20261022150000-create-policy-versions.cjs | 113 + src/models/policyDrafts.ts | 78 + src/models/policyVersions.ts | 74 + src/routes/systemConfig.routes.ts | 179 ++ src/schemas/authEvent.types.ts | 5 + src/schemas/policy.schema.ts | 31 + src/server.ts | 2 + src/services/policyDryRun.ts | 307 +++ src/services/policyVersionService.ts | 318 +++ tests/integration/systemConfig/policy.spec.ts | 465 ++++ tests/postgres/policyVersions.spec.ts | 318 +++ tests/setup/mocks.ts | 23 + tests/unit/models/models.spec.ts | 2 + tests/unit/services/policyDryRun.spec.ts | 317 +++ .../services/policyVersionService.spec.ts | 342 +++ 25 files changed, 6570 insertions(+), 2 deletions(-) create mode 100644 .changeset/staged-policy-changes.md create mode 100644 src/controllers/policy.ts create mode 100644 src/migrations/20261022150000-create-policy-versions.cjs create mode 100644 src/models/policyDrafts.ts create mode 100644 src/models/policyVersions.ts create mode 100644 src/schemas/policy.schema.ts create mode 100644 src/services/policyDryRun.ts create mode 100644 src/services/policyVersionService.ts create mode 100644 tests/integration/systemConfig/policy.spec.ts create mode 100644 tests/postgres/policyVersions.spec.ts create mode 100644 tests/unit/services/policyDryRun.spec.ts create mode 100644 tests/unit/services/policyVersionService.spec.ts diff --git a/.changeset/staged-policy-changes.md b/.changeset/staged-policy-changes.md new file mode 100644 index 00000000..0fad8e45 --- /dev/null +++ b/.changeset/staged-policy-changes.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': minor +--- + +Auth policy changes are versioned, can be staged as a draft, and can be dry-run and rolled back. + +- New routes under `/system-config/policy`: read the published version and draft, save or discard the draft, publish it, roll back to an earlier version, list versions, and dry-run a policy against recent sign-ins. Publish and rollback need `admin:write` and a fresh step-up, take `expectedVersion`, and answer `409 policy_version_conflict` when another version was published since. +- Every policy change is recorded in the new `policy_versions` table, including a whole-config `PATCH /system-config/admin` that changes a policy key (source `config_patch`). The first boot after upgrading records the policy in force as version 1 (source `bootstrap`). Run the migration before starting this release. +- The dry run reports, per account that signed in during the window, the sign-ins a candidate policy would refuse and whether the account would be locked out. It evaluates at most 10,000 accounts. Accounts in `BREAK_GLASS_EMAILS` are counted, never listed. +- New auth event types: `policy_draft_saved`, `policy_draft_discarded`, `policy_published`, `policy_rolled_back` and `policy_dry_run`. diff --git a/README.md b/README.md index 27c4026d..40e86eec 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md). - [docs/oauth.md](./docs/oauth.md) for OAuth provider setup and security behavior - [docs/webauthn-prf.md](./docs/webauthn-prf.md) for PRF-capable passkey usage - [docs/fido-conformance.md](./docs/fido-conformance.md) for running the FIDO2 conformance tools locally -- [docs/admin-operations.md](./docs/admin-operations.md) for scoped admin and recovery operations +- [docs/admin-operations.md](./docs/admin-operations.md) for scoped admin, recovery operations and staged policy changes - [docs/production-operations.md](./docs/production-operations.md) for production deployment guidance - [docs/security-posture.md](./docs/security-posture.md) for deliberate security tradeoffs (enumeration, token replay, data at rest) diff --git a/adapter-manifest.json b/adapter-manifest.json index d9f0a976..b6aa1fa6 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -199,6 +199,13 @@ { "method": "POST", "path": "/system-config/oauth-providers", "credential": "access" }, { "method": "DELETE", "path": "/system-config/oauth-providers/{id}", "credential": "access" }, { "method": "PATCH", "path": "/system-config/oauth-providers/{id}", "credential": "access" }, + { "method": "GET", "path": "/system-config/policy", "credential": "access" }, + { "method": "DELETE", "path": "/system-config/policy/draft", "credential": "access" }, + { "method": "PUT", "path": "/system-config/policy/draft", "credential": "access" }, + { "method": "POST", "path": "/system-config/policy/dry-run", "credential": "access" }, + { "method": "POST", "path": "/system-config/policy/publish", "credential": "access" }, + { "method": "POST", "path": "/system-config/policy/rollback", "credential": "access" }, + { "method": "GET", "path": "/system-config/policy/versions", "credential": "access" }, { "method": "GET", "path": "/system-config/public", "credential": "none" }, { "method": "GET", "path": "/system-config/roles", "credential": "access" }, { "method": "POST", "path": "/totp/disable", "credential": "access" }, diff --git a/docs/admin-operations.md b/docs/admin-operations.md index ab910440..18459e3f 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -102,6 +102,83 @@ Administrative session endpoints can list sessions and revoke individual or all Lockout is checked after a user has been identified. Keep route-level and destination-aware limits enabled for unknown identifiers and delivery abuse. +## Policy Changes + +The policy keys are the `system_config` keys that decide who can sign in, and how: +`login_methods`, `passkey_login_fallback_enabled`, `phishing_resistant_only`, +`prompt_passkey_enrollment`, `authenticator_policy`, `lockout_policy`, `recovery_codes` and +`account_recovery`. A change to them can lock people out, so every change is kept as a numbered +version that can be restored in one step, and a change can be staged as a draft and dry-run before +it applies. + +Staging is optional. A whole-config `PATCH /system-config/admin` that changes a policy key still +applies immediately, and is recorded as a `config_patch` version in the same transaction, so it +can be rolled back like any other. See [Policy versions](./configuration.md#policy-versions) for +how versions are recorded. + +| Route | Access | Does | +| ------------------------------------- | ------------------------- | ---------------------------------------------------------- | +| `GET /system-config/policy` | `admin:read` | The published version and the draft, if any | +| `PUT /system-config/policy/draft` | `admin:write` | Saves the draft. Nothing changes until it is published | +| `DELETE /system-config/policy/draft` | `admin:write` | Discards the draft. `404` when there is none | +| `POST /system-config/policy/publish` | `admin:write` and step-up | Applies the draft as the next version | +| `POST /system-config/policy/rollback` | `admin:write` and step-up | Publishes a copy of an earlier version as the next version | +| `GET /system-config/policy/versions` | `admin:read` | Every version, newest first | +| `POST /system-config/policy/dry-run` | `admin:read` | Who the request's policy, or the draft, would refuse | + +There is one draft per instance, shared by every administrator. It records the published version it +was started from (`basedOnVersion`) and keeps it across saves, so a draft begun before someone else +published still says so. + +Publish and rollback take `expectedVersion`, the published version the caller reviewed, and answer +`409 policy_version_conflict` when another version has been published since. A stale draft cannot +silently undo someone else's change. Both need a fresh step-up (`403 step_up_required` otherwise). +Publish writes every policy key to `system_config`, records the version and clears the draft in one +transaction, so a publish that fails part way leaves the previous policy in force. Rollback never +rewrites history: rolling back to version 2 from version 5 publishes version 6, a copy of 2, with +`rolledBackTo: 2`. + +Each step is audited: `policy_draft_saved`, `policy_draft_discarded`, `policy_published`, +`policy_rolled_back` and `policy_dry_run`. + +### Dry run + +`POST /system-config/policy/dry-run` evaluates `policy` from the request body, or the draft when the +body has none (`400 policy_candidate_required` when there is neither), against the completed +sign-ins of the last `days` days (default 7, at most 30). + +```json +{ "policy": { "login_methods": ["passkey"], "passkey_login_fallback_enabled": false }, "days": 14 } +``` + +Sign-ins are read from the success events the coverage report reads, folded by attempt, so an email +code verification written twice counts once. The code a new account verifies its address with +during registration is left out, since every policy allows it. For each account that signed in, the +report lists the sign-ins the candidate would refuse and why: + +| Reason | Meaning | +| ----------------------------- | ----------------------------------------------------------------------------------------------- | +| `method_disabled` | The method is no longer in `login_methods` | +| `phishing_resistant_required` | `phishing_resistant_only` is on and the sign-in was not a passkey | +| `fallback_disabled` | The account holds a passkey and `passkey_login_fallback_enabled` is off (OAuth is not affected) | + +`lockedOut` is true when none of the account's enrolled factors would still sign it in: no passkey +under a passkey-only policy, no usable address-based method, and no linked identity at an enabled +OAuth provider. `usersLockedOut` counts them, and they are listed first. + +Authenticator rules (`attachment`, the AAGUID lists, `requireKnownAuthenticator` and +`syncedPasskeys`) are applied when a passkey is registered, not when it signs in, so tightening them +leaves enrolled passkeys working and the dry run does not report them as refusals. The API does not +record whether a past assertion performed user verification, so `userVerification: "required"` is +not predicted either. Both reasons exist in the response contract for when that changes. + +Accounts listed in `BREAK_GLASS_EMAILS` (comma separated) are exempt from policy. They are never +evaluated or listed; `breakGlassAccounts` counts how many exist. + +A run is bounded. It reads `auth_events` through the `(type, created_at)` index for the window and +evaluates at most 10,000 accounts, the first by id. A run that reaches the cap says so in its +`policy_dry_run` event (`capped: true`) and in the API log. + ## Audit Events Admin actions are recorded as auth events with redacted metadata. Do not store raw secrets, tokens, OTPs, magic-link URLs, PRF values, account keys, or provider tokens in admin metadata. diff --git a/docs/configuration.md b/docs/configuration.md index fbbdba64..7017ecbe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -470,6 +470,25 @@ Reads are cached in-process for five minutes. Both write paths already invalidat change through the admin endpoints takes effect immediately; a direct database edit does not, and is not visible until the entry expires. See [`getSystemConfig.ts`](../src/config/getSystemConfig.ts). +### Policy versions + +The policy keys (`login_methods`, `passkey_login_fallback_enabled`, `phishing_resistant_only`, +`prompt_passkey_enrollment`, `authenticator_policy`, `lockout_policy`, `recovery_codes` and +`account_recovery`) are versioned in `policy_versions`, whichever way they change: + +- **`bootstrap`**: the first boot after upgrading records the policy in force as version 1. A later + boot records a new version only when an environment variable changed a policy key that no + administrator has taken over (see above). +- **`config_patch`**: a whole-config `PATCH /system-config/admin` that changes a policy key, in the + same transaction as the write. A patch that names a policy key but leaves the policy as it was + records nothing. +- **`publish`** and **`rollback`**: the staged draft routes. See + [Policy Changes](./admin-operations.md#policy-changes). + +Publish and rollback mark every policy row admin-managed (`updatedBy`), so the environment no longer +overrides them on boot. A database edit to a policy row is not versioned until the next boot +notices it. + ## See also - [`.env.example`](../.env.example) - annotated template diff --git a/openapi.json b/openapi.json index ee2f1049..933a6e25 100644 --- a/openapi.json +++ b/openapi.json @@ -3468,6 +3468,11 @@ "otp_failed", "otp_success", "otp_suspicious", + "policy_draft_discarded", + "policy_draft_saved", + "policy_dry_run", + "policy_published", + "policy_rolled_back", "refresh_token_failed", "refresh_token_success", "refresh_token_suspicious", @@ -3547,6 +3552,11 @@ "otp_failed", "otp_success", "otp_suspicious", + "policy_draft_discarded", + "policy_draft_saved", + "policy_dry_run", + "policy_published", + "policy_rolled_back", "refresh_token_failed", "refresh_token_success", "refresh_token_suspicious", @@ -14011,6 +14021,1867 @@ } } }, + "/system-config/policy": { + "get": { + "summary": "Read the published auth policy and the draft, if any", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "published": { + "version": 0, + "policy": { + "login_methods": [null], + "passkey_login_fallback_enabled": true, + "phishing_resistant_only": null, + "prompt_passkey_enrollment": null, + "authenticator_policy": null, + "lockout_policy": null, + "recovery_codes": null, + "account_recovery": null + }, + "source": null, + "publishedAt": null, + "publishedBy": "string", + "rolledBackTo": 0 + }, + "draft": { + "policy": { + "login_methods": [null], + "passkey_login_fallback_enabled": true, + "phishing_resistant_only": null, + "prompt_passkey_enrollment": null, + "authenticator_policy": null, + "lockout_policy": null, + "recovery_codes": null, + "account_recovery": null + }, + "basedOnVersion": 0, + "updatedAt": null, + "updatedBy": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "published": { + "type": "object", + "properties": { + "version": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { + "type": "boolean", + "default": false + }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { + "type": "string", + "pattern": "^\\d+[smhd]$", + "default": "24h" + } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "source": { + "type": "string", + "enum": ["publish", "rollback", "config_patch", "bootstrap"] + }, + "publishedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "publishedBy": { "type": "string", "nullable": true }, + "rolledBackTo": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": ["version", "policy", "source", "publishedAt", "publishedBy"] + }, + "draft": { + "type": "object", + "nullable": true, + "properties": { + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { + "type": "boolean", + "default": false + }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { + "type": "string", + "pattern": "^\\d+[smhd]$", + "default": "24h" + } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "basedOnVersion": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "updatedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "updatedBy": { "type": "string", "nullable": true } + }, + "required": ["policy", "basedOnVersion", "updatedAt", "updatedBy"] + } + }, + "required": ["published", "draft"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/system-config/policy/draft": { + "put": { + "summary": "Save the auth policy draft", + "description": "Replaces the single draft. A draft changes nothing until it is published, and keeps the version it was started from across saves.", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { "type": "string", "pattern": "^\\d+[smhd]$", "default": "24h" } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + } + }, + "required": ["policy"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "policy": { + "login_methods": [null], + "passkey_login_fallback_enabled": true, + "phishing_resistant_only": null, + "prompt_passkey_enrollment": null, + "authenticator_policy": null, + "lockout_policy": null, + "recovery_codes": null, + "account_recovery": null + }, + "basedOnVersion": 0, + "updatedAt": null, + "updatedBy": "string" + }, + "schema": { + "type": "object", + "properties": { + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { + "type": "string", + "pattern": "^\\d+[smhd]$", + "default": "24h" + } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "basedOnVersion": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, + "updatedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "updatedBy": { "type": "string", "nullable": true } + }, + "required": ["policy", "basedOnVersion", "updatedAt", "updatedBy"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + }, + "delete": { + "summary": "Discard the auth policy draft", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/system-config/policy/publish": { + "post": { + "summary": "Publish the auth policy draft", + "description": "Applies the draft to system config and records it as the next version in one transaction. Answers 409 when expectedVersion is no longer the published version.", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expectedVersion": { "type": "integer", "minimum": 0, "exclusiveMinimum": true } + }, + "required": ["expectedVersion"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "version": 0, + "policy": { + "login_methods": [null], + "passkey_login_fallback_enabled": true, + "phishing_resistant_only": null, + "prompt_passkey_enrollment": null, + "authenticator_policy": null, + "lockout_policy": null, + "recovery_codes": null, + "account_recovery": null + }, + "source": null, + "publishedAt": null, + "publishedBy": "string", + "rolledBackTo": 0 + }, + "schema": { + "type": "object", + "properties": { + "version": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { + "type": "string", + "pattern": "^\\d+[smhd]$", + "default": "24h" + } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "source": { + "type": "string", + "enum": ["publish", "rollback", "config_patch", "bootstrap"] + }, + "publishedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "publishedBy": { "type": "string", "nullable": true }, + "rolledBackTo": { "type": "integer", "minimum": 0, "exclusiveMinimum": true } + }, + "required": ["version", "policy", "source", "publishedAt", "publishedBy"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/system-config/policy/rollback": { + "post": { + "summary": "Roll the auth policy back to an earlier version", + "description": "Publishes a copy of the named version as the next version. Answers 409 when expectedVersion is no longer the published version.", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, + "expectedVersion": { "type": "integer", "minimum": 0, "exclusiveMinimum": true } + }, + "required": ["version", "expectedVersion"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "version": 0, + "policy": { + "login_methods": [null], + "passkey_login_fallback_enabled": true, + "phishing_resistant_only": null, + "prompt_passkey_enrollment": null, + "authenticator_policy": null, + "lockout_policy": null, + "recovery_codes": null, + "account_recovery": null + }, + "source": null, + "publishedAt": null, + "publishedBy": "string", + "rolledBackTo": 0 + }, + "schema": { + "type": "object", + "properties": { + "version": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { + "type": "string", + "pattern": "^\\d+[smhd]$", + "default": "24h" + } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "source": { + "type": "string", + "enum": ["publish", "rollback", "config_patch", "bootstrap"] + }, + "publishedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "publishedBy": { "type": "string", "nullable": true }, + "rolledBackTo": { "type": "integer", "minimum": 0, "exclusiveMinimum": true } + }, + "required": ["version", "policy", "source", "publishedAt", "publishedBy"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/system-config/policy/versions": { + "get": { + "summary": "List published auth policy versions, newest first", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "versions": [null] }, + "schema": { + "type": "object", + "properties": { + "versions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { "type": "integer", "minimum": 0, "exclusiveMinimum": true }, + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "passkey", + "magic_link", + "email_otp", + "phone_otp", + "oauth" + ] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { + "type": "boolean", + "default": false + }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { + "type": "string", + "pattern": "^\\d+[smhd]$", + "default": "24h" + } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "source": { + "type": "string", + "enum": ["publish", "rollback", "config_patch", "bootstrap"] + }, + "publishedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "publishedBy": { "type": "string", "nullable": true }, + "rolledBackTo": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + } + }, + "required": ["version", "policy", "source", "publishedAt", "publishedBy"] + } + } + }, + "required": ["versions"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/system-config/policy/dry-run": { + "post": { + "summary": "Evaluate an auth policy against recent sign-ins", + "description": "Evaluates the policy in the request, or the draft when none is sent, against the sign-ins of the last `days` days. Evaluates at most 10,000 accounts.", + "tags": ["SystemConfig"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "policy": { + "type": "object", + "properties": { + "login_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] + }, + "minItems": 1 + }, + "passkey_login_fallback_enabled": { "type": "boolean" }, + "phishing_resistant_only": { "type": "boolean", "default": false }, + "prompt_passkey_enrollment": { "type": "boolean", "default": false }, + "authenticator_policy": { + "type": "object", + "properties": { + "attachment": { + "type": "string", + "enum": ["any", "platform", "cross-platform"], + "default": "any" + }, + "userVerification": { + "type": "string", + "enum": ["required", "preferred", "discouraged"], + "default": "required" + }, + "attestation": { + "type": "string", + "enum": ["none", "direct"], + "default": "none" + }, + "requireKnownAuthenticator": { "type": "boolean", "default": false }, + "syncedPasskeys": { + "type": "string", + "enum": ["allow", "block"], + "default": "allow" + }, + "aaguidAllowList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "aaguidDenyList": { + "type": "array", + "items": { "type": "string" }, + "default": [] + } + }, + "default": { + "attachment": "any", + "userVerification": "required", + "attestation": "none", + "requireKnownAuthenticator": false, + "syncedPasskeys": "allow", + "aaguidAllowList": [], + "aaguidDenyList": [] + } + }, + "lockout_policy": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "maxFailures": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 10 + }, + "windowSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + }, + "lockoutSeconds": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 900 + } + }, + "default": { + "enabled": true, + "maxFailures": 10, + "windowSeconds": 900, + "lockoutSeconds": 900 + } + }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { "type": "string", "pattern": "^\\d+[smhd]$", "default": "24h" } + }, + "default": { "enabled": false, "delay": "24h" } + } + }, + "required": ["login_methods", "passkey_login_fallback_enabled"] + }, + "days": { "type": "integer", "minimum": 1, "maximum": 30, "default": 7 } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "window": { "from": null, "to": null }, + "signInsEvaluated": 0, + "usersEvaluated": 0, + "wouldFail": [null], + "usersLockedOut": 0, + "breakGlassAccounts": 0 + }, + "schema": { + "type": "object", + "properties": { + "window": { + "type": "object", + "properties": { + "from": { "type": "string", "nullable": true, "format": "date-time" }, + "to": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["from", "to"] + }, + "signInsEvaluated": { "type": "integer", "minimum": 0 }, + "usersEvaluated": { "type": "integer", "minimum": 0 }, + "wouldFail": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { "type": "string" }, + "email": { "type": "string" }, + "signIns": { "type": "integer", "minimum": 0 }, + "reasons": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "method_disabled", + "phishing_resistant_required", + "fallback_disabled", + "synced_passkey_blocked", + "authenticator_not_allowed", + "user_verification_required" + ] + }, + "minItems": 1 + }, + "lockedOut": { "type": "boolean" } + }, + "required": ["userId", "email", "signIns", "reasons", "lockedOut"] + } + }, + "usersLockedOut": { "type": "integer", "minimum": 0 }, + "breakGlassAccounts": { "type": "integer", "minimum": 0 } + }, + "required": [ + "window", + "signInsEvaluated", + "usersEvaluated", + "wouldFail", + "usersLockedOut", + "breakGlassAccounts" + ] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/totp/status": { "get": { "summary": "Get TOTP status for the current user", diff --git a/src/controllers/policy.ts b/src/controllers/policy.ts new file mode 100644 index 00000000..2d35d446 --- /dev/null +++ b/src/controllers/policy.ts @@ -0,0 +1,172 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { resolveSystemConfigUpdatedBy } from '../lib/systemConfigActor.js'; +import { type PolicyDocument, PolicyDocumentSchema } from '../schemas/policy.schema.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { runPolicyDryRun } from '../services/policyDryRun.js'; +import { + discardPolicyDraft, + getPolicyDraft, + getPolicyState, + listPolicyVersions, + PolicyDraftNotFoundError, + PolicyVersionConflictError, + PolicyVersionNotFoundError, + publishPolicyDraft, + rollbackPolicy, + savePolicyDraft, + serializePolicyDraft, + serializePolicyVersion, +} from '../services/policyVersionService.js'; +import { ServiceRequest } from '../types/types.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('policy'); + +function refuse(res: Response, error: unknown) { + if (error instanceof PolicyVersionConflictError) { + return res.status(409).json({ + error: 'policy_version_conflict', + message: `Version ${error.currentVersion} is the published policy now. Review it and retry.`, + }); + } + + if (error instanceof PolicyDraftNotFoundError) { + return res.status(404).json({ error: 'policy_draft_not_found' }); + } + + if (error instanceof PolicyVersionNotFoundError) { + return res.status(404).json({ error: 'policy_version_not_found' }); + } + + throw error; +} + +export async function getPolicyStateHandler(_req: Request, res: Response) { + return res.status(200).json(await getPolicyState()); +} + +export async function listPolicyVersionsHandler(_req: Request, res: Response) { + return res.status(200).json({ versions: await listPolicyVersions() }); +} + +export async function savePolicyDraftHandler(req: ServiceRequest, res: Response) { + const { policy } = req.body as { policy: PolicyDocument }; + const draft = await savePolicyDraft(policy, resolveSystemConfigUpdatedBy(req)); + + await AuthEventService.log({ + type: 'policy_draft_saved', + req, + metadata: { basedOnVersion: draft.basedOnVersion }, + }); + + return res.status(200).json(serializePolicyDraft(draft)); +} + +export async function discardPolicyDraftHandler(req: ServiceRequest, res: Response) { + if (!(await discardPolicyDraft())) { + return res.status(404).json({ error: 'policy_draft_not_found' }); + } + + await AuthEventService.log({ type: 'policy_draft_discarded', req }); + + return res.status(200).json({ message: 'Policy draft discarded' }); +} + +export async function publishPolicyHandler(req: ServiceRequest, res: Response) { + const { expectedVersion } = req.body as { expectedVersion: number }; + let result: Awaited>; + + try { + result = await publishPolicyDraft({ + expectedVersion, + actor: resolveSystemConfigUpdatedBy(req), + }); + } catch (error) { + return refuse(res, error); + } + + const published = serializePolicyVersion(result.published); + + await AuthEventService.log({ + type: 'policy_published', + req, + metadata: { + version: published.version, + previousVersion: result.previousVersion, + basedOnVersion: result.basedOnVersion, + }, + }); + + return res.status(200).json(published); +} + +export async function rollbackPolicyHandler(req: ServiceRequest, res: Response) { + const { version, expectedVersion } = req.body as { version: number; expectedVersion: number }; + let result: Awaited>; + + try { + result = await rollbackPolicy({ + version, + expectedVersion, + actor: resolveSystemConfigUpdatedBy(req), + }); + } catch (error) { + return refuse(res, error); + } + + const published = serializePolicyVersion(result.published); + + await AuthEventService.log({ + type: 'policy_rolled_back', + req, + metadata: { + version: published.version, + rolledBackTo: version, + previousVersion: result.previousVersion, + }, + }); + + return res.status(200).json(published); +} + +export async function dryRunPolicyHandler(req: ServiceRequest, res: Response) { + const { policy, days } = req.body as { policy?: PolicyDocument; days: number }; + const draft = policy ? null : await getPolicyDraft(); + const candidate = policy ?? (draft ? PolicyDocumentSchema.parse(draft.policy) : null); + + if (!candidate) { + return res.status(400).json({ + error: 'policy_candidate_required', + message: 'Send a policy to evaluate, or save a draft first.', + }); + } + + const { capped, ...report } = await runPolicyDryRun({ policy: candidate, days }); + + if (capped) { + logger.warn(`Policy dry run evaluated only the first ${report.usersEvaluated} accounts`); + } + + await AuthEventService.log({ + type: 'policy_dry_run', + req, + metadata: { + candidate: policy ? 'request' : 'draft', + days, + signInsEvaluated: report.signInsEvaluated, + usersEvaluated: report.usersEvaluated, + wouldFail: report.wouldFail.length, + usersLockedOut: report.usersLockedOut, + capped, + }, + }); + + return res.status(200).json(report); +} diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 1d177a47..6483bec0 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -15,6 +15,11 @@ import { createPatchSystemConfigSchema } from '../schemas/systemConfig.patch.sch import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; import { AuthEventService } from '../services/authEventService.js'; import { getLoginPolicy } from '../services/loginPolicyService.js'; +import { + isPolicyKey, + lockPolicyForConfigPatch, + recordConfigPatchPolicyVersion, +} from '../services/policyVersionService.js'; import { ServiceRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -88,7 +93,13 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { const updatedBy = resolveSystemConfigUpdatedBy(req); - await SystemConfig.sequelize!.transaction(async (tx) => { + const touchesPolicy = Object.keys(updates).some(isPolicyKey); + + const policyVersion = await SystemConfig.sequelize!.transaction(async (tx) => { + if (touchesPolicy) { + await lockPolicyForConfigPatch(tx); + } + for (const [key, value] of Object.entries(updates)) { await SystemConfig.upsert( { @@ -100,6 +111,10 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { { transaction: tx }, ); } + + // Drafts are optional, so a policy change made here is versioned like a publish and + // can be rolled back the same way. + return touchesPolicy ? recordConfigPatchPolicyVersion(tx, updatedBy) : null; }); invalidateSystemConfigCache(); @@ -110,6 +125,7 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { metadata: { before: existingMap, after: updates, + ...(policyVersion ? { policyVersion: policyVersion.version } : {}), }, }); diff --git a/src/generated/api.ts b/src/generated/api.ts index 2f913319..e08f5db5 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -3160,6 +3160,11 @@ export interface paths { | 'otp_failed' | 'otp_success' | 'otp_suspicious' + | 'policy_draft_discarded' + | 'policy_draft_saved' + | 'policy_dry_run' + | 'policy_published' + | 'policy_rolled_back' | 'refresh_token_failed' | 'refresh_token_success' | 'refresh_token_suspicious' @@ -3233,6 +3238,11 @@ export interface paths { | 'otp_failed' | 'otp_success' | 'otp_suspicious' + | 'policy_draft_discarded' + | 'policy_draft_saved' + | 'policy_dry_run' + | 'policy_published' + | 'policy_rolled_back' | 'refresh_token_failed' | 'refresh_token_success' | 'refresh_token_suspicious' @@ -14202,6 +14212,1817 @@ export interface paths { }; trace?: never; }; + '/system-config/policy': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read the published auth policy and the draft, if any */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "published": { + * "version": 0, + * "policy": { + * "login_methods": [ + * null + * ], + * "passkey_login_fallback_enabled": true, + * "phishing_resistant_only": null, + * "prompt_passkey_enrollment": null, + * "authenticator_policy": null, + * "lockout_policy": null, + * "recovery_codes": null, + * "account_recovery": null + * }, + * "source": null, + * "publishedAt": null, + * "publishedBy": "string", + * "rolledBackTo": 0 + * }, + * "draft": { + * "policy": { + * "login_methods": [ + * null + * ], + * "passkey_login_fallback_enabled": true, + * "phishing_resistant_only": null, + * "prompt_passkey_enrollment": null, + * "authenticator_policy": null, + * "lockout_policy": null, + * "recovery_codes": null, + * "account_recovery": null + * }, + * "basedOnVersion": 0, + * "updatedAt": null, + * "updatedBy": "string" + * } + * } + */ + 'application/json': { + published: { + version: number; + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only: boolean; + /** @default false */ + prompt_passkey_enrollment: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy: { + /** + * @default any + * @enum {string} + */ + attachment: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy: { + /** @default true */ + enabled: boolean; + /** @default 10 */ + maxFailures: number; + /** @default 900 */ + windowSeconds: number; + /** @default 900 */ + lockoutSeconds: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; + }; + /** @enum {string} */ + source: 'publish' | 'rollback' | 'config_patch' | 'bootstrap'; + /** Format: date-time */ + publishedAt: string | null; + publishedBy: string | null; + rolledBackTo?: number; + }; + draft: { + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only: boolean; + /** @default false */ + prompt_passkey_enrollment: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy: { + /** + * @default any + * @enum {string} + */ + attachment: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy: { + /** @default true */ + enabled: boolean; + /** @default 10 */ + maxFailures: number; + /** @default 900 */ + windowSeconds: number; + /** @default 900 */ + lockoutSeconds: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; + }; + basedOnVersion: number; + /** Format: date-time */ + updatedAt: string | null; + updatedBy: string | null; + } | null; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/system-config/policy/draft': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Save the auth policy draft + * @description Replaces the single draft. A draft changes nothing until it is published, and keeps the version it was started from across saves. + */ + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only?: boolean; + /** @default false */ + prompt_passkey_enrollment?: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy?: { + /** + * @default any + * @enum {string} + */ + attachment?: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification?: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation?: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator?: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys?: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList?: string[]; + /** @default [] */ + aaguidDenyList?: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy?: { + /** @default true */ + enabled?: boolean; + /** @default 10 */ + maxFailures?: number; + /** @default 900 */ + windowSeconds?: number; + /** @default 900 */ + lockoutSeconds?: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes?: { + /** @default true */ + enabled?: boolean; + /** @default false */ + requireAtEnrollment?: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery?: { + /** @default false */ + enabled?: boolean; + /** @default 24h */ + delay?: string; + }; + }; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "policy": { + * "login_methods": [ + * null + * ], + * "passkey_login_fallback_enabled": true, + * "phishing_resistant_only": null, + * "prompt_passkey_enrollment": null, + * "authenticator_policy": null, + * "lockout_policy": null, + * "recovery_codes": null, + * "account_recovery": null + * }, + * "basedOnVersion": 0, + * "updatedAt": null, + * "updatedBy": "string" + * } + */ + 'application/json': { + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only: boolean; + /** @default false */ + prompt_passkey_enrollment: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy: { + /** + * @default any + * @enum {string} + */ + attachment: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy: { + /** @default true */ + enabled: boolean; + /** @default 10 */ + maxFailures: number; + /** @default 900 */ + windowSeconds: number; + /** @default 900 */ + lockoutSeconds: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; + }; + basedOnVersion: number; + /** Format: date-time */ + updatedAt: string | null; + updatedBy: string | null; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + post?: never; + /** Discard the auth policy draft */ + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string" + * } + */ + 'application/json': { + message: string; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/system-config/policy/publish': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Publish the auth policy draft + * @description Applies the draft to system config and records it as the next version in one transaction. Answers 409 when expectedVersion is no longer the published version. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + expectedVersion: number; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "version": 0, + * "policy": { + * "login_methods": [ + * null + * ], + * "passkey_login_fallback_enabled": true, + * "phishing_resistant_only": null, + * "prompt_passkey_enrollment": null, + * "authenticator_policy": null, + * "lockout_policy": null, + * "recovery_codes": null, + * "account_recovery": null + * }, + * "source": null, + * "publishedAt": null, + * "publishedBy": "string", + * "rolledBackTo": 0 + * } + */ + 'application/json': { + version: number; + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only: boolean; + /** @default false */ + prompt_passkey_enrollment: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy: { + /** + * @default any + * @enum {string} + */ + attachment: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy: { + /** @default true */ + enabled: boolean; + /** @default 10 */ + maxFailures: number; + /** @default 900 */ + windowSeconds: number; + /** @default 900 */ + lockoutSeconds: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; + }; + /** @enum {string} */ + source: 'publish' | 'rollback' | 'config_patch' | 'bootstrap'; + /** Format: date-time */ + publishedAt: string | null; + publishedBy: string | null; + rolledBackTo?: number; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/system-config/policy/rollback': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Roll the auth policy back to an earlier version + * @description Publishes a copy of the named version as the next version. Answers 409 when expectedVersion is no longer the published version. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + version: number; + expectedVersion: number; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "version": 0, + * "policy": { + * "login_methods": [ + * null + * ], + * "passkey_login_fallback_enabled": true, + * "phishing_resistant_only": null, + * "prompt_passkey_enrollment": null, + * "authenticator_policy": null, + * "lockout_policy": null, + * "recovery_codes": null, + * "account_recovery": null + * }, + * "source": null, + * "publishedAt": null, + * "publishedBy": "string", + * "rolledBackTo": 0 + * } + */ + 'application/json': { + version: number; + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only: boolean; + /** @default false */ + prompt_passkey_enrollment: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy: { + /** + * @default any + * @enum {string} + */ + attachment: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy: { + /** @default true */ + enabled: boolean; + /** @default 10 */ + maxFailures: number; + /** @default 900 */ + windowSeconds: number; + /** @default 900 */ + lockoutSeconds: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; + }; + /** @enum {string} */ + source: 'publish' | 'rollback' | 'config_patch' | 'bootstrap'; + /** Format: date-time */ + publishedAt: string | null; + publishedBy: string | null; + rolledBackTo?: number; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/system-config/policy/versions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List published auth policy versions, newest first */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "versions": [ + * null + * ] + * } + */ + 'application/json': { + versions: { + version: number; + policy: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only: boolean; + /** @default false */ + prompt_passkey_enrollment: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy: { + /** + * @default any + * @enum {string} + */ + attachment: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList: string[]; + /** @default [] */ + aaguidDenyList: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy: { + /** @default true */ + enabled: boolean; + /** @default 10 */ + maxFailures: number; + /** @default 900 */ + windowSeconds: number; + /** @default 900 */ + lockoutSeconds: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; + }; + /** @enum {string} */ + source: 'publish' | 'rollback' | 'config_patch' | 'bootstrap'; + /** Format: date-time */ + publishedAt: string | null; + publishedBy: string | null; + rolledBackTo?: number; + }[]; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/system-config/policy/dry-run': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Evaluate an auth policy against recent sign-ins + * @description Evaluates the policy in the request, or the draft when none is sent, against the sign-ins of the last `days` days. Evaluates at most 10,000 accounts. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + policy?: { + login_methods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + passkey_login_fallback_enabled: boolean; + /** @default false */ + phishing_resistant_only?: boolean; + /** @default false */ + prompt_passkey_enrollment?: boolean; + /** + * @default { + * "attachment": "any", + * "userVerification": "required", + * "attestation": "none", + * "requireKnownAuthenticator": false, + * "syncedPasskeys": "allow", + * "aaguidAllowList": [], + * "aaguidDenyList": [] + * } + */ + authenticator_policy?: { + /** + * @default any + * @enum {string} + */ + attachment?: 'any' | 'platform' | 'cross-platform'; + /** + * @default required + * @enum {string} + */ + userVerification?: 'required' | 'preferred' | 'discouraged'; + /** + * @default none + * @enum {string} + */ + attestation?: 'none' | 'direct'; + /** @default false */ + requireKnownAuthenticator?: boolean; + /** + * @default allow + * @enum {string} + */ + syncedPasskeys?: 'allow' | 'block'; + /** @default [] */ + aaguidAllowList?: string[]; + /** @default [] */ + aaguidDenyList?: string[]; + }; + /** + * @default { + * "enabled": true, + * "maxFailures": 10, + * "windowSeconds": 900, + * "lockoutSeconds": 900 + * } + */ + lockout_policy?: { + /** @default true */ + enabled?: boolean; + /** @default 10 */ + maxFailures?: number; + /** @default 900 */ + windowSeconds?: number; + /** @default 900 */ + lockoutSeconds?: number; + }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes?: { + /** @default true */ + enabled?: boolean; + /** @default false */ + requireAtEnrollment?: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery?: { + /** @default false */ + enabled?: boolean; + /** @default 24h */ + delay?: string; + }; + }; + /** @default 7 */ + days?: number; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "window": { + * "from": null, + * "to": null + * }, + * "signInsEvaluated": 0, + * "usersEvaluated": 0, + * "wouldFail": [ + * null + * ], + * "usersLockedOut": 0, + * "breakGlassAccounts": 0 + * } + */ + 'application/json': { + window: { + /** Format: date-time */ + from: string | null; + /** Format: date-time */ + to: string | null; + }; + signInsEvaluated: number; + usersEvaluated: number; + wouldFail: { + userId: string; + email: string; + signIns: number; + reasons: ( + | 'method_disabled' + | 'phishing_resistant_required' + | 'fallback_disabled' + | 'synced_passkey_blocked' + | 'authenticator_not_allowed' + | 'user_verification_required' + )[]; + lockedOut: boolean; + }[]; + usersLockedOut: number; + breakGlassAccounts: number; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/totp/status': { parameters: { query?: never; diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index b57cf5f9..69d7bab5 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -43,6 +43,7 @@ const PREFIX_CATEGORIES: ReadonlyArray<[string, AuthEventCategory]> = [ ['service_token_', 'token'], ['jwks_', 'token'], ['system_config_', 'system'], + ['policy_', 'system'], ['admin_', 'system'], ['user_data_', 'system'], ]; diff --git a/src/migrations/20261022150000-create-policy-versions.cjs b/src/migrations/20261022150000-create-policy-versions.cjs new file mode 100644 index 00000000..1e106365 --- /dev/null +++ b/src/migrations/20261022150000-create-policy-versions.cjs @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Versioned auth policy, and the one draft that can be staged against it. + * + * `policy_versions` keeps every policy that has ever been applied, so a change can be + * undone by publishing an older one again. Versions are numbered by the service under an + * advisory lock rather than by a sequence, because a sequence leaves gaps on rollback and + * the number is what an operator quotes when asking for a restore. + * + * `policy_drafts` holds at most one row. The check on `id` is what makes "the draft" a + * single thing two administrators edit, instead of one draft each that publish in turn + * and undo each other. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('policy_versions', { + version: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + }, + policy: { + type: Sequelize.JSONB, + allowNull: false, + }, + source: { + type: Sequelize.STRING(16), + allowNull: false, + }, + rolled_back_to: { + type: Sequelize.INTEGER, + allowNull: true, + references: { model: 'policy_versions', key: 'version' }, + onDelete: 'RESTRICT', + }, + created_by: { + type: Sequelize.UUID, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + + await queryInterface.addConstraint('policy_versions', { + type: 'check', + fields: ['source'], + name: 'policy_versions_source_check', + where: { source: ['publish', 'rollback', 'config_patch', 'bootstrap'] }, + }); + + await queryInterface.addConstraint('policy_versions', { + type: 'check', + fields: ['version'], + name: 'policy_versions_version_positive', + where: { version: { [Sequelize.Op.gt]: 0 } }, + }); + + await queryInterface.createTable('policy_drafts', { + id: { + type: Sequelize.SMALLINT, + primaryKey: true, + allowNull: false, + defaultValue: 1, + }, + policy: { + type: Sequelize.JSONB, + allowNull: false, + }, + based_on_version: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'policy_versions', key: 'version' }, + onDelete: 'RESTRICT', + }, + created_by: { + type: Sequelize.UUID, + allowNull: true, + }, + updated_by: { + type: Sequelize.UUID, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + + await queryInterface.addConstraint('policy_drafts', { + type: 'check', + fields: ['id'], + name: 'policy_drafts_single_row', + where: { id: 1 }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('policy_drafts'); + await queryInterface.dropTable('policy_versions'); + }, +}; diff --git a/src/models/policyDrafts.ts b/src/models/policyDrafts.ts new file mode 100644 index 00000000..4015a376 --- /dev/null +++ b/src/models/policyDrafts.ts @@ -0,0 +1,78 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +/** The migration constrains the table to this one row. */ +export const POLICY_DRAFT_ID = 1; + +export interface PolicyDraftAttributes { + id: number; + policy: unknown; + basedOnVersion: number; + createdBy?: string | null; + updatedBy?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +type PolicyDraftCreationAttributes = Optional< + PolicyDraftAttributes, + 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt' +>; + +export class PolicyDraft + extends Model + implements PolicyDraftAttributes +{ + declare id: number; + declare policy: unknown; + declare basedOnVersion: number; + declare createdBy: string | null; + declare updatedBy: string | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +const initializePolicyDraftModel = (sequelize: Sequelize) => { + PolicyDraft.init( + { + id: { + type: DataTypes.SMALLINT, + primaryKey: true, + allowNull: false, + defaultValue: POLICY_DRAFT_ID, + }, + policy: { + type: DataTypes.JSONB, + allowNull: false, + }, + basedOnVersion: { + type: DataTypes.INTEGER, + allowNull: false, + }, + createdBy: { + type: DataTypes.UUID, + allowNull: true, + }, + updatedBy: { + type: DataTypes.UUID, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'PolicyDraft', + tableName: 'policy_drafts', + underscored: true, + timestamps: true, + }, + ); + + return PolicyDraft; +}; + +export default initializePolicyDraftModel; diff --git a/src/models/policyVersions.ts b/src/models/policyVersions.ts new file mode 100644 index 00000000..d68564e4 --- /dev/null +++ b/src/models/policyVersions.ts @@ -0,0 +1,74 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { PolicyVersionSource } from '@seamless-auth/types'; +import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +export interface PolicyVersionAttributes { + version: number; + policy: unknown; + source: PolicyVersionSource; + rolledBackTo?: number | null; + createdBy?: string | null; + createdAt?: Date; +} + +type PolicyVersionCreationAttributes = Optional< + PolicyVersionAttributes, + 'rolledBackTo' | 'createdBy' | 'createdAt' +>; + +export class PolicyVersion + extends Model + implements PolicyVersionAttributes +{ + declare version: number; + declare policy: unknown; + declare source: PolicyVersionSource; + declare rolledBackTo: number | null; + declare createdBy: string | null; + declare readonly createdAt: Date; +} + +const initializePolicyVersionModel = (sequelize: Sequelize) => { + PolicyVersion.init( + { + version: { + type: DataTypes.INTEGER, + primaryKey: true, + allowNull: false, + }, + policy: { + type: DataTypes.JSONB, + allowNull: false, + }, + source: { + type: DataTypes.STRING(16), + allowNull: false, + }, + rolledBackTo: { + type: DataTypes.INTEGER, + allowNull: true, + }, + createdBy: { + type: DataTypes.UUID, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'PolicyVersion', + tableName: 'policy_versions', + underscored: true, + timestamps: true, + updatedAt: false, + }, + ); + + return PolicyVersion; +}; + +export default initializePolicyVersionModel; diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index d969d8c3..d68873d2 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -10,6 +10,15 @@ import { listOAuthProviders, updateOAuthProvider, } from '../controllers/oauthProviders.js'; +import { + discardPolicyDraftHandler, + dryRunPolicyHandler, + getPolicyStateHandler, + listPolicyVersionsHandler, + publishPolicyHandler, + rollbackPolicyHandler, + savePolicyDraftHandler, +} from '../controllers/policy.js'; import { getAvailableRoles, getPublicSystemConfig, @@ -18,6 +27,7 @@ import { } from '../controllers/systemConfig.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { requireStepUp } from '../middleware/requireStepUp.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { OAuthProviderCreateSchema, @@ -29,6 +39,18 @@ import { OAuthProviderResponseSchema, OAuthProvidersListResponseSchema, } from '../schemas/oauthProviders.responses.js'; +import { + MessageResponseSchema, + PolicyDraftSaveRequestSchema, + PolicyDraftSchema, + PolicyDryRunRequestSchema, + PolicyDryRunResponseSchema, + PolicyPublishRequestSchema, + PolicyRollbackRequestSchema, + PolicyStateResponseSchema, + PolicyVersionSchema, + PolicyVersionsResponseSchema, +} from '../schemas/policy.schema.js'; import { SystemConfigPatchSchema } from '../schemas/systemConfig.patch.schema.js'; import { AvailableRolesResponseSchema, @@ -204,4 +226,161 @@ systemConfigRouter.delete( deleteOAuthProvider, ); +systemConfigRouter.get( + '/policy', + { + auth: 'access', + summary: 'Read the published auth policy and the draft, if any', + tags: ['SystemConfig'], + + middleware: [requireAdmin('read')], + + schemas: { + response: { + 200: PolicyStateResponseSchema, + 401: ErrorSchema, + }, + }, + }, + getPolicyStateHandler, +); + +systemConfigRouter.put( + '/policy/draft', + { + auth: 'access', + summary: 'Save the auth policy draft', + description: + 'Replaces the single draft. A draft changes nothing until it is published, and keeps ' + + 'the version it was started from across saves.', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write')], + + schemas: { + body: PolicyDraftSaveRequestSchema, + response: { + 200: PolicyDraftSchema, + 401: ErrorSchema, + }, + }, + }, + savePolicyDraftHandler, +); + +systemConfigRouter.delete( + '/policy/draft', + { + auth: 'access', + summary: 'Discard the auth policy draft', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write')], + + schemas: { + response: { + 200: MessageResponseSchema, + 401: ErrorSchema, + 404: ErrorSchema, + }, + }, + }, + discardPolicyDraftHandler, +); + +systemConfigRouter.post( + '/policy/publish', + { + auth: 'access', + summary: 'Publish the auth policy draft', + description: + 'Applies the draft to system config and records it as the next version in one ' + + 'transaction. Answers 409 when expectedVersion is no longer the published version.', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write'), requireStepUp()], + + schemas: { + body: PolicyPublishRequestSchema, + response: { + 200: PolicyVersionSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 409: ErrorSchema, + }, + }, + }, + publishPolicyHandler, +); + +systemConfigRouter.post( + '/policy/rollback', + { + auth: 'access', + summary: 'Roll the auth policy back to an earlier version', + description: + 'Publishes a copy of the named version as the next version. Answers 409 when ' + + 'expectedVersion is no longer the published version.', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write'), requireStepUp()], + + schemas: { + body: PolicyRollbackRequestSchema, + response: { + 200: PolicyVersionSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 409: ErrorSchema, + }, + }, + }, + rollbackPolicyHandler, +); + +systemConfigRouter.get( + '/policy/versions', + { + auth: 'access', + summary: 'List published auth policy versions, newest first', + tags: ['SystemConfig'], + + middleware: [requireAdmin('read')], + + schemas: { + response: { + 200: PolicyVersionsResponseSchema, + 401: ErrorSchema, + }, + }, + }, + listPolicyVersionsHandler, +); + +systemConfigRouter.post( + '/policy/dry-run', + { + auth: 'access', + summary: 'Evaluate an auth policy against recent sign-ins', + description: + 'Evaluates the policy in the request, or the draft when none is sent, against the ' + + 'sign-ins of the last `days` days. Evaluates at most 10,000 accounts.', + tags: ['SystemConfig'], + + middleware: [requireAdmin('read')], + + schemas: { + body: PolicyDryRunRequestSchema, + response: { + 200: PolicyDryRunResponseSchema, + 400: ErrorSchema, + 401: ErrorSchema, + }, + }, + }, + dryRunPolicyHandler, +); + export default systemConfigRouter.router; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index fcbadb8a..c2991bb1 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -42,6 +42,11 @@ export const AUTH_EVENT_TYPES = [ 'otp_failed', 'otp_success', 'otp_suspicious', + 'policy_draft_discarded', + 'policy_draft_saved', + 'policy_dry_run', + 'policy_published', + 'policy_rolled_back', 'refresh_token_failed', 'refresh_token_success', 'refresh_token_suspicious', diff --git a/src/schemas/policy.schema.ts b/src/schemas/policy.schema.ts new file mode 100644 index 00000000..c46424c1 --- /dev/null +++ b/src/schemas/policy.schema.ts @@ -0,0 +1,31 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +export type { + PolicyDocument, + PolicyDraft, + PolicyDryRunReason, + PolicyDryRunResponse, + PolicyDryRunUser, + PolicyKey, + PolicyStateResponse, + PolicyVersion, + PolicyVersionSource, +} from '@seamless-auth/types'; +export { + MessageResponseSchema, + POLICY_KEYS, + PolicyDocumentSchema, + PolicyDraftSaveRequestSchema, + PolicyDraftSchema, + PolicyDryRunRequestSchema, + PolicyDryRunResponseSchema, + PolicyPublishRequestSchema, + PolicyRollbackRequestSchema, + PolicyStateResponseSchema, + PolicyVersionSchema, + PolicyVersionsResponseSchema, +} from '@seamless-auth/types'; diff --git a/src/server.ts b/src/server.ts index 8983f75e..54e55563 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { initializeModels } from './models/index.js'; import { startAuditMaintenance } from './services/auditRetention.js'; import { initializeMetadataService } from './services/metadataServiceBootstrap.js'; import { warnIfUnboundOAuthStateAllowed } from './services/oauthService.js'; +import { recordBootstrapPolicyVersion } from './services/policyVersionService.js'; import { assertSchemaCurrent } from './services/schemaGuard.js'; import getLogger from './utils/logger.js'; import { initDevSigningKey } from './utils/signingKeyStore.js'; @@ -36,6 +37,7 @@ async function startServer() { await connectToDb(models); await assertSchemaCurrent(models.sequelize); await bootstrapSystemConfig(); + await recordBootstrapPolicyVersion(); // After config is bootstrapped, since it decides whether attestation is // requested at all. Never throws: a metadata blob that cannot be fetched is diff --git a/src/services/policyDryRun.ts b/src/services/policyDryRun.ts new file mode 100644 index 00000000..92e9e069 --- /dev/null +++ b/src/services/policyDryRun.ts @@ -0,0 +1,307 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { QueryTypes } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { getSequelize } from '../models/index.js'; +import type { + PolicyDocument, + PolicyDryRunReason, + PolicyDryRunResponse, + PolicyDryRunUser, +} from '../schemas/policy.schema.js'; +import { SIGN_IN_EVENT_TYPES } from './coverageReport.js'; +import { + isLoginMethodEnabled, + isPasskeyRequired, + type LoginPolicy, + normalizeLoginPolicy, + resolveAvailableLoginMethods, +} from './loginPolicyService.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * The most accounts one dry run evaluates, so its cost stays bounded on a large instance. + * Past this it evaluates the first accounts by id and records that it was capped. + */ +export const POLICY_DRY_RUN_USER_CAP = 10_000; + +/** `otp` is a code verification written before the channel was recorded. */ +export type DryRunSignInMethod = + 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'otp' | 'oauth' | 'totp'; + +export interface DryRunAccount { + email: string | null; + phone: string | null; + hasPasskey: boolean; + oauthProviders: string[]; +} + +interface SignInRow { + userId: string; + email: string | null; + phone: string | null; + method: DryRunSignInMethod; + signIns: number; + hasPasskey: boolean; + oauthProviders: string[] | null; +} + +/** + * Break-glass accounts are exempt from policy, so a dry run counts them and never lists + * them. Read from `BREAK_GLASS_EMAILS`, a comma separated list. + */ +export function breakGlassEmails(): Set { + return new Set( + (process.env.BREAK_GLASS_EMAILS ?? '') + .split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean), + ); +} + +/** + * Why the policy would refuse a sign-in made with `method`, or null if it would not. + * + * Mirrors the checks the sign-in endpoints make. Authenticator rules are not among them: + * the API applies `authenticator_policy` when a passkey is registered, not when it signs + * in, so tightening it leaves an enrolled passkey working and is not reported here. + */ +export function signInRefusal( + policy: LoginPolicy, + method: DryRunSignInMethod, + hasPasskey: boolean, +): PolicyDryRunReason | null { + if (method === 'passkey') { + return isLoginMethodEnabled(policy, 'passkey') ? null : 'method_disabled'; + } + + if (policy.phishingResistantOnly) { + return 'phishing_resistant_required'; + } + + if (method !== 'totp') { + const enabled = + method === 'otp' + ? isLoginMethodEnabled(policy, 'email_otp') || isLoginMethodEnabled(policy, 'phone_otp') + : isLoginMethodEnabled(policy, method); + + if (!enabled) { + return 'method_disabled'; + } + } + + // OAuth completes without the ephemeral token the passkey requirement is checked on. + if (method !== 'oauth' && isPasskeyRequired(policy, hasPasskey)) { + return 'fallback_disabled'; + } + + return null; +} + +/** Whether any factor the account holds would still sign it in under the policy. */ +export function hasUsableFactor( + policy: LoginPolicy, + account: DryRunAccount, + enabledOAuthProviders: ReadonlySet, +) { + const methods = resolveAvailableLoginMethods({ + policy, + user: { email: account.email, phone: account.phone }, + hasPasskeyCredential: account.hasPasskey, + }); + + if (methods.length > 0) return true; + + return ( + isLoginMethodEnabled(policy, 'oauth') && + account.oauthProviders.some((provider) => enabledOAuthProviders.has(provider)) + ); +} + +/** + * Completed sign-ins in the window per account and method, folded by attempt the way the + * coverage report folds them. Registration attempts are left out: the code a new account + * verifies its address with is allowed under every policy, so it says nothing about one. + * + * Driven by the `(type, created_at)` index on `auth_events`, and capped at one more + * account than is evaluated so the caller can tell the cap was reached. + */ +async function signInsInWindow(from: Date, to: Date, cap: number) { + return getSequelize().query( + ` + WITH sign_ins AS ( + SELECT e.user_id, + COALESCE(e.attempt_id::text, e.id::text) AS attempt, + CASE + WHEN e.type = :passkeyType THEN 'passkey' + WHEN e.type = :otpType AND e.metadata->>'channel' = 'email' THEN 'email_otp' + WHEN e.type = :otpType AND e.metadata->>'channel' = 'sms' THEN 'phone_otp' + WHEN e.type = :otpType THEN 'otp' + WHEN e.type = :magicLinkType THEN 'magic_link' + WHEN e.type = :totpType THEN 'totp' + WHEN e.type = :oauthType THEN 'oauth' + END AS method + FROM auth_events e + WHERE e.type IN (:types) + AND e.created_at >= :from + AND e.created_at < :to + AND e.user_id IS NOT NULL + AND (e.type <> :totpType OR e.metadata->>'flow' = 'login') + AND NOT ( + e.attempt_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM auth_events r + WHERE r.attempt_id = e.attempt_id AND r.type = 'user_created' + ) + ) + ), + scoped AS ( + SELECT u.id, u.email, u.phone + FROM users u + WHERE u.revoked = false + AND u.id IN (SELECT user_id FROM sign_ins) + ORDER BY u.id + LIMIT :cap + ) + SELECT s.id AS "userId", + s.email, + s.phone, + i.method, + COUNT(DISTINCT i.attempt)::int AS "signIns", + EXISTS (SELECT 1 FROM credentials c WHERE c."userId" = s.id) AS "hasPasskey", + ARRAY( + SELECT DISTINCT o.provider_id FROM oauth_identities o WHERE o.user_id = s.id + ) AS "oauthProviders" + FROM scoped s + JOIN sign_ins i ON i.user_id = s.id + GROUP BY s.id, s.email, s.phone, i.method + ORDER BY s.id, i.method + `, + { + replacements: { + from, + to, + cap, + types: Object.values(SIGN_IN_EVENT_TYPES), + passkeyType: SIGN_IN_EVENT_TYPES.passkey, + otpType: SIGN_IN_EVENT_TYPES.otp, + magicLinkType: SIGN_IN_EVENT_TYPES.magic_link, + totpType: SIGN_IN_EVENT_TYPES.totp, + oauthType: SIGN_IN_EVENT_TYPES.oauth, + }, + type: QueryTypes.SELECT, + }, + ); +} + +async function countBreakGlassAccounts(emails: ReadonlySet) { + if (emails.size === 0) return 0; + + const [row] = await getSequelize().query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM users WHERE revoked = false AND lower(email) IN (:emails)`, + { replacements: { emails: [...emails] }, type: QueryTypes.SELECT }, + ); + + return row?.count ?? 0; +} + +function byAccount(rows: SignInRow[]) { + const accounts = new Map(); + + for (const row of rows) { + const entry = accounts.get(row.userId) ?? { + account: { + email: row.email, + phone: row.phone, + hasPasskey: Boolean(row.hasPasskey), + oauthProviders: row.oauthProviders ?? [], + }, + email: row.email ?? '', + rows: [], + }; + + entry.rows.push(row); + accounts.set(row.userId, entry); + } + + return accounts; +} + +export async function runPolicyDryRun(params: { + policy: PolicyDocument; + days: number; + now?: Date; + cap?: number; +}): Promise { + const to = params.now ?? new Date(); + const from = new Date(to.getTime() - params.days * DAY_MS); + const cap = params.cap ?? POLICY_DRY_RUN_USER_CAP; + const policy = normalizeLoginPolicy(params.policy as unknown as Record); + const breakGlass = breakGlassEmails(); + + const [rows, breakGlassAccounts, config] = await Promise.all([ + signInsInWindow(from, to, cap + 1), + countBreakGlassAccounts(breakGlass), + getSystemConfig(), + ]); + + const enabledOAuthProviders = new Set( + config.oauth_providers.filter((provider) => provider.enabled).map((provider) => provider.id), + ); + const accounts = [...byAccount(rows)]; + const capped = accounts.length > cap; + + let signInsEvaluated = 0; + let usersEvaluated = 0; + const wouldFail: PolicyDryRunUser[] = []; + + for (const [userId, { account, email, rows: signIns }] of accounts.slice(0, cap)) { + if (breakGlass.has(email.toLowerCase())) continue; + + usersEvaluated += 1; + + const reasons = new Set(); + let refused = 0; + + for (const row of signIns) { + signInsEvaluated += row.signIns; + + const reason = signInRefusal(policy, row.method, account.hasPasskey); + + if (reason) { + reasons.add(reason); + refused += row.signIns; + } + } + + if (refused > 0) { + wouldFail.push({ + userId, + email, + signIns: refused, + reasons: [...reasons].sort(), + lockedOut: !hasUsableFactor(policy, account, enabledOAuthProviders), + }); + } + } + + // Accounts that would have no way back in first, since they are the ones to act on. + wouldFail.sort( + (a, b) => Number(b.lockedOut) - Number(a.lockedOut) || a.email.localeCompare(b.email), + ); + + return { + window: { from: from.toISOString(), to: to.toISOString() }, + signInsEvaluated, + usersEvaluated, + wouldFail, + usersLockedOut: wouldFail.filter((user) => user.lockedOut).length, + breakGlassAccounts, + capped, + }; +} diff --git a/src/services/policyVersionService.ts b/src/services/policyVersionService.ts new file mode 100644 index 00000000..bba88d28 --- /dev/null +++ b/src/services/policyVersionService.ts @@ -0,0 +1,318 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { Transaction } from 'sequelize'; + +import { invalidateSystemConfigCache } from '../config/getSystemConfig.js'; +import { POLICY_DRAFT_ID, PolicyDraft } from '../models/policyDrafts.js'; +import { PolicyVersion } from '../models/policyVersions.js'; +import { SystemConfig } from '../models/systemConfig.js'; +import { + POLICY_KEYS, + type PolicyDocument, + PolicyDocumentSchema, + type PolicyDraft as PolicyDraftView, + type PolicyStateResponse, + type PolicyVersion as PolicyVersionView, + type PolicyVersionSource, +} from '../schemas/policy.schema.js'; + +// Every write that assigns a version number takes this lock first, so two publishers +// cannot both read version N and both write N + 1. +const POLICY_VERSION_LOCK_KEY = 4020403; + +export class PolicyVersionConflictError extends Error { + constructor(readonly currentVersion: number) { + super(`Policy version ${currentVersion} has been published since`); + this.name = 'PolicyVersionConflictError'; + } +} + +export class PolicyDraftNotFoundError extends Error { + constructor() { + super('There is no policy draft'); + this.name = 'PolicyDraftNotFoundError'; + } +} + +export class PolicyVersionNotFoundError extends Error { + constructor(readonly version: number) { + super(`Policy version ${version} does not exist`); + this.name = 'PolicyVersionNotFoundError'; + } +} + +export function isPolicyKey(key: string): boolean { + return (POLICY_KEYS as readonly string[]).includes(key); +} + +// Parsing fixes the key order, so two documents compare equal however they were stored. +function samePolicy(a: unknown, b: unknown) { + return ( + JSON.stringify(PolicyDocumentSchema.parse(a)) === JSON.stringify(PolicyDocumentSchema.parse(b)) + ); +} + +function transaction(work: (tx: Transaction) => Promise): Promise { + return SystemConfig.sequelize!.transaction(work); +} + +async function lockPolicyVersions(tx: Transaction) { + await PolicyVersion.sequelize!.query('SELECT pg_advisory_xact_lock(:key)', { + replacements: { key: POLICY_VERSION_LOCK_KEY }, + transaction: tx, + }); +} + +async function readAppliedPolicy(tx: Transaction): Promise { + const rows = await SystemConfig.findAll({ + where: { key: [...POLICY_KEYS] }, + transaction: tx, + }); + + return PolicyDocumentSchema.parse(Object.fromEntries(rows.map((row) => [row.key, row.value]))); +} + +async function applyPolicy(tx: Transaction, policy: PolicyDocument, updatedBy: string | null) { + for (const key of POLICY_KEYS) { + // updatedBy marks the row admin-managed, so the next boot does not put the env value back. + await SystemConfig.upsert({ key, value: policy[key], updatedBy }, { transaction: tx }); + } +} + +function latestVersion(tx?: Transaction) { + return PolicyVersion.findOne({ order: [['version', 'DESC']], transaction: tx }); +} + +async function appendVersion( + tx: Transaction, + entry: { + policy: PolicyDocument; + source: PolicyVersionSource; + createdBy: string | null; + rolledBackTo?: number; + }, +) { + const latest = await latestVersion(tx); + + return PolicyVersion.create( + { + version: (latest?.version ?? 0) + 1, + policy: entry.policy, + source: entry.source, + createdBy: entry.createdBy, + rolledBackTo: entry.rolledBackTo ?? null, + }, + { transaction: tx }, + ); +} + +/** + * Records the applied policy as a new version when it differs from the latest one, or + * when there is no version yet. Callers hold the version lock. + */ +async function recordIfChanged( + tx: Transaction, + source: PolicyVersionSource, + createdBy: string | null, +) { + const applied = await readAppliedPolicy(tx); + const latest = await latestVersion(tx); + + if (latest && samePolicy(latest.policy, applied)) { + return null; + } + + return appendVersion(tx, { policy: applied, source, createdBy }); +} + +async function currentVersion(tx: Transaction) { + return (await latestVersion(tx)) ?? (await recordIfChanged(tx, 'bootstrap', null))!; +} + +export function serializePolicyVersion(row: PolicyVersion): PolicyVersionView { + return { + version: row.version, + policy: PolicyDocumentSchema.parse(row.policy), + source: row.source, + publishedAt: row.createdAt.toISOString(), + publishedBy: row.createdBy ?? null, + ...(row.rolledBackTo ? { rolledBackTo: row.rolledBackTo } : {}), + }; +} + +export function serializePolicyDraft(row: PolicyDraft): PolicyDraftView { + return { + policy: PolicyDocumentSchema.parse(row.policy), + basedOnVersion: row.basedOnVersion, + updatedAt: row.updatedAt.toISOString(), + updatedBy: row.updatedBy ?? null, + }; +} + +/** + * Run at startup, after `bootstrapSystemConfig`. The first boot after upgrading records + * the policy in force as version 1, and a later boot records a new version only when env + * changed a policy key that no administrator has taken over. + */ +export function recordBootstrapPolicyVersion() { + return transaction(async (tx) => { + await lockPolicyVersions(tx); + return recordIfChanged(tx, 'bootstrap', null); + }); +} + +/** + * For a whole-config PATCH that names a policy key, inside its transaction. Call it + * before the patch is written, so an instance that has no version yet records the policy + * the patch replaces rather than losing it. + */ +export async function lockPolicyForConfigPatch(tx: Transaction) { + await lockPolicyVersions(tx); + await currentVersion(tx); +} + +/** After the patch is written, in the same transaction. Null when the policy is unchanged. */ +export function recordConfigPatchPolicyVersion(tx: Transaction, createdBy: string | null) { + return recordIfChanged(tx, 'config_patch', createdBy); +} + +export async function getPolicyState(): Promise { + const published = await transaction(async (tx) => { + const latest = await latestVersion(tx); + + if (latest) return latest; + + await lockPolicyVersions(tx); + return currentVersion(tx); + }); + const draft = await PolicyDraft.findByPk(POLICY_DRAFT_ID); + + return { + published: serializePolicyVersion(published), + draft: draft ? serializePolicyDraft(draft) : null, + }; +} + +export async function getPolicyDraft() { + return PolicyDraft.findByPk(POLICY_DRAFT_ID); +} + +export async function listPolicyVersions() { + const rows = await PolicyVersion.findAll({ order: [['version', 'DESC']] }); + + return rows.map(serializePolicyVersion); +} + +/** + * Replaces the draft. A draft keeps the version it was started from across saves, so a + * draft begun before someone else published still reads as based on the older version. + */ +export function savePolicyDraft(policy: PolicyDocument, actor: string | null) { + return transaction(async (tx) => { + await lockPolicyVersions(tx); + const published = await currentVersion(tx); + const existing = await PolicyDraft.findByPk(POLICY_DRAFT_ID, { transaction: tx }); + + if (existing) { + return existing.update({ policy, updatedBy: actor }, { transaction: tx }); + } + + return PolicyDraft.create( + { + id: POLICY_DRAFT_ID, + policy, + basedOnVersion: published.version, + createdBy: actor, + updatedBy: actor, + }, + { transaction: tx }, + ); + }); +} + +/** Whether there was a draft to discard. */ +export async function discardPolicyDraft() { + return (await PolicyDraft.destroy({ where: { id: POLICY_DRAFT_ID } })) > 0; +} + +/** + * Applies the draft to `system_config` and records it as the next version, in one + * transaction, so a publish that fails part way leaves the previous policy in force. + */ +export async function publishPolicyDraft(params: { + expectedVersion: number; + actor: string | null; +}) { + const result = await transaction(async (tx) => { + await lockPolicyVersions(tx); + const previous = await currentVersion(tx); + + if (previous.version !== params.expectedVersion) { + throw new PolicyVersionConflictError(previous.version); + } + + const draft = await PolicyDraft.findByPk(POLICY_DRAFT_ID, { transaction: tx }); + + if (!draft) { + throw new PolicyDraftNotFoundError(); + } + + const policy = PolicyDocumentSchema.parse(draft.policy); + + await applyPolicy(tx, policy, params.actor); + const published = await appendVersion(tx, { + policy, + source: 'publish', + createdBy: params.actor, + }); + await draft.destroy({ transaction: tx }); + + return { published, previousVersion: previous.version, basedOnVersion: draft.basedOnVersion }; + }); + + invalidateSystemConfigCache(); + + return result; +} + +/** Publishes a copy of an older version as the next one. History is never rewritten. */ +export async function rollbackPolicy(params: { + version: number; + expectedVersion: number; + actor: string | null; +}) { + const result = await transaction(async (tx) => { + await lockPolicyVersions(tx); + const previous = await currentVersion(tx); + + if (previous.version !== params.expectedVersion) { + throw new PolicyVersionConflictError(previous.version); + } + + const target = await PolicyVersion.findByPk(params.version, { transaction: tx }); + + if (!target) { + throw new PolicyVersionNotFoundError(params.version); + } + + const policy = PolicyDocumentSchema.parse(target.policy); + + await applyPolicy(tx, policy, params.actor); + const published = await appendVersion(tx, { + policy, + source: 'rollback', + createdBy: params.actor, + rolledBackTo: target.version, + }); + + return { published, previousVersion: previous.version }; + }); + + invalidateSystemConfigCache(); + + return result; +} diff --git a/tests/integration/systemConfig/policy.spec.ts b/tests/integration/systemConfig/policy.spec.ts new file mode 100644 index 00000000..bb875459 --- /dev/null +++ b/tests/integration/systemConfig/policy.spec.ts @@ -0,0 +1,465 @@ +import request from 'supertest'; +import { Application } from 'express'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createApp } from '../../../src/app'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { getSequelize } from '../../../src/models/index.js'; +import { PolicyDraft } from '../../../src/models/policyDrafts.js'; +import { PolicyVersion } from '../../../src/models/policyVersions.js'; +import { Session } from '../../../src/models/sessions.js'; +import { SystemConfig } from '../../../src/models/systemConfig.js'; +import { User } from '../../../src/models/users.js'; +import { PolicyDocumentSchema } from '../../../src/schemas/policy.schema.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { runPolicyDryRun } from '../../../src/services/policyDryRun.js'; +import { buildSession } from '../../factories/sessionFactory.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +vi.mock('../../../src/models/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + getSequelize: vi.fn(), +})); + +vi.mock('../../../src/services/policyDryRun.js', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, runPolicyDryRun: vi.fn(actual.runPolicyDryRun) }; +}); + +let app: Application; + +const applied = PolicyDocumentSchema.parse({ + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, +}); + +const draftPolicy = PolicyDocumentSchema.parse({ + login_methods: ['passkey'], + passkey_login_fallback_enabled: false, +}); + +const at = new Date('2026-10-20T00:00:00Z'); +const query = vi.fn(); + +function version(number: number, extra = {}) { + return { + version: number, + policy: applied, + source: 'publish', + createdBy: 'user-1', + createdAt: at, + rolledBackTo: null, + ...extra, + }; +} + +function draft(extra = {}) { + return { + id: 1, + policy: draftPolicy, + basedOnVersion: 3, + updatedBy: 'user-1', + updatedAt: at, + destroy: vi.fn(), + ...extra, + }; +} + +function freshStepUp() { + vi.mocked(Session.findOne).mockResolvedValue( + buildSession({ stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn' }) as never, + ); +} + +function loggedTypes() { + return vi.mocked(AuthEventService.log).mock.calls.map(([options]) => options.type); +} + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.mocked(getSequelize).mockReturnValue({ query } as never); + vi.mocked(getSystemConfig).mockResolvedValue(buildSystemConfig() as never); + vi.mocked(SystemConfig.findAll).mockResolvedValue( + Object.entries(applied).map(([key, value]) => ({ key, value })) as never, + ); + vi.mocked(PolicyVersion.findOne).mockResolvedValue(version(3) as never); + vi.mocked(PolicyVersion.create).mockImplementation((async (values: object) => ({ + createdAt: at, + ...values, + })) as never); +}); + +describe('GET /system-config/policy', () => { + it('returns the published version and the draft', async () => { + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(draft() as never); + + const res = await request(app).get('/system-config/policy'); + + expect(res.status).toBe(200); + expect(res.body.published).toMatchObject({ version: 3, source: 'publish' }); + expect(res.body.draft).toMatchObject({ basedOnVersion: 3, policy: draftPolicy }); + }); +}); + +describe('PUT /system-config/policy/draft', () => { + it('saves the draft and audits it', async () => { + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(null); + vi.mocked(PolicyDraft.create).mockImplementation((async (values: object) => ({ + updatedAt: at, + ...values, + })) as never); + + const res = await request(app) + .put('/system-config/policy/draft') + .send({ policy: { login_methods: ['passkey'], passkey_login_fallback_enabled: false } }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + policy: draftPolicy, + basedOnVersion: 3, + updatedAt: at.toISOString(), + updatedBy: 'user-1', + }); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'policy_draft_saved', metadata: { basedOnVersion: 3 } }), + ); + // A draft changes nothing until it is published. + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('rejects a policy that fails the schema', async () => { + const res = await request(app) + .put('/system-config/policy/draft') + .send({ policy: { login_methods: [], passkey_login_fallback_enabled: true } }); + + expect(res.status).toBe(400); + expect(PolicyDraft.create).not.toHaveBeenCalled(); + }); +}); + +describe('DELETE /system-config/policy/draft', () => { + it('discards the draft', async () => { + vi.mocked(PolicyDraft.destroy).mockResolvedValue(1); + + const res = await request(app).delete('/system-config/policy/draft'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'Policy draft discarded' }); + expect(loggedTypes()).toContain('policy_draft_discarded'); + }); + + it('answers 404 when there is no draft', async () => { + const res = await request(app).delete('/system-config/policy/draft'); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('policy_draft_not_found'); + expect(loggedTypes()).not.toContain('policy_draft_discarded'); + }); +}); + +describe('POST /system-config/policy/publish', () => { + it('requires a fresh step-up', async () => { + vi.mocked(Session.findOne).mockResolvedValue( + buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }) as never, + ); + + const res = await request(app) + .post('/system-config/policy/publish') + .send({ expectedVersion: 3 }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('step_up_required'); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('publishes the draft as the next version', async () => { + freshStepUp(); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(draft() as never); + + const res = await request(app) + .post('/system-config/policy/publish') + .send({ expectedVersion: 3 }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + version: 4, + policy: draftPolicy, + source: 'publish', + publishedAt: at.toISOString(), + publishedBy: 'user-1', + }); + expect(SystemConfig.upsert).toHaveBeenCalledWith( + { key: 'login_methods', value: ['passkey'], updatedBy: 'user-1' }, + expect.anything(), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'policy_published', + metadata: { version: 4, previousVersion: 3, basedOnVersion: 3 }, + }), + ); + }); + + it('answers 409 when another version was published since', async () => { + freshStepUp(); + vi.mocked(PolicyVersion.findOne).mockResolvedValue(version(5) as never); + + const res = await request(app) + .post('/system-config/policy/publish') + .send({ expectedVersion: 3 }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe('policy_version_conflict'); + expect(res.body.message).toContain('Version 5'); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('answers 404 when there is no draft to publish', async () => { + freshStepUp(); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(null); + + const res = await request(app) + .post('/system-config/policy/publish') + .send({ expectedVersion: 3 }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('policy_draft_not_found'); + }); + + it('requires expectedVersion', async () => { + freshStepUp(); + + const res = await request(app).post('/system-config/policy/publish').send({}); + + expect(res.status).toBe(400); + }); + + it('passes an unexpected failure to the error handler', async () => { + freshStepUp(); + vi.mocked(PolicyDraft.findByPk).mockRejectedValue(new Error('db down')); + + const res = await request(app) + .post('/system-config/policy/publish') + .send({ expectedVersion: 3 }); + + expect(res.status).toBe(500); + }); +}); + +describe('POST /system-config/policy/rollback', () => { + it('requires a fresh step-up', async () => { + vi.mocked(Session.findOne).mockResolvedValue( + buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }) as never, + ); + + const res = await request(app) + .post('/system-config/policy/rollback') + .send({ version: 1, expectedVersion: 3 }); + + expect(res.status).toBe(403); + }); + + it('publishes a copy of the older version', async () => { + freshStepUp(); + vi.mocked(PolicyVersion.findByPk).mockResolvedValue( + version(1, { source: 'bootstrap' }) as never, + ); + + const res = await request(app) + .post('/system-config/policy/rollback') + .send({ version: 1, expectedVersion: 3 }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ version: 4, source: 'rollback', rolledBackTo: 1 }); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'policy_rolled_back', + metadata: { version: 4, rolledBackTo: 1, previousVersion: 3 }, + }), + ); + }); + + it('answers 409 when the expected version is stale', async () => { + freshStepUp(); + + const res = await request(app) + .post('/system-config/policy/rollback') + .send({ version: 1, expectedVersion: 2 }); + + expect(res.status).toBe(409); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('answers 404 for a version that does not exist', async () => { + freshStepUp(); + vi.mocked(PolicyVersion.findByPk).mockResolvedValue(null); + + const res = await request(app) + .post('/system-config/policy/rollback') + .send({ version: 9, expectedVersion: 3 }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('policy_version_not_found'); + }); +}); + +describe('GET /system-config/policy/versions', () => { + it('lists versions newest first', async () => { + vi.mocked(PolicyVersion.findAll).mockResolvedValue([ + version(2, { source: 'config_patch' }), + version(1, { source: 'bootstrap', createdBy: null }), + ] as never); + + const res = await request(app).get('/system-config/policy/versions'); + + expect(res.status).toBe(200); + expect(res.body.versions.map((entry: { version: number }) => entry.version)).toEqual([2, 1]); + expect(res.body.versions[1].publishedBy).toBeNull(); + }); +}); + +describe('POST /system-config/policy/dry-run', () => { + it('evaluates the policy in the request', async () => { + query.mockResolvedValueOnce([ + { + userId: 'u-1', + email: 'a@example.com', + phone: null, + method: 'magic_link', + signIns: 2, + hasPasskey: false, + oauthProviders: [], + }, + ]); + + const res = await request(app) + .post('/system-config/policy/dry-run') + .send({ + policy: { login_methods: ['passkey'], passkey_login_fallback_enabled: true }, + days: 3, + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + signInsEvaluated: 2, + usersEvaluated: 1, + usersLockedOut: 1, + breakGlassAccounts: 0, + wouldFail: [{ userId: 'u-1', signIns: 2, reasons: ['method_disabled'], lockedOut: true }], + }); + expect(res.body).not.toHaveProperty('capped'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'policy_dry_run', + metadata: expect.objectContaining({ candidate: 'request', days: 3, wouldFail: 1 }), + }), + ); + }); + + it('evaluates the draft when no policy is sent', async () => { + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(draft() as never); + query.mockResolvedValueOnce([]); + + const res = await request(app).post('/system-config/policy/dry-run').send({}); + + expect(res.status).toBe(200); + expect(res.body.wouldFail).toEqual([]); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ candidate: 'draft', days: 7 }), + }), + ); + }); + + it('records that a run stopped at the account cap', async () => { + vi.mocked(runPolicyDryRun).mockResolvedValueOnce({ + window: { from: at.toISOString(), to: at.toISOString() }, + signInsEvaluated: 0, + usersEvaluated: 0, + wouldFail: [], + usersLockedOut: 0, + breakGlassAccounts: 0, + capped: true, + }); + + const res = await request(app) + .post('/system-config/policy/dry-run') + .send({ policy: { login_methods: ['passkey'], passkey_login_fallback_enabled: true } }); + + expect(res.status).toBe(200); + expect(res.body).not.toHaveProperty('capped'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ capped: true }) }), + ); + }); + + it('answers 400 when there is neither a policy nor a draft', async () => { + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(null); + + const res = await request(app).post('/system-config/policy/dry-run').send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('policy_candidate_required'); + expect(query).not.toHaveBeenCalled(); + }); + + it('refuses a window longer than 30 days', async () => { + const res = await request(app).post('/system-config/policy/dry-run').send({ days: 31 }); + + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /system-config/admin policy versioning', () => { + beforeEach(() => { + vi.mocked(User.findAll).mockResolvedValue([] as never); + }); + + it('records a config_patch version in the same transaction', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue( + version(3, { policy: draftPolicy }) as never, + ); + + const res = await request(app) + .patch('/system-config/admin') + .send({ login_methods: ['passkey', 'magic_link'] }); + + expect(res.status).toBe(200); + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ version: 4, source: 'config_patch', createdBy: 'user-1' }), + expect.anything(), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'system_config_updated', + metadata: expect.objectContaining({ policyVersion: 4 }), + }), + ); + }); + + it('records no version when the patch leaves the policy as it was', async () => { + const res = await request(app) + .patch('/system-config/admin') + .send({ login_methods: ['passkey', 'magic_link'] }); + + expect(res.status).toBe(200); + expect(PolicyVersion.create).not.toHaveBeenCalled(); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0].metadata).not.toHaveProperty( + 'policyVersion', + ); + }); + + it('leaves versioning alone for a patch that names no policy key', async () => { + const res = await request(app) + .patch('/system-config/admin') + .send({ available_roles: ['user', 'admin'] }); + + expect(res.status).toBe(200); + expect(PolicyVersion.sequelize!.query).not.toHaveBeenCalled(); + expect(PolicyVersion.create).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/postgres/policyVersions.spec.ts b/tests/postgres/policyVersions.spec.ts new file mode 100644 index 00000000..21b23366 --- /dev/null +++ b/tests/postgres/policyVersions.spec.ts @@ -0,0 +1,318 @@ +import { randomUUID } from 'crypto'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { bootstrapSystemConfig } from '../../src/config/bootstrapSystemConfig.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { PolicyVersion } from '../../src/models/policyVersions.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; +import { PolicyDocumentSchema } from '../../src/schemas/policy.schema.js'; +import { runPolicyDryRun } from '../../src/services/policyDryRun.js'; +import { + getPolicyState, + listPolicyVersions, + lockPolicyForConfigPatch, + PolicyVersionConflictError, + publishPolicyDraft, + recordBootstrapPolicyVersion, + recordConfigPatchPolicyVersion, + rollbackPolicy, + savePolicyDraft, +} from '../../src/services/policyVersionService.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +const admin = randomUUID(); + +const passkeyOnly = PolicyDocumentSchema.parse({ + login_methods: ['passkey'], + passkey_login_fallback_enabled: false, +}); + +async function insert(table: string, row: Record) { + const columns = Object.keys(row); + await getSequelize().query( + `INSERT INTO ${table} (${columns.map((column) => `"${column}"`).join(', ')}) + VALUES (${columns.map((column) => `:${column}`).join(', ')})`, + { + replacements: Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + value !== null && typeof value === 'object' && !(value instanceof Date) + ? JSON.stringify(value) + : value, + ]), + ), + }, + ); +} + +async function storedLoginMethods() { + const row = await SystemConfig.findByPk('login_methods'); + return row?.value; +} + +beforeAll(async () => { + await initializeModels(); + + for (const [key, value] of Object.entries( + buildSystemConfig({ frontend_url: 'http://localhost:5174' }), + )) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, :value, now(), now()) + ON CONFLICT (key) DO NOTHING`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + await bootstrapSystemConfig(); + await recordBootstrapPolicyVersion(); +}); + +afterAll(async () => { + await getSequelize().close(); +}); + +describe('policy versions against the migrated schema', () => { + it('records the policy in force at boot, once', async () => { + const before = await listPolicyVersions(); + + expect(before.length).toBeGreaterThan(0); + expect(await recordBootstrapPolicyVersion()).toBeNull(); + expect(await listPolicyVersions()).toHaveLength(before.length); + }); + + it('rolls a publish back completely when it fails part way', async () => { + const { published } = await getPolicyState(); + const loginMethodsBefore = await storedLoginMethods(); + + await savePolicyDraft(passkeyOnly, admin); + + // Fails after every system_config row has been written in the transaction. + const create = vi.spyOn(PolicyVersion, 'create').mockRejectedValueOnce(new Error('disk full')); + + await expect( + publishPolicyDraft({ expectedVersion: published.version, actor: admin }), + ).rejects.toThrow('disk full'); + create.mockRestore(); + + const after = await getPolicyState(); + expect(after.published.version).toBe(published.version); + expect(after.draft?.policy).toEqual(passkeyOnly); + expect(await storedLoginMethods()).toEqual(loginMethodsBefore); + }); + + it('lets one of two concurrent publishes through and refuses the other', async () => { + const { published } = await getPolicyState(); + + await savePolicyDraft(passkeyOnly, admin); + + const results = await Promise.allSettled([ + publishPolicyDraft({ expectedVersion: published.version, actor: admin }), + publishPolicyDraft({ expectedVersion: published.version, actor: admin }), + ]); + + const fulfilled = results.filter((result) => result.status === 'fulfilled'); + const rejected = results.filter((result) => result.status === 'rejected'); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf( + PolicyVersionConflictError, + ); + + const after = await getPolicyState(); + expect(after.published).toMatchObject({ + version: published.version + 1, + source: 'publish', + publishedBy: admin, + policy: passkeyOnly, + }); + expect(after.draft).toBeNull(); + expect(await storedLoginMethods()).toEqual(['passkey']); + + const row = await SystemConfig.findByPk('login_methods'); + expect(row?.updatedBy).toBe(admin); + }); + + it('rolls back by publishing a copy of an older version', async () => { + const versions = await listPolicyVersions(); + const [current] = versions; + const target = versions[versions.length - 1]; + + const { published } = await rollbackPolicy({ + version: target.version, + expectedVersion: current.version, + actor: admin, + }); + + expect(published.version).toBe(current.version + 1); + expect(published.rolledBackTo).toBe(target.version); + expect(await storedLoginMethods()).toEqual(target.policy.login_methods); + expect((await listPolicyVersions())[0]).toMatchObject({ + version: current.version + 1, + source: 'rollback', + rolledBackTo: target.version, + }); + }); + + it('versions a whole-config patch that changes the policy in its transaction', async () => { + const { published } = await getPolicyState(); + + const recorded = await getSequelize().transaction(async (tx) => { + await lockPolicyForConfigPatch(tx); + await SystemConfig.upsert( + { + key: 'passkey_login_fallback_enabled', + value: !published.policy.passkey_login_fallback_enabled, + updatedBy: admin, + }, + { transaction: tx }, + ); + return recordConfigPatchPolicyVersion(tx, admin); + }); + + expect(recorded?.version).toBe(published.version + 1); + expect((await getPolicyState()).published).toMatchObject({ + source: 'config_patch', + policy: { + passkey_login_fallback_enabled: !published.policy.passkey_login_fallback_enabled, + }, + }); + }); + + it('refuses a second draft row', async () => { + await expect( + insert('policy_drafts', { id: 2, policy: passkeyOnly, based_on_version: 1 }), + ).rejects.toThrow(/policy_drafts_single_row/); + }); +}); + +describe('policy dry run against the migrated schema', () => { + // Seeded in May 2001 and windowed to it, so rows the other files write stay out. + const now = new Date('2001-05-08T00:00:00Z'); + const at = (day: number, time = '10:00:00') => new Date(`2001-05-0${day}T${time}.000Z`); + + const holder = randomUUID(); + const emailOnly = randomUUID(); + const revoked = randomUUID(); + const newcomer = randomUUID(); + const breakGlass = randomUUID(); + + async function user(id: string, email: string, extra = {}) { + await insert('users', { + id, + email, + roles: '{user}', + created_at: at(1), + updated_at: at(1), + ...extra, + }); + } + + async function event(type: string, userId: string, time: Date, extra = {}) { + await insert('auth_events', { + id: randomUUID(), + user_id: userId, + type, + created_at: time, + updated_at: time, + ...extra, + }); + } + + beforeAll(async () => { + await user(holder, `holder-${holder}@example.com`); + await user(emailOnly, `email-${emailOnly}@example.com`); + await user(revoked, `revoked-${revoked}@example.com`, { revoked: true }); + await user(newcomer, `new-${newcomer}@example.com`); + await user(breakGlass, `root-${breakGlass}@example.com`); + + await insert('credentials', { + id: `cred-${holder}`, + userId: holder, + publicKey: Buffer.from('public-key'), + deviceType: 'multiDevice', + backedup: true, + createdAt: at(1), + updatedAt: at(1), + }); + + // Two magic link sign-ins and a passkey one for the passkey holder. + await event('magic_link_poll_completed_successfully', holder, at(2), { + attempt_id: randomUUID(), + }); + await event('magic_link_poll_completed_successfully', holder, at(3), { + attempt_id: randomUUID(), + }); + await event('webauthn_login_success', holder, at(4), { attempt_id: randomUUID() }); + + // An email code sign-in written twice in one attempt, as the OTP flow does. + const otpAttempt = randomUUID(); + await event('verify_otp_success', emailOnly, at(5), { + attempt_id: otpAttempt, + metadata: { channel: 'email' }, + }); + await event('verify_otp_success', emailOnly, at(5, '10:00:05'), { + attempt_id: otpAttempt, + metadata: { channel: 'email' }, + }); + // TOTP enrolment, not a sign-in. + await event('totp_success', emailOnly, at(5), { metadata: { flow: 'enrollment' } }); + // Outside the window. + await event( + 'magic_link_poll_completed_successfully', + emailOnly, + new Date('2001-04-20T10:00:00Z'), + ); + + await event('magic_link_poll_completed_successfully', revoked, at(5)); + + // A registration attempt verifying the new account's address. + const registration = randomUUID(); + await event('user_created', newcomer, at(6), { attempt_id: registration }); + await event('verify_otp_success', newcomer, at(6, '10:01:00'), { + attempt_id: registration, + metadata: { channel: 'email' }, + }); + + await event('magic_link_poll_completed_successfully', breakGlass, at(6)); + }); + + it('reports the sign-ins a passkey-only policy would refuse', async () => { + vi.stubEnv('BREAK_GLASS_EMAILS', `ROOT-${breakGlass}@example.com`); + + const report = await runPolicyDryRun({ policy: passkeyOnly, days: 7, now }); + + expect(report.window).toEqual({ + from: '2001-05-01T00:00:00.000Z', + to: '2001-05-08T00:00:00.000Z', + }); + expect(report.usersEvaluated).toBe(2); + expect(report.signInsEvaluated).toBe(4); + expect(report.breakGlassAccounts).toBe(1); + expect(report.capped).toBe(false); + expect(report.wouldFail).toEqual([ + { + userId: emailOnly, + email: `email-${emailOnly}@example.com`, + signIns: 1, + reasons: ['method_disabled'], + lockedOut: true, + }, + { + userId: holder, + email: `holder-${holder}@example.com`, + signIns: 2, + reasons: ['method_disabled'], + lockedOut: false, + }, + ]); + expect(report.usersLockedOut).toBe(1); + }); + + it('stops at the cap', async () => { + const report = await runPolicyDryRun({ policy: passkeyOnly, days: 7, now, cap: 1 }); + + expect(report.capped).toBe(true); + expect(report.usersEvaluated).toBeLessThanOrEqual(1); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 9926a1e6..0c2525ee 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -38,6 +38,29 @@ vi.mock('../../src/models/systemConfig.js', () => ({ }, })); +// Policy writes run inside SystemConfig's transaction and take an advisory lock through +// this query, so the double offers both. +vi.mock('../../src/models/policyVersions.js', () => ({ + PolicyVersion: { + findOne: vi.fn(), + findAll: vi.fn(async () => []), + findByPk: vi.fn(), + create: vi.fn(), + sequelize: { + query: vi.fn(async () => [[], 0]), + }, + }, +})); + +vi.mock('../../src/models/policyDrafts.js', () => ({ + POLICY_DRAFT_ID: 1, + PolicyDraft: { + findByPk: vi.fn(), + create: vi.fn(), + destroy: vi.fn(async () => 0), + }, +})); + vi.mock('../../src/models/authFailures.js', () => ({ AuthFailure: { create: vi.fn(), diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index 03cb0877..5597762a 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -13,6 +13,8 @@ vi.unmock('../../../src/models/organizations.js'); vi.unmock('../../../src/models/organizationMemberships.js'); vi.unmock('../../../src/models/webauthnChallenges.js'); vi.unmock('../../../src/models/userExternalIds.js'); +vi.unmock('../../../src/models/policyVersions.js'); +vi.unmock('../../../src/models/policyDrafts.js'); // The predicate the loader filters on. It used to ask whether a file ended with its own // extension, which is always true, so everything but index was imported and required to diff --git a/tests/unit/services/policyDryRun.spec.ts b/tests/unit/services/policyDryRun.spec.ts new file mode 100644 index 00000000..b00f3739 --- /dev/null +++ b/tests/unit/services/policyDryRun.spec.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { getSequelize } from '../../../src/models/index.js'; +import { PolicyDocumentSchema } from '../../../src/schemas/policy.schema.js'; +import { normalizeLoginPolicy } from '../../../src/services/loginPolicyService.js'; +import { + breakGlassEmails, + hasUsableFactor, + POLICY_DRY_RUN_USER_CAP, + runPolicyDryRun, + signInRefusal, +} from '../../../src/services/policyDryRun.js'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +vi.mock('../../../src/models/index.js', () => ({ + getSequelize: vi.fn(), +})); + +const query = vi.fn(); +const now = new Date('2026-10-22T12:00:00Z'); + +function policy(overrides: Record) { + return PolicyDocumentSchema.parse({ + login_methods: ['passkey', 'magic_link', 'email_otp', 'phone_otp', 'oauth'], + passkey_login_fallback_enabled: true, + ...overrides, + }); +} + +function login(overrides: Record) { + return normalizeLoginPolicy(policy(overrides) as unknown as Record); +} + +const account = { email: 'a@example.com', phone: null, hasPasskey: false, oauthProviders: [] }; + +beforeEach(() => { + vi.mocked(getSequelize).mockReturnValue({ query } as never); + vi.mocked(getSystemConfig).mockResolvedValue( + buildSystemConfig({ + oauth_providers: [ + { id: 'acme', enabled: true }, + { id: 'retired', enabled: false }, + ], + }) as never, + ); +}); + +describe('signInRefusal', () => { + it('admits every method the policy still enables', () => { + const open = login({}); + + for (const method of [ + 'passkey', + 'magic_link', + 'email_otp', + 'phone_otp', + 'otp', + 'oauth', + 'totp', + ]) { + expect(signInRefusal(open, method as never, false)).toBeNull(); + } + }); + + it('refuses a method the policy no longer enables', () => { + const passkeyOnly = login({ login_methods: ['passkey'] }); + + expect(signInRefusal(passkeyOnly, 'magic_link', false)).toBe('method_disabled'); + expect(signInRefusal(passkeyOnly, 'oauth', false)).toBe('method_disabled'); + expect(signInRefusal(passkeyOnly, 'otp', false)).toBe('method_disabled'); + expect(signInRefusal(login({ login_methods: ['magic_link'] }), 'passkey', true)).toBe( + 'method_disabled', + ); + }); + + it('judges a code written before the channel was recorded against either code method', () => { + expect(signInRefusal(login({ login_methods: ['phone_otp'] }), 'otp', false)).toBeNull(); + }); + + it('refuses every other factor in phishing-resistant-only mode', () => { + const strict = login({ phishing_resistant_only: true }); + + expect(signInRefusal(strict, 'passkey', true)).toBeNull(); + expect(signInRefusal(strict, 'email_otp', true)).toBe('phishing_resistant_required'); + expect(signInRefusal(strict, 'totp', false)).toBe('phishing_resistant_required'); + expect(signInRefusal(strict, 'oauth', false)).toBe('phishing_resistant_required'); + }); + + it('refuses a fallback for a passkey holder when fallback is off', () => { + const noFallback = login({ passkey_login_fallback_enabled: false }); + + expect(signInRefusal(noFallback, 'magic_link', true)).toBe('fallback_disabled'); + expect(signInRefusal(noFallback, 'totp', true)).toBe('fallback_disabled'); + expect(signInRefusal(noFallback, 'magic_link', false)).toBeNull(); + // OAuth completes without the ephemeral token the requirement is checked on. + expect(signInRefusal(noFallback, 'oauth', true)).toBeNull(); + }); +}); + +describe('hasUsableFactor', () => { + const enabled = new Set(['acme']); + + it('is true while any address-based method still applies', () => { + expect(hasUsableFactor(login({ login_methods: ['magic_link'] }), account, enabled)).toBe(true); + }); + + it('is false for an account with no passkey under a passkey-only policy', () => { + expect(hasUsableFactor(login({ login_methods: ['passkey'] }), account, enabled)).toBe(false); + expect( + hasUsableFactor( + login({ login_methods: ['passkey'] }), + { ...account, hasPasskey: true }, + enabled, + ), + ).toBe(true); + }); + + it('counts a linked OAuth identity only while its provider is enabled', () => { + const oauthOnly = login({ login_methods: ['oauth'] }); + const noEmail = { ...account, email: null }; + + expect(hasUsableFactor(oauthOnly, { ...noEmail, oauthProviders: ['acme'] }, enabled)).toBe( + true, + ); + expect(hasUsableFactor(oauthOnly, { ...noEmail, oauthProviders: ['retired'] }, enabled)).toBe( + false, + ); + expect( + hasUsableFactor( + login({ login_methods: ['passkey'] }), + { ...noEmail, oauthProviders: ['acme'] }, + enabled, + ), + ).toBe(false); + }); +}); + +describe('breakGlassEmails', () => { + it('reads a trimmed, lower-cased list', () => { + vi.stubEnv('BREAK_GLASS_EMAILS', ' Root@Example.com, ,ops@example.com '); + + expect([...breakGlassEmails()]).toEqual(['root@example.com', 'ops@example.com']); + }); + + it('is empty when unset', () => { + vi.stubEnv('BREAK_GLASS_EMAILS', ''); + + expect(breakGlassEmails().size).toBe(0); + }); +}); + +describe('runPolicyDryRun', () => { + const row = (overrides: Record) => ({ + userId: 'u-1', + email: 'a@example.com', + phone: null, + method: 'magic_link', + signIns: 1, + hasPasskey: false, + oauthProviders: [], + ...overrides, + }); + + it('reports refused sign-ins, lockouts first, over a bounded window', async () => { + query.mockResolvedValueOnce([ + row({ + userId: 'u-1', + email: 'b@example.com', + method: 'magic_link', + signIns: 3, + hasPasskey: true, + }), + row({ + userId: 'u-1', + email: 'b@example.com', + method: 'passkey', + signIns: 2, + hasPasskey: true, + }), + row({ userId: 'u-2', email: 'c@example.com', method: 'email_otp', signIns: 1 }), + row({ + userId: 'u-3', + email: 'z@example.com', + method: 'oauth', + signIns: 4, + oauthProviders: null, + }), + ]); + + const report = await runPolicyDryRun({ + policy: policy({ + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: false, + }), + days: 7, + now, + }); + + const [sql, options] = query.mock.calls[0]; + expect(String(sql)).toContain('LIMIT :cap'); + expect(options.replacements).toMatchObject({ + from: new Date('2026-10-15T12:00:00Z'), + to: now, + cap: POLICY_DRY_RUN_USER_CAP + 1, + }); + expect(report).toEqual({ + window: { from: '2026-10-15T12:00:00.000Z', to: now.toISOString() }, + signInsEvaluated: 10, + usersEvaluated: 3, + wouldFail: [ + { + userId: 'u-2', + email: 'c@example.com', + signIns: 1, + reasons: ['method_disabled'], + lockedOut: false, + }, + { + userId: 'u-1', + email: 'b@example.com', + signIns: 3, + reasons: ['fallback_disabled'], + lockedOut: false, + }, + { + userId: 'u-3', + email: 'z@example.com', + signIns: 4, + reasons: ['method_disabled'], + lockedOut: false, + }, + ].sort((a, b) => a.email.localeCompare(b.email)), + usersLockedOut: 0, + breakGlassAccounts: 0, + capped: false, + }); + }); + + it('flags an account with nothing left to sign in with', async () => { + query.mockResolvedValueOnce([ + row({ userId: 'u-1', email: 'a@example.com', method: 'email_otp' }), + row({ userId: 'u-2', email: 'b@example.com', method: 'magic_link', hasPasskey: true }), + ]); + + const report = await runPolicyDryRun({ + policy: policy({ phishing_resistant_only: true }), + days: 1, + now, + }); + + expect(report.wouldFail.map((user) => [user.userId, user.lockedOut])).toEqual([ + ['u-1', true], + ['u-2', false], + ]); + expect(report.wouldFail[0].reasons).toEqual(['phishing_resistant_required']); + expect(report.usersLockedOut).toBe(1); + }); + + it('counts break-glass accounts and never lists them', async () => { + vi.stubEnv('BREAK_GLASS_EMAILS', 'Root@example.com'); + query + .mockResolvedValueOnce([ + row({ userId: 'u-1', email: 'root@example.com', method: 'magic_link' }), + row({ userId: 'u-2', email: 'b@example.com', method: 'magic_link' }), + ]) + .mockResolvedValueOnce([{ count: 1 }]); + + const report = await runPolicyDryRun({ + policy: policy({ login_methods: ['passkey'] }), + days: 7, + now, + }); + + expect(report.wouldFail.map((user) => user.userId)).toEqual(['u-2']); + expect(report.usersEvaluated).toBe(1); + expect(report.breakGlassAccounts).toBe(1); + expect(query.mock.calls[1][1].replacements).toEqual({ emails: ['root@example.com'] }); + }); + + it('answers zero break-glass accounts when the count query returns nothing', async () => { + vi.stubEnv('BREAK_GLASS_EMAILS', 'root@example.com'); + query.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const report = await runPolicyDryRun({ policy: policy({}), days: 7, now }); + + expect(report.breakGlassAccounts).toBe(0); + }); + + it('evaluates at most the cap and says it was reached', async () => { + query.mockResolvedValueOnce([ + row({ userId: 'u-1' }), + row({ userId: 'u-2', email: null }), + row({ userId: 'u-3' }), + ]); + + const report = await runPolicyDryRun({ + policy: policy({ login_methods: ['passkey'] }), + days: 7, + now, + cap: 2, + }); + + expect(report.capped).toBe(true); + expect(report.usersEvaluated).toBe(2); + expect(report.wouldFail.map((user) => user.userId)).toEqual(['u-2', 'u-1']); + expect(report.wouldFail[0].email).toBe(''); + }); + + it('defaults the window end to now', async () => { + query.mockResolvedValueOnce([]); + + const report = await runPolicyDryRun({ policy: policy({}), days: 30 }); + + expect(Date.parse(report.window.to) - Date.parse(report.window.from)).toBe(30 * 86_400_000); + }); +}); diff --git a/tests/unit/services/policyVersionService.spec.ts b/tests/unit/services/policyVersionService.spec.ts new file mode 100644 index 00000000..6376144a --- /dev/null +++ b/tests/unit/services/policyVersionService.spec.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { invalidateSystemConfigCache } from '../../../src/config/getSystemConfig.js'; +import { PolicyDraft } from '../../../src/models/policyDrafts.js'; +import { PolicyVersion } from '../../../src/models/policyVersions.js'; +import { SystemConfig } from '../../../src/models/systemConfig.js'; +import { POLICY_KEYS, PolicyDocumentSchema } from '../../../src/schemas/policy.schema.js'; +import { + discardPolicyDraft, + getPolicyState, + isPolicyKey, + listPolicyVersions, + lockPolicyForConfigPatch, + PolicyDraftNotFoundError, + PolicyVersionConflictError, + PolicyVersionNotFoundError, + publishPolicyDraft, + recordBootstrapPolicyVersion, + recordConfigPatchPolicyVersion, + rollbackPolicy, + savePolicyDraft, +} from '../../../src/services/policyVersionService.js'; + +const applied = PolicyDocumentSchema.parse({ + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, +}); + +const stricter = PolicyDocumentSchema.parse({ + login_methods: ['passkey'], + passkey_login_fallback_enabled: false, +}); + +const publishedAt = new Date('2026-10-01T00:00:00Z'); + +function versionRow(version: number, policy: unknown = applied, extra = {}) { + return { + version, + policy, + source: 'publish', + createdBy: 'admin-1', + createdAt: publishedAt, + rolledBackTo: null, + ...extra, + }; +} + +function draftRow(extra = {}) { + return { + id: 1, + policy: stricter, + basedOnVersion: 3, + updatedBy: 'admin-1', + updatedAt: publishedAt, + update: vi.fn(async function (this: object, values: object) { + return { ...this, ...values }; + }), + destroy: vi.fn(), + ...extra, + }; +} + +const tx = { LOCK: { UPDATE: 'UPDATE' } }; + +beforeEach(() => { + vi.mocked(SystemConfig.sequelize!.transaction).mockImplementation(((fn: any) => fn(tx)) as never); + vi.mocked(SystemConfig.findAll).mockResolvedValue( + Object.entries(applied).map(([key, value]) => ({ key, value })) as never, + ); + vi.mocked(PolicyVersion.create).mockImplementation((async (values: object) => ({ + createdAt: publishedAt, + ...values, + })) as never); +}); + +function lockTaken() { + expect(PolicyVersion.sequelize!.query).toHaveBeenCalledWith( + 'SELECT pg_advisory_xact_lock(:key)', + expect.objectContaining({ transaction: tx }), + ); +} + +describe('isPolicyKey', () => { + it('names the keys a change can lock people out with', () => { + expect(isPolicyKey('login_methods')).toBe(true); + expect(isPolicyKey('account_recovery')).toBe(true); + expect(isPolicyKey('app_name')).toBe(false); + }); +}); + +describe('recordBootstrapPolicyVersion', () => { + it('records the applied policy as version 1 on the first boot', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(null); + + const created = await recordBootstrapPolicyVersion(); + + lockTaken(); + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ + version: 1, + source: 'bootstrap', + createdBy: null, + policy: applied, + }), + { transaction: tx }, + ); + expect(created?.version).toBe(1); + }); + + it('records nothing when the latest version is the policy in force', async () => { + // Stored JSONB comes back in its own key order, which must not read as a change. + const reordered = Object.fromEntries(Object.entries(applied).reverse()); + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(4, reordered) as never); + + expect(await recordBootstrapPolicyVersion()).toBeNull(); + expect(PolicyVersion.create).not.toHaveBeenCalled(); + }); + + it('records a new version when env changed the policy since the last one', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(4, stricter) as never); + + await recordBootstrapPolicyVersion(); + + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ version: 5, source: 'bootstrap' }), + { transaction: tx }, + ); + }); +}); + +describe('config patch versioning', () => { + it('records the replaced policy first when there is no version yet', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValueOnce(null).mockResolvedValueOnce(null); + + await lockPolicyForConfigPatch(tx as never); + + lockTaken(); + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ version: 1, source: 'bootstrap' }), + { transaction: tx }, + ); + }); + + it('records the patched policy as a config_patch version', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(2, stricter) as never); + + const created = await recordConfigPatchPolicyVersion(tx as never, 'admin-2'); + + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ version: 3, source: 'config_patch', createdBy: 'admin-2' }), + { transaction: tx }, + ); + expect(created?.version).toBe(3); + }); +}); + +describe('getPolicyState', () => { + it('returns the published version and the draft', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(3) as never); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(draftRow() as never); + + const state = await getPolicyState(); + + expect(state.published).toEqual({ + version: 3, + policy: applied, + source: 'publish', + publishedAt: publishedAt.toISOString(), + publishedBy: 'admin-1', + }); + expect(state.draft).toEqual({ + policy: stricter, + basedOnVersion: 3, + updatedAt: publishedAt.toISOString(), + updatedBy: 'admin-1', + }); + expect(PolicyVersion.sequelize!.query).not.toHaveBeenCalled(); + }); + + it('records the baseline when no version exists yet', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(null); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(null); + + const state = await getPolicyState(); + + lockTaken(); + expect(state.published.version).toBe(1); + expect(state.published.source).toBe('bootstrap'); + expect(state.published.publishedBy).toBeNull(); + expect(state.draft).toBeNull(); + }); +}); + +describe('listPolicyVersions', () => { + it('lists newest first and carries the version a rollback restored', async () => { + vi.mocked(PolicyVersion.findAll).mockResolvedValue([ + versionRow(2, applied, { source: 'rollback', rolledBackTo: 1, createdBy: null }), + versionRow(1), + ] as never); + + const versions = await listPolicyVersions(); + + expect(PolicyVersion.findAll).toHaveBeenCalledWith({ order: [['version', 'DESC']] }); + expect(versions[0]).toMatchObject({ version: 2, rolledBackTo: 1, publishedBy: null }); + expect(versions[1]).not.toHaveProperty('rolledBackTo'); + }); +}); + +describe('savePolicyDraft', () => { + it('starts a draft from the published version', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(3) as never); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(null); + vi.mocked(PolicyDraft.create).mockImplementation((async (values: object) => values) as never); + + await savePolicyDraft(stricter, 'admin-1'); + + lockTaken(); + expect(PolicyDraft.create).toHaveBeenCalledWith( + { + id: 1, + policy: stricter, + basedOnVersion: 3, + createdBy: 'admin-1', + updatedBy: 'admin-1', + }, + { transaction: tx }, + ); + }); + + it('keeps the version an existing draft was started from', async () => { + const existing = draftRow({ basedOnVersion: 2 }); + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(5) as never); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(existing as never); + + const saved = await savePolicyDraft(applied, 'admin-2'); + + expect(existing.update).toHaveBeenCalledWith( + { policy: applied, updatedBy: 'admin-2' }, + { transaction: tx }, + ); + expect(saved.basedOnVersion).toBe(2); + expect(PolicyDraft.create).not.toHaveBeenCalled(); + }); +}); + +describe('discardPolicyDraft', () => { + it('reports whether there was a draft', async () => { + vi.mocked(PolicyDraft.destroy).mockResolvedValueOnce(1).mockResolvedValueOnce(0); + + expect(await discardPolicyDraft()).toBe(true); + expect(await discardPolicyDraft()).toBe(false); + expect(PolicyDraft.destroy).toHaveBeenCalledWith({ where: { id: 1 } }); + }); +}); + +describe('publishPolicyDraft', () => { + it('applies every policy key, records the version and clears the draft', async () => { + const draft = draftRow(); + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(3) as never); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(draft as never); + + const result = await publishPolicyDraft({ expectedVersion: 3, actor: 'admin-1' }); + + lockTaken(); + for (const key of POLICY_KEYS) { + expect(SystemConfig.upsert).toHaveBeenCalledWith( + { key, value: stricter[key], updatedBy: 'admin-1' }, + { transaction: tx }, + ); + } + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ version: 4, source: 'publish', policy: stricter }), + { transaction: tx }, + ); + expect(draft.destroy).toHaveBeenCalledWith({ transaction: tx }); + expect(invalidateSystemConfigCache).toHaveBeenCalled(); + expect(result).toMatchObject({ previousVersion: 3, basedOnVersion: 3 }); + expect(result.published.version).toBe(4); + }); + + it('refuses a stale expected version before writing anything', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(4) as never); + + const publishing = publishPolicyDraft({ expectedVersion: 3, actor: 'admin-1' }); + + await expect(publishing).rejects.toBeInstanceOf(PolicyVersionConflictError); + await expect(publishing).rejects.toMatchObject({ currentVersion: 4 }); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + expect(PolicyVersion.create).not.toHaveBeenCalled(); + expect(invalidateSystemConfigCache).not.toHaveBeenCalled(); + }); + + it('refuses when there is no draft', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(3) as never); + vi.mocked(PolicyDraft.findByPk).mockResolvedValue(null); + + await expect(publishPolicyDraft({ expectedVersion: 3, actor: null })).rejects.toBeInstanceOf( + PolicyDraftNotFoundError, + ); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); +}); + +describe('rollbackPolicy', () => { + it('publishes a copy of the older version as the next one', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(5, stricter) as never); + vi.mocked(PolicyVersion.findByPk).mockResolvedValue(versionRow(2, applied) as never); + + const result = await rollbackPolicy({ version: 2, expectedVersion: 5, actor: 'admin-1' }); + + lockTaken(); + expect(SystemConfig.upsert).toHaveBeenCalledWith( + { key: 'login_methods', value: applied.login_methods, updatedBy: 'admin-1' }, + { transaction: tx }, + ); + expect(PolicyVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ version: 6, source: 'rollback', rolledBackTo: 2 }), + { transaction: tx }, + ); + expect(invalidateSystemConfigCache).toHaveBeenCalled(); + expect(result.previousVersion).toBe(5); + }); + + it('refuses a stale expected version', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(6) as never); + + await expect( + rollbackPolicy({ version: 2, expectedVersion: 5, actor: null }), + ).rejects.toBeInstanceOf(PolicyVersionConflictError); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('refuses a version that does not exist', async () => { + vi.mocked(PolicyVersion.findOne).mockResolvedValue(versionRow(5) as never); + vi.mocked(PolicyVersion.findByPk).mockResolvedValue(null); + + await expect( + rollbackPolicy({ version: 9, expectedVersion: 5, actor: null }), + ).rejects.toBeInstanceOf(PolicyVersionNotFoundError); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); +}); From 08183ef736ae43c963257485b1b311f35ef69df3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 08:17:49 -0400 Subject: [PATCH 2/4] feat(policy): report passkeys a policy would not admit at re-enrollment in the dry run --- .changeset/staged-policy-changes.md | 2 + docs/admin-operations.md | 14 ++++-- src/services/policyDryRun.ts | 63 ++++++++++++++++++++++-- tests/unit/services/policyDryRun.spec.ts | 59 ++++++++++++++++++++++ 4 files changed, 128 insertions(+), 10 deletions(-) diff --git a/.changeset/staged-policy-changes.md b/.changeset/staged-policy-changes.md index 0fad8e45..58ee3a67 100644 --- a/.changeset/staged-policy-changes.md +++ b/.changeset/staged-policy-changes.md @@ -8,3 +8,5 @@ Auth policy changes are versioned, can be staged as a draft, and can be dry-run - Every policy change is recorded in the new `policy_versions` table, including a whole-config `PATCH /system-config/admin` that changes a policy key (source `config_patch`). The first boot after upgrading records the policy in force as version 1 (source `bootstrap`). Run the migration before starting this release. - The dry run reports, per account that signed in during the window, the sign-ins a candidate policy would refuse and whether the account would be locked out. It evaluates at most 10,000 accounts. Accounts in `BREAK_GLASS_EMAILS` are counted, never listed. - New auth event types: `policy_draft_saved`, `policy_draft_discarded`, `policy_published`, `policy_rolled_back` and `policy_dry_run`. + +The dry run also reports `authenticator_not_allowed` and `synced_passkey_blocked` for an account holding a passkey the candidate authenticator policy would not admit at enrollment. Those passkeys keep signing in, so such an entry counts no refused sign-in and is never `lockedOut`. diff --git a/docs/admin-operations.md b/docs/admin-operations.md index eec6d0b3..3f9c9aba 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -183,16 +183,20 @@ report lists the sign-ins the candidate would refuse and why: | `method_disabled` | The method is no longer in `login_methods` | | `phishing_resistant_required` | `phishing_resistant_only` is on and the sign-in was not a passkey | | `fallback_disabled` | The account holds a passkey and `passkey_login_fallback_enabled` is off (OAuth is not affected) | +| `authenticator_not_allowed` | The account holds a passkey the AAGUID lists or `requireKnownAuthenticator` would not admit | +| `synced_passkey_blocked` | The account holds a synced passkey and `syncedPasskeys` would be `block` | `lockedOut` is true when none of the account's enrolled factors would still sign it in: no passkey under a passkey-only policy, no usable address-based method, and no linked identity at an enabled OAuth provider. `usersLockedOut` counts them, and they are listed first. -Authenticator rules (`attachment`, the AAGUID lists, `requireKnownAuthenticator` and -`syncedPasskeys`) are applied when a passkey is registered, not when it signs in, so tightening them -leaves enrolled passkeys working and the dry run does not report them as refusals. The API does not -record whether a past assertion performed user verification, so `userVerification: "required"` is -not predicted either. Both reasons exist in the response contract for when that changes. +Authenticator rules (the AAGUID lists, `requireKnownAuthenticator` and `syncedPasskeys`) are applied +when a passkey is registered, not when it signs in, so a passkey the candidate would not admit keeps +working. The two authenticator reasons therefore count no refused sign-in (`signIns` can be `0`) and +never make an account `lockedOut`: they say the user could not enroll that authenticator again, +which matters when they replace a device. `attachment` is a hint to the browser and is not +evaluated. The API does not record whether a past assertion performed user verification, so +`userVerification: "required"` is not predicted. Accounts listed in `BREAK_GLASS_EMAILS` (comma separated) are exempt from policy. They are never evaluated or listed; `breakGlassAccounts` counts how many exist. diff --git a/src/services/policyDryRun.ts b/src/services/policyDryRun.ts index 92e9e069..03115bf1 100644 --- a/src/services/policyDryRun.ts +++ b/src/services/policyDryRun.ts @@ -7,6 +7,8 @@ import { QueryTypes } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import type { AttestationType } from '../lib/attestationType.js'; +import { Credential } from '../models/credentials.js'; import { getSequelize } from '../models/index.js'; import type { PolicyDocument, @@ -14,6 +16,7 @@ import type { PolicyDryRunResponse, PolicyDryRunUser, } from '../schemas/policy.schema.js'; +import { evaluateAuthenticatorPolicy } from './authenticatorPolicyService.js'; import { SIGN_IN_EVENT_TYPES } from './coverageReport.js'; import { isLoginMethodEnabled, @@ -210,6 +213,51 @@ async function countBreakGlassAccounts(emails: ReadonlySet) { return row?.count ?? 0; } +const ATTESTATION_TYPES: ReadonlySet = new Set(['none', 'self', 'basic']); + +/** + * Users holding a passkey the candidate authenticator policy would not admit if it were + * registered today. That passkey keeps signing in, since the policy applies at + * registration, so this is reported as a reason without counting a refused sign-in: it is + * what the user could not enroll again. + */ +async function authenticatorRefusals( + userIds: string[], + policy: PolicyDocument['authenticator_policy'], +) { + const refusals = new Map>(); + if (userIds.length === 0) return refusals; + + const credentials = await Credential.findAll({ + where: { userId: userIds }, + attributes: ['userId', 'aaguid', 'deviceType', 'attestationType'], + }); + + for (const credential of credentials) { + const attestationType: AttestationType = ATTESTATION_TYPES.has(credential.attestationType ?? '') + ? (credential.attestationType as AttestationType) + : 'none'; + const verdict = evaluateAuthenticatorPolicy({ + policy, + aaguid: credential.aaguid, + deviceType: credential.deviceType, + attestationType, + }); + + if (verdict.allowed) continue; + + const reasons = refusals.get(credential.userId) ?? new Set(); + reasons.add( + verdict.reason === 'synced_passkey_not_allowed' + ? 'synced_passkey_blocked' + : 'authenticator_not_allowed', + ); + refusals.set(credential.userId, reasons); + } + + return refusals; +} + function byAccount(rows: SignInRow[]) { const accounts = new Map(); @@ -259,13 +307,18 @@ export async function runPolicyDryRun(params: { let signInsEvaluated = 0; let usersEvaluated = 0; const wouldFail: PolicyDryRunUser[] = []; + const evaluated = accounts + .slice(0, cap) + .filter(([, { email }]) => !breakGlass.has(email.toLowerCase())); + const passkeyRefusals = await authenticatorRefusals( + evaluated.filter(([, { account }]) => account.hasPasskey).map(([userId]) => userId), + params.policy.authenticator_policy, + ); - for (const [userId, { account, email, rows: signIns }] of accounts.slice(0, cap)) { - if (breakGlass.has(email.toLowerCase())) continue; - + for (const [userId, { account, email, rows: signIns }] of evaluated) { usersEvaluated += 1; - const reasons = new Set(); + const reasons = new Set(passkeyRefusals.get(userId)); let refused = 0; for (const row of signIns) { @@ -279,7 +332,7 @@ export async function runPolicyDryRun(params: { } } - if (refused > 0) { + if (reasons.size > 0) { wouldFail.push({ userId, email, diff --git a/tests/unit/services/policyDryRun.spec.ts b/tests/unit/services/policyDryRun.spec.ts index b00f3739..728a2e35 100644 --- a/tests/unit/services/policyDryRun.spec.ts +++ b/tests/unit/services/policyDryRun.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Credential } from '../../../src/models/credentials.js'; import { getSequelize } from '../../../src/models/index.js'; import { PolicyDocumentSchema } from '../../../src/schemas/policy.schema.js'; import { normalizeLoginPolicy } from '../../../src/services/loginPolicyService.js'; @@ -36,6 +37,7 @@ const account = { email: 'a@example.com', phone: null, hasPasskey: false, oauthP beforeEach(() => { vi.mocked(getSequelize).mockReturnValue({ query } as never); + (Credential.findAll as any).mockResolvedValue([]); vi.mocked(getSystemConfig).mockResolvedValue( buildSystemConfig({ oauth_providers: [ @@ -162,6 +164,63 @@ describe('runPolicyDryRun', () => { ...overrides, }); + it('reports a passkey the candidate policy would not admit at enrollment, without a refused sign-in', async () => { + query.mockResolvedValueOnce([ + row({ + userId: 'u-1', + email: 'b@example.com', + method: 'passkey', + signIns: 2, + hasPasskey: true, + }), + ]); + (Credential.findAll as any).mockResolvedValueOnce([ + { userId: 'u-1', aaguid: null, deviceType: 'multiDevice', attestationType: 'none' }, + ]); + + const report = await runPolicyDryRun({ + policy: policy({ authenticator_policy: { syncedPasskeys: 'block' } }), + days: 7, + now, + }); + + expect(report.wouldFail).toEqual([ + { + userId: 'u-1', + email: 'b@example.com', + signIns: 0, + reasons: ['synced_passkey_blocked'], + lockedOut: false, + }, + ]); + expect(Credential.findAll).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: ['u-1'] } }), + ); + }); + + it('reports no authenticator reason for a passkey the policy admits', async () => { + query.mockResolvedValueOnce([ + row({ + userId: 'u-1', + email: 'b@example.com', + method: 'passkey', + signIns: 2, + hasPasskey: true, + }), + ]); + (Credential.findAll as any).mockResolvedValueOnce([ + { userId: 'u-1', aaguid: null, deviceType: 'singleDevice', attestationType: 'none' }, + ]); + + const report = await runPolicyDryRun({ + policy: policy({ authenticator_policy: { syncedPasskeys: 'block' } }), + days: 7, + now, + }); + + expect(report.wouldFail).toEqual([]); + }); + it('reports refused sign-ins, lockouts first, over a bounded window', async () => { query.mockResolvedValueOnce([ row({ From 644654f319835a2d75b2bfe338cfb91cc6659b08 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 15:26:22 -0400 Subject: [PATCH 3/4] test(policy): step up before PATCH /system-config/admin in the versioning tests --- tests/integration/systemConfig/policy.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/systemConfig/policy.spec.ts b/tests/integration/systemConfig/policy.spec.ts index bb875459..5c62d5da 100644 --- a/tests/integration/systemConfig/policy.spec.ts +++ b/tests/integration/systemConfig/policy.spec.ts @@ -417,6 +417,7 @@ describe('POST /system-config/policy/dry-run', () => { describe('PATCH /system-config/admin policy versioning', () => { beforeEach(() => { vi.mocked(User.findAll).mockResolvedValue([] as never); + freshStepUp(); }); it('records a config_patch version in the same transaction', async () => { From 5a0a5b5e99bb30b580d76cedaef92ff569665ea0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 24 Oct 2026 15:26:22 -0400 Subject: [PATCH 4/4] refactor(policy): share the break-glass parser and keep request input out of isPasskeyRequired The dry run kept its own BREAK_GLASS_EMAILS parser from before src/config/breakGlass.ts landed. Use the shared one and drop the duplicate tests, which breakGlass.spec covers. The dry run's policy is request input, and isPasskeyRequired also guards session issuance, so CodeQL reported a user-controlled bypass (alert 50). Evaluate the fallback rule inline instead; the phishing-resistant case it also covers is already answered earlier. --- src/services/policyDryRun.ts | 25 ++++++++++-------------- tests/unit/services/policyDryRun.spec.ts | 15 -------------- 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/src/services/policyDryRun.ts b/src/services/policyDryRun.ts index 03115bf1..c2d2dfee 100644 --- a/src/services/policyDryRun.ts +++ b/src/services/policyDryRun.ts @@ -6,6 +6,7 @@ import { QueryTypes } from 'sequelize'; +import { breakGlassEmails } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; import type { AttestationType } from '../lib/attestationType.js'; import { Credential } from '../models/credentials.js'; @@ -20,7 +21,6 @@ import { evaluateAuthenticatorPolicy } from './authenticatorPolicyService.js'; import { SIGN_IN_EVENT_TYPES } from './coverageReport.js'; import { isLoginMethodEnabled, - isPasskeyRequired, type LoginPolicy, normalizeLoginPolicy, resolveAvailableLoginMethods, @@ -55,19 +55,6 @@ interface SignInRow { oauthProviders: string[] | null; } -/** - * Break-glass accounts are exempt from policy, so a dry run counts them and never lists - * them. Read from `BREAK_GLASS_EMAILS`, a comma separated list. - */ -export function breakGlassEmails(): Set { - return new Set( - (process.env.BREAK_GLASS_EMAILS ?? '') - .split(',') - .map((email) => email.trim().toLowerCase()) - .filter(Boolean), - ); -} - /** * Why the policy would refuse a sign-in made with `method`, or null if it would not. * @@ -100,7 +87,14 @@ export function signInRefusal( } // OAuth completes without the ephemeral token the passkey requirement is checked on. - if (method !== 'oauth' && isPasskeyRequired(policy, hasPasskey)) { + // isPasskeyRequired, less the phishing-resistant case handled above. Not called + // directly: it also guards session issuance, and this policy is request input. + if ( + method !== 'oauth' && + hasPasskey && + !policy.passkeyFallbackEnabled && + isLoginMethodEnabled(policy, 'passkey') + ) { return 'fallback_disabled'; } @@ -290,6 +284,7 @@ export async function runPolicyDryRun(params: { const from = new Date(to.getTime() - params.days * DAY_MS); const cap = params.cap ?? POLICY_DRY_RUN_USER_CAP; const policy = normalizeLoginPolicy(params.policy as unknown as Record); + // Break-glass accounts are exempt from policy, so they are counted and never listed. const breakGlass = breakGlassEmails(); const [rows, breakGlassAccounts, config] = await Promise.all([ diff --git a/tests/unit/services/policyDryRun.spec.ts b/tests/unit/services/policyDryRun.spec.ts index 728a2e35..86d53fe2 100644 --- a/tests/unit/services/policyDryRun.spec.ts +++ b/tests/unit/services/policyDryRun.spec.ts @@ -6,7 +6,6 @@ import { getSequelize } from '../../../src/models/index.js'; import { PolicyDocumentSchema } from '../../../src/schemas/policy.schema.js'; import { normalizeLoginPolicy } from '../../../src/services/loginPolicyService.js'; import { - breakGlassEmails, hasUsableFactor, POLICY_DRY_RUN_USER_CAP, runPolicyDryRun, @@ -138,20 +137,6 @@ describe('hasUsableFactor', () => { }); }); -describe('breakGlassEmails', () => { - it('reads a trimmed, lower-cased list', () => { - vi.stubEnv('BREAK_GLASS_EMAILS', ' Root@Example.com, ,ops@example.com '); - - expect([...breakGlassEmails()]).toEqual(['root@example.com', 'ops@example.com']); - }); - - it('is empty when unset', () => { - vi.stubEnv('BREAK_GLASS_EMAILS', ''); - - expect(breakGlassEmails().size).toBe(0); - }); -}); - describe('runPolicyDryRun', () => { const row = (overrides: Record) => ({ userId: 'u-1',