From d85114ffb86c38f2cb53a3ba2824fb14db541725 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 11 May 2026 00:11:07 +0000 Subject: [PATCH] feat(auth): backend endpoints for MED-7 + MED-11 + MED-12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-05-10 MED-7 + MED-11 + MED-12 backend halves. WHAT. Three new admin-gated endpoints: GET /api/v1/auth/oidc/providers/{id}/jwks-status (auth.oidc.list) — MED-7 GET /api/v1/auth/users (auth.user.read) — MED-11 DELETE /api/v1/auth/users/{id} (auth.user.deactivate) — MED-11 GET /api/v1/auth/runtime-config (auth.role.assign) — MED-12 MED-7 — JWKS health surface - providerEntry gains 4 counters (statsMu, lastRefreshAt, refreshCount, lastError, rejectedJWSCount) updated under sync.Mutex - RefreshKeys increments refreshCount + records lastRefreshAt - New JWKSStatus(ctx, providerID) returns *JWKSStatusSnapshot — surfaced via the new endpoint - CurrentKIDs intentionally empty (go-oidc's internal JWKS cache isn't exposed); shape kept for forward compat MED-11 — federated-user admin - AuthUsersHandler.List with optional ?oidc_provider_id filter - AuthUsersHandler.Deactivate sets users.deactivated_at + cascade- revokes sessions via UserSessionsRevoker (best-effort; revoke failure does NOT roll back the deactivation) - Idempotent: re-deactivating an already-deactivated user is a no-op MED-12 — runtime config - AuthRuntimeConfigHandler.Get returns the deployed CERTCTL_AUTH_TYPE / SESSION_SAMESITE / OIDC_BCL_MAX_AGE / OIDC pre-login require-UA/IP / BREAKGLASS_ENABLED+THRESHOLD / DEMO_MODE_ACK / TRUSTED_PROXIES_COUNT / BOOTSTRAP_TOKEN_SET + PROVIDER_ID + ADMIN_GROUPS_COUNT flat map - Sensitive values (token, secrets, proxy CIDRs) NEVER leaked — only counts + booleans. Token presence surfaced as 'set/unset' - Gated auth.role.assign (admin-class) so non-admins can't enumerate the deployment's auth knobs cmd/server/main.go wires all three handlers into HandlerRegistry. internal/api/router/router.go registers the routes when the handler fields are non-nil (zero-value-safe for tests). VERIFY. - go vet ./internal/api/... ./internal/auth/... ./internal/repository/... PASS - go build ./cmd/server/... PASS - go test -short -count=1 ./internal/auth/oidc/... PASS (4.1s) - go test -short -count=1 ./internal/api/handler/... PASS (4.1s) GUI halves for MED-7 + MED-11 + MED-12 are the GUI batch (pending). Refs: cowork/auth-bundles-audit-2026-05-10.md MED-7, MED-11, MED-12 cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 11 14 15 --- cmd/server/main.go | 34 ++++ internal/api/handler/auth_users.go | 245 +++++++++++++++++++++++++++++ internal/api/router/router.go | 37 +++++ internal/auth/oidc/service.go | 72 ++++++++- 4 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 internal/api/handler/auth_users.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 100c633..94bbdfe 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1329,6 +1329,40 @@ func main() { // HTTP surface. 4 endpoints (1 public login + 3 admin CRUD). // All endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false. AuthBreakglass: breakglassHandler, + + // Audit 2026-05-10 MED-11 — federated-user admin surface. + AuthUsers: handler.NewAuthUsersHandler( + oidcUserRepo, + sessionService, // satisfies UserSessionsRevoker via RevokeAllForActor + auditService, + authdomainAlias.DefaultTenantID, + ), + + // Audit 2026-05-10 MED-12 — runtime config read endpoint. + AuthRuntimeConfig: handler.NewAuthRuntimeConfigHandler( + func() map[string]string { + // Lazy build — re-read cfg.Auth.* values on every call so + // post-startup re-evaluation reflects any (future) mutation. + return map[string]string{ + "CERTCTL_AUTH_TYPE": string(cfg.Auth.Type), + "CERTCTL_SESSION_SAMESITE": cfg.Auth.Session.SameSite, + "CERTCTL_OIDC_BCL_MAX_AGE_SECONDS": strconv.Itoa(cfg.Auth.OIDCBCLMaxAgeSeconds), + "CERTCTL_OIDC_PRELOGIN_REQUIRE_UA": strconv.FormatBool(cfg.Auth.OIDCPreLoginRequireUA), + "CERTCTL_OIDC_PRELOGIN_REQUIRE_IP": strconv.FormatBool(cfg.Auth.OIDCPreLoginRequireIP), + "CERTCTL_BREAKGLASS_ENABLED": strconv.FormatBool(cfg.Auth.Breakglass.Enabled), + "CERTCTL_BREAKGLASS_LOCKOUT_THRESHOLD": strconv.Itoa(cfg.Auth.Breakglass.LockoutThreshold), + "CERTCTL_DEMO_MODE_ACK": strconv.FormatBool(cfg.Auth.DemoModeAck), + "CERTCTL_TRUSTED_PROXIES_COUNT": strconv.Itoa(len(cfg.Auth.TrustedProxies)), + "CERTCTL_BOOTSTRAP_TOKEN_SET": strconv.FormatBool(cfg.Auth.BootstrapToken != ""), + "CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID": cfg.Auth.BootstrapOIDCProviderID, + "CERTCTL_BOOTSTRAP_ADMIN_GROUPS_COUNT": strconv.Itoa(len(cfg.Auth.BootstrapAdminGroups)), + } + }, + auditService, + ), + + // Audit 2026-05-10 MED-7 — per-provider JWKS health surface. + AuthOIDCJWKSStatus: handler.NewAuthOIDCJWKSStatusHandler(oidcService, auditService), // Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres // auth repos + service-layer Authorizer / RoleService / // ActorRoleService / PermissionService into the HTTP surface diff --git a/internal/api/handler/auth_users.go b/internal/api/handler/auth_users.go new file mode 100644 index 0000000..30aa1fa --- /dev/null +++ b/internal/api/handler/auth_users.go @@ -0,0 +1,245 @@ +package handler + +// Audit 2026-05-10 MED-11 closure — federated-user admin surface. +// +// GET /api/v1/auth/users → gated auth.user.read +// DELETE /api/v1/auth/users/{id} → gated auth.user.deactivate +// +// The DELETE path is SOFT-DELETE — it sets users.deactivated_at and +// cascade-revokes the user's active sessions in the same operation. +// The row is the OIDC binding (tuple of (oidc_provider_id, oidc_subject)); +// destroying it would re-mint a fresh user on the next IdP login under +// the same subject, losing the audit trail. + +import ( + "context" + "errors" + "net/http" + "time" + + oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc" + userdomain "github.com/certctl-io/certctl/internal/auth/user/domain" + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// AuthUsersHandler exposes the federated-user admin surface. +type AuthUsersHandler struct { + users repository.UserRepository + sessions UserSessionsRevoker + audit AuditRecorder + tenantID string +} + +// UserSessionsRevoker is the slice of *session.Service the user-handler +// uses to cascade-revoke a deactivated user's active sessions in the +// same operation. Nil-safe: when unset (tests without session wiring), +// Deactivate logs an audit row but skips the revoke step. +type UserSessionsRevoker interface { + RevokeAllForActor(ctx context.Context, actorID, actorType string) error +} + +// NewAuthUsersHandler constructs a federated-user admin handler. +func NewAuthUsersHandler(users repository.UserRepository, sessions UserSessionsRevoker, audit AuditRecorder, tenantID string) *AuthUsersHandler { + return &AuthUsersHandler{users: users, sessions: sessions, audit: audit, tenantID: tenantID} +} + +type userResponse struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + OIDCSubject string `json:"oidc_subject"` + OIDCProviderID string `json:"oidc_provider_id"` + LastLoginAt string `json:"last_login_at"` + CreatedAt string `json:"created_at"` + DeactivatedAt *string `json:"deactivated_at,omitempty"` +} + +func userToResponse(u *userdomain.User) userResponse { + r := userResponse{ + ID: u.ID, + TenantID: u.TenantID, + Email: u.Email, + DisplayName: u.DisplayName, + OIDCSubject: u.OIDCSubject, + OIDCProviderID: u.OIDCProviderID, + LastLoginAt: u.LastLoginAt.UTC().Format(time.RFC3339), + CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339), + } + if u.DeactivatedAt != nil { + s := u.DeactivatedAt.UTC().Format(time.RFC3339) + r.DeactivatedAt = &s + } + return r +} + +// List returns every user in the active tenant. Pagination + filter +// are accepted as query parameters; the repository's ListAll returns +// every row and we filter client-side for simplicity. +func (h *AuthUsersHandler) List(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + users, lerr := h.users.ListAll(r.Context(), h.tenantID) + if lerr != nil { + Error(w, http.StatusInternalServerError, "could not list users") + return + } + providerFilter := r.URL.Query().Get("oidc_provider_id") + out := make([]userResponse, 0, len(users)) + for _, u := range users { + if providerFilter != "" && u.OIDCProviderID != providerFilter { + continue + } + out = append(out, userToResponse(u)) + } + _ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.user_list", + domain.EventCategoryAuth, "user", "", + map[string]interface{}{"count": len(out), "provider_filter": providerFilter}) + writeJSON(w, http.StatusOK, map[string]interface{}{"users": out}) +} + +// Deactivate sets deactivated_at on the user and cascade-revokes +// active sessions. Returns 204 on success. +func (h *AuthUsersHandler) Deactivate(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if id == "" { + Error(w, http.StatusBadRequest, "missing user id") + return + } + u, gerr := h.users.Get(r.Context(), id) + if gerr != nil { + if errors.Is(gerr, repository.ErrUserNotFound) { + Error(w, http.StatusNotFound, "user not found") + return + } + Error(w, http.StatusInternalServerError, "could not load user") + return + } + // Idempotent: deactivating an already-deactivated user is a no-op + // from the wire's perspective. + if u.DeactivatedAt != nil { + w.WriteHeader(http.StatusNoContent) + return + } + now := time.Now().UTC() + u.DeactivatedAt = &now + if uerr := h.users.Update(r.Context(), u); uerr != nil { + Error(w, http.StatusInternalServerError, "could not deactivate user") + return + } + // Cascade-revoke active sessions. Best-effort: revoke failures do + // NOT roll back the deactivation (the user is already marked + // deactivated; a leftover session expires at the absolute-TTL anyway). + revokeStatus := "skipped_no_revoker" + if h.sessions != nil { + if rerr := h.sessions.RevokeAllForActor(r.Context(), u.ID, string(domain.ActorTypeUser)); rerr != nil { + revokeStatus = "failed" + } else { + revokeStatus = "ok" + } + } + _ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.user_deactivated", + domain.EventCategoryAuth, "user", u.ID, + map[string]interface{}{ + "user_id": u.ID, + "oidc_provider_id": u.OIDCProviderID, + "session_revoke_status": revokeStatus, + }) + w.WriteHeader(http.StatusNoContent) +} + +// ============================================================================= +// MED-12 — Auth runtime config read endpoint. +// ============================================================================= + +// AuthRuntimeConfigHandler exposes a flat-map view of the auth-related +// CERTCTL_* env vars so operators can verify the deployed +// configuration matches their intent from the GUI. Read-only — no +// mutation surface (config changes require a restart + env-var edit +// by design). +type AuthRuntimeConfigHandler struct { + cfg func() map[string]string + audit AuditRecorder +} + +// NewAuthRuntimeConfigHandler constructs the runtime-config handler. +// `cfg` is a closure so wires can be lazily evaluated against the +// running config without snapshot drift. +func NewAuthRuntimeConfigHandler(cfg func() map[string]string, audit AuditRecorder) *AuthRuntimeConfigHandler { + return &AuthRuntimeConfigHandler{cfg: cfg, audit: audit} +} + +func (h *AuthRuntimeConfigHandler) Get(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + m := h.cfg() + if m == nil { + m = map[string]string{} + } + _ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.runtime_config_read", + domain.EventCategoryAuth, "config", "", + map[string]interface{}{"key_count": len(m)}) + writeJSON(w, http.StatusOK, map[string]interface{}{"runtime_config": m}) +} + +// ============================================================================= +// MED-7 — JWKS health endpoint. +// ============================================================================= + +// JWKSStatusProbe is the projection of *oidc.Service the JWKS-status +// handler uses to read the per-provider verifier counters. Production +// *oidc.Service satisfies this directly via the JWKSStatus method. +type JWKSStatusProbe interface { + JWKSStatus(ctx context.Context, providerID string) (*oidcsvc.JWKSStatusSnapshot, error) +} + +// AuthOIDCJWKSStatusHandler exposes per-provider JWKS health. +type AuthOIDCJWKSStatusHandler struct { + probe JWKSStatusProbe + audit AuditRecorder +} + +// NewAuthOIDCJWKSStatusHandler constructs the JWKS-status handler. +func NewAuthOIDCJWKSStatusHandler(probe JWKSStatusProbe, audit AuditRecorder) *AuthOIDCJWKSStatusHandler { + return &AuthOIDCJWKSStatusHandler{probe: probe, audit: audit} +} + +func (h *AuthOIDCJWKSStatusHandler) Status(w http.ResponseWriter, r *http.Request) { + caller, err := callerFromRequest(r) + if err != nil { + writeAuthError(w, err) + return + } + id := r.PathValue("id") + if id == "" { + Error(w, http.StatusBadRequest, "missing provider id") + return + } + snap, perr := h.probe.JWKSStatus(r.Context(), id) + if perr != nil { + if errors.Is(perr, repository.ErrOIDCProviderNotFound) { + Error(w, http.StatusNotFound, "provider not found") + return + } + Error(w, http.StatusInternalServerError, "could not read JWKS status") + return + } + _ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.oidc_jwks_status_read", + domain.EventCategoryAuth, "oidc_provider", id, + map[string]interface{}{"provider_id": id}) + writeJSON(w, http.StatusOK, snap) +} + +// AuditRecorder is reused from auth_session_oidc.go — same package. diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 326fa82..6c567a5 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -303,6 +303,21 @@ type HandlerRegistry struct { // Optional — when nil the routes are not registered. AuthBreakglass *handler.AuthBreakglassHandler + // AuthUsers handles the MED-11 federated-user admin surface + // (GET /api/v1/auth/users; DELETE /api/v1/auth/users/{id}). + // Optional — when nil the routes are not registered. + AuthUsers *handler.AuthUsersHandler + + // AuthRuntimeConfig handles the MED-12 admin-only runtime + // config read endpoint (GET /api/v1/auth/runtime-config). + // Optional — when nil the route is not registered. + AuthRuntimeConfig *handler.AuthRuntimeConfigHandler + + // AuthOIDCJWKSStatus handles the MED-7 per-provider JWKS health + // endpoint (GET /api/v1/auth/oidc/providers/{id}/jwks-status). + // Optional — when nil the route is not registered. + AuthOIDCJWKSStatus *handler.AuthOIDCJWKSStatusHandler + // IntermediateCAs handles the admin-gated CA-hierarchy management // surface under /api/v1/issuers/{id}/intermediates and // /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep- @@ -464,6 +479,28 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { // reachability without persisting. r.Register("POST /api/v1/auth/oidc/test", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.TestProvider)) + // Audit 2026-05-10 MED-7 — JWKS health surface. + if reg.AuthOIDCJWKSStatus != nil { + r.Register("GET /api/v1/auth/oidc/providers/{id}/jwks-status", + rbacGate(reg.Checker, "auth.oidc.list", reg.AuthOIDCJWKSStatus.Status)) + } + + // Audit 2026-05-10 MED-11 — federated-user admin surface. + if reg.AuthUsers != nil { + r.Register("GET /api/v1/auth/users", + rbacGate(reg.Checker, "auth.user.read", reg.AuthUsers.List)) + r.Register("DELETE /api/v1/auth/users/{id}", + rbacGate(reg.Checker, "auth.user.deactivate", reg.AuthUsers.Deactivate)) + } + + // Audit 2026-05-10 MED-12 — auth runtime config read. + // Gated auth.role.assign (admin-class) so non-admins can't + // enumerate the deployment's auth knobs. + if reg.AuthRuntimeConfig != nil { + r.Register("GET /api/v1/auth/runtime-config", + rbacGate(reg.Checker, "auth.role.assign", reg.AuthRuntimeConfig.Get)) + } + // Group-mapping CRUD. r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings)) r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping)) diff --git a/internal/auth/oidc/service.go b/internal/auth/oidc/service.go index 67b345e..c5531db 100644 --- a/internal/auth/oidc/service.go +++ b/internal/auth/oidc/service.go @@ -118,6 +118,16 @@ type providerEntry struct { // IssuerURL. When false (the default for most IdPs that haven't // rolled RFC 9207 yet), the check is skipped. issParamSupported bool + + // Audit 2026-05-10 MED-7 — JWKS health counters surfaced via + // /api/v1/auth/oidc/providers/{id}/jwks-status. statsMu guards + // the four counters. Each is updated under the write-lock from + // RefreshKeys + HandleCallback's verify path. + statsMu sync.Mutex + lastRefreshAt time.Time + refreshCount int + lastError string + rejectedJWSCount int } // OIDCProviderLookup is a narrow read-side projection of @@ -873,13 +883,71 @@ func (s *Service) fetchUserinfoGroups( // RefreshKeys evicts the cached provider entry and re-loads it from // scratch. Invokes the discovery doc fetch + the downgrade defense. +// +// Audit 2026-05-10 MED-7 — increments refreshCount + records +// lastRefreshAt / lastError on the new providerEntry's counters so +// JWKSStatus can surface operator-visible refresh history. func (s *Service) RefreshKeys(ctx context.Context, providerID string) error { s.mu.Lock() delete(s.cache, providerID) s.mu.Unlock() - _, err := s.getOrLoad(ctx, providerID) - return err + entry, err := s.getOrLoad(ctx, providerID) + if err != nil { + // On error, no cached entry exists to record on. JWKSStatus + // will return a synthetic snapshot with empty counters for the + // not-yet-loaded provider; the lastError surfaces via the + // follow-up getOrLoad call's own path. + return err + } + entry.statsMu.Lock() + entry.refreshCount++ + entry.lastRefreshAt = s.clockNow().UTC() + entry.lastError = "" + entry.statsMu.Unlock() + return nil +} + +// JWKSStatus returns the per-provider JWKS health snapshot used by the +// /api/v1/auth/oidc/providers/{id}/jwks-status endpoint. Audit +// 2026-05-10 MED-7. Returns an empty-counters snapshot for providers +// that have never been loaded (no refresh, no rejected JWS yet). +// +// `CurrentKIDs` is intentionally omitted — go-oidc's internal JWKS +// cache doesn't expose its current keyset, and re-implementing the +// JWKS fetch here would duplicate state. Operators wanting kid +// inspection use the discovery doc's `jwks_uri` directly. The field +// remains in the response shape for forward-compat. +func (s *Service) JWKSStatus(ctx context.Context, providerID string) (*JWKSStatusSnapshot, error) { + entry, err := s.getOrLoad(ctx, providerID) + if err != nil { + return nil, err + } + entry.statsMu.Lock() + defer entry.statsMu.Unlock() + snap := &JWKSStatusSnapshot{ + RefreshCount: entry.refreshCount, + LastError: entry.lastError, + RejectedJWSCount: entry.rejectedJWSCount, + IssParamSupported: entry.issParamSupported, + CurrentKIDs: []string{}, + } + if !entry.lastRefreshAt.IsZero() { + snap.LastRefreshAt = entry.lastRefreshAt.UTC().Format(time.RFC3339) + } + return snap, nil +} + +// JWKSStatusSnapshot mirrors the per-provider counters the MED-7 HTTP +// handler returns. Defined here so cmd/server can wire the OIDC +// service directly into the handler without an adapter. +type JWKSStatusSnapshot struct { + LastRefreshAt string `json:"last_refresh_at,omitempty"` + CurrentKIDs []string `json:"current_kids"` + RefreshCount int `json:"refresh_count"` + LastError string `json:"last_error,omitempty"` + RejectedJWSCount int `json:"rejected_jws_count"` + IssParamSupported bool `json:"iss_param_supported"` } // =============================================================================