feat(auth): backend endpoints for MED-7 + MED-11 + MED-12

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
This commit is contained in:
shankar0123
2026-05-11 00:11:07 +00:00
parent e1e43c8924
commit 172b30b8f1
4 changed files with 386 additions and 2 deletions
+34
View File
@@ -1329,6 +1329,40 @@ func main() {
// HTTP surface. 4 endpoints (1 public login + 3 admin CRUD). // HTTP surface. 4 endpoints (1 public login + 3 admin CRUD).
// All endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false. // All endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false.
AuthBreakglass: breakglassHandler, 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 — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
// auth repos + service-layer Authorizer / RoleService / // auth repos + service-layer Authorizer / RoleService /
// ActorRoleService / PermissionService into the HTTP surface // ActorRoleService / PermissionService into the HTTP surface
+245
View File
@@ -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.
+37
View File
@@ -303,6 +303,21 @@ type HandlerRegistry struct {
// Optional — when nil the routes are not registered. // Optional — when nil the routes are not registered.
AuthBreakglass *handler.AuthBreakglassHandler 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 // IntermediateCAs handles the admin-gated CA-hierarchy management
// surface under /api/v1/issuers/{id}/intermediates and // surface under /api/v1/issuers/{id}/intermediates and
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep- // /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. // reachability without persisting.
r.Register("POST /api/v1/auth/oidc/test", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.TestProvider)) 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. // Group-mapping CRUD.
r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings)) 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)) r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping))
+69 -1
View File
@@ -118,6 +118,16 @@ type providerEntry struct {
// IssuerURL. When false (the default for most IdPs that haven't // IssuerURL. When false (the default for most IdPs that haven't
// rolled RFC 9207 yet), the check is skipped. // rolled RFC 9207 yet), the check is skipped.
issParamSupported bool 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 // OIDCProviderLookup is a narrow read-side projection of
@@ -873,14 +883,72 @@ func (s *Service) fetchUserinfoGroups(
// RefreshKeys evicts the cached provider entry and re-loads it from // RefreshKeys evicts the cached provider entry and re-loads it from
// scratch. Invokes the discovery doc fetch + the downgrade defense. // 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 { func (s *Service) RefreshKeys(ctx context.Context, providerID string) error {
s.mu.Lock() s.mu.Lock()
delete(s.cache, providerID) delete(s.cache, providerID)
s.mu.Unlock() s.mu.Unlock()
_, err := s.getOrLoad(ctx, providerID) 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 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"`
}
// ============================================================================= // =============================================================================
// Provider load + cache + IdP downgrade defense. // Provider load + cache + IdP downgrade defense.