Files
tenant-registry/internal/server/server.go
Sharang ParnerkarandClaude Fable 5 576d733eae
ci / shared (pull_request) Successful in 14s
ci / test (pull_request) Failing after 16m9s
ci / image (pull_request) Skipped
feat(tenants): provision the product tenant with the registry UUID on create
Closes the tenant-anchor divergence that blocked the Auth-5 sdk/backend flips.

The registry is the authority for tenant identity (model B2), but nothing ever
told the product about a new tenant. The anchors drifted: the registry held
acme/matrix-acme while the product database held only the legacy seed tenant
9282a473. With the SDK gate enabled its TenantResolver does GetTenantBySlug and
would 403 EVERY authenticated request, because no real user's org slug existed
locally.

New internal/product port, mirroring keycloak.Adapter: handlers depend on the
interface, main wires HTTPProvisioner when PRODUCT_API_URL is set and
NoopProvisioner otherwise, so an unconfigured deployment still creates tenants.
Tenant creation now also provisions the product tenant with OUR uuid, using the
same best-effort contract as Keycloak provisioning: a failure does not roll the
tenant back, it emits a product.provision_failed audit event so the divergence
is traceable. Success emits product.tenant_provisioned.

A 409 from the product counts as success — onboarding may retry and the
product's insert is idempotent on the primary key (compliance#217).

Server.productProvisioner() guarantees the documented never-nil invariant;
tests construct Server directly and would otherwise panic mid-tenant-creation.

5 tests incl. the point of the whole port (the registry UUID is what gets sent).
Full suite green with -race, coverage 71.4% (gate 70).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
2026-09-01 23:16:33 +02:00

132 lines
5.0 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/product"
"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)
Product product.Provisioner // never nil — main wires Noop when PRODUCT_API_URL is unset
}
// productProvisioner guarantees the "never nil" invariant the struct documents.
// Tests construct Server directly and would otherwise panic; an unset provisioner
// simply means no downstream provisioning, never a crash mid-tenant-creation.
func (s *Server) productProvisioner() product.Provisioner {
if s.Product == nil {
return product.NoopProvisioner{}
}
return s.Product
}
// 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"})
}