mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:01:30 +00:00
d85114ffb8
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
246 lines
8.6 KiB
Go
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.
|