feat(auth): membership authority endpoint + fail-closed API auth (RBAC Phase 1)
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
31cb06cf3d
commit
4a49c630d4
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// membershipItem is one tenant a user belongs to. Source records which
|
||||
// system produced the authoritative half: "registry" when the tenant exists
|
||||
// here (status/plan/products come from our tables), "keycloak" when only
|
||||
// the attribute projection knows it (e.g. a tenant seeded directly in the
|
||||
// realm that the registry has not onboarded yet).
|
||||
type membershipItem struct {
|
||||
keycloak.Claims
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type membershipsResp struct {
|
||||
UserID string `json:"user_id"`
|
||||
Memberships []membershipItem `json:"memberships"`
|
||||
}
|
||||
|
||||
// getUserMemberships is GET /v1/users/{id}/memberships — the membership
|
||||
// authority endpoint (ratified auth design, model B2). Product backends
|
||||
// call it to answer "which tenants does this JWT subject belong to, with
|
||||
// which roles", instead of trusting token claims or client headers.
|
||||
//
|
||||
// Resolution: Keycloak (via the Adapter) supplies user→tenant links and
|
||||
// org_roles; where the tenant is registered here, status, plan, and
|
||||
// product entitlements are overridden from the registry tables, which are
|
||||
// authoritative for lifecycle and billing state.
|
||||
func (s *Server) getUserMemberships(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("id")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "user id required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := s.Keycloak.Memberships(ctx, userID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, keycloak.ErrUserNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "user does not exist")
|
||||
case errors.Is(err, keycloak.ErrUnavailable), errors.Is(err, keycloak.ErrUnauthorized):
|
||||
writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]membershipItem, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
items = append(items, s.enrichMembership(ctx, c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, membershipsResp{UserID: userID, Memberships: items})
|
||||
}
|
||||
|
||||
// enrichMembership overrides the Keycloak attribute projection with
|
||||
// registry truth when the tenant is known here. Lookup prefers the slug —
|
||||
// the attribute tenant_id predates the registry for hand-seeded dev users
|
||||
// and may not be a registry id.
|
||||
func (s *Server) enrichMembership(ctx context.Context, c keycloak.Claims) membershipItem {
|
||||
var (
|
||||
t *store.Tenant
|
||||
err error
|
||||
)
|
||||
if c.TenantSlug != "" {
|
||||
t, err = s.Store.GetTenantBySlug(ctx, c.TenantSlug)
|
||||
} else {
|
||||
t, err = s.Store.GetTenant(ctx, c.TenantID)
|
||||
}
|
||||
if err != nil || t == nil {
|
||||
return membershipItem{Claims: c, Source: "keycloak"}
|
||||
}
|
||||
|
||||
products := []string{}
|
||||
if tps, perr := s.Store.ListTenantProducts(ctx, t.ID); perr == nil {
|
||||
for _, p := range tps {
|
||||
if p.Enabled {
|
||||
products = append(products, p.Product)
|
||||
}
|
||||
}
|
||||
}
|
||||
return membershipItem{
|
||||
Claims: keycloak.Claims{
|
||||
TenantID: t.ID,
|
||||
TenantSlug: t.Slug,
|
||||
OrgRoles: c.OrgRoles, // roles stay Keycloak-owned
|
||||
Products: products,
|
||||
Plan: t.Plan,
|
||||
TenantStatus: t.Status,
|
||||
},
|
||||
Source: "registry",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
||||
)
|
||||
|
||||
type membershipsBody struct {
|
||||
UserID string `json:"user_id"`
|
||||
Memberships []struct {
|
||||
keycloak.Claims
|
||||
Source string `json:"source"`
|
||||
} `json:"memberships"`
|
||||
}
|
||||
|
||||
func TestGetUserMemberships(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
// The KC attribute projection carries a stale plan/status and a
|
||||
// legacy tenant_id — the registry row must win (source: registry).
|
||||
h.kcMock.Claims["u-1"] = keycloak.Claims{
|
||||
TenantID: "kc-legacy-id", TenantSlug: "acme",
|
||||
OrgRoles: []string{"IT_ADMIN"}, Plan: "stale-plan", TenantStatus: "stale",
|
||||
}
|
||||
resp, raw := h.do(http.MethodGet, "/v1/users/u-1/memberships", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, raw)
|
||||
}
|
||||
body := decode[membershipsBody](t, raw)
|
||||
if body.UserID != "u-1" || len(body.Memberships) != 1 {
|
||||
t.Fatalf("unexpected body: %s", raw)
|
||||
}
|
||||
m := body.Memberships[0]
|
||||
if m.Source != "registry" {
|
||||
t.Errorf("want source registry, got %q", m.Source)
|
||||
}
|
||||
if m.TenantID != h.tenant.ID || m.TenantSlug != "acme" {
|
||||
t.Errorf("registry identity not authoritative: %+v", m.Claims)
|
||||
}
|
||||
if m.Plan != h.tenant.Plan || m.TenantStatus != h.tenant.Status {
|
||||
t.Errorf("registry lifecycle not authoritative: plan=%q status=%q", m.Plan, m.TenantStatus)
|
||||
}
|
||||
if len(m.OrgRoles) != 1 || m.OrgRoles[0] != "IT_ADMIN" {
|
||||
t.Errorf("org_roles must stay keycloak-owned: %v", m.OrgRoles)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserMemberships_unknownTenantFallsBackToKeycloak(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
h.kcMock.Claims["u-2"] = keycloak.Claims{
|
||||
TenantID: "ghost-001", TenantSlug: "ghost",
|
||||
OrgRoles: []string{"USER"}, Plan: "Scale", TenantStatus: "active",
|
||||
}
|
||||
resp, raw := h.do(http.MethodGet, "/v1/users/u-2/memberships", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, raw)
|
||||
}
|
||||
m := decode[membershipsBody](t, raw).Memberships[0]
|
||||
if m.Source != "keycloak" || m.TenantSlug != "ghost" || m.Plan != "Scale" {
|
||||
t.Errorf("expected untouched keycloak projection, got %+v (source %q)", m.Claims, m.Source)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserMemberships_unknownUser404(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do(http.MethodGet, "/v1/users/nobody/memberships", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user