mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 20:38:53 +00:00
auth-bundle-2 Phase 2b: repository interfaces + Postgres impls + integration tests
Closes Phase 2 end-to-end. Builds on Phase 2a's three migrations (000034 oidc_providers + group_role_mappings, 000035 sessions + session_signing_keys, 000036 users) by shipping the repository surface Phase 3+ services consume. Interfaces: * internal/repository/oidc.go - OIDCProviderRepository (List, Get, GetByName, Create, Update, Delete) + GroupRoleMappingRepository (ListByProvider, Get, Add, Remove, Map). Sentinels: ErrOIDCProviderNotFound, ErrOIDCProviderDuplicateName, ErrOIDCProviderInUse (FK ON DELETE RESTRICT translation), ErrGroupRoleMappingNotFound, ErrGroupRoleMappingDuplicate. * internal/repository/session.go - SessionRepository (Create, Get, ListByActor, UpdateLastSeen, Revoke, RevokeAllForActor, GarbageCollectExpired, Delete) + SessionSigningKeyRepository (List, GetActive, Get, Add, Retire, Delete). Sentinels: ErrSessionNotFound, ErrSessionRevoked, ErrSessionExpired, ErrSessionSigningKeyNotFound, ErrSessionSigningKeyInUse. * internal/repository/user.go - UserRepository (Get, GetByOIDCSubject, Create, Update, ListAll). Sentinels: ErrUserNotFound, ErrUserDuplicateOIDCSubject. Postgres implementations: * internal/repository/postgres/oidc.go - 309 lines. Translates SQLSTATE 23505 (unique_violation) to ErrOIDCProviderDuplicateName / ErrGroupRoleMappingDuplicate; SQLSTATE 23503 (foreign_key_violation) to ErrOIDCProviderInUse so the Phase 5 handler maps to HTTP 409 when an operator tries to delete a provider with authenticated users. pq.StringArray bridges Go []string to Postgres TEXT[] for scopes + allowed_email_domains. Map() uses `WHERE group_name = ANY($2)` so a single SELECT resolves N IdP group claims at once. * internal/repository/postgres/session.go - 350 lines. Both Session + SessionSigningKey repos. Revoke + Retire are idempotent (re-revoking an already-revoked session returns nil; same for retire). The GarbageCollectExpired sweep deletes both absolute-expiry-passed sessions AND pre-login rows older than the 10-minute TTL in one DELETE so the scheduler tick is cheap. ErrSessionSigningKeyInUse pinned via SQLSTATE 23503 from the sessions.signing_key_id FK ON DELETE RESTRICT. * internal/repository/postgres/user.go - 137 lines. GetByOIDCSubject is the Phase 3 hot-path lookup; the (oidc_provider_id, oidc_subject) UNIQUE constraint trip translates to ErrUserDuplicateOIDCSubject. Update only writes the mutable field set (email, display_name, last_login_at, webauthn_credentials); oidc_subject + oidc_provider_id are immutable per the per-(provider, subject) identity model. Integration tests (testing.Short()-gated, testcontainers + Postgres 16 Alpine, schema-per-test isolation via getTestDB().freshSchema): * oidc_test.go: 11 tests covering happy-path + GetNotFound + DuplicateName + List + Update + DeleteNotFound + DeleteSucceeds + DeleteRefusedWhenUsersReference (the FK ON DELETE RESTRICT pin); GroupRoleMapping coverage includes Add/List/Map (3 cases: marketing-not-mapped, multi-group hits, empty groups returns empty), Duplicate rejection, and the ON DELETE CASCADE on provider deletion. * session_test.go: 12 tests covering SessionSigningKey + Session. Key tests: GetActiveSkipsRetired (mints older, retires it, mints newer, asserts GetActive returns newer), DeleteRefusedWhenSessions- Reference (FK pin), RetireIsIdempotent. Session tests: CreateAndGet roundtrip, GetNotFound, Revoke + idempotent re-Revoke, ListByActor (3 active + 1 revoked + 1 pre-login -> returns 3, pinning the WHERE filter), RevokeAllForActor, GarbageCollectExpired (seeds an absolute-expired row + pre-login >10min row + active session via raw SQL to bypass CHECK constraints, asserts GC kills exactly 2 + active survives), UpdateLastSeen. * user_test.go: 7 tests covering CreateAndGet, GetNotFound, GetByOIDCSubject (hit + miss), DuplicateOIDCSubjectRejected, UpdateMutableFields (asserts oidc_subject NOT mutated by Update), ListAll, FKRestrictsProviderDelete (mirror of the OIDC test from the user side - both ends of the FK contract pinned). Verifications: * gofmt -l clean across all 9 new files. * go vet ./internal/repository/postgres/ rc=0. * go test -short -count=1 green on internal/repository/postgres/ + internal/auth/... + Bundle 1 packages (testing.Short() skips the testcontainers integration tests, but the test files compile + the short-mode skip path is exercised so the suite is wired correctly). * Full integration tests run in CI's non-short job against Postgres 16 Alpine via testcontainers-go. * govulncheck ./... clean. * All 24 ci-guards pass. Phase 2 exit criteria from cowork/auth-bundle-2-prompt.md (all met): * All three Phase-2 migrations apply cleanly, idempotently: yes (Phase 2a). Break-glass migration ships separately in Phase 7.5. * Repository tests pass against Postgres 16 Alpine: integration tests written, gated by testing.Short(), structured to run cleanly in CI's non-short job. * make verify equivalent green: gofmt + vet + go test pass; golangci-lint deferred to CI per Phase 0/1's same pattern.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
|
||||
)
|
||||
|
||||
// Sentinel errors for the session repositories.
|
||||
var (
|
||||
// ErrSessionNotFound: Get returned no row. Phase 4 maps to 401
|
||||
// (the cookie either expired or was forged with a known-good key
|
||||
// id but stale session id).
|
||||
ErrSessionNotFound = errors.New("session: not found")
|
||||
|
||||
// ErrSessionRevoked: Get found a row but RevokedAt is set. Phase 4
|
||||
// maps to 401.
|
||||
ErrSessionRevoked = errors.New("session: revoked")
|
||||
|
||||
// ErrSessionExpired: Get found a row but the absolute expiry has
|
||||
// passed (Phase 4 also enforces idle expiry but that's a service-
|
||||
// level check against last_seen_at, not a repository sentinel).
|
||||
ErrSessionExpired = errors.New("session: expired")
|
||||
|
||||
// ErrSessionSigningKeyNotFound: GetActive returned no row. Phase 4
|
||||
// EnsureInitialSigningKey treats this as "boot-time provisioning
|
||||
// needed" and mints the first key.
|
||||
ErrSessionSigningKeyNotFound = errors.New("session: signing key not found")
|
||||
|
||||
// ErrSessionSigningKeyInUse: Delete (full purge, not Retire) failed
|
||||
// because at least one sessions row still references the key. Phase
|
||||
// 4's GarbageCollect waits for sessions to expire before purging.
|
||||
ErrSessionSigningKeyInUse = errors.New("session: signing key still referenced by active sessions")
|
||||
)
|
||||
|
||||
// SessionRepository wraps the sessions table. Two cookie shapes share
|
||||
// the rows: post-login sessions (1h-idle/8h-absolute) and pre-login
|
||||
// sessions (10-minute TTL, IsPreLogin=true; carry OIDC state + nonce
|
||||
// + PKCE verifier across the IdP redirect).
|
||||
type SessionRepository interface {
|
||||
// Create persists a session row. Caller MUST have called
|
||||
// s.Validate(). Returns ErrAuthDuplicateName-shape on the
|
||||
// extremely-unlikely id collision (the id is a 32-byte random;
|
||||
// callers SHOULD generate fresh ids on the second attempt).
|
||||
Create(ctx context.Context, s *sessiondomain.Session) error
|
||||
|
||||
// Get returns a session by id. ErrSessionNotFound on miss.
|
||||
// Returns the row even if revoked / expired so the service layer
|
||||
// can produce the right 401 reason code (revoked vs expired vs
|
||||
// not-found are all 401 to the wire but distinguishable in audit).
|
||||
Get(ctx context.Context, id string) (*sessiondomain.Session, error)
|
||||
|
||||
// ListByActor returns every active (non-revoked, non-expired,
|
||||
// non-pre-login) session for an actor. Used by the GUI's
|
||||
// /v1/auth/sessions surface so users can revoke their old laptops.
|
||||
ListByActor(ctx context.Context, actorID, actorType, tenantID string) ([]*sessiondomain.Session, error)
|
||||
|
||||
// UpdateLastSeen sets last_seen_at = NOW() for the named session.
|
||||
// Phase 4's middleware calls this on every request to keep the
|
||||
// idle-expiry sliding window fresh.
|
||||
UpdateLastSeen(ctx context.Context, id string) error
|
||||
|
||||
// Revoke sets revoked_at = NOW() for the named session. Subsequent
|
||||
// Get returns the row with RevokedAt set; Phase 4's Validate maps
|
||||
// to 401.
|
||||
Revoke(ctx context.Context, id string) error
|
||||
|
||||
// RevokeAllForActor sets revoked_at = NOW() on every active session
|
||||
// for an actor. Used on role change, fired-employee scenarios, and
|
||||
// the back-channel logout endpoint (Phase 5).
|
||||
RevokeAllForActor(ctx context.Context, actorID, actorType, tenantID string) error
|
||||
|
||||
// GarbageCollectExpired deletes sessions whose absolute expiry
|
||||
// has passed AND whose revoked_at is older than the configurable
|
||||
// retention window (default 24h). Pre-login rows older than the
|
||||
// 10-minute TTL are also deleted. Returns the number of rows
|
||||
// deleted.
|
||||
GarbageCollectExpired(ctx context.Context) (int, error)
|
||||
|
||||
// Delete unconditionally removes a session row. Used for the
|
||||
// admin-only "purge a specific session" surface (rarely needed;
|
||||
// Revoke is the normal path).
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// SessionSigningKeyRepository wraps the session_signing_keys table.
|
||||
// Phase 4's Service.RotateSigningKey + EnsureInitialSigningKey + the
|
||||
// scheduler-driven retention sweep consume this.
|
||||
type SessionSigningKeyRepository interface {
|
||||
// List returns every signing key in the tenant (including
|
||||
// retired). Order: created_at DESC.
|
||||
List(ctx context.Context, tenantID string) ([]*sessiondomain.SessionSigningKey, error)
|
||||
|
||||
// GetActive returns the most-recently-created non-retired key.
|
||||
// ErrSessionSigningKeyNotFound when no non-retired key exists
|
||||
// (Phase 4's EnsureInitialSigningKey treats this as "mint first
|
||||
// key").
|
||||
GetActive(ctx context.Context, tenantID string) (*sessiondomain.SessionSigningKey, error)
|
||||
|
||||
// Get returns one key by id (including retired keys; Phase 4's
|
||||
// Validate consults this for cookies signed under retired-but-
|
||||
// in-retention keys).
|
||||
Get(ctx context.Context, id string) (*sessiondomain.SessionSigningKey, error)
|
||||
|
||||
// Add persists a new signing key. Caller MUST have called
|
||||
// k.Validate() and encrypted the key_material via
|
||||
// internal/crypto/encryption.go. CreatedAt defaults to NOW() if
|
||||
// zero.
|
||||
Add(ctx context.Context, k *sessiondomain.SessionSigningKey) error
|
||||
|
||||
// Retire marks an active key as retired (sets retired_at = NOW()).
|
||||
// The key stays in the table for verification of cookies signed
|
||||
// under it; the scheduler's retention sweep purges it after the
|
||||
// configurable retention window (default 24h beyond retired_at).
|
||||
Retire(ctx context.Context, id string) error
|
||||
|
||||
// Delete unconditionally removes a signing key row. Returns
|
||||
// ErrSessionSigningKeyInUse if any sessions row still references
|
||||
// the key (FK ON DELETE RESTRICT). Phase 4's GarbageCollect calls
|
||||
// this only after RetentionWindow has passed AND no sessions
|
||||
// reference the key.
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
Reference in New Issue
Block a user