RBAC Phase 1: membership authority endpoint + fail-closed API auth (#14)
Implements the model-B2 membership authority (ratified compliance auth design) and inbound token verification.
- GET /v1/users/{id}/memberships: Keycloak supplies user->tenant links + org_roles (attribute projection until the realm migrates to Organizations); registered tenants override status/plan/products from registry tables (source: registry|keycloak). Closes the M5.2-deferred org_roles stub.
- New internal/authn: OIDC discovery + JWKS verification (go-oidc/v3), audience tenant-registry. /healthz + /readyz stay PUBLIC_EXPLICIT; all other routes INTERNAL_SERVICE_ONLY. AUTH_ENABLED=true refuses to start on incomplete config (fail-closed); false (dev default) keeps current behavior.
- Full suite green incl. postgres testcontainers; openapi.yaml updated (contract test passes).
Activation is a separate step (Auth-5): requires the orca-infra env merge and a portal service token (portal currently calls with no auth).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com>
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -5,9 +5,11 @@
|
||||
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"
|
||||
@@ -19,15 +21,24 @@ type Server struct {
|
||||
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 {
|
||||
mux := http.NewServeMux()
|
||||
root := http.NewServeMux()
|
||||
|
||||
// health + status
|
||||
mux.HandleFunc("GET /healthz", s.healthz)
|
||||
mux.HandleFunc("GET /readyz", s.readyz)
|
||||
// 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)
|
||||
@@ -60,7 +71,35 @@ func NewRouter(s *Server) http.Handler {
|
||||
// the "pull" complement for when the realm is reconfigured to fetch.
|
||||
mux.HandleFunc("POST /v1/internal/keycloak/claims", s.kcClaims)
|
||||
|
||||
return logRequest(s.Log)(mux)
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user