diff --git a/migrations/000034_oidc_providers.down.sql b/migrations/000034_oidc_providers.down.sql new file mode 100644 index 0000000..d3a3dee --- /dev/null +++ b/migrations/000034_oidc_providers.down.sql @@ -0,0 +1,16 @@ +-- 000034_oidc_providers.down.sql +-- Reverses 000034_oidc_providers.up.sql. Destructive: every configured +-- OIDC provider + every group→role mapping is dropped. Existing OIDC +-- sessions in the `sessions` table (000035) become orphaned but are +-- not auto-revoked here; operators run `certctl-cli auth sessions +-- revoke-all` after a down-migration if they need clean state. +-- +-- FK-safe order: group_role_mappings → oidc_providers (mappings ref +-- provider_id, so mappings drop first). +BEGIN; + +DROP INDEX IF EXISTS idx_group_role_mappings_provider_id; +DROP TABLE IF EXISTS group_role_mappings; +DROP TABLE IF EXISTS oidc_providers; + +COMMIT; diff --git a/migrations/000034_oidc_providers.up.sql b/migrations/000034_oidc_providers.up.sql new file mode 100644 index 0000000..7c88066 --- /dev/null +++ b/migrations/000034_oidc_providers.up.sql @@ -0,0 +1,93 @@ +-- 000034_oidc_providers.up.sql +-- Auth Bundle 2 / Phase 2: OIDC provider configuration + group→role +-- mapping tables. Backs internal/auth/oidc/domain/{OIDCProvider, +-- GroupRoleMapping}. Phase 3 (OIDC service) reads these rows to +-- validate ID tokens against the configured IdP allow-list. +-- +-- All operations use IF NOT EXISTS / IF EXISTS / ON CONFLICT DO NOTHING +-- so the migration is idempotent: safe to re-run on every +-- certctl-server boot per the project's "Idempotent migrations" +-- architecture decision. Wrapped in a single transaction so a +-- partial-fail leaves no half-state. +-- +-- Schema convention follows CLAUDE.md "Architecture Decisions": TEXT +-- primary keys with prefixes (`op-`, `grm-`), TIMESTAMPTZ for time +-- columns, FK cascade behaviour explicit (group_role_mappings cascades +-- on provider deletion). +-- +-- Multi-tenant readiness: every row carries tenant_id with +-- DEFAULT 't-default'. Bundle 2 ships single-tenant; the future +-- managed-service multi-tenant offering activates by inserting +-- additional tenants without a schema migration. +-- +-- client_secret_encrypted holds the v2 blob produced by +-- `internal/crypto/encryption.go` (magic byte 0x02 || salt(16) || +-- nonce(12) || ciphertext+tag). Plaintext NEVER lives in the DB. + +BEGIN; + +-- OIDC providers: operator-configured IdP records. One row per IdP. +-- N providers supported from day one for the future managed-service +-- offering where a multi-team customer may have multiple IdPs. +CREATE TABLE IF NOT EXISTS oidc_providers ( + id TEXT PRIMARY KEY, -- prefix `op-` + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + issuer_url TEXT NOT NULL, -- must be https:// (validated at app layer) + client_id TEXT NOT NULL, + client_secret_encrypted BYTEA NOT NULL, -- v2 blob; never plaintext + redirect_uri TEXT NOT NULL, -- must be https:// (validated at app layer) + groups_claim_path TEXT NOT NULL DEFAULT 'groups', + groups_claim_format TEXT NOT NULL DEFAULT 'string-array', + fetch_userinfo BOOLEAN NOT NULL DEFAULT FALSE, + scopes TEXT[] NOT NULL DEFAULT ARRAY['openid','profile','email'], + allowed_email_domains TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + iat_window_seconds INTEGER NOT NULL DEFAULT 300, -- min 1, max 600 enforced at app layer + jwks_cache_ttl_seconds INTEGER NOT NULL DEFAULT 3600, -- min 60 enforced at app layer + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (tenant_id, name), + + -- Closed enum for groups_claim_format. Phase 3's resolver + -- dispatches on this column. + CONSTRAINT oidc_providers_claim_format_check + CHECK (groups_claim_format IN ('string-array', 'json-path')), + + -- Defense-in-depth: app-layer Validate() also enforces these. + CONSTRAINT oidc_providers_iat_window_bounds + CHECK (iat_window_seconds > 0 AND iat_window_seconds <= 600), + CONSTRAINT oidc_providers_jwks_ttl_bounds + CHECK (jwks_cache_ttl_seconds >= 60) +); + +-- Group→role mappings: one row per (provider, group_name, role) tuple. +-- ON DELETE CASCADE on provider so deleting a provider cleans up its +-- mappings. Name-based per the forward-compat seam: if the IdP renames +-- a group, the operator updates the mapping. We don't depend on +-- IdP-internal identifiers (which differ per IdP and resist +-- documentation). +CREATE TABLE IF NOT EXISTS group_role_mappings ( + id TEXT PRIMARY KEY, -- prefix `grm-` + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL REFERENCES oidc_providers(id) ON DELETE CASCADE, + group_name TEXT NOT NULL, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- One mapping per (provider, group_name, role_id) tuple. An + -- operator can map one group to multiple roles by inserting + -- multiple rows with different role_ids; the unique constraint + -- prevents accidental duplicates. + UNIQUE (provider_id, group_name, role_id) +); + +-- Indexes for the hot paths Phase 3's service consumes: +-- ListByProvider walks all mappings for a given provider; Map(group_names) +-- reads the same rows then filters in-memory. +CREATE INDEX IF NOT EXISTS idx_group_role_mappings_provider_id + ON group_role_mappings (provider_id); + +COMMIT; diff --git a/migrations/000035_sessions.down.sql b/migrations/000035_sessions.down.sql new file mode 100644 index 0000000..b76cb98 --- /dev/null +++ b/migrations/000035_sessions.down.sql @@ -0,0 +1,19 @@ +-- 000035_sessions.down.sql +-- Reverses 000035_sessions.up.sql. Destructive: every active session +-- + every signing key is dropped. Operators MUST take a backup before +-- running this; sessions cannot be recovered. +-- +-- FK-safe order: sessions → session_signing_keys (sessions ref +-- signing_key_id, so sessions drop first). +BEGIN; + +DROP INDEX IF EXISTS idx_sessions_absolute_expires_at; +DROP INDEX IF EXISTS idx_sessions_pre_login_gc; +DROP INDEX IF EXISTS idx_sessions_active; +DROP INDEX IF EXISTS idx_sessions_actor_id; +DROP TABLE IF EXISTS sessions; + +DROP INDEX IF EXISTS idx_session_signing_keys_active; +DROP TABLE IF EXISTS session_signing_keys; + +COMMIT; diff --git a/migrations/000035_sessions.up.sql b/migrations/000035_sessions.up.sql new file mode 100644 index 0000000..df96e54 --- /dev/null +++ b/migrations/000035_sessions.up.sql @@ -0,0 +1,99 @@ +-- 000035_sessions.up.sql +-- Auth Bundle 2 / Phase 2: server-side session management. Two cookie +-- shapes share the `sessions` table: +-- +-- 1. Post-login row: minted by SessionService.Create after a +-- successful OIDC callback or break-glass authenticate. Carries +-- the cookie HMAC-signed via the active session_signing_keys row. +-- Idle timeout 1h default, absolute timeout 8h default. +-- +-- 2. Pre-login row: minted at /auth/oidc/login to hold OIDC state + +-- nonce + PKCE verifier across the IdP redirect. Same row shape, +-- `is_pre_login = true`, 10-minute absolute TTL, GC'd by the same +-- scheduler sweep as expired post-login sessions. +-- +-- session_signing_keys holds the HMAC key material. Phase 4's +-- Service.RotateSigningKey mints new keys and retires old ones; the +-- retention window keeps retired keys valid for verification of +-- cookies signed under them so existing sessions don't immediately +-- fail. +-- +-- All operations idempotent. Wrapped in a single transaction. +-- Multi-tenant ready (tenant_id on every row). + +BEGIN; + +-- Session signing keys. The "active" key is the most recently created +-- non-retired row; Phase 4's Service.GetActive returns it. Retired keys +-- (RetiredAt IS NOT NULL) stay in the table for the configurable +-- retention window so cookies signed under them still verify. +CREATE TABLE IF NOT EXISTS session_signing_keys ( + id TEXT PRIMARY KEY, -- prefix `sk-` + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + key_material_encrypted BYTEA NOT NULL, -- v2 blob; never plaintext + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + retired_at TIMESTAMPTZ NULL, + + CONSTRAINT session_signing_keys_retired_after_created + CHECK (retired_at IS NULL OR retired_at >= created_at) +); + +-- Index on (tenant_id, retired_at IS NULL, created_at DESC) backs the +-- GetActive query: most-recently-created non-retired key per tenant. +CREATE INDEX IF NOT EXISTS idx_session_signing_keys_active + ON session_signing_keys (tenant_id, created_at DESC) + WHERE retired_at IS NULL; + +-- Sessions table. Holds both post-login and pre-login rows; is_pre_login +-- discriminates. CSRFTokenHash is SHA-256 hex of the operator-facing +-- CSRF token (the plaintext lives in a separate JS-readable cookie so +-- the GUI can echo it into the X-CSRF-Token header). +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- prefix `ses-` + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + actor_id TEXT NOT NULL, + actor_type TEXT NOT NULL, -- matches domain.ActorType strings + signing_key_id TEXT NOT NULL REFERENCES session_signing_keys(id) ON DELETE RESTRICT, + is_pre_login BOOLEAN NOT NULL DEFAULT FALSE, + csrf_token_hash TEXT NOT NULL DEFAULT '', -- 64 lowercase hex chars when set; '' for pre-login rows + idle_expires_at TIMESTAMPTZ NOT NULL, + absolute_expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + revoked_at TIMESTAMPTZ NULL, + + CONSTRAINT sessions_expiry_order + CHECK (absolute_expires_at > idle_expires_at), + CONSTRAINT sessions_idle_after_created + CHECK (idle_expires_at > created_at) +); + +-- Index for "list sessions for me" hot path (Phase 5 +-- GET /v1/auth/sessions) — actor_id is the WHERE clause. +CREATE INDEX IF NOT EXISTS idx_sessions_actor_id + ON sessions (actor_id, actor_type) + WHERE revoked_at IS NULL AND is_pre_login = FALSE; + +-- Index for the active-session lookup (Phase 4 Validate hot path). +-- Partial index (revoked_at IS NULL) keeps it small; revoked sessions +-- are GC'd separately. +CREATE INDEX IF NOT EXISTS idx_sessions_active + ON sessions (id) + WHERE revoked_at IS NULL; + +-- Index for the pre-login GC sweep: walk pre-login rows older than +-- the 10-minute TTL. +CREATE INDEX IF NOT EXISTS idx_sessions_pre_login_gc + ON sessions (created_at) + WHERE is_pre_login = TRUE; + +-- Index for the absolute-expired GC sweep: walk rows past the absolute +-- expiry window. +CREATE INDEX IF NOT EXISTS idx_sessions_absolute_expires_at + ON sessions (absolute_expires_at); + +COMMIT; diff --git a/migrations/000036_users.down.sql b/migrations/000036_users.down.sql new file mode 100644 index 0000000..cc4d688 --- /dev/null +++ b/migrations/000036_users.down.sql @@ -0,0 +1,16 @@ +-- 000036_users.down.sql +-- Reverses 000036_users.up.sql. Destructive: every federated-human +-- user record is dropped. Operators MUST take a backup before +-- running this; SSO logins fail until a fresh login re-creates rows. +-- +-- The actor_roles table (Bundle 1's RBAC) does NOT cascade-delete +-- here because actor_roles.actor_id is a TEXT column without an FK +-- to users. Down-migrating users orphans actor_roles rows whose +-- actor_id matches a deleted user; those rows become unreachable +-- via the normal UI but are not auto-cleaned. +BEGIN; + +DROP INDEX IF EXISTS idx_users_email; +DROP TABLE IF EXISTS users; + +COMMIT; diff --git a/migrations/000036_users.up.sql b/migrations/000036_users.up.sql new file mode 100644 index 0000000..80c5706 --- /dev/null +++ b/migrations/000036_users.up.sql @@ -0,0 +1,54 @@ +-- 000036_users.up.sql +-- Auth Bundle 2 / Phase 2: federated-human user identity table. +-- +-- Distinction from Bundle 1's `actor_roles`: actor_roles indexes +-- `actor_id` strings (free-form, e.g. API-key names). For federated +-- humans, the user's actor_id IS users.id; so for SSO logins, +-- `actor_roles.actor_id = users.id` and the actor_type column is +-- `'User'` (matches domain.ActorTypeUser). +-- +-- Identity is per-(provider, oidc_subject) tuple. A person who +-- authenticates against multiple OIDC providers gets multiple rows by +-- design; identity is per-provider, not global. The future managed +-- offering can collapse identities at the application layer if a +-- customer requires it. +-- +-- webauthn_credentials JSONB column reserved for v3 (Decision 12). +-- Bundle 2 always stores `[]`; v3's WebAuthn enrollment populates it. +-- +-- All operations idempotent. Wrapped in a single transaction. + +BEGIN; + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- prefix `u-` + tenant_id TEXT NOT NULL DEFAULT 't-default' + REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + oidc_subject TEXT NOT NULL, + oidc_provider_id TEXT NOT NULL REFERENCES oidc_providers(id) ON DELETE RESTRICT, + last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + webauthn_credentials JSONB NOT NULL DEFAULT '[]'::JSONB, -- reserved for v3; always [] in Bundle 2 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Identity invariant: one row per (provider, oidc_subject) tuple. + -- Phase 3 HandleCallback uses this to look up an existing user + -- before deciding to insert. + UNIQUE (oidc_provider_id, oidc_subject) +); + +-- Email lookup (operator GUI 'find user by email' surface). Not +-- unique because the same email can appear in multiple providers +-- (per the per-provider identity model above). +CREATE INDEX IF NOT EXISTS idx_users_email + ON users (tenant_id, email); + +-- ON DELETE RESTRICT on oidc_provider_id keeps Phase 3's +-- "delete provider only when no users authenticated via it" rule +-- enforced at the DB layer; the OIDCProviderRepository.Delete +-- implementation translates the SQLSTATE 23503 into +-- repository.ErrAuthRoleInUse-equivalent for HTTP 409. + +COMMIT;