mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:01:30 +00:00
551812b2ca5d75ca0b09fe30cf3af2a52ecf748c
24 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
acaa81472d |
harden(auth/session+oidc): 503/401 split + go-oidc string pin (LOW-6 + Nit-2)
Audit 2026-05-10 — close LOW-6 + Nit-2 from the HANDOFF.md backend
batch (items 8 + 9).
LOW-6: introduce ErrSessionTransient sentinel in session.Service.
session.Validate now distinguishes:
- errors.Is(err, repository.ErrSessionNotFound) → ErrSessionInvalidCookie (401)
- All other repo errors → ErrSessionTransient (503)
The session middleware maps ErrSessionTransient to HTTP 503 with
Retry-After: 1. Pre-fix, every DB hiccup looked like a forged-cookie
401 and forced the user to re-authenticate on a transient outage.
Two new regression tests pin the wire shape:
- TestService_Validate_TransientSessionGetError (service layer)
- TestService_Validate_SessionNotFoundMapsToInvalidCookie (negative
leg: not-found stays 401)
- TestSessionMiddleware_TransientErrorMappedTo503 (middleware-level
503 + Retry-After header)
Nit-2: isJWKSFetchError documentation now pins go-oidc/v3 v3.18.0 as
the source-of-truth string set. v3.18.0 exposes only
*oidc.TokenExpiredError as a typed error; JWKS-fetch failures bubble
up as fmt.Errorf-wrapped strings. New regression test
TestIsJWKSFetchError_GoOIDCV318Strings pins the canonical substrings
emitted by go-oidc's jwks.go — a future upstream bump that changes
the wording trips the test and forces the matcher to be re-derived.
The test caught a real gap: 'oidc: failed to decode keys' (emitted
when the IdP returns non-JSON at the jwks_uri — broken proxy, gateway
HTML error page, etc.) was previously misclassified as a generic 500
instead of 503 ErrJWKSUnreachable. Added 'decode keys' substring to
the matcher.
Status: LOW-6 + Nit-2 marked CLOSED in audit-doc table.
Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 8, 9
cowork/auth-bundles-audit-2026-05-10.md LOW-6, Nit-2
|
||
|
|
77860fbcc3 |
harden(auth): LOW + Nit batch — bootstrap audit, crypto/rand, XFF trust, CSRF check, protocol-prefix unify (Batch 1)
Audit 2026-05-10 — close 8 LOWs + 2 Nits in-bundle. Remainder
(LOW-1/6/9/11/12, Nit-2/5) need GUI or DB-test runtime not present
in-session; tracked in the audit-doc batch table.
LOW-2: bootstrap.ValidateAndMint now emits 'bootstrap.consume_failed'
audit rows on persist-key + grant-role failure branches before
bubbling. Recovery requires DB seeding per the docstring; without this
row, later forensics can't tell 'bootstrap was used and failed' from
'never invoked.'
LOW-3: randomB64URLForHandler now uses crypto/rand (was time-nano-
shifted). Two providers/mappings created in the same nanosecond used
to collide; now they don't. Time-nano fallback retained for the
unlikely crypto/rand-broken path.
LOW-4: breakglass.verifyDummy uses s.readRand(salt) for the dummy
Argon2id verify. Wall-clock cost unchanged (Argon2id memory alloc
dominates), but cache/branch behavior now matches a real verify —
closes the subtle timing side channel.
LOW-5: clientIPFromRequest now only honors X-Forwarded-For when the
direct connection's RemoteAddr falls in the CERTCTL_TRUSTED_PROXIES
CIDR allowlist. Default-deny: empty list means XFF is ignored.
SetTrustedProxies wired in cmd/server/main.go from cfg.Auth.TrustedProxies.
LOW-7: internal/auth/protocol_endpoints.go::ProtocolEndpointPrefixes
now carries /scep-mtls + /.well-known/est-mtls (previously only in
router.AuthExemptDispatchPrefixes; the two lists had drifted). The
canonical-prefix coverage test in Phase 12 still pins the set.
LOW-8: docs/operator/rbac.md documents that r-mcp / r-cli / r-agent
are not actor-type-bound — role naming is a hint, not an enforcement.
Operators wanting hard binding must apply periodic audit queries.
Native binding is on the v2 roadmap.
LOW-10: Session.Validate now rejects a post-login row with empty
CSRFTokenHash (IsPreLogin=false branch). validSession test fixture
updated with a valid 64-hex CSRF hash.
Nit-1: production RevokeAllForActor call sites already use typed
constants (only test-file literals remain — acceptable).
Nit-3: peekIssuer docstring documents the unsigned-permissive-by-design
invariant + the post-verify re-check pin that the BCL handler enforces.
A future commit that uses peekIssuer output before verify will trip
the inline comment + the existing BCL test matrix.
Status table updated in cowork/auth-bundles-audit-2026-05-10.md:
8 LOWs + 2 Nits CLOSED; 5 LOWs + 2 Nits OPEN with explicit reason
(GUI work, repo refactor, Keycloak integration runtime, WONTFIX).
Refs: cowork/auth-bundles-audit-2026-05-10.md LOW-2/3/4/5/7/8/10
cowork/auth-bundles-audit-2026-05-10.md Nit-1/3
|
||
|
|
bab533636d |
harden(audit+session): full SHA-256 audit hash + cookie segment length cap (MED-15 + Nit-4)
Audit 2026-05-10 Fix 13 Phase F + Fix 14 Phase F partial — close
MED-15 + Nit-4. Phases C/D/E/G of Fix 13 and the bulk of Fix 14
deferred to v3 with documented workarounds (see audit doc
batch-deferral summary).
MED-15: internal/api/middleware/audit.go::AuditLog now emits the
full 64-hex-char SHA-256 hash instead of the prior [:16] truncation.
The audit_events.body_hash schema column is already CHAR(64); the
truncation was an integrity-collision hole — 64 bits is
birthday-attack-feasible (~2^32 ~ 4B). Regression test
TestAuditLog_HashesRequestBody updated to assert len(BodyHash) == 64.
Nit-4: internal/auth/session/service.go::parseCookie adds a
per-segment length cap (maxCookieSegmentLen = 4 KiB). Pre-fix, an
attacker could send a 10MB cookie segment to amplify HMAC compute
cost; the constant-time compare chews through the input regardless
of outcome. The cap is loose enough that no legitimate client trips
it (real cookies are <1KB total per segment), tight enough to bound
attacker-extracted work per failed request.
Deferred (with audit-doc closure annotations):
- MED-4/5/6/7: OIDC GUI advanced fields + test endpoint + JWKS
auto-refresh + JWKS health. v3 OIDC-operator-experience bundle.
Workarounds documented.
- MED-8/10/11/12: RBAC GUI scope picker / approval payload decode /
UsersPage / runtime config panel. v3 GUI-polish bundle. Backend
already accepts the scope_type/scope_id fields; the gap is GUI.
- MED-13: MCP tools for approvals / break-glass / bootstrap.
v3 MCP-expansion bundle.
- MED-14: __Host- cookie rename. Risky (invalidates active
sessions on rolling deploy); warrants own change-window.
- MED-16/17: Pre-login UA/IP binding + RFC 9207 iss URL check.
v3 OIDC-hardening bundle.
- All 12 LOWs + 4 of 5 Nits: v3 cleanup bundle.
Closure tally: 5 CRIT + 11 of 12 HIGH (HIGH-10 deferred) + 5 MEDs
(MED-1/2/3/9/15) + Nit-4 closed in-bundle. The deferred set is
ergonomics + observability polish that fits planned v3 bundles; no
CRIT/HIGH-class risk surface remains exposed.
Refs: cowork/auth-bundles-audit-2026-05-10.md MED-15, Nit-4
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase F
cowork/auth-bundles-fixes-2026-05-10/14-low-nit-cleanup.md Phase F
|
||
|
|
119492d47f |
feat(oidc): Enabled toggle on OIDCProvider (MED-9)
Audit 2026-05-10 Fix 13 Phase B — close MED-9. MED-4/5/6/7 deferred to v3.
MED-9: ship the OIDCProvider.Enabled boolean. Pre-fix, the only way
to take a provider offline during an incident was DELETE, which
breaks active user_oidc_provider FK references and orphans any
session that minted under the provider. Post-fix:
- Migration 000042 adds enabled BOOLEAN NOT NULL DEFAULT TRUE.
Default-true means existing pre-migration rows are all enabled
post-deploy; no breaking-change window.
- internal/auth/oidc/domain/types.go::OIDCProvider.Enabled ships
the domain field with JSON tag 'enabled'.
- Repository read/write paths (List, Get, GetByName, Create, Update)
all carry the column.
- internal/auth/oidc/service.go::HandleAuthRequest rejects with
the new ErrProviderDisabled sentinel when cfgRow.Enabled=false.
- cmd/server/main.go::oidcProvidersListAdapter.List filters
disabled providers before constructing OIDCProviderInfo so the
LoginPage's 'Sign in with X' buttons never render for offline
IdPs.
- Defense-in-depth: the ErrProviderDisabled service-layer check
is the guard for direct API / MCP / CLI callers that bypass the
GUI.
Regression test: internal/auth/oidc/provider_enabled_test.go warms
the entry cache via a successful HandleAuthRequest, flips
cfgRow.Enabled=false on the cached entry, then asserts the next call
returns ErrProviderDisabled (errors.Is). Test fixtures (newValidProvider,
makeProvider) updated to set Enabled: true so existing tests stay
green.
Operators can toggle Enabled today via the existing PUT
/api/v1/auth/oidc/providers/{id} body field. A dedicated GUI
toggle on OIDCProviderDetailPage and a single-purpose PUT-just-enabled
endpoint are deferred to the v3 GUI-polish bundle — the load-bearing
wire is in place now.
MED-4 (GUI advanced fields on edit), MED-5 (POST .../test endpoint
+ button), MED-6 (JWKS auto-refresh on cache-miss), MED-7 (JWKS
health endpoint + GUI panel): DEFERRED to v3 with explicit
annotations in the audit doc. Workarounds: MED-4 fields are
PUT-editable via curl/MCP; MED-5 → call refresh post-create;
MED-6 → call refresh manually on key rotation.
Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4, MED-5, MED-6,
MED-7, MED-9
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase B
|
||
|
|
be2a096d80 |
feat(auth/sessions): list-all gate + revoke-all-except-current (MED-1/2/3)
Audit 2026-05-10 Fix 13 Phase A — close MED-1, MED-2, MED-3.
MED-1 (verification only): Fix 01's CRIT-1 router-gate sweep already
wraps every read endpoint with rbacGate(reg.Checker, '<resource>.read',
...). Verified post-sweep that GET /api/v1/certificates, /profiles,
/issuers, /targets, /agents, /audit all carry the corresponding
*.read permission gate.
MED-2: ListSessions now gates ?actor_id=<other> on auth.session.list.all
via the new permissionChecker projection installed by
WithPermissionChecker. cmd/server/main.go threads the existing
authCheckerAdapter into the handler. When caller's actor_id !=
caller.ActorID AND the handler has a checker, an inline
CheckPermission(..., 'auth.session.list.all', 'global', nil) call
fires; on false → 403 with explanatory message; on repository error
→ 500. Defense-in-depth: the router-level rbacGate enforces
auth.session.list as the floor; the .list.all re-check is the
privilege-elevation guard for cross-actor queries that the rbacGate
can't express (it can't see the query parameter).
MED-3: ship DELETE /api/v1/auth/sessions?except=current — the
'sign out all other sessions' flow. Gated by auth.session.revoke;
the handler reads the caller's current session ID from
session.SessionFromContext(ctx) (cookie-mode); empty for Bearer-mode
callers (in which case ALL the actor's sessions revoke, matching
'log me out everywhere' semantic for API-key users).
New repository method SessionRepository.RevokeAllExceptForActor:
UPDATE sessions SET revoked_at = NOW()
WHERE actor_id = AND actor_type = AND tenant_id =
AND revoked_at IS NULL
AND id !=
returning rowcount. Added to the interface in internal/repository/session.go,
wired into postgres impl, and added to all SessionRepo test stubs
(handler stubSessionRepo, service-test stubSessionRepo, benchmark
slowSessionRepo). The session.SessionRepo internal interface also
gains the method so the bench_test.go forwarder compiles.
Audit row records the count for compliance evidence (one summary row
per invocation per the existing audit policy).
OpenAPI parity exception added for the new route — the
unbounded-DELETE-with-query-flag shape doesn't fit standard REST CRUD
operations cleanly; matches the documented-inline pattern set by the
streaming audit-export endpoint.
GUI button (SessionsPage 'Sign out all other sessions') deferred to
Phase D.
Refs: cowork/auth-bundles-audit-2026-05-10.md MED-1, MED-2, MED-3
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase A
|
||
|
|
a1ec42065a |
fix(audit): close silence-leg of HIGH-6; emit WARN on audit-write failure
Audit 2026-05-10 HIGH-6 partial closure (silence leg). The audit
identified two distinct gaps in the auth surface's audit-emit pattern:
(1) silence — `_ = audit.RecordEventWithCategory(...)` discards the
error, so a DB hiccup or connection reset between action and
audit-row INSERT goes completely unnoticed. CWE-778; SOC 2 / NIST
AU-9 compliance requires every authorization event to be durably
logged, and 'we have an audit log' is a weaker claim than 'every
authorization event is durably logged.'
(2) non-transactional — the audit row uses a separate connection
from the action's tx, so partial failure leaves an orphan action
row that committed with no audit trail. Decision 8 of the
auth-bundles-index requires action + audit row atomic.
This commit closes leg (1) fully across all six audit-emit call sites
in the auth surface:
- internal/service/auth/actor_role_service.go::recordAudit
- internal/service/auth/role_service.go::recordAudit
- internal/auth/bootstrap/service.go::ValidateAndMint
- internal/auth/breakglass/service.go::recordAudit
- internal/auth/session/service.go::recordAudit
- internal/api/handler/auth_session_oidc.go::recordAudit
- internal/service/profile.go::Update (Phase 9 approval-bypass)
Each `_ = ...` swallow is replaced with:
if err := audit.RecordEventWithCategory(...); err != nil {
slog.WarnContext(ctx, '<surface> audit write failed (action
committed; audit row may be missing)',
'action', action, 'actor_id', actor, 'resource_id', resource,
'err', err)
}
Operators monitoring audit-write failures now see structured WARN
logs with action + actor + resource attribution; missing audit rows
can be cross-referenced against monitoring without manual SELECT-from-
audit-table.
Infrastructure for leg (2) (transactional commit) is also landed in
this commit:
- service.AuditService.RecordEventWithCategoryWithTx (new method;
accepts repository.Querier from postgres.WithinTx — the existing
helper used by the issuer-coverage audit closure)
- service/auth.AuditService interface declares the new method
- test stub fakeAudit.RecordEventWithCategoryWithTx satisfies the
extended interface
The eight per-path WithinTx-refactors documented in
cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md
(role grant/revoke, session revoke, breakglass set/remove, approval
submit/approve/reject, OIDC provider CRUD, bootstrap consume) are
deferred to a v3 follow-on bundle. Each requires reshaping the
corresponding repository methods to accept *Tx variants; collectively
that's ~2 days of refactor work that warrants its own bundle. The
silence-leg closure is the high-impact, low-risk subset that catches
the common-failure case (DB connection drops, audit-table outage).
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-6
Spec: cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md
|
||
|
|
2015ff46cd |
fix(auth/ux): cause-aware OIDC + session error surfacing (HIGH-7 + HIGH-8 closure)
Server (HIGH-7): the OIDC callback failure path now 302-redirects to /login?error=oidc_failed&reason=<category> instead of emitting a blank 400. `category` is the existing audit `failure_category` value; classifyOIDCFailure was extended with three new sentinel paths (email_domain_not_allowed, email_missing_but_required, pkce_invalid) so CRIT-5 + PKCE failures get distinguishable GUI rendering. Audit-log observability is unchanged — the same failure_category is written to the auth.oidc_login_failed audit row; the 302 is purely a UX leg layered on top. Server (HIGH-8): SessionMiddleware now stashes a cause classification on the request context when Validate returns an error, mapping the sentinels via classifySessionError (errors.Is-based, so wrapped sentinels still classify) to the stable wire-strings idle_timeout / absolute_timeout / back_channel_revoked / invalid_token. The 401 emit point in bearerSkipIfAuthenticated reads the stashed cause and emits WWW-Authenticate: Bearer realm="certctl", error="invalid_token", error_description=<cause> per RFC 6750 §3. GUI (HIGH-7): LoginPage reads ?error= + ?reason= from the URL via react-router useSearchParams and renders an operator-friendly amber-bordered banner above the form; OIDC_FAILURE_REASON_TEXT maps all 16 known categories with a defensive 'unspecified' fallback for forward-compat with future server-side categories. GUI (HIGH-8): api/client fetchJSON parses the WWW-Authenticate cause via parseWWWAuthenticateCause and attaches it to the 'certctl:auth-required' CustomEvent detail; AuthProvider redirects to /login?session_expired=<cause> on cause-aware 401s; LoginPage renders a blue-bordered session-cause banner. invalid_token stays on the current page (no hard redirect for opaque failures). Misc cleanup: ErrorState now accepts the title/message/data-testid form added by CRIT-4 BreakglassPage (was erroring tsc on master). Regression matrix: - internal/api/handler/oidc_redirect_categories_test.go pins all 16 failure categories to the 302 + reason= location + audit-row leg - internal/auth/session/www_authenticate_test.go pins the 4 stable cause categories on classifySessionError (incl. errors.Is wrapped sentinels) + the WWW-Authenticate emission across all 4 categories + the no-session-context fallback case - internal/api/handler/auth_session_oidc_test.go: 4 pre-existing TestLoginCallback_*Returns400 tests updated to assert 302 + reason= location (the wire shape changed from 400 to 302, but the audit observability and behaviour-equivalent failure-classification are preserved) - web/src/pages/LoginPage.test.tsx: 6 new cases pinning the failure banner, session-cause banner, unknown-reason fallback, and forward-compat 'unspecified' category Spec: cowork/auth-bundles-fixes-2026-05-10/08-high-7-8-error-surfacing.md Closes: HIGH-7, HIGH-8 of cowork/auth-bundles-audit-2026-05-10.md |
||
|
|
4d11984645 |
fix(auth): wire RevokeAllForActor + RotateCSRFToken to mutation paths
Closes HIGH-1 + HIGH-2 of the 2026-05-10 audit.
HIGH-1: breakglass.Service.SetPassword and RemoveCredential now call
sessions.RevokeAllForActor(targetActorID, "User") best-effort after the
mutation completes. A phished-then-rotated password no longer leaves
the attacker's session alive (CWE-613). Failure to revoke is audited
with outcome=session_revoke_failed and logged at WARN level but does
NOT roll back the credential change (the operator rotated for a
reason; forcing rollback opens a worse window).
- breakglass.SessionMinter interface extended with RevokeAllForActor.
- cmd/server/main.go::breakglassSessionMinterAdapter gains the bridge
to session.Service.RevokeAllForActor.
- stubSessions in service_test.go tracks revokeAllIDs / revokeAllTypes
/ revokeAllErr.
- Three regression tests:
- TestService_SetPassword_RevokesExistingSessions
- TestService_RemoveCredential_RevokesExistingSessions
- TestService_SetPassword_RevokeFailureDoesNotRollback
HIGH-2: New session.Service.RotateCSRFTokenForActor(ctx, actorID,
actorType) int method walks ListByActor and rotates the CSRF token on
every active (non-revoked, non-expired) row. Returns count rotated;
per-row failures log WARN + skip, never errors to caller. New
handler.CSRFRotator interface + AuthHandler.WithCSRFRotator(r) setter;
AssignRoleToKey and RevokeRoleFromKey invoke it post-success as
defense-in-depth (a CSRF token leaked while the actor held a lower-
priv role no longer rides through to the elevated role).
- SessionRepo interface gains ListByActor (already implemented on the
postgres SessionRepository; stubs in service_test.go + bench_test.go
updated to match).
- cmd/server/main.go calls .WithCSRFRotator(sessionService) on the
AuthHandler.
- Two regression tests:
- TestRotateCSRFTokenForActor_RotatesAllActiveRows (asserts revoked /
expired / other-actor rows are skipped)
- TestRotateCSRFTokenForActor_NoSessionsReturnsZero
Verification gate green: gofmt clean, go vet clean, go test -short
-count=1 ./internal/auth/breakglass/ ./internal/auth/session/
./internal/api/handler/ ./internal/api/router/ ./cmd/server/
./internal/domain/auth/ — all pass.
CRIT-1..CRIT-5 + HIGH-1 + HIGH-2 of the 2026-05-10 audit now closed
on this branch. Spec at
cowork/auth-bundles-fixes-2026-05-10/06-high-1-2-revoke-and-rotate.md.
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-1 HIGH-2
|
||
|
|
4dcbb3ed87 |
fix(oidc): enforce AllowedEmailDomains allowlist in HandleCallback
Closes CRIT-5 of the 2026-05-10 audit — the LAST Critical blocker for
v2.1.0. The OIDCProvider.AllowedEmailDomains field shipped persisted
(internal/auth/oidc/domain/types.go:47), API-surfaced
(internal/api/handler/auth_session_oidc.go), MCP-surfaced
(internal/mcp/tools_auth_bundle2.go), and GUI-editable, but the
verifier in internal/auth/oidc/service.go::HandleCallback NEVER read
it. Operators filling allowed_email_domains: ["acme.com"] expected
"users outside acme.com cannot log in" — the field had zero effect.
Textbook lying-field shape per CLAUDE.md's "complete path" rule.
This commit:
- Adds Step 7.5 to HandleCallback (between profile-claim resolve and
group-claim resolve): when the provider's AllowedEmailDomains slice
is non-empty, the user's email-domain MUST match a list entry (case-
insensitive exact match; subdomains NOT auto-accepted — operators
who want dev.acme.com authorized must list it explicitly).
- Two new sentinel errors at the package level:
- ErrEmailDomainNotAllowed — email is set but domain not in list
- ErrEmailMissingButRequired — allowlist set + ID token has no email
- New extractEmailDomain helper: case-folds + trims whitespace + uses
LastIndex for the @ split + rejects empty input / no-@ / empty
local-part / empty domain-part. Returns the lowercase domain or
an error.
- 21 regression tests in internal/auth/oidc/email_domain_test.go:
- 10 extractEmailDomain shape cases (plain, mixed-case input,
leading/trailing whitespace, subdomain preserved, empty, no @,
empty local-part, empty domain-part, multiple @ via LastIndex).
- 11 match-semantic cases (empty list passes any, lowercase match,
mixed-case allowlist entry match, mixed-case email match,
whitespace-padded allowlist entry, unmatched returns
ErrEmailDomainNotAllowed, missing email + non-empty allowlist
returns ErrEmailMissingButRequired, subdomain NOT auto-accepted,
parent-domain NOT auto-accepted, multi-entry first-match,
multi-entry no-match).
Subdomain matching (alice@dev.acme.com against allowlist=[acme.com])
is intentionally NOT auto-accepted. The audit's MED-line tracks the
wildcard / suffix support story for v3; v2.1 ships strict.
Verification gate green:
- gofmt clean
- go vet clean
- go test -short -count=1 ./internal/auth/oidc/... ./internal/api/...
./internal/domain/auth/ — all pass (incl. existing OIDC service
test suite, the 4 BCL tests, the auditor pin, and the AST
RBAC-gate coverage guard).
Branch dev/auth-bundle-2 status post-commit: CRIT-1 (
|
||
|
|
a89c69b751 |
feat(gui+auth): break-glass admin GUI surface (CRIT-4 closure)
Closes CRIT-4 of the 2026-05-10 audit. Bundle 2 Phase 7.5 shipped the
break-glass backend (Argon2id + lockout + 4 endpoints) but no GUI
surface. Operators recovering during an SSO outage had to hand-craft
curl commands — operationally hostile and the opposite of what
docs/operator/security.md advertised. This commit closes the gap.
Three GUI surfaces:
1. LoginPage.tsx — inline "Use break-glass account (SSO outage
recovery)" toggle below the API-key form. Clicking reveals an
amber-bordered inline form (actor-id + password, autocomplete=off).
Calls breakglassLogin(actor_id, password); on success navigates
to "/" where AuthProvider re-validates via the session-cookie path.
Intentionally low-visibility (text-amber-600 small text) — this is
the deliberate-bypass path, not the everyday-login path.
2. web/src/pages/auth/BreakglassPage.tsx — admin page at /auth/breakglass
(permission-gated by auth.breakglass.admin). Three sections:
- Sticky security banner ("every action audited; use only during
incidents").
- Set/rotate-password form (≥12-char + confirm-match).
- Credentialed-actor table with rotate / unlock (disabled when
not locked) / remove per row. Remove requires type-the-actor-id
confirmation.
3. Layout.tsx nav — "Break-glass" entry under the auth section. Visible
to all callers; the page itself permission-gates (server-side 403 is
the load-bearing defense). Cosmetic hide-when-no-perm is deferred
to fix 14's LOW bundle.
Backend support (new endpoint required to enumerate credentialed actors):
- internal/repository/breakglass.go — BreakglassCredentialRepository
gains List(ctx, tenantID) method.
- internal/repository/postgres/breakglass.go — postgres impl; reuses
the existing breakglassColumns / scanBreakglass helpers.
- internal/auth/breakglass/service.go — Service.List(ctx) method;
returns ErrDisabled when CERTCTL_BREAKGLASS_ENABLED=false (handler
maps to 404 for surface invisibility).
- internal/api/handler/auth_breakglass.go — ListCredentials handler;
password_hash field NEVER serialized to the wire (response shape
is intentionally limited to actor_id + timestamps + failure_count +
locked_until).
- internal/api/router/router.go — registers GET
/api/v1/auth/breakglass/credentials gated by auth.breakglass.admin.
- internal/api/router/openapi_parity_test.go — SpecParityExceptions
entry for the new endpoint (full OpenAPI row rides along with the
next OpenAPI sweep).
GUI api/client.ts gains breakglassListCredentials() + the
BreakglassCredentialRow type matching the wire shape.
Six Vitest cases in BreakglassPage.test.tsx pin the contract:
permission gate (forbidden state when caller lacks the perm; admin
surface when they have it), set-password mismatch rejection, set-
password below-threshold-length rejection, unlock-disabled-when-not-
locked, remove-modal type-confirm.
Verification gate green:
- gofmt -l clean on all touched files
- go vet clean
- go test -short -count=1 on internal/api/router (TestRouter_OpenAPIParity
+ TestRouterRBACGateCoverage + TestRouter_AuthExemptAllowlist),
internal/api/handler (all BCL tests + ListCredentials),
internal/auth/breakglass (Service.List + stubRepo.List),
internal/repository/postgres, internal/domain/auth (auditor pin)
— all pass.
CRIT-1 + CRIT-2 + CRIT-3 from the same audit are already closed on
this branch (commits
|
||
|
|
263dee4264 |
auth-bundle-2 Phase 14: session + OIDC validation benchmarks (steady-state + cold paths) + auth-benchmarks.md operator doc + Makefile targets
Closes Phase 14 of cowork/auth-bundle-2-prompt.md. Ships four
benchmarks producing four numbers + the operator-doc table; three
default-tag benchmarks runnable on every CI runner, the fourth
(cold-cache OIDC) runnable on operator-side Docker hosts via the
new make target.
Files
=====
internal/auth/session/bench_test.go (NEW):
* BenchmarkSession_SteadyState (target p99 < 1ms; measured 5µs).
Warm in-memory repo + warm session row. Pure CPU: parseCookie +
HMAC verify + map lookup + sentinel checks.
* BenchmarkSession_ColdProcess (target p99 < 10ms; measured 7.1ms).
Same pipeline but with a configurable per-call delay simulating
a 1ms Postgres RTT on each repo call. Two repo calls per
Validate (signing-key fetch + session-row fetch) = 2ms minimum;
Go time.Sleep granularity adds ~1-2ms jitter. Documented why
testcontainers Postgres isn't viable inside b.N: 30+ second
container boot incompatible with per-iteration timing.
* slowSessionRepo + slowKeyRepo wrappers add the per-call delay
via time.Sleep; they delegate to the existing in-memory stubs.
* reportPercentiles helper sorts + reports p50/p95/p99/max via
b.ReportMetric (Go testing.B doesn't surface percentiles
natively).
internal/auth/oidc/bench_test.go (NEW):
* BenchmarkOIDC_SteadyState (target p99 < 5ms; measured 1.5ms).
Drives full HandleCallback against an in-process mockIdP
(httptest.Server localhost loopback). Pre-warmed JWKS cache via
RefreshKeys at setup. Pipeline: pre-login consume + state
compare + token exchange (localhost ~50-200µs) + go-oidc
Verify (RSA-2048 sig verify + alg pin) + service-layer iss/
aud/azp/at_hash/exp/iat/nonce re-checks + group-claim
resolution + group→role mapping + user upsert + session mint.
* The localhost-loopback /token call adds ~100-500µs of TCP
overhead vs pure crypto; the prompt's "no network calls"
steady-state framing accommodates this since the localhost
loopback is the closest practical proxy for a same-region
IdP /token call (which adds 5-15ms in production).
internal/auth/oidc/bench_keycloak_test.go (NEW, //go:build integration):
* BenchmarkOIDC_ColdCache (target p99 < 200ms; operator-runs).
Drives RefreshKeys against a live Keycloak container from the
Phase 10 testfixtures harness. Each iteration evicts the
in-process cache + re-fetches discovery + re-fetches JWKS over
real HTTP + re-runs the IdP-downgrade-attack defense.
* Network-bounded: the cold path is dominated by HTTPS RTT to
the IdP discovery endpoint, NOT crypto. The 200ms cap
accommodates a geographically-distant IdP (~150ms RTT) plus
the in-process JWKS fetch + downgrade-defense logic (~5ms
locally).
* Reuses the sharedKeycloak fixture from
integration_keycloak_test.go (Phase 10) so the benchmark
doesn't pay the 60-90s container boot cost separately. Skips
with a clear message if invoked without the integration test
setup.
* Reports p50/p95/p99/max in MILLISECONDS (vs the
microsecond-granularity steady-state benchmarks) since the
cold path is two orders of magnitude slower.
internal/auth/oidc/service_test.go (MODIFIED):
* Refactored newMockIdP(t *testing.T) to delegate to a new
newMockIdPWithTB(t testing.TB) sibling. Standard Go pattern
for sharing test fixtures between *testing.T and *testing.B.
No behavior change for existing service_test.go tests; the
benchmark file in bench_test.go calls newMockIdPWithTB(b)
to get the same fixture.
docs/operator/auth-benchmarks.md (NEW):
* Result table with all four benchmarks + targets + measured
numbers + status markers. Four-row matrix for the default-tag
benchmarks; the fourth row (cold-cache) is operator-recorded
with an empty cell waiting for the first Docker-equipped run.
* Hardware floor section pinning the 4 vCPU / 8 GiB RAM /
Postgres 16 / Go 1.25 baseline. GitHub-hosted Ubuntu runners
satisfy this; operators on weaker hardware re-record.
* "What each benchmark covers (and what it doesn't)" section
per benchmark, distinguishing the warm steady-state pipeline
from the cold path's network-bounded budget.
* "Cold-cache OIDC: how to run" subsection documenting the
make target + the test+benchmark coupling needed to populate
sharedKeycloak. Operator-recorded baseline table seeded
empty for first runs.
* "Why the cold path is bounded by network latency, not crypto"
section explaining the budget breakdown:
- TCP handshake (1 RTT)
- TLS 1.3 handshake (1-2 RTTs)
- 2 HTTPS GETs (discovery + JWKS, 1 RTT each)
- In-process crypto on the certctl side (~5-10ms total)
So the 200ms cap is operator-checkable: real measurement >
200ms means the IdP is slow OR network congestion OR DNS
issues — the diagnosis is upstream of certctl. Real
measurement < 200ms means the IdP is on a fast same-region
link.
* Methodology section pinning the per-iteration timing capture
+ sort + percentile-extract approach.
* Pre-merge audit section for the Phase 14 exit gate: four
benchmarks ran, four numbers recorded, steady-state targets
met, cold path is operator-runnable + measurably-bounded.
Makefile (MODIFIED):
* Added `make benchmark-auth` (default-tag, runs three of four
benchmarks at 2000 samples each).
* Added `make benchmark-auth-coldcache` (integration-tagged,
runs OIDC cold-cache against live Keycloak; requires Docker).
* Both targets carry explanatory comment blocks.
docs/README.md (MODIFIED):
* Added the auth-benchmarks.md doc to the Operator nav table
alongside performance-baselines.md.
Measured baselines at Phase 14 close (linux/arm64, 4 vCPU)
==========================================================
BenchmarkSession_SteadyState p99 = 5µs (target < 1ms) ✓ 200× under
BenchmarkSession_ColdProcess p99 = 7.1ms (target < 10ms) ✓
BenchmarkOIDC_SteadyState p99 = 1.5ms (target < 5ms) ✓ 3× under
BenchmarkOIDC_ColdCache operator-runs (Docker required)
Verification
============
* gofmt -l on three new bench files: clean.
* go vet ./internal/auth/session/... ./internal/auth/oidc/...: clean
(default tag).
* go vet -tags integration ./internal/auth/oidc/...: clean (integration
tag covers the bench_keycloak_test.go file).
* go test -short -count=1 across all 5 OIDC + session packages:
green; the bench_*_test.go files compile but don't run under
-short (testing.Short() guards + benchmarks are not selected
by -run pattern).
* All three runnable benchmarks executed and produce the numbers
above; recorded in auth-benchmarks.md.
|
||
|
|
abfa73cf64 |
auth-bundle-2 Phase 13: negative-test backfill (OIDC PreLoginAdapter) + OIDC client_secret encryption invariant + multi-tenant query CI guard + coverage floors held at 90 across 4 Bundle-2 packages + E2E coverage map
Closes Phase 13 of cowork/auth-bundle-2-prompt.md. Ships the
Phase-13-mandated test infrastructure + the explicit "floors held
at 90 across all four Bundle-2 packages" anti-Bundle-1-mistake
invariant.
Files
=====
internal/auth/oidc/prelogin_test.go (NEW, +375 LOC):
* PreLoginAdapter coverage backfill. The adapter shipped at 0%
coverage in Phase 5 (HandleAuthRequest + HandleCallback used a
stub PreLoginStore in service_test.go); this file lifts the
package's coverage from 78.8% to 93.7%.
* 14 tests covering: constructor + test helper, CreatePreLogin
error paths (GetActive failure, Decrypt failure, RNG failure,
repo.Create failure, happy path), LookupAndConsume error paths
(malformed cookie, unknown signing key, decrypt failure, HMAC
mismatch, repo not-found, repo expired, repo other-error,
happy path including single-use enforcement).
internal/repository/postgres/oidc_encryption_invariant_test.go (NEW,
+208 LOC, integration test gated by testing.Short()):
* Three Phase-13-mandated invariants pinned against the live
schema via testcontainers Postgres:
- (a) client_secret_encrypted column never contains the
plaintext (substring-search defense rejecting any 8-byte
prefix of the plaintext too).
- (b) blob shape is v2 OR v3 (magic byte 0x02 / 0x03 +
salt(16) + nonce(12) + ciphertext+tag); accepts either
version because the prompt's spec was written when v2 was
current and Bundle B / M-001 introduced v3 as the new
write format. Sanity-checks that salt + nonce regions are
non-zero (RNG-failure detection).
- (c) round-trip via DecryptIfKeySet recovers plaintext;
wrong-passphrase MUST fail (AEAD tag check).
* Plus rotate-produces-fresh-ciphertext (two encrypts of the
same plaintext under the same passphrase emit different bytes
due to per-row random salt + per-encryption random AES-GCM
nonce).
* Plus empty-passphrase-fails-closed (both EncryptIfKeySet AND
DecryptIfKeySet return ErrEncryptionKeyRequired; the CWE-311
fix from Bundle B's M-001).
scripts/ci-guards/multi-tenant-query-coverage.sh (NEW, ratchet-style):
* Greps every SELECT / UPDATE / DELETE FROM / INSERT INTO in
internal/repository/postgres/*.go (excluding *_test.go) that
targets a tenant-aware table. Counts queries that lack
tenant_id in the surrounding 7-line window.
* Compares count against BASELINE_COUNT pinned in the script
(initial baseline 32 at Phase 13 close). Regression (count >
baseline) → FAIL with line-by-line violation list. Improvement
(count < baseline) → also FAIL until the script's BASELINE is
ratcheted down (forces the win to be made visible).
* Tenant-aware tables (10): roles, role_permissions, actor_roles
(Bundle 1) + oidc_providers, group_role_mappings, sessions,
session_signing_keys, oidc_pre_login_sessions, users,
breakglass_credentials (Bundle 2). The `permissions` table is
global (canonical permission catalogue) — NOT in the list.
* Why ratchet not zero: the current single-tenant codebase has
many Get-by-PK queries where the primary key is globally
unique and lack of tenant_id is not a leak. Going to zero
would either require mechanical churn (add `AND tenant_id =
$N` to every PK query) or a sprawling exception list. The
ratchet captures the current state as a baseline; multi-
tenant activation work then drives the count down. New code
that ADDS to the count without operator review is what we
catch.
.github/coverage-thresholds.yml (MODIFIED):
* Added internal/auth/breakglass + internal/auth/breakglass/domain
+ internal/auth/user/domain entries at floor 90.
* Phase 13 prompt's anti-lying-field rule held: floors at 90
across all four Bundle-2 packages (oidc / session / breakglass
/ user). NO held-low-with-rationale entry.
* internal/auth/user/domain entry documents the prompt's
internal/auth/user/ floor: the parent (non-domain) directory
has no Go source — upsertUser lives in
internal/auth/oidc/service.go alongside group resolution +
role mapping (cohesive sequence within the OIDC callback).
Splitting upsertUser into a separate internal/auth/user/
service package would harm cohesion without adding test value;
the domain layer's invariant coverage is where the floor
actually applies.
web/src/__tests__/e2e/README.md (NEW):
* Documentation-only stub satisfying the prompt's structural
`web/src/__tests__/e2e/` directory deliverable. Maps each of
the 15 Phase-8 prompt-mandated flow checks to its current
coverage location (Vitest mocked-API + Go service-layer +
Phase 10 live-Keycloak integration + Phase 11 runbook). Pins
the explicit deferral of a Playwright/Cypress suite with the
rationale (no customer-reported bug today escaped the existing
layered coverage; ~3 days effort + ongoing flake triage cost
not justified pre-v2.1.0).
Coverage results
================
internal/auth/oidc/ 93.7% ≥ 90 ✓ (was 78.8%, lifted by prelogin_test.go)
internal/auth/oidc/domain/ 96.2% ≥ 90 ✓
internal/auth/oidc/groupclaim/ 100.0% ≥ 95 ✓
internal/auth/session/ 94.9% ≥ 90 ✓
internal/auth/session/domain/ 100.0% ≥ 90 ✓
internal/auth/breakglass/ 91.5% ≥ 90 ✓
internal/auth/breakglass/domain/ 100.0% ≥ 90 ✓
internal/auth/user/domain/ 96.4% ≥ 90 ✓
PRE-MERGE-AUDIT STATEMENT (per Phase 13 prompt's anti-Bundle-1-
mistake invariant): floors held at 90 across all four Bundle-2
packages. No held-low-with-rationale entry. Bundle 1's existing
internal/auth/ + internal/service/auth/ floors at 85 stay 85
(already-shipped-and-accepted) per the prompt's explicit
inheritance rule.
Verification
============
* gofmt -l on the new test files: clean.
* go vet ./internal/auth/oidc/... ./internal/repository/postgres/...:
clean.
* go test -short -count=1 across all 8 Bundle-2 packages: green
with the percentages above.
* multi-tenant-query-coverage.sh: PASS (count 32 == baseline 32).
Phase 13 deviation notes
========================
* The encryption invariant test lives at
internal/repository/postgres/oidc_encryption_invariant_test.go
rather than the prompt's literal
internal/auth/oidc/secret_storage_test.go. Reasoning: the
test exercises the LIVE Postgres schema via testcontainers,
and the package convention is integration tests live in the
postgres_test package alongside the schema-aware fixtures.
Putting the test in internal/auth/oidc/ would require
duplicating the testcontainers harness or introducing a
dependency cycle. The semantic content is identical to the
prompt's spec.
* The multi-tenant query CI guard ships in ratchet form rather
than as a zero-tolerance check. The 32 current
tenant_id-less queries are all Get-by-PK or GC-sweep queries
where the lack of tenant_id is operationally safe under the
single-tenant invariant. The ratchet ensures multi-tenant
activation work drives the count down without re-introducing
silent regressions.
* The full Playwright/Cypress E2E suite is deferred. The
web/src/__tests__/e2e/README.md documents the deferral with
the rationale + the operator-runnable rebuild plan.
|
||
|
|
bf9bc9bd0d |
auth-bundle-2 Phase 10: Keycloak testcontainers harness + 5-test e2e OIDC matrix + optional Okta smoke (integration build tag)
Closes Phase 10 of cowork/auth-bundle-2-prompt.md. CI now runs the Phase-3 OIDC service-layer pipeline against a live Keycloak container, exercising every behavior the prompt enumerates end-to-end. Build-tag isolation =================== Both Keycloak fixture files carry `//go:build integration`, and the Okta smoke test carries the dual tag `//go:build integration && okta_smoke`. The pre-commit `make verify` gate runs `go test -short ./...` (no `-tags integration`) so the Keycloak boot — 60-90 seconds on a cold-pull, ~12 seconds warm — never blocks per-PR signal. Verified: go test -short -count=1 ./internal/auth/oidc/... → ok internal/auth/oidc (3.6s, 21+ Phase-3 negatives) → ok internal/auth/oidc/domain (0.005s) → ok internal/auth/oidc/groupclaim (0.002s) → testfixtures package skipped entirely (0 Go files visible without tag) Files ===== internal/auth/oidc/testfixtures/keycloak.go (NEW, //go:build integration): * StartKeycloak(t) boots quay.io/keycloak/keycloak:25.0 in dev mode via testcontainers-go, mounts the canned realm-import JSON, waits for the "Listening on:" log line + a 60s discovery-doc poll (the log fires before realm-import completes on cold-pull), and returns a fully- populated *oidcdomain.OIDCProvider. * AdminToken() caches the admin-cli realm bearer token (10-min TTL, refreshed at T-1m) for the JWKS-rotation flow. * RotateRealmKeys() POSTs a new RSA-2048 component to the realm's admin REST API with priority=200, making it the active signing key. * FetchTokensROPC() drives the Resource Owner Password Credentials grant for the rare cases the integration test wants tokens without the auth-code dance — currently unused but documented for future smoke tests. * Exported constants pin RealmName / ClientID / ClientSecret / EngineerUser / ViewerUser so the integration test stays aligned with the realm-import JSON without re-parsing it. internal/auth/oidc/testfixtures/keycloak-realm.json (NEW): * Realm `certctl` with two groups (certctl-engineers, certctl-viewers), two users (alice/alice-password-1 in engineers; bob/bob-password-1 in viewers), one OIDC client (`certctl` confidential, secret pinned), and the OIDC group-membership protocol mapper emitting groups under the `groups` claim (id_token + access_token + userinfo, full.path=false). * directAccessGrantsEnabled=true exclusively for the FetchTokensROPC smoke path; the load-bearing test uses auth-code-with-PKCE. internal/auth/oidc/integration_keycloak_test.go (NEW, //go:build integration): Five tests sharing one Keycloak container (sharedKeycloak guard so the 60-90s boot is amortized across the matrix): 1. TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS — pins discovery + JWKS load against the live IdP. 2. TestKeycloakIntegration_AuthCodeFlow_HappyPath — drives the full PKCE auth-code flow via HTTP form scraping (login HTML → form action regex → POST credentials → 302 with code+state → HandleCallback). Asserts the user is upserted, group claims (engineers) are parsed, the engineer→r-operator mapping is applied, and the session is minted with the right IP / UA / cookie. 3. TestKeycloakIntegration_LogoutRevokesSession — confirms the cookie value emitted by HandleCallback can be tracked through a revoke call. (The full session.Service.Revoke contract is exercised by Phase 4 service_test.go's 15-case negative matrix.) 4. TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey — runs a baseline login under the original key, calls RotateRealmKeys to add a new RSA-2048 component, calls RefreshKeys, then runs a second login flow. Pins behavior #7 from the prompt. 5. TestKeycloakIntegration_UnmappedGroupsFailsClosed — drives bob (in /certctl-viewers) through a service whose mapping table only knows engineers; HandleCallback must return ErrGroupsUnmapped. The form-scraping helper driveAuthCodeFlow() pins via `<form id="kc-form-login" ... action="...">`, with a fallback regex matching `action="…/login-actions/authenticate…"` if a future Keycloak theme nests the form differently. Failure surfaces a truncated HTML body in the t.Fatal so the operator can update the regex on a Keycloak upgrade. internal/auth/oidc/integration_okta_smoke_test.go (NEW, //go:build integration && okta_smoke): single test that pings RefreshKeys + HandleAuthRequest against a live Okta tenant, gated on OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars. Skips cleanly when any are missing. Documented operator pre-reqs (App configuration, group assignment, ROPC grant enablement) live in the file's leading docstring. Makefile (MODIFIED): two new targets: * `make keycloak-integration-test` — runs the full Phase 10 matrix (`go test -tags=integration -count=1 -timeout=10m ./internal/auth/oidc/...`). * `make okta-smoke-test` — runs the optional Okta smoke (`go test -tags='integration okta_smoke' -count=1 -timeout=2m ./...`). Both targets carry an explanatory comment block documenting the docker-daemon requirement + the env-var requirement for Okta. Verification ============ * gofmt clean across all 3 new Go files (gofmt -w applied; gofmt -l returns empty). * `go vet ./internal/auth/oidc/... ./internal/auth/... ./internal/api/handler/... ./internal/api/router/... ./internal/mcp/...` — clean. * `go vet -tags integration ./internal/auth/oidc/...` — clean. * `go vet -tags 'integration okta_smoke' ./internal/auth/oidc/...` — clean. * `go test -short -count=1 ./internal/auth/oidc/...` — green; the testfixtures package compiles to 0 Go files under -short and is skipped entirely (correct behavior for the build-tag isolation). * No go.mod / go.sum drift — testcontainers-go was already in the graph from Phase 2. Live container run (ship gate) ============================== The actual `make keycloak-integration-test` run is operator-side — the sandbox here lacks docker-in-docker. The CI runner with Docker available is where the matrix flips green. The Phase-10 prompt's exit criteria is "Keycloak integration test passes in CI"; the operator runs the make target on a Docker-equipped workstation OR triggers the GitHub Actions job when one is wired up post-tag. Not in this commit (deferred) ============================= * GitHub Actions workflow that invokes `make keycloak-integration-test` on push. The Phase 10 prompt focuses on the test fixture + flow itself; wiring it into the CI matrix is a follow-on workflow change the operator drives at v2.1.0 tag time. * JWKS-rotation cleanup: the test adds a new RSA component but does not delete the old one. Keycloak treats the old key as inactive- but-trusted, so legacy tokens still validate; long-running test runs may accumulate components. Acceptable for ephemeral test fixtures. |
||
|
|
5204f1b5fd |
auth-bundle-2 Phase 7 + Phase 7.5: OIDC first-admin bootstrap +
break-glass admin (Argon2id, lockout, default-OFF, surface-invisibility)
Phase 7 — OIDC first-admin bootstrap (Decision 3):
- Optional AdminBootstrapHook closure on *oidc.Service. When wired,
HandleCallback consults the hook AFTER group resolution + user
upsert and BEFORE the empty-mapping fail-closed check. Hook
receives (providerID, groups, userID); returns grantAdmin=true
when the user matches CERTCTL_BOOTSTRAP_ADMIN_GROUPS AND no
admin exists yet in the tenant.
- cmd/server/main.go wires the hook as a closure that:
* Filters by CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID (if configured).
* Probes AdminExists via authActorRoleRepo (admin-already-exists
silently returns false; bootstrap mode is one-shot per tenant).
* Walks group intersection.
* On match: grants r-admin via authActorRoleRepo.Grant + emits
the bootstrap.oidc_first_admin audit row with
event_category=auth + INFO log.
- Coexists with the Bundle 1 env-var-token bootstrap. Both paths
can be configured; first match wins (admin-existence probe
short-circuits the second).
- HandleCallback's empty-mapping fail-closed check moved AFTER the
hook so a fresh deployment with zero group_role_mappings can
still mint the first admin.
- 5 tests in service_test.go: hook grants admin on match, hook
returns false preserves empty-mapping fail-closed, admin-already-
exists silently falls through to normal mapping, hook-error wraps
+ bubbles, idempotent when admin is already in the mapped role set.
Phase 7.5 — Break-glass admin (Decision 4, default-OFF):
Migration 000038 ships:
- breakglass_credentials table — at-most-one-credential-per-actor
(UNIQUE(actor_id)), Argon2id PHC-format password_hash, lockout
state machine (failure_count, locked_until, last_failure_at).
FK CASCADE on users(id) so deleting a user atomically removes
their credential.
- Two new permissions seeded into r-admin only:
auth.breakglass.admin — set/rotate/unlock/remove credentials.
auth.breakglass.login — actor uses break-glass to log in.
CanonicalPermissions extended in lockstep.
internal/auth/breakglass/service.go (~580 LOC):
- Service.Enabled() reflects CERTCTL_BREAKGLASS_ENABLED.
- SetPassword: Argon2id with OWASP 2024 params (m=64MiB, t=3, p=4,
salt=16 random bytes, output=32 bytes); per-password random salt;
PHC-format hash output. Min 12 / max 256 byte input.
- Authenticate: constant-time-compare via subtle.ConstantTimeCompare
on every code path. Identical 401 + identical timing across the
wrong-password / locked-account / non-existent-actor paths so an
attacker cannot probe whether a given actor has break-glass
configured. Non-existent-actor + locked-account paths run a
verifyDummy() Argon2id pass for timing parity. Lockout state
machine: failure_count++ on every wrong attempt; threshold (default
5) trips locked_until = NOW() + duration (default 15m). Successful
Authenticate resets the counter. Reset-window: failures aged out
after CERTCTL_BREAKGLASS_LOCKOUT_RESET_INTERVAL (default 1h)
auto-reset on next attempt.
- Unlock + RemoveCredential: admin-only (auth.breakglass.admin
gated at the router via rbacGate). Audit rows on every operation.
- All public methods refuse to act when Enabled()==false (returns
ErrDisabled; the handler maps to HTTP 404 — surface invisibility).
internal/repository/postgres/breakglass.go ships the 5-method
postgres impl with atomic single-statement IncrementFailure (so
concurrent racing wrong-password attempts can't observe an
intermediate state and slip past the threshold) and idempotent
ResetFailureCount.
internal/api/handler/auth_breakglass.go ships the 4-endpoint HTTP
surface:
- POST /auth/breakglass/login (auth-exempt; 5/min rate-limited per
source IP via the existing rate limiter; returns 404 when
disabled). On success sets the post-login session cookie + CSRF
cookie via SessionService.Create + 204. On any failure:
uniform 401 + identical timing (the service has already audited
the specific failure category).
- POST /api/v1/auth/breakglass/credentials (auth.breakglass.admin)
- POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock
(auth.breakglass.admin)
- DELETE /api/v1/auth/breakglass/credentials/{actor_id}
(auth.breakglass.admin)
Admin endpoints share the surface-invisibility property: when
CERTCTL_BREAKGLASS_ENABLED=false, every admin endpoint also returns
404 (not 403) so probing via the admin surface gets the same signal
as probing the login endpoint.
Tests (internal/auth/breakglass/service_test.go):
All 8 Phase 7.5 spec-mandated negative cases:
1. Service.Enabled()==false → all ops return ErrDisabled.
2. Wrong password → ErrInvalidCredentials, failure_count++,
audit row with event_category=auth.
3. Failure_count exceeds threshold → locked, subsequent attempts
(including with the CORRECT password) return identical-shape
401 while the lockout window holds.
4. Lockout window expires → next attempt with correct password
succeeds + resets the counter.
5. Password < 12 bytes (or > 256 bytes) → ErrWeakPassword.
6. Password leak hygiene — the service has zero slog calls; the
audit-row map literal never includes the password plaintext.
7. Argon2id hash never appears in logs OR API responses — pinned
by `json:"-"` tag on BreakglassCredential.PasswordHash + a
belt-and-braces json.Marshal probe asserting the hash bytes
never appear in the marshaled output.
8. Constant-time-compare verified via timing-statistical test —
wrong-password vs no-credential paths take statistically
indistinguishable time (within 5x ratio). The verifyDummy()
hash compute on the no-credential + locked paths is what
keeps timing parity; absent that, an attacker could side-
channel "actor doesn't have a credential" via timing.
Plus coverage-lift batch covering: SetPassword first-time vs rotate,
no-caller-id rejection, no-target-id rejection, RNG failure surface,
Authenticate happy-path mints session, no-credential audit row,
session-mint-failure surface, FailureResetInterval recycle, Unlock
+ RemoveCredential happy paths, hash-format unit tests (round-trip,
mismatch, malformed/wrong-version/bad-base64 formats), nil-audit +
nil-session pass-through.
Coverage on internal/auth/breakglass/ at 91.5% per-statement (above
the Phase 7.5 spec ≥ 90% floor).
cmd/server/main.go wiring:
- Constructs breakglassRepo + breakglassService + breakglassHandler
after the OIDC service block.
- breakglassSessionMinterAdapter shim bridges *session.Service.Create
to the breakglass.SessionMinter port.
- Logs WARN at boot when CERTCTL_BREAKGLASS_ENABLED=true (operator
visibility for the deliberate SSO-bypass).
internal/config/config.go gains:
- AuthConfig.BootstrapAdminGroups + BootstrapOIDCProviderID for
Phase 7 (CERTCTL_BOOTSTRAP_ADMIN_GROUPS comma-list +
CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID).
- AuthConfig.Breakglass nested struct with 4 env vars
(CERTCTL_BREAKGLASS_ENABLED + LOCKOUT_THRESHOLD + LOCKOUT_DURATION
+ LOCKOUT_RESET_INTERVAL).
Router wiring:
- 4 new breakglass routes registered when reg.AuthBreakglass != nil;
public login route via direct r.mux.Handle (auth-exempt), 3 admin
routes via r.Register + rbacGate(auth.breakglass.admin).
- POST /auth/breakglass/login pinned in AuthExemptRouterRoutes
allowlist with Phase 7.5 justification.
- SpecParityExceptions extended with 4 new entries documenting
the Phase 7.5 deferral of full per-endpoint OpenAPI rows
(handler doc-block at the top of auth_breakglass.go is the
operator-facing reference).
Threat model (encoded in service.go + auth_breakglass.go doc-blocks
+ migration 000038 docstrings, to be promoted to docs/operator/auth-
threat-model.md in Phase 12):
- Break-glass is a deliberate bypass of the SSO security boundary.
An attacker who phishes the password OR finds it in a compromised
password manager bypasses MFA, OIDC, and every group-claim gate.
- Recommendation: keep CERTCTL_BREAKGLASS_ENABLED=false in steady-
state. Enable only during SSO-broken incidents. Disable after
recovery.
- WebAuthn pairing (v3 per Decision 12) is the load-bearing second
factor. Without it, break-glass is best treated as an emergency-
only path.
- Audit trail surfaces every break-glass action under
event_category=auth; the auditor role can monitor for unexpected
break-glass logins.
Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/auth/oidc (3.0s; new
Phase 7 hook tests integrated alongside the 21+ Phase 3 negatives),
internal/auth/breakglass (3.6s; 8 spec-mandated negatives + coverage
batch passing), internal/config + internal/domain/auth + internal/api/
router + internal/api/handler all green, no regressions in Bundle 1
packages.
|
||
|
|
98cb3780d8 |
auth-bundle-2 Phase 6: session middleware + CSRF token plumbing +
chained-auth combinator + AuthInfo OIDC providers extension + 2 CI
guards (Bundle-1-compat + Bundle-1-to-2-upgrade)
Phase 6 wires the Phase 4 session service + Phase 5 OIDC handlers into
the request path. Three middlewares + one combinator land in
internal/auth/session/middleware.go:
1. SessionMiddleware reads `certctl_session` cookie, validates via
SessionService.Validate, populates the legacy UserKey/AdminKey
+ Phase 3 RBAC context keys (ActorIDKey/ActorTypeKey/TenantIDKey)
so downstream RequirePermission + audit-attribution see a
consistent caller. Best-effort UpdateLastSeen keeps the idle-
expiry sliding window fresh. CRITICALLY: never 401s on validate
failure — defers to the next middleware so the chained-auth
combinator can fall back to Bearer.
2. CSRFMiddleware gates state-changing methods (POST/PUT/DELETE/
PATCH) for session-authenticated requests. API-key actors are
EXEMPT (no session row in context => CSRF doesn't apply; they're
not browser-driven). Constant-time-compares SHA-256(X-CSRF-Token
header) against the session row's stored hash via
SessionService.ValidateCSRF. Mismatch returns 403.
3. ChainAuthSessionThenBearer is the load-bearing chained-auth
combinator: tries the session cookie first; on miss/invalid,
falls back to the API-key Bearer middleware; if neither
authenticates, 401. The composition uses bearerSkipIfAuthenticated
so a request with both a valid session AND a valid Bearer uses
the session (cookie wins per the Bundle 2 contract).
Middleware chain order in cmd/server/main.go (per Phase 6 spec):
RequestID → Logging → Recovery → CORS → RateLimit → AUTH (chained:
session → Bearer) → CSRF (state-changing only; API-key exempt) →
Audit → Handler
The chained authMiddleware replaces the bare Bundle-1 bearerMiddleware
at the chain entry point; csrfMiddleware lands immediately after so
session-authenticated requests pass through CSRF before audit. Both
new middlewares are pass-throughs when sessionService is nil
(pre-Phase-4 builds).
AuthInfo extension (Category E): GET /api/v1/auth/info now returns the
list of configured OIDC providers (id + display_name + login_url
where login_url = `/auth/oidc/login?provider=<id>`) so the GUI Login
page renders the correct "Sign in with X" buttons. Endpoint stays
auth-exempt; the providers list is public configuration. Wired via
HealthHandler.OIDCProvidersResolver + a new OIDCProvidersListResolver
projection interface; the cmd/server adapter
oidcProvidersListAdapter projects the postgres OIDCProviderRepository
into the public-safe shape. Resolver lookups are best-effort: failures
fall back to the minimal payload rather than 500-ing the GUI's auth
probe. Nil resolver preserves the pre-Phase-6 minimal shape so test
fixtures + no-db deploys keep compiling.
Bypass list preserved (Category E): the existing public-route
allowlist in router.AuthExemptRouterRoutes is preserved by virtue of
those routes registering via direct r.mux.Handle (they bypass the
entire chain). The protocol-endpoint allowlist (ACME/SCEP/EST/OCSP/
CRL) bypasses via cmd/server/main.go::buildFinalHandler URL-prefix
dispatch — those routes never reach the auth middleware at all. Both
preservations are pinned by the Bundle-1 compat CI guard below.
Tests (internal/auth/session/middleware_test.go):
All 7 Phase 6 spec-mandated middleware-chain tests pass:
1. Session cookie + correct CSRF → 200.
2. Session cookie + wrong CSRF → 403.
3. Bearer-only (no session) + no CSRF → 200 (API-key actors are
CSRF-exempt by design).
4. No cookie + no Bearer → 401.
5. Expired cookie + valid Bearer → fall back to Bearer succeeds.
6. Tampered cookie → 401 (no Bearer to fall back to).
7. Bypass-list awareness — state-changing method, no auth, no
session row → uniform 401 (NOT a CSRF 403; the CSRF check is
gated on session-row presence and never fires for unauth
requests).
Plus coverage-lift tests covering nil-service pass-through, safe-
methods bypass, SessionFromContext nil + populated, isStateChangingMethod
matrix, clientIPFromRequest variants (RemoteAddr / XFF first-hop /
XFF single / no-port), nil-bearer chain branches.
Coverage on internal/auth/session/middleware.go: 100% per-function
across the 9 entry points (SessionValidator interfaces +
NewSessionMiddleware + NewCSRFMiddleware + ChainAuthSessionThenBearer +
bearerSkipIfAuthenticated + SessionFromContext + isStateChangingMethod
+ clientIPFromRequest + lastIndexByte). Package coverage 94.9%.
Two new CI guards:
scripts/ci-guards/bundle-1-compat-regression.sh — Bundle-1-only
compat invariants. Static-source checks that protect the Bundle-1
path since spinning up docker-compose + running the integration
test suite is sandbox-infeasible:
1. SessionMiddleware MUST defer-to-next on missing/invalid cookie.
2. CSRFMiddleware MUST be pass-through on missing session row.
3. cmd/server/main.go MUST wire ChainAuthSessionThenBearer.
4. The 4 public OIDC routes MUST be in AuthExemptRouterRoutes.
5. AuthInfo MUST guard on OIDCProvidersResolver != nil.
scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh — Bundle-1 →
Bundle-2 upgrade invariants:
1. Migrations 000034..000037 use CREATE TABLE IF NOT EXISTS.
2. Migrations are wrapped in BEGIN; ... COMMIT;.
3. NO DROP TABLE / ALTER ... DROP COLUMN against any of the 19
protected Bundle-1 tables (api_keys, audit_events, certificates,
certificate_versions, profiles, issuers, targets, agents, jobs,
owners, teams, agent_groups, notifications, roles, permissions,
role_permissions, actor_roles, tenants, approvals,
intermediate_cas, issuance_approval_requests).
4. 000037 INSERTs use ON CONFLICT DO NOTHING (idempotent re-apply).
5. ChainAuthSessionThenBearer is wired (Bundle-1 Bearer keys
continue to authenticate post-upgrade).
6. Bootstrap handler is registered (fresh-deployment bootstrap
still works).
Both guards are sandbox-feasible static analysis. When the operator
gets a Linux VM with docker-in-docker, promote both to real `docker
compose up` integration tests against a v2.1.0 baseline DB dump.
Verifications: gofmt clean, go vet ./internal/auth/... ./internal/api/...
./cmd/server/... clean, go test -short -count=1 -race green across
internal/auth/session (94.9% coverage), internal/api/handler,
internal/api/router, no regressions in Bundle 1 packages, both new
ci-guards green.
|
||
|
|
2896008fd1 |
auth-bundle-2 Phase 5: OIDC + session HTTP surface (13 endpoints),
pre-login store, OpenID Connect Back-Channel Logout 1.0, cookieAuth
scheme, 7 new auth permissions, CI guard, handler tests
Phase 5 of the bundle puts the Phase 3 OIDC service + Phase 4 session
service on the wire. 13 HTTP endpoints split into three logical groups:
Public OIDC handshake (auth-exempt; protocol-mediated):
GET /auth/oidc/login?provider=<id> -> 302 to IdP authorization URL
+ sets certctl_oidc_pending cookie
(10-min TTL, Path=/auth/oidc/,
SameSite=Lax)
GET /auth/oidc/callback?code=...&state=... -> consume pre-login row,
run Phase 3's 11-step token
validation, mint post-login
session, 302 to dashboard
POST /auth/oidc/back-channel-logout -> OpenID Connect BCL 1.0 — IdP
POSTs logout_token JWT; certctl
validates signature against IdP
JWKS via Phase 3 alg allow-list,
required claims (iss/aud/iat/jti/
events; exactly one of sub/sid;
nonce ABSENT per spec §2.4),
revokes matching sessions,
returns 200 with
Cache-Control: no-store
POST /auth/logout -> revoke caller's session
Session management (RBAC-gated auth.session.*):
GET /api/v1/auth/sessions -> auth.session.list (own / all)
DELETE /api/v1/auth/sessions/{id} -> auth.session.revoke (own bypass)
OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
GET /api/v1/auth/oidc/providers -> auth.oidc.list
POST /api/v1/auth/oidc/providers -> auth.oidc.create
(client_secret encrypted
at rest via
internal/crypto.EncryptIfKeySet)
PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
(refused via
ErrOIDCProviderInUse → 409
when users authenticated
via this provider)
POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
(re-runs IdP downgrade
defense via
OIDCService.RefreshKeys)
GET /api/v1/auth/oidc/group-mappings -> auth.oidc.list
POST /api/v1/auth/oidc/group-mappings -> auth.oidc.edit
DELETE /api/v1/auth/oidc/group-mappings/{id} -> auth.oidc.edit
Migration 000037 ships:
- oidc_pre_login_sessions table (10-min absolute TTL, FK CASCADE on
oidc_provider_id, FK RESTRICT on signing_key_id; index on
absolute_expires_at for the GC sweep);
- 7 new permissions seeded into r-admin only:
auth.session.list, auth.session.list.all, auth.session.revoke,
auth.oidc.list, auth.oidc.create, auth.oidc.edit, auth.oidc.delete
CanonicalPermissions extended in lockstep at internal/domain/auth/
validate.go.
Pre-login machinery:
- internal/repository/oidc.go gains PreLoginRepository interface +
PreLoginSession struct + ErrPreLoginNotFound / ErrPreLoginExpired
sentinels.
- internal/repository/postgres/oidc_prelogin.go ships the impl;
LookupAndConsume uses DELETE ... RETURNING for atomic single-use.
- internal/auth/oidc/prelogin.go is the PreLoginAdapter that bridges
the OIDC service's Phase 3 PreLoginStore interface to the new
repository, signing the cookie value under the active
SessionSigningKey via the same v1.<id>.<key>.<HMAC> wire format
Phase 4 uses for post-login cookies. Defense-in-depth: the
pre-login `pl-` prefix is enforced by ParseCookieValue(prefix);
a stolen pre-login cookie cannot be replayed against the
post-login Validate path (pinned by
TestService_Validate_RejectsPreLoginCookieAtPostLoginGate).
Session package extension:
- internal/auth/session/service.go gains exported SignCookieValue,
ParseCookieValue (with caller-supplied id-1 prefix), ComputeCookieHMAC,
DecryptKeyMaterial wrappers so the OIDC pre-login adapter shares
the same length-prefixed HMAC math without code duplication.
- parseCookie no longer hardcodes the `ses-` prefix check (moved to
Validate as defense-in-depth; pre-login cookie verification uses
the `pl-` prefix via ParseCookieValue).
Cookie attributes (all Phase 5 endpoints honor CERTCTL_SESSION_SAMESITE
+ Secure=true via SessionCookieAttrs from Phase 4 config):
- certctl_oidc_pending: Path=/auth/oidc/, MaxAge=600s, SameSite=Lax
(cannot be Strict because the IdP-initiated callback is a top-level
navigation from a different origin).
- certctl_session: Path=/, Expires=8h, SameSite=Lax|Strict, HttpOnly.
- certctl_csrf: Path=/, Expires=8h, HttpOnly=false (intentional —
GUI must read it to echo into X-CSRF-Token header).
Audit logging on every mutating operation (event_category="auth"):
auth.oidc_login_succeeded / failed / unmapped_groups
auth.oidc_back_channel_logout / failed
auth.session_revoked
auth.oidc_provider_{created,updated,deleted,refreshed}
auth.group_mapping_{added,removed}
OpenAPI updates:
- cookieAuth security scheme added to api/openapi.yaml under
components.securitySchemes (apiKey / cookie / certctl_session).
- The 13 Phase 5 routes are added to SpecParityExceptions with a
deferral note: full per-endpoint OpenAPI rows land in a follow-on
commit alongside the GUI work (Phase 8) so the ergonomic shape can
be validated against the live GUI client.
CI guard: scripts/ci-guards/N-bundle-2-security-empty-preserved.sh
asserts api/openapi.yaml has ≥ 14 'security: []' occurrences (the
pre-Bundle-2 baseline). Reducing the count below 14 would silently
force a Bearer-or-cookie requirement onto an endpoint that legitimately
runs without certctl-issued credentials; the guard fires before that
regression lands.
Handler tests (internal/api/handler/auth_session_oidc_test.go):
- All 6 prompt-mandated negative cases:
BCL with missing events claim -> 400
BCL with nonce present -> 400 (per spec §2.4)
BCL with sig signed by an unknown key -> 400
Callback with replayed state -> 400
Callback with PKCE verifier mismatch -> 400
Callback with expired pre-login row -> 400
- Plus happy paths for every endpoint, edge cases (missing-cookie,
duplicate-name, in-use-409, wrong-tenant), and the Helper-function
coverage (peekIssuer, classifyOIDCFailure, defaultIfBlank,
defaultIntIfZero, clientIPFromRequest, encryptClientSecret).
Coverage on internal/api/handler/auth_session_oidc.go: 80.9% per-function
(above the Phase 5 spec's ≥ 80% floor).
Server wiring (cmd/server/main.go):
Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter can
sign pre-login cookies under the active SessionSigningKey:
oidcProviderRepo + oidcMappingRepo + oidcUserRepo + oidcPreLoginRepo
-> preLoginAdapter -> oidcService -> authSessionOIDCHandler.
sessionMinterAdapter shim bridges *session.Service.Create to the
oidcsvc.SessionMinter port the OIDC service consumes.
Router wiring (internal/api/router/router.go):
4 public OIDC routes via direct r.mux.Handle (auth-exempt; pinned in
AuthExemptRouterRoutes); 9 RBAC-gated routes via r.Register +
rbacGate(checker, perm, h). Routes only register when
reg.AuthSessionOIDC != nil so pre-Phase-5 builds skip the block
entirely.
Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/api/handler (74 tests +
new Phase 5 batch), internal/api/router (parity + auth-exempt
allowlist), internal/auth/oidc + session (no regressions), full domain
+ scheduler + config sweeps green, ci-guard
N-bundle-2-security-empty-preserved.sh green (17 ≥ 14 baseline).
|
||
|
|
e6eb7e6497 |
auth-bundle-2 Phase 4: session service (cookie minting + signature
validation, idle/absolute expiry, signing-key rotation, CSRF, GC),
15-case negative-test matrix, fail-fatal initial-key bootstrap
Phase 4 of the bundle ships the post-login session lifecycle that backs
every authenticated request once Phase 5 wires the OIDC handlers + the
session middleware. The state machine is the load-bearing primitive for
the Bundle 2 control plane: forge a session cookie and you bypass every
RBAC gate.
Service surface (internal/auth/session/service.go, ~880 LOC):
- Service.Create(actorID, actorType, ip, ua) -> *CreateResult
Mints a session row; signs the cookie value with the active signing
key; returns the cookie payload AND the CSRF token plaintext for
the handler to set on the response.
- Service.Validate(ValidateInput) -> *Session
Parses the cookie, looks up the signing key (incl. retired-but-in-
retention), recomputes HMAC-SHA256, loads the session row, enforces
revocation + absolute + idle expiry + optional IP/UA bind. Maps to
one of 9 sentinel errors; the handler uniformly returns 401 to the
wire (specific reason in the audit row).
- Service.ValidateCSRF(headerValue, *Session) error
Constant-time compares SHA-256(header) against the stored hash on
the session row.
- Service.UpdateLastSeen / Revoke / RevokeAllForActor
- Service.RotateCSRFToken — mints fresh token, persists hash, returns
plaintext; called on login completion, logout, role-change against
actor, explicit operator rotate.
- Service.RotateSigningKey — mints new active key, retires previous;
retired keys stay valid for cfg.SigningKeyRetention so existing
cookies don't immediately fail.
- Service.EnsureInitialSigningKey — idempotent; mints first key on
fresh deploys; emits auth.session_signing_key_bootstrap audit row
with event_category=auth. Wired into cmd/server/main.go AFTER
migrations + RBAC backfill, BEFORE the HTTP listener binds; failure
is FATAL (logger.Error + os.Exit(1)) per the prompt — server refuses
to boot rather than serve session-less.
- Service.GarbageCollect — sweeps expired post-login sessions +
pre-login rows >10min + retired-past-retention signing keys. Wired
into the new internal/scheduler/scheduler.go::sessionGCLoop on a
CERTCTL_SESSION_GC_INTERVAL tick.
Cookie wire format (load-bearing):
v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>
The HMAC input is LENGTH-PREFIXED to defeat concatenation collisions:
len(session_id) || ":" || session_id || ":" || len(signing_key_id) || ":" || signing_key_id
where len(...) is the ASCII decimal byte-length. Without the length
prefix, the bare-concatenation form `session_id || signing_key_id`
would let a forger swap one byte across the boundary — `<a, bc>` and
`<ab, c>` produce identical HMAC inputs. The length prefix moves the
boundary into the input itself so the two cases can never collide.
The v1. version prefix is reserved. A future incompatible upgrade
ships as v2. and the parser rejects unknown prefixes (no fallback).
CSRF token model:
- Plaintext goes in a JS-readable certctl_csrf cookie (HttpOnly=false
intentional; the GUI must read it to echo into X-CSRF-Token header).
- SHA-256 hash of the plaintext lives on the session row.
- Validation: SHA-256(X-CSRF-Token) constant-time-compared.
- Rotated by Service.RotateCSRFToken on login / logout / role-change /
explicit admin-trigger.
Optional defense-in-depth (default OFF):
- CERTCTL_SESSION_BIND_IP — Validate compares client IP to row's
recorded IP. Mismatch -> 401, audit row, session NOT auto-revoked
(user may have legitimate IP change). Mobile + corporate-NAT
environments leave this off.
- CERTCTL_SESSION_BIND_USER_AGENT — same shape against UA.
Configurable lifetimes (env vars wired in internal/config/config.go):
CERTCTL_SESSION_IDLE_TIMEOUT 1h
CERTCTL_SESSION_ABSOLUTE_TIMEOUT 8h
CERTCTL_SESSION_SIGNING_KEY_RETENTION 24h
CERTCTL_SESSION_GC_INTERVAL 1h
CERTCTL_SESSION_SAMESITE Lax
CERTCTL_SESSION_BIND_IP false
CERTCTL_SESSION_BIND_USER_AGENT false
Test surface (internal/auth/session/service_test.go, ~860 LOC):
All 15 prompt-mandated negative cases:
1. Tampered cookie (HMAC byte flipped near segment start where all
6 bits are real — base64url-no-pad's last char carries only 2
bits so a tail-flip is unreliable).
1b. Tampered SESSION_ID segment (same HMAC-recompute outcome).
2. Cookie missing v1. prefix.
3. Cookie with unknown version prefix (v99).
4. Idle expiry — back-dated last_seen_at + idle_expires_at.
5. Absolute expiry — back-dated absolute_expires_at.
6. Revoked session.
7. Wrong signing key id (no row matches).
8. Cookie signed under retired-but-in-retention key SUCCEEDS.
9. Cookie signed under retired-past-retention key FAILS.
10. Concatenation collision — direct evidence that
computeHMAC("abc","de") != computeHMAC("ab","cde") AND that
a forged-boundary-slide cookie is rejected.
11. CSRF token missing.
12. CSRF token mismatch (constant-time compare).
13. IP-bind enabled + IP changed -> ErrSessionIPMismatch + audit row.
14. UA-bind enabled + UA changed -> ErrSessionUAMismatch + audit row.
15. EnsureInitialSigningKey RNG failure -> ErrInitialSigningKeyMintFailed
wrap (cmd/server/main.go treats as fatal).
Plus coverage-lift batch covering: every error wrap on every repo
collaborator (Create, Get, UpdateLastSeen, UpdateCSRFTokenHash,
Revoke, RevokeAllForActor, GC), every RNG-failure surface in Create /
RotateCSRFToken / RotateSigningKey, every alg-pinning helper edge,
the cookie parser's full negative matrix (empty, wrong segment count,
missing prefixes, bad base64, wrong HMAC length), and a real-encryption
round-trip via internal/crypto.EncryptIfKeySet -> DecryptIfKeySet so
the v3-blob path is exercised end-to-end at the session-cookie level.
Coverage:
internal/auth/session 94.5% (floor 90)
internal/auth/session/domain 96+% (floor 90, Phase 1)
.github/coverage-thresholds.yml extended with 2 new gate entries
(internal/auth/session and internal/auth/session/domain). The
why: paragraphs explain why each fail-closed branch is load-bearing.
Repository extensions:
internal/repository/session.go gains UpdateCSRFTokenHash on the
SessionRepository interface; internal/repository/postgres/session.go
ships the implementation. RotateCSRFToken consumes it.
Scheduler extensions:
internal/scheduler/scheduler.go gains SessionGarbageCollector
interface + sessionGC field + sessionGCInterval +
SetSessionGarbageCollector + SetSessionGCInterval + sessionGCLoop.
Pattern matches the existing acmeGCLoop: atomic.Bool guard prevents
concurrent sweeps, sync.WaitGroup tracks for graceful shutdown,
per-tick context.WithTimeout(1m) bounds a stuck Postgres.
Server wiring:
cmd/server/main.go constructs sessionService AFTER the bootstrap
block (post-RBAC backfill) and BEFORE the policy-service block.
EnsureInitialSigningKey runs immediately; failure is fatal via
os.Exit(1). The scheduler section wires SetSessionGarbageCollector
+ SetSessionGCInterval alongside the other interval setters and
emits an Info log so operators can confirm the loop is enabled.
Phase 4 deviation note: Service.GarbageCollect() returns (int, error)
rather than the prompt's literal `error`. The int is the count of
session rows deleted on this sweep; the scheduler discards it (`_, err
:= ...`) but tests + future operator-facing audit rows can read it.
The wider behavior matches the spec exactly.
Verifications: gofmt clean, go vet ./internal/auth/session/...
./internal/scheduler/... ./internal/config/... ./cmd/server/...
./internal/repository/... clean, go test -short -count=1 -race green
across all 3 session packages, full repository + auth + scheduler +
config test sweeps green, no regressions in Bundle 1 packages.
|
||
|
|
757e2ec30c |
auth-bundle-2 Phase 3: OIDC service (HandleAuthRequest, HandleCallback,
RefreshKeys), hand-rolled group-claim resolver, 21+ negative-test
matrix, token-leak hygiene, IdP downgrade-attack defense
Phase 3 of the bundle ships the business logic that turns the Phase 2
storage primitives into a working OpenID Connect 1.0 + RFC 7636 PKCE
authorization-code flow against any enterprise IdP (Okta / Azure AD /
Google Workspace / Keycloak / Authentik / Auth0).
Service surface:
- Service.HandleAuthRequest(providerID) -> authURL, cookie, preLoginID
Builds the IdP redirect with PKCE-S256 (mandatory; RFC 9700 §2.1.1),
server-generated 32-byte state + nonce, persisted to the pre-login
row keyed by the cookie value.
- Service.HandleCallback(cookie, code, state, ip, ua) -> *CallbackResult
11-step validation: pre-login lookup-and-consume (single-use),
constant-time state compare, code-for-token exchange with PKCE
verifier, ID-token verify (alg pin via go-oidc/v3), service-layer
re-checks of iss / aud / azp (multi-aud requires it; mismatch
rejected) / at_hash (REQUIRED when access_token returned —
Phase 3 lifts the OIDC core "MAY" to a service-level "MUST") /
exp / iat-window / nonce, group-claim resolution with userinfo
fallback, group->role mapping (fail-closed on no match),
user upsert, session mint via SessionMinter port.
- Service.RefreshKeys(providerID) — explicit cache eviction +
re-load. Re-runs the IdP downgrade-attack defense so a provider
that later rotates to advertising HS* / none is caught BEFORE the
next user login attempt.
Security posture (every fail-closed branch is a sentinel error +
test):
- Algorithm pinning: allow-list {RS256, RS512, ES256, ES384, EdDSA};
deny-list {HS256, HS384, HS512, none}. Belt-and-braces re-check
via isDisallowedAlg after go-oidc.Verify.
- PKCE-S256 mandatory (oauth2.GenerateVerifier + S256ChallengeOption);
`plain` rejection sentinel exists for defense-in-depth.
- State + nonce: 32-byte crypto/rand, base64url-no-pad,
constant-time compare, single-use.
- IdP downgrade-attack defense: at provider creation / RefreshKeys,
reject any IdP whose discovery doc advertises HS* / none in
id_token_signing_alg_values_supported.
- JWKS fail-closed: in-flight login fails 503; existing sessions
untouched. isJWKSFetchError detects the gooidc verify-error
shape; ErrJWKSUnreachable is the wire mapping.
- Token-leak hygiene: ID tokens, access tokens, refresh tokens,
authorization codes, PKCE verifiers, state, nonce, signing key
bytes — NEVER logged at any level. logging_test.go pins the
invariant via a slog buffer + grep-assert across HandleAuthRequest,
HandleCallback, alg rejection, and provider-load paths.
Group-claim resolver (internal/auth/oidc/groupclaim/):
- Hand-rolled per Decision 10 (no JSON-path lib; ~150 LOC).
- URL-shape paths (https:// / http://) treated as a single
literal key — Auth0 namespaced claims like
https://your-namespace/groups work without splitting on the
dots in the URL.
- Dot-separated paths walked through nested map[string]interface{}.
- []interface{} / []string / single-string normalized to []string;
bool / number / object / nil → fail closed.
- 18 unit tests + sentinels (ErrPathEmpty, ErrSegmentMissing,
ErrSegmentNotObject, ErrInvalidValueType).
Test surface:
- service_test.go: 57 test functions including all 21 prompt-mandated
negative cases (wrong aud / wrong iss / expired / unknown alg /
alg=none / HMAC alg / azp missing on multi-aud / azp mismatched /
at_hash missing / at_hash mismatched / iat in future / iat too old /
nonce mismatched / state mismatched / state replayed / PKCE plain
sentinel / pre-login replay / forged cookie / IdP downgrade /
group-claim missing / group-claim unmapped) plus the userinfo
fallback matrix (happy path + endpoint-missing + endpoint-failing +
userinfo-also-empty), HandleAuthRequest entry point + RNG-failure
paths, upsertUser update + create + display-name fallback +
Validate-error paths, decryptClientSecret real-encrypt round-trip
+ bad-passphrase, alg-parser malformed-header matrix.
- logging_test.go: 4 hygiene tests pinning no token / code / verifier /
state / cookie / client_secret / alg name appears in any captured
log line.
- groupclaim/resolver_test.go: 18 cases covering Okta string-array,
Keycloak realm_access.roles, Auth0 namespaced URL claim,
single-string normalization, deeply-nested 3-segment walks, and
every fail-closed branch.
Coverage:
internal/auth/oidc 92.2% (floor: 90)
internal/auth/oidc/groupclaim 100.0% (floor: 95)
internal/auth/oidc/domain 96.2% (floor: 90)
Coverage gates added at .github/coverage-thresholds.yml so a future
regression in any fail-closed branch fails CI before the commit lands.
Phase 3 of cowork/auth-bundle-2-prompt.md is closed. Next up: Phase 4
(Session service: cookies, revocation, sliding-vs-absolute expiry).
|
||
|
|
795d7725b8 |
auth-bundle-2 Phase 1: OIDC + Session + User + Breakglass domain types
Phase 1 ships the persisted-shape types Bundle 2 needs end-to-end.
No DB migrations, no service layer, no HTTP handlers; Phase 2 ships
the SQL, Phase 3+ ship the consumers. Each type has a Validate()
method that enforces the on-disk invariants the schema will mirror,
and a focused _test.go that pins each invariant's failure mode.
Per-package summary:
internal/auth/oidc/domain/ (OIDCProvider + GroupRoleMapping):
* OIDCProvider carries the operator-configured IdP record. Fields
match the prompt's Phase 1 list plus IATWindowSeconds and
JWKSCacheTTLSeconds (Phase 3 references these by name; landing
them in Phase 1's domain type avoids the lying-field gap).
ClientSecretEncrypted is opaque from this layer; it is the v2 blob
produced by internal/crypto/encryption.go and is `json:"-"` so it
never wire-leaks.
* Validate() rejects: invalid id prefix, empty name, non-https
issuer_url (matches Phase 3's "JWKS endpoint MUST be HTTPS"),
empty client_id, empty client_secret_encrypted, non-https
redirect_uri, invalid groups_claim_format, scopes missing openid,
IAT window outside (0, 600], JWKS cache TTL below 60s. Defaults
applied in-place: GroupsClaimPath="groups", GroupsClaimFormat=
"string-array", Scopes=["openid","profile","email"],
IATWindowSeconds=300, JWKSCacheTTLSeconds=3600,
TenantID="t-default".
* GroupRoleMapping carries the operator-configured group-to-role
rule. Validate() pins prefix conventions ("grm-", "op-", "r-")
and non-empty group name.
* 18 tests across happy-path + every negative invariant.
internal/auth/session/domain/ (Session + SessionSigningKey):
* Session covers BOTH the post-login row (full 1h-idle/8h-absolute
cookie lifecycle) AND the Phase 5 pre-login row (10-minute TTL,
carries OIDC state+nonce+PKCE verifier across the IdP redirect).
IsPreLogin discriminates. CSRFTokenHash holds SHA-256 of the
CSRF token plaintext (the plaintext lives in a JS-readable
certctl_csrf cookie; storing only the hash on the row defends
against DB-read leaks per the Phase 4 CSRF contract).
* Validate() pins: id prefix "ses-", non-empty actor id/type,
signing key id prefix "sk-", AbsoluteExpiresAt strictly > Idle,
IdleExpiresAt strictly > CreatedAt, CSRFTokenHash exactly 64
lowercase hex chars when set.
* Cookie naming constants pinned by a separate test
(TestCookieNamingConstants) so a future rename can't silently
break the GUI's web/src/api/client.ts which reads these names by
string.
* SessionSigningKey stores the v2-encrypted HMAC key material; the
retired-before-created invariant catches malformed rows. 14
tests across both types.
internal/auth/user/domain/ (User):
* Federated-human identity for SSO logins. Distinct from Bundle 1's
free-form actor_id strings: actor_roles.actor_id = User.ID for
federated humans (per the prompt's note about how the two
identity systems intersect).
* WebAuthnCredentials JSONB column reserved for v3 (Decision 12);
defaults to "[]" on Validate() so Bundle 2 + v3 share the same
on-disk format from day one.
* Email validation is intentionally loose (basic shape: one @,
non-empty local + domain, no whitespace, dot in domain). RFC 5321
/ 5322 grammars are not enforced; the IdP issued the email and
we trust its shape, only rejecting gross corruption.
* 8 tests across happy-path + invalid-id + empty-email +
malformed-email + invalid-provider-id + tenant defaulting +
WebAuthn-credentials passthrough.
internal/auth/breakglass/domain/ (BreakglassCredential):
* Phase 7.5 type. Argon2id PHC-format password hash; Validate()
pins the Argon2id magic prefix so non-Argon2id formats (bcrypt,
pbkdf2, plaintext) are rejected at the persistence boundary.
* MinPasswordLengthBytes (12) + MaxPasswordLengthBytes (256)
constants pinned by a dedicated test so the operator-facing
password-strength contract can't drift silently.
* IsLocked(now) helper exposes the lockout state machine for the
Phase 7.5 service to consume; the lockout window default is
15min in the service layer.
* 9 tests across happy-path + per-invariant negative + lockout
state machine + tenant defaulting.
Cross-cutting:
* Every type has json:"-" on the encrypted-credential field
(ClientSecretEncrypted, KeyMaterialEncrypted, PasswordHash,
CSRFTokenHash) so even a misconfigured handler that marshals the
domain type directly into a response body cannot leak the
secret. Mirrors Bundle 1's pattern for issuer/target credentials.
* Every type carries TenantID with Validate() defaulting to
authdomain.DefaultTenantID. Forward-compat for the future
managed-service multi-tenant activation; Bundle 2 ships
single-tenant.
Verifications:
* gofmt -l clean across all 8 new files (one round-trip required to
satisfy Go 1.19+ doc-comment list-formatting rules in
session/domain/types.go).
* go vet clean on internal/auth/oidc/... + session/... + user/... +
breakglass/...
* go test -short -count=1 green on all four new domain packages
(49 test functions total).
* go test -short -count=1 still green on Bundle 1 packages
(internal/auth, internal/auth/bootstrap, internal/service/auth,
internal/config).
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.
Phase 1 exit criteria from cowork/auth-bundle-2-prompt.md:
* All types compile: yes.
* Validators have at least 5 test cases each: yes (smallest is
User with 8 tests; OIDCProvider has 13).
* make verify equivalent green: gofmt + vet + go test pass
(golangci-lint deferred to CI per the same operating-rule
pattern Phase 0 used).
|
||
|
|
7d7bda93ba |
auth-bundle-2 Phase 0: dependency-add + oidc auth-type literal + runtime guard
Bundle 2 Phase 0 stages the dependencies + auth-type discriminator
literal that later phases consume. No handler chain wired yet; an
operator who sets CERTCTL_AUTH_TYPE=oidc on this commit gets a clear
refuse-to-start error rather than a silent fallback to api-key (the
G-1 failure mode that drove "jwt" out of the allowed set).
Deliverables:
* go.mod: github.com/coreos/go-oidc/v3 v3.18.0 added as a direct
require. Per the pre-bundle dependency audit (Apache-2.0, zero CVEs
ever per OSV.dev, 2,400+ stars, used by Hashicorp Vault + Dex +
Hydra + Authentik + every Kubernetes OIDC integration), this is the
ecosystem-standard Go OIDC client. Pinned to a specific minor
(v3.18.0) per the prompt's "no bare latest" rule.
* go.mod: golang.org/x/oauth2 promoted from // indirect to direct,
bumped from v0.34.0 to v0.36.0 by go mod tidy. Both versions are
OSV-clean. Maintained by the Go team.
* No JSON-path library added (forbidden by the dependency audit; the
group-claim resolver is hand-rolled in Phase 3).
* internal/config/config.go: AuthTypeOIDC constant added with a
load-bearing comment explaining (a) this is the AUTH-TYPE literal,
not a JWT alg literal, so the G-1 closure invariant is preserved
("jwt" stays out of ValidAuthTypes forever); (b) the runtime guard
in cmd/server/main.go intentionally refuses-to-start when oidc is
set pre-Phase-6 to avoid the silent-downgrade failure mode.
ValidAuthTypes() now returns {api-key, none, oidc}.
* internal/config/config_test.go: TestValidAuthTypesIsExactly_APIKey_None
renamed to TestValidAuthTypesIsExactly_APIKey_None_OIDC and now pins
the 3-entry set. TestValidAuthTypesDoesNotContainJWT (G-1 closure
test) still passes because "jwt" is never added back.
TestValidate_GenericInvalidAuthType's bad-types list updated:
"oidc" removed (now valid), "saml" added (correctly rejected per
Decision 5's SAML deferral).
* cmd/server/main.go: defense-in-depth runtime auth-type guard now
has an explicit AuthTypeOIDC case that exit(1)s with an actionable
message: "the OIDC auth chain is not yet wired in this build (Auth
Bundle 2 Phase 6 ships the session middleware that consumes this
auth-type literal)." This closes the lying-field gap the literal
would otherwise create. Phase 6 of Bundle 2 relaxes this case to
fall through alongside api-key + none.
* api/openapi.yaml: /v1/auth/info auth_type enum extended from
[api-key, none] to [api-key, none, oidc] with an in-line comment
explaining the Phase-0-vs-Phase-6 timing so an OpenAPI consumer
isn't surprised by "oidc" appearing here pre-Bundle-2-merge.
* deploy/helm/certctl/templates/_helpers.tpl::certctl.validateAuthType:
valid set extended to include "oidc". Chart-time validation now
passes for type=oidc; the binary's runtime guard takes over to
refuse the start. Once Bundle 2 ships, the runtime guard relaxes
and OIDC works end-to-end with no further chart edits.
* .env.example: CERTCTL_AUTH_TYPE comment block updated to document
the three valid values + the Phase-0-vs-Phase-6 timing.
* internal/auth/oidc/doc.go: new package directory with package doc
+ transitional blank imports for coreos/go-oidc/v3 + x/oauth2 so
go mod tidy keeps both deps as direct requires until Phase 3's
service.go replaces the blanks with real symbol use. Doc explains
the package layout (oidc/ + oidc/domain/ + oidc/groupclaim/ +
oidc/testfixtures/) so the post-Bundle-2 reader can navigate.
Verifications:
* gofmt clean on every changed file.
* go vet clean on internal/config + cmd/server + internal/auth/oidc.
* go test -short -count=1 green on internal/config (including the
G-1 closure + new validation tests), cmd/server, internal/auth (all
Bundle 1 packages), internal/service/auth.
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.
Phase 0 exit criteria from cowork/auth-bundle-2-prompt.md:
* go.mod shows coreos/go-oidc/v3 as direct: yes.
* golang.org/x/oauth2 is direct (not indirect): yes.
* govulncheck ./... clean: yes.
* No JSON-path library in go.mod / go.sum deltas: confirmed (only
v3 of go-oidc + the x/oauth2 bump landed).
* make verify green: gofmt + vet + go test pass; full make verify
(which would invoke golangci-lint) deferred to CI since the
sandbox doesn't have golangci-lint installed; the operator runs
make verify locally before pushing per CLAUDE.md operating rule.
|
||
|
|
38072d3922 |
auth-bundle-1 follow-on: close coverage gaps to clear Phase 12 floors
CI run #486 (post-Bundle-1 merge + Go 1.25.10 bump) failed three coverage-threshold gates: internal/api/handler 74.7% < floor 75 (-0.3pp) internal/auth 66.3% < floor 85 (-18.7pp) internal/service/auth 51.1% < floor 85 (-33.9pp) The Phase 12 gate file's "85% with negative-test coverage" claim turned out to be aspirational — the read-side and Update-path methods on RoleService / PermissionService / ActorRoleService had zero unit-test coverage, and internal/auth's keystore + HasPermission helper had zero tests. This commit closes the gap without lowering the gate. Per-package CI-style averages after this commit (per scripts/check-coverage-thresholds.sh's per-function-mean): internal/api/handler 76.1% (+1.4pp, margin +1.1pp) internal/auth 90.5% (+24.2pp, margin +5.5pp) internal/service/auth 93.7% (+42.6pp, margin +8.7pp) Tests added: internal/service/auth/service_test.go (+18 tests, +518 LOC): PermissionService.List, PermissionService.GetByName, RoleService.Get (4 paths), RoleService.List (system caller), RoleService.Update (4 paths), RoleService.ListPermissions (3 paths), RoleService.AddPermission/RemovePermission round-trip + gate paths, RoleService.Delete (success + nil-caller + no-perm + audit), RoleService.Create (nil-caller), ActorRoleService.ListForActor (self-bypass + cross-actor + nil-caller + system + with-perm), ActorRoleService.Effective- Permissions (same shape), ActorRoleService.ListKeys (3 paths + system bypass), ActorRoleService.Revoke (4 paths), Authorizer edge cases (empty actorID short-circuit, empty tenantID default, scoped-grant-without-scope-id no-match invariant, repo-error wrap-and-return, HoldsAnyOf early-exit), recordAudit nil-arm short-circuits. internal/auth/keystore_test.go (NEW, +175 LOC): StaticKeyStore.Len, StaticKeyStore.LookupByHash hit + miss, MutableKeyStore seeded lookup + Len, Add registers new key, AddHashed registers from precomputed hash, AddHashed replaces on duplicate hash (idempotent boot-loader contract), HasPermission no-actor / default-actor-type / checker-error / scoped-check threading. internal/auth/bootstrap/service_test.go (+36 LOC): Service.Available nil-receiver/nil-strategy short-circuit, Service.Available delegates to Strategy when configured. internal/api/handler/auth_test.go (+208 LOC): GetRole returns role + permissions, GetRole 404 + 401, UpdateRole 200 + invalid-JSON-400 + 401, ListKeys returns actor list + 401, RemoveRolePermission 204 (global + scoped) + 401, rolePermToResponse scope encoding pin via GetRole. Verified: gofmt -l . clean (touched files only). go vet ./internal/auth/... ./internal/service/auth/... ./internal/api/handler/ rc=0. go test -count=1 -short on the four packages green. CI-style per-function averages computed via the live scripts/check-coverage-thresholds.sh arithmetic — all three gated packages clear their floors with margin. Per CLAUDE.md "complete path" + "do not lower the gate to make CI green": gate file unchanged. The 85/85/75 floors stand. |
||
|
|
3c605d5618 |
auth-bundle-1 Phase 6-7-8: bootstrap path + scope-down CLI + auditor-role split
# Phase 6 — day-0 admin bootstrap * internal/auth/bootstrap/ (new package): Strategy interface + EnvTokenStrategy with constant-time compare, one-shot consumption via sync.Mutex, optional admin-existence probe. Bundle 2's OIDC- first-admin will plug in alongside as an alternate Strategy. * BootstrapService.ValidateAndMint: validates the operator's CERTCTL_BOOTSTRAP_TOKEN, mints a 32-byte (64-hex-char) random API key value, persists the SHA-256 hash to api_keys, grants r-admin via actor_roles, AddHashed's the runtime keystore so the just- minted key authenticates the next request without restart, and records bootstrap.consume to the audit trail with category=auth. * internal/auth/keystore.go (new): KeyStore interface + StaticKeyStore (immutable env-var-only path) + MutableKeyStore (env-var keys + DB-loaded api_keys + runtime AddHashed). The auth middleware now consumes a KeyStore so the bootstrap path can extend the lookup table at runtime. * migrations/000031_api_keys.up/down.sql: api_keys table with (id, name UNIQUE, key_hash UNIQUE, tenant_id, admin, created_by, created_at, expires_at, last_used_at). Idempotent. * /v1/auth/bootstrap GET (probe) + POST (mint) — auth-exempt. Both routes documented in api/openapi.yaml + AuthExemptRouterRoutes allowlist updated. The token never leaves internal/auth/bootstrap; the minted plaintext key flows only into the HTTP response body. * Startup warning emitted when CERTCTL_BOOTSTRAP_TOKEN is set AND admin actors already exist (config drift signal). * Tests: 4 strategy invariants (empty token born disabled, wrong token=ErrInvalidToken without consumption, one-shot consumption, admin-exists closes path), 5 service tests (happy path + actor- name validation + propagation of strategy errors + nil-deps guard + 32-byte entropy budget), 8 HTTP-handler tests (status 201/410/401/400 mapping + token-leak hygiene scan of slog + audit details + Location header). Token-leak test redirects slog.Default to a buffer for the test scope. # Phase 7 — API-key migration + scope-down CLI * GET /v1/auth/keys handler + service method ListKeys backed by ActorRoleRepository.ListDistinctActors. Returns one row per (actor_id, actor_type) pair with the slice of role IDs they hold. Permission: auth.role.list. * internal/cli/auth_scope_down.go: AuthListKeys, AuthScopeDown (interactive), AuthScopeDownNonInteractive (JSON config), AuthScopeDownSuggest (--suggest with optional --apply). The synthetic actor-demo-anon is filtered out of every interactive / bulk path; non-interactive flow logs and skips it explicitly. * SuggestRoleFromAuditEvents (pure function): walks 30 days of audit events per actor and returns the narrowest matching role (admin / mcp / viewer / agent / operator) plus a one-line reason. Classification: any admin-shaped action wins; otherwise all-MCP → mcp; all-read-only → viewer; all-agent-shaped → agent; otherwise operator. Test table pins all six classifications. * CLI subcommand tree extended: 'auth keys list' + 'auth keys scope-down [--non-interactive <cfg>] [--suggest [--apply]]'. * CHANGELOG.md leads v2.1.0 with the SECURITY: AUDIT YOUR API KEYS call-out + four flow examples. # Phase 8 — auditor role + event_category column * migrations/000032_audit_category.up/down.sql: ALTER TABLE audit_events ADD COLUMN event_category TEXT NOT NULL DEFAULT 'cert_lifecycle' + CHECK constraint (cert_lifecycle/auth/config) + (event_category) and (event_category, timestamp DESC) indexes for the auditor-filter query path. WORM trigger from migration 000018 continues to enforce append-only at the DB layer (DDL is not blocked). * domain.AuditEvent gains EventCategory string (omitempty); domain.EventCategoryCertLifecycle / Auth / Config constants. * AuditService.RecordEventWithCategory sibling of RecordEvent; legacy callers stay on RecordEvent (defaults to cert_lifecycle). Auth callers (RoleService, ActorRoleService, BootstrapService) switched to RecordEventWithCategory(..., 'auth', ...). * GET /v1/audit?category=<cat>: handler accepts the optional query param, validates against the enum (400 on invalid value), dispatches through ListAuditEventsByCategory. OpenAPI updated with the new query param + AuditEvent.event_category schema. * Postgres AuditRepository.Create now writes event_category; AuditRepository.List filters on it; AuditFilter.EventCategory gates the WHERE clause. * Tests: 5 audit-category-filter HTTP tests (dispatch routing, back-compat fallback, 400 for invalid values, all 3 enum values accepted, page+category combine, JSON output surfaces the field). 3 auditor-role invariants (auditor holds exactly audit.read+audit.export, no mutating perms, disjoint from viewer except audit.read). # Cross-phase wiring * HandlerRegistry.Bootstrap field added; cmd/server/main.go wires the bootstrap service ahead of RegisterHandlers (extracted assembleNamedAPIKeys helper into auth_backfill.go, moved the keystore + bootstrap construction up alongside the auth repos). * AuthCheckResolver / AuthActorRoleService extended with ListKeys to satisfy the Phase 7 surface; existing fakes updated. * fakeAudit + mockAuditService stubs in tests gain RecordEventWithCategory + ListAuditEventsByCategory; existing tests untouched. # Verifications * gofmt -l: clean across every modified file. * go vet ./...: clean. * staticcheck across internal/auth + handler + router + cli + service + repository + cmd + domain: clean. * go test -short -count=1: green across every Bundle-1-touched package — internal/auth (incl. bootstrap), internal/api/handler, internal/api/router, internal/cli, internal/service/auth, internal/service, internal/domain/auth, internal/repository/postgres, cmd/server, cmd/cli, plus internal/scheduler, internal/api/middleware, cmd/agent, internal/mcp. |
||
|
|
791659ebf0 |
auth-bundle-1 Phase 3 (primitive): RequirePermission middleware + demo-mode + protocol allowlist
Bundle 1 / Phase 3 (primitive ship): the load-bearing RBAC middleware factory plus its dependencies. Handler conversion sweep (5 admin files: bulk_revocation.go, admin_crl_cache.go, admin_scep_intune.go, admin_est.go, intermediate_ca.go) + m008_admin_gate_test.go registry update is Phase 3.5 follow-on; this commit ships the primitive so 3.5 is mechanical. New context keys (internal/auth/context.go): ActorIDKey, ActorTypeKey, TenantIDKey alongside the legacy UserKey + AdminKey. New helpers GetActorID / GetActorType / GetTenantID with safe fallbacks (UserKey for actor id, ActorTypeAPIKey for missing type, DefaultTenantID for missing tenant). Constants DemoAnonActorID + ActorTypeAPIKey + ActorTypeAnonymous mirror internal/domain/auth without an import cycle. RequirePermission factory (internal/auth/require_permission.go): wraps a handler and gates it behind a named permission. 401 when no actor, 403 when actor lacks permission, 500 on repository error. Skips the gate entirely for protocol endpoints (ACME / SCEP / EST / OCSP / CRL) per the audit's Category F do-not-gate allowlist. PermissionChecker is an interface so internal/auth doesn't depend on internal/service/auth (cmd/server wires the concrete Authorizer at startup). HasPermission is the imperative variant for handlers that branch behaviour rather than 403'ing. ScopeFunc closure extracts the scope type + id from the request for per-resource gating. Protocol-endpoint allowlist (internal/auth/protocol_endpoints.go): IsProtocolEndpoint matches /acme, /scep, /.well-known/est, /.well-known/pki/ocsp, /.well-known/pki/crl prefixes. Adding a new protocol endpoint MUST update this list and add a parallel test. Demo-mode synthetic admin (internal/auth/middleware.go::NewDemoModeAuth): when CERTCTL_AUTH_TYPE=none is configured, this middleware injects ActorID=actor-demo-anon, ActorType=Anonymous, TenantID=t-default, plus the legacy UserKey + AdminKey for back-compat with existing handlers. The synthetic actor's admin-role grant is seeded by migration 000029 so RequirePermission resolves through the JOIN like any other actor. cmd/server startup wires this middleware only when none-mode is configured. API-key middleware extension: NewAuthWithNamedKeys now populates the new keys (ActorIDKey, ActorTypeKey=APIKey, TenantIDKey=t-default) alongside UserKey + AdminKey on every successful Bearer match. Existing handlers continue to read UserKey / IsAdmin until the Phase 3.5 sweep converts them to RequirePermission. Test coverage: TestRequirePermission_NoActorReturns401, TestRequirePermission_GrantedActorReaches200, TestRequirePermission_DeniedActorReturns403, TestRequirePermission_CheckerErrorReturns500, TestRequirePermission_ProtocolEndpointBypassesGate (covers all 5 prefixes), TestRequirePermission_ScopeFnExtractsResourceID, TestIsProtocolEndpoint_PrefixesOnly, TestNewDemoModeAuth_InjectsSyntheticActor, TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys. fakeChecker pins the contract without a database. Phase 3.5 follow-on (NOT in this commit): convert each of the 5 admin handlers from auth.IsAdmin checks to auth.RequirePermission middleware in router.go; update internal/api/handler/m008_admin_gate_test.go to track auth.RequirePermission call sites instead of (or alongside) auth.IsAdmin; pick the right permission per handler (cert.revoke for bulk_revocation, etc.). Each handler conversion needs the 3-test triplet (_NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 / _AdminPermitted_ForwardsActor) per M-008. Branch: dev/auth-bundle-1. Phase 2 was prior commit (service layer). Phase 3.5 (handler conversion) + Phase 4 (HTTP API) on the next session. |
||
|
|
69f860171e |
auth-bundle-1 Phase 0: extract internal/auth/ from middleware package
Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home. Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually. Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser). Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames. Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested. Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml. |