Skip to content

feat: stage, publish, roll back and dry-run auth policy changes - #492

Open
Bccorb wants to merge 3 commits into
mainfrom
feat/staged-policy
Open

Bccorb wants to merge 3 commits into
mainfrom
feat/staged-policy

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Closes #402. Closes #403.

Depends on #482 (chore/types-0.30.0, @seamless-auth/types 0.30.0 and the recovery_codes/account_recovery keys). This branch is stacked on it, so the diff includes that commit until #482 merges. Merge #482 first.

Behaviour

  • Every change to a policy key (login_methods, passkey_login_fallback_enabled, phishing_resistant_only, prompt_passkey_enrollment, authenticator_policy, lockout_policy, recovery_codes, account_recovery) is a numbered version in policy_versions:
    • bootstrap: the first boot after upgrading records the policy in force as version 1. A later boot adds one only when env changed a policy key no admin has taken over.
    • config_patch: a whole-config PATCH /system-config/admin that changes a policy key, written in the PATCH's own transaction. A patch that leaves the policy unchanged records nothing.
    • publish and rollback: the new routes.
  • Staging is optional. There is one draft per instance (policy_drafts, constrained to a single row). It keeps basedOnVersion across saves.
  • Publish writes all policy keys to system_config (marked admin-managed), records the version and clears the draft in one transaction, then invalidates the config cache. Rollback publishes a copy of an older version as a new version with rolledBackTo. Both take expectedVersion and answer 409 policy_version_conflict when stale. Version numbers are assigned under a transaction-scoped advisory lock.
  • Dry run: evaluates the request's policy, or the draft (400 policy_candidate_required if neither), over the last days (1 to 30, default 7) of completed sign-ins, folded by attempt as the coverage report does. Registration address verification is excluded. Reasons are method_disabled, phishing_resistant_required and fallback_disabled, mirroring loginPolicyService. lockedOut uses resolveAvailableLoginMethods plus linked identities at enabled OAuth providers. BREAK_GLASS_EMAILS accounts are counted in breakGlassAccounts and never evaluated or listed. The query is driven by the (type, created_at) index and capped at 10,000 accounts; a capped run is flagged in its audit event and logged.
  • Not reported by the dry run: authenticator rules (attachment, AAGUID lists, synced passkeys, known authenticator) are enforced only at passkey registration, not at sign-in, so they never refuse an existing passkey's sign-in. User verification is not recorded per assertion. Those enum values stay unused; this is documented.
  • Audit events: policy_draft_saved, policy_draft_discarded, policy_published, policy_rolled_back, policy_dry_run (category system).

Routes (contract change)

Route Auth Response
GET /system-config/policy admin:read PolicyStateResponse
PUT /system-config/policy/draft admin:write PolicyDraft
DELETE /system-config/policy/draft admin:write message, 404 if none
POST /system-config/policy/publish admin:write + step-up PolicyVersion, 404 no draft, 409 stale
POST /system-config/policy/rollback admin:write + step-up PolicyVersion, 404 unknown version, 409 stale
GET /system-config/policy/versions admin:read PolicyVersionsResponse
POST /system-config/policy/dry-run admin:read PolicyDryRunResponse

Additive only. The server adapter has no catch-all proxy, so seamless-auth-server needs passthroughs for these routes before the dashboard (seamless-auth-admin-dashboard#295) can use them. openapi.json, adapter-manifest.json and src/generated/api.ts are regenerated.

Migration verification

Run against a scratch database seamless_policy_versions_test on the local Postgres. The database was dropped afterwards.

db:migrate        == 20261022150000-create-policy-versions: migrated (0.006s)
db:migrate:undo   == 20261022150000-create-policy-versions: reverted (0.006s)
                  to_regclass('policy_versions'), to_regclass('policy_drafts') -> both null
db:migrate        == 20261022150000-create-policy-versions: migrated (0.010s)

\d shows policy_versions (PK version, JSONB policy, source and positivity checks, self FK rolled_back_to, created_by, created_at) and policy_drafts (id check = 1, FK based_on_version, created/updated by/at).

Tests

  • Unit: policyVersionService.spec.ts, policyDryRun.spec.ts.
  • Integration: systemConfig/policy.spec.ts covers every route, step-up refusal, 409, 404, and PATCH config_patch versioning.
  • Real Postgres (tests/postgres/policyVersions.spec.ts):
    • A publish failing after the config writes rolls back completely.
    • Two concurrent publishes produce exactly one success and one conflict. With the advisory lock removed this test fails, confirmed.
    • Rollback, the config_patch transaction, and the single-draft constraint.
    • The dry-run SQL over seeded users, credentials and events (attempt folding, registration exclusion, TOTP flow filter, revoked users, out-of-window rows, break-glass, cap).

Checks

  • npm run typecheck: pass
  • npm run lint: pass
  • npx prettier --check .: all files formatted
  • npm run build: pass
  • npm run check:changeset-bump: no major changesets
  • npm run coverage: 144 files, 2037 passed, 1 todo. All files 98.95 lines / 95.47 branches / 99.35 functions / 99.17 statements (thresholds 98/95/98/98)
  • TEST_DB=postgres npm run test:run (scratch DB): 147 files, 2059 passed, 1 todo
  • Security review of the diff: no high-confidence findings

Changeset: minor.

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.
# Conflicts:
#	src/routes/systemConfig.routes.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: dry-run a policy change against recent sign-ins feat: draft, publish and roll back auth policy changes

1 participant