Files
certctl/internal/api/handler/auth_users.go
T
shankar0123 172b30b8f1 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
2026-05-11 00:11:07 +00:00

246 lines
8.6 KiB
Go

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.