GET /v1/users/{id}/memberships answers 'which tenants does this JWT
subject belong to, with which org_roles and entitlements' (ratified
auth design, model B2). Keycloak supplies user->tenant links and roles
via the Adapter (attribute projection until the realm migrates to
Organizations); registered tenants override status/plan/products from
registry tables.
New internal/authn verifies Keycloak bearer tokens (OIDC discovery +
JWKS, audience AUTH_EXPECTED_AUDIENCE, default tenant-registry). With
AUTH_ENABLED=true all routes except /healthz + /readyz require a token
and the server refuses to start if the verifier cannot initialize;
false (dev default) keeps the API open. Completes the M5.2-deferred
org_roles lookup and replaces the 'M4.3 adds JWT validation' TODO in
the spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
4.5 KiB
Go
120 lines
4.5 KiB
Go
// Package server wires the HTTP surface for tenant-registry.
|
|
//
|
|
// All routes are registered in NewRouter; per-concern handlers live in
|
|
// peer files (tenants.go, catalog.go, apikeys.go, audit.go, keycloak.go).
|
|
package server
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/authn"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
|
)
|
|
|
|
// Server bundles the dependencies every handler needs.
|
|
type Server struct {
|
|
Cfg *config.Config
|
|
Log *slog.Logger
|
|
Store store.Store
|
|
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
|
Auth *authn.Verifier // nil ⇒ AUTH_ENABLED=false, API is open (dev only)
|
|
}
|
|
|
|
// NewRouter builds the http.Handler with logging middleware applied.
|
|
//
|
|
// Route auth classes (ratified auth design): /healthz and /readyz are
|
|
// PUBLIC_EXPLICIT (orca probes, no auth by design); every other route is
|
|
// INTERNAL_SERVICE_ONLY and sits behind requireAuth. New routes land in
|
|
// the protected mux by construction — registering one on the root mux is
|
|
// the exception and needs a PUBLIC_EXPLICIT justification comment.
|
|
func NewRouter(s *Server) http.Handler {
|
|
root := http.NewServeMux()
|
|
|
|
// PUBLIC_EXPLICIT: health + status probes.
|
|
root.HandleFunc("GET /healthz", s.healthz)
|
|
root.HandleFunc("GET /readyz", s.readyz)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// tenants
|
|
mux.HandleFunc("POST /v1/tenants", s.createTenant)
|
|
mux.HandleFunc("GET /v1/tenants/{id}", s.getTenant)
|
|
mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", s.getTenantBySlug)
|
|
mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant)
|
|
mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant)
|
|
|
|
// entitlements
|
|
mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts)
|
|
|
|
// catalog
|
|
mux.HandleFunc("GET /v1/catalog", s.getCatalog)
|
|
mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest)
|
|
mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest)
|
|
|
|
// api keys
|
|
mux.HandleFunc("POST /v1/api-keys", s.createAPIKey)
|
|
mux.HandleFunc("GET /v1/api-keys", s.listAPIKeys)
|
|
mux.HandleFunc("DELETE /v1/api-keys/{id}", s.revokeAPIKey)
|
|
mux.HandleFunc("POST /v1/internal/api-keys/verify", s.verifyAPIKey)
|
|
|
|
// audit
|
|
mux.HandleFunc("POST /v1/audit", s.appendAudit)
|
|
mux.HandleFunc("GET /v1/audit", s.listAudit)
|
|
|
|
// keycloak claims refresh — the URL the protocol mapper would call at
|
|
// token issuance to grab the up-to-date entitlement bundle. Today the
|
|
// dev realm projects user attributes (set by SyncClaims) — this is
|
|
// the "pull" complement for when the realm is reconfigured to fetch.
|
|
mux.HandleFunc("POST /v1/internal/keycloak/claims", s.kcClaims)
|
|
|
|
// memberships — the B2 membership authority: which tenants does a
|
|
// JWT subject belong to, with which org_roles and entitlements.
|
|
mux.HandleFunc("GET /v1/users/{id}/memberships", s.getUserMemberships)
|
|
|
|
root.Handle("/", s.requireAuth(mux))
|
|
return logRequest(s.Log)(root)
|
|
}
|
|
|
|
// requireAuth gates the INTERNAL_SERVICE_ONLY routes. With Auth nil
|
|
// (AUTH_ENABLED=false) it passes through — the startup log carries the
|
|
// warning, and the Auth-5 activation flips the env, not the code.
|
|
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
if s.Auth == nil {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p, err := s.Auth.Verify(r.Context(), r.Header.Get("Authorization"))
|
|
if err != nil {
|
|
if errors.Is(err, authn.ErrNoToken) {
|
|
writeError(w, http.StatusUnauthorized, "TOKEN_MISSING", "bearer token required")
|
|
return
|
|
}
|
|
// Signature, issuer, expiry, and audience failures all land
|
|
// here; the message says which without echoing the token.
|
|
writeError(w, http.StatusUnauthorized, "TOKEN_INVALID", err.Error())
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
|
|
})
|
|
}
|
|
|
|
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.Store.Ping(r.Context()); err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
|
|
return
|
|
}
|
|
if err := s.Keycloak.Health(r.Context()); err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
|
}
|