From 4a49c630d4f6aa651d276498f42a6ca621ca0b76 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:09:53 +0200 Subject: [PATCH] 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 --- cmd/server/main.go | 17 ++- go.mod | 3 + go.sum | 6 + internal/authn/authn.go | 88 +++++++++++++++ internal/authn/authn_test.go | 189 ++++++++++++++++++++++++++++++++ internal/config/config.go | 10 ++ internal/keycloak/adapter.go | 9 ++ internal/keycloak/mock.go | 18 +++ internal/keycloak/users.go | 74 +++++++++++++ internal/keycloak/users_test.go | 106 ++++++++++++++++++ internal/server/server.go | 49 ++++++++- internal/server/users.go | 104 ++++++++++++++++++ internal/server/users_test.go | 74 +++++++++++++ openapi.yaml | 57 +++++++++- 14 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 internal/authn/authn.go create mode 100644 internal/authn/authn_test.go create mode 100644 internal/keycloak/users.go create mode 100644 internal/keycloak/users_test.go create mode 100644 internal/server/users.go create mode 100644 internal/server/users_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 58bb386..a029b1f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "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/server" @@ -59,7 +60,21 @@ func main() { kc = keycloak.NewMock() } - handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc}) + var av *authn.Verifier + if cfg.AuthEnabled { + av, err = authn.New(bootCtx, cfg.KeycloakIssuer, cfg.AuthAudience) + if err != nil { + // Fail closed: never start an "authenticated" server that + // cannot actually verify tokens. + slog.Error("AUTH_CONFIG_INCOMPLETE — AUTH_ENABLED=true but verifier init failed", "err", err) + os.Exit(1) + } + slog.Info("api auth enabled", "issuer", cfg.KeycloakIssuer, "audience", cfg.AuthAudience) + } else { + slog.Warn("AUTH_ENABLED=false — API is unauthenticated (dev only)") + } + + handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc, Auth: av}) srv := &http.Server{ Addr: cfg.Addr, Handler: handler, diff --git a/go.mod b/go.mod index a93ae1c..a45193d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module gitea.meghsakha.com/platform/tenant-registry go 1.25.0 require ( + github.com/coreos/go-oidc/v3 v3.20.0 github.com/getkin/kin-openapi v0.138.0 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 @@ -75,6 +77,7 @@ require ( go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/go.sum b/go.sum index 9e757e9..94e49c1 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -43,6 +45,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -179,6 +183,8 @@ go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/authn/authn.go b/internal/authn/authn.go new file mode 100644 index 0000000..f6a7c1e --- /dev/null +++ b/internal/authn/authn.go @@ -0,0 +1,88 @@ +// Package authn validates Keycloak-issued bearer tokens for the +// tenant-registry API (RBAC rollout Phase 1; fail-closed per the ratified +// compliance auth design, model B2). +// +// The package only verifies — issuer, signature via JWKS, expiry, and +// audience. It deliberately does not authorize: tenant-registry IS the +// membership authority, so its callers are services (INTERNAL_SERVICE_ONLY +// posture) holding client_credentials tokens whose audience includes +// AUTH_EXPECTED_AUDIENCE. +package authn + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// ErrNoToken means the Authorization header was absent or not a Bearer +// scheme. Handlers map it to 401 TOKEN_MISSING. +var ErrNoToken = errors.New("authorization header missing or not Bearer") + +// Principal is the verified caller identity, placed in the request context +// so handlers (and, later, audit writes) can attribute actions. +type Principal struct { + Subject string // JWT sub — the Keycloak user or service-account id + ClientID string // JWT azp — which OAuth client obtained the token + Issuer string +} + +type ctxKey struct{} + +// WithPrincipal returns ctx carrying p. +func WithPrincipal(ctx context.Context, p *Principal) context.Context { + return context.WithValue(ctx, ctxKey{}, p) +} + +// PrincipalFrom extracts the verified principal, if any. +func PrincipalFrom(ctx context.Context) (*Principal, bool) { + p, ok := ctx.Value(ctxKey{}).(*Principal) + return p, ok +} + +// Verifier checks bearer tokens against one issuer + audience. A nil +// *Verifier means auth is disabled (AUTH_ENABLED=false) and the server +// passes requests through unauthenticated. +type Verifier struct { + issuer string + verifier *oidc.IDTokenVerifier +} + +// New performs OIDC discovery against issuer and prepares JWKS-backed +// verification. Callers must treat an error as fatal when AUTH_ENABLED=true +// (AUTH_CONFIG_INCOMPLETE — refuse to start, never fall open). +func New(ctx context.Context, issuer, audience string) (*Verifier, error) { + if issuer == "" || audience == "" { + return nil, errors.New("issuer and audience are required") + } + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("oidc discovery for %s: %w", issuer, err) + } + return &Verifier{ + issuer: issuer, + verifier: provider.Verifier(&oidc.Config{ClientID: audience}), + }, nil +} + +// Verify checks the raw Authorization header value and returns the caller +// principal. Signature, issuer, expiry, and audience are all enforced by +// the underlying oidc verifier. +func (v *Verifier) Verify(ctx context.Context, authorization string) (*Principal, error) { + raw, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || strings.TrimSpace(raw) == "" { + return nil, ErrNoToken + } + tok, err := v.verifier.Verify(ctx, strings.TrimSpace(raw)) + if err != nil { + return nil, err + } + var claims struct { + Azp string `json:"azp"` + } + _ = tok.Claims(&claims) // azp is informational; absence is not an error + return &Principal{Subject: tok.Subject, ClientID: claims.Azp, Issuer: tok.Issuer}, nil +} diff --git a/internal/authn/authn_test.go b/internal/authn/authn_test.go new file mode 100644 index 0000000..6236025 --- /dev/null +++ b/internal/authn/authn_test.go @@ -0,0 +1,189 @@ +package authn_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v4" + + "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/server" + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +// stubIssuer is a minimal OIDC issuer: discovery + JWKS + an RS256 signer. +type stubIssuer struct { + URL string + key *rsa.PrivateKey + sign func(t *testing.T, claims map[string]any) string +} + +func newStubIssuer(t *testing.T) *stubIssuer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + s := &stubIssuer{key: key} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": s.URL, + "jwks_uri": s.URL + "/jwks", + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ + {Key: key.Public(), KeyID: "test-kid", Algorithm: "RS256", Use: "sig"}, + }}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + s.URL = srv.URL + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: key}, + (&jose.SignerOptions{}).WithHeader("kid", "test-kid"), + ) + if err != nil { + t.Fatal(err) + } + s.sign = func(t *testing.T, claims map[string]any) string { + t.Helper() + payload, _ := json.Marshal(claims) + jws, err := signer.Sign(payload) + if err != nil { + t.Fatal(err) + } + raw, err := jws.CompactSerialize() + if err != nil { + t.Fatal(err) + } + return raw + } + return s +} + +func (s *stubIssuer) claims(overrides map[string]any) map[string]any { + c := map[string]any{ + "iss": s.URL, + "aud": "tenant-registry", + "sub": "svc-account-1", + "azp": "compliance-svc", + "exp": time.Now().Add(5 * time.Minute).Unix(), + "iat": time.Now().Unix(), + } + for k, v := range overrides { + c[k] = v + } + return c +} + +func TestVerifier(t *testing.T) { + iss := newStubIssuer(t) + v, err := authn.New(context.Background(), iss.URL, "tenant-registry") + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := context.Background() + + t.Run("valid token yields principal", func(t *testing.T) { + p, err := v.Verify(ctx, "Bearer "+iss.sign(t, iss.claims(nil))) + if err != nil { + t.Fatalf("verify: %v", err) + } + if p.Subject != "svc-account-1" || p.ClientID != "compliance-svc" || p.Issuer != iss.URL { + t.Errorf("principal wrong: %+v", p) + } + }) + + fail := func(name, header string) { + t.Run(name, func(t *testing.T) { + if _, err := v.Verify(ctx, header); err == nil { + t.Fatal("expected verification failure") + } + }) + } + fail("missing header", "") + fail("not bearer", "Basic abc") + fail("garbage token", "Bearer not.a.jwt") + fail("expired", "Bearer "+iss.sign(t, iss.claims(map[string]any{"exp": time.Now().Add(-time.Minute).Unix()}))) + fail("wrong audience", "Bearer "+iss.sign(t, iss.claims(map[string]any{"aud": "someone-else"}))) + fail("wrong issuer", "Bearer "+iss.sign(t, iss.claims(map[string]any{"iss": "https://evil.example"}))) + + t.Run("missing header is ErrNoToken", func(t *testing.T) { + if _, err := v.Verify(ctx, ""); err != authn.ErrNoToken { + t.Fatalf("want ErrNoToken, got %v", err) + } + }) +} + +func TestNew_failsClosed(t *testing.T) { + if _, err := authn.New(context.Background(), "", "aud"); err == nil { + t.Fatal("empty issuer must error") + } + if _, err := authn.New(context.Background(), "http://127.0.0.1:1/realms/none", "aud"); err == nil { + t.Fatal("unreachable issuer must error") + } +} + +// TestRouterGating proves the wiring: health stays PUBLIC_EXPLICIT, every +// API route fails closed without a token, and a valid service token passes. +func TestRouterGating(t *testing.T) { + iss := newStubIssuer(t) + v, err := authn.New(context.Background(), iss.URL, "tenant-registry") + if err != nil { + t.Fatalf("New: %v", err) + } + handler := server.NewRouter(&server.Server{ + Cfg: &config.Config{Env: "dev"}, + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + Store: store.NewMemory(), + Keycloak: keycloak.NewMock(), + Auth: v, + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + get := func(t *testing.T, path, authz string) (int, string) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil) + if authz != "" { + req.Header.Set("Authorization", authz) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(raw) + } + + if code, _ := get(t, "/healthz", ""); code != http.StatusOK { + t.Errorf("healthz must stay public, got %d", code) + } + if code, body := get(t, "/v1/catalog", ""); code != http.StatusUnauthorized || !strings.Contains(body, "TOKEN_MISSING") { + t.Errorf("no token: want 401 TOKEN_MISSING, got %d %s", code, body) + } + if code, body := get(t, "/v1/catalog", "Bearer junk"); code != http.StatusUnauthorized || !strings.Contains(body, "TOKEN_INVALID") { + t.Errorf("bad token: want 401 TOKEN_INVALID, got %d %s", code, body) + } + if code, _ := get(t, "/v1/catalog", "Bearer "+iss.sign(t, iss.claims(nil))); code != http.StatusOK { + t.Errorf("valid token: want 200, got %d", code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0e05e2a..e8c69f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,13 @@ type Config struct { KeycloakClientID string KeycloakClientSecret string KeycloakTimeout time.Duration + + // Inbound API auth (RBAC Phase 1). With AuthEnabled the server refuses + // to start unless OIDC discovery against KeycloakIssuer succeeds, and + // every non-health route requires a bearer token whose audience + // contains AuthAudience. + AuthEnabled bool + AuthAudience string } func Load() (*Config, error) { @@ -39,6 +46,9 @@ func Load() (*Config, error) { KeycloakClientID: os.Getenv("KEYCLOAK_CLIENT_ID"), KeycloakClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"), KeycloakTimeout: 10 * time.Second, + + AuthEnabled: getenv("AUTH_ENABLED", "false") == "true", + AuthAudience: getenv("AUTH_EXPECTED_AUDIENCE", "tenant-registry"), }, nil } diff --git a/internal/keycloak/adapter.go b/internal/keycloak/adapter.go index afb6fbd..756f8c2 100644 --- a/internal/keycloak/adapter.go +++ b/internal/keycloak/adapter.go @@ -25,6 +25,7 @@ var ( ErrOrgConflict = errors.New("keycloak: organization already exists") ErrUserConflict = errors.New("keycloak: user already exists") ErrUnavailable = errors.New("keycloak: unreachable") + ErrUserNotFound = errors.New("keycloak: user does not exist") ) // InviteInput captures the per-tenant onboarding event from POST /v1/tenants. @@ -73,6 +74,14 @@ type Adapter interface { // flows, M14.x cancel, M12.x trial transitions). SyncClaims(ctx context.Context, userID string, c Claims) error + // Memberships resolves the tenants user userID (the Keycloak user id, + // i.e. the JWT `sub`) belongs to, as Keycloak records them. The realm + // has no Organizations yet, so this reads the user's attribute + // projection — zero or one memberships. When the realm migrates to + // Organizations this becomes an org-membership query and callers keep + // working unchanged. Returns ErrUserNotFound for an unknown user id. + Memberships(ctx context.Context, userID string) ([]Claims, error) + // Health pings the admin endpoint. Used by readyz and the cluster cold- // start sequence (INFRASTRUCTURE.md §10 scenario F). Health(ctx context.Context) error diff --git a/internal/keycloak/mock.go b/internal/keycloak/mock.go index 50e8709..db0ff31 100644 --- a/internal/keycloak/mock.go +++ b/internal/keycloak/mock.go @@ -52,6 +52,24 @@ func (m *Mock) CreateOrgAndInvite(_ context.Context, in InviteInput) (*InviteRes }, nil } +// Memberships returns the last-synced Claims for userID as its single +// membership, mirroring the attribute-projection behavior of the real +// adapter. Unknown users yield ErrUserNotFound. +func (m *Mock) Memberships(_ context.Context, userID string) ([]Claims, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.FailNext != nil { + err := m.FailNext + m.FailNext = nil + return nil, err + } + c, ok := m.Claims[userID] + if !ok { + return nil, ErrUserNotFound + } + return []Claims{c}, nil +} + func (m *Mock) SyncClaims(_ context.Context, userID string, c Claims) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/keycloak/users.go b/internal/keycloak/users.go new file mode 100644 index 0000000..23184b8 --- /dev/null +++ b/internal/keycloak/users.go @@ -0,0 +1,74 @@ +package keycloak + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// userRepresentation is the slice of the Admin API's UserRepresentation we +// need. Attributes arrive as map[name][]values; the KC admin console writes +// multivalued attributes either as separate list entries or as one entry +// joined with "##", so both shapes must be accepted. +type userRepresentation struct { + ID string `json:"id"` + Username string `json:"username"` + Enabled bool `json:"enabled"` + Attributes map[string][]string `json:"attributes"` +} + +// Memberships implements Adapter against GET /admin/realms/{realm}/users/{id}. +func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) { + var u userRepresentation + resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + _ = resp.Body.Close() + return nil, ErrUserNotFound + } + if resp.StatusCode/100 != 2 { + _ = resp.Body.Close() + return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode) + } + return claimsFromAttributes(u.Attributes), nil +} + +// claimsFromAttributes builds the membership list from a user's attribute +// projection. A user with no tenant_id and no tenant_slug attribute simply +// has no memberships — that is a valid state, not an error. +func claimsFromAttributes(attrs map[string][]string) []Claims { + c := Claims{ + TenantID: attrValue(attrs, "tenant_id"), + TenantSlug: attrValue(attrs, "tenant_slug"), + OrgRoles: attrValues(attrs, "org_roles"), + Products: attrValues(attrs, "products"), + Plan: attrValue(attrs, "plan"), + TenantStatus: attrValue(attrs, "tenant_status"), + } + if c.TenantID == "" && c.TenantSlug == "" { + return []Claims{} + } + return []Claims{c} +} + +func attrValue(attrs map[string][]string, key string) string { + if vs := attrValues(attrs, key); len(vs) > 0 { + return vs[0] + } + return "" +} + +func attrValues(attrs map[string][]string, key string) []string { + out := []string{} + for _, entry := range attrs[key] { + for _, v := range strings.Split(entry, "##") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + } + return out +} diff --git a/internal/keycloak/users_test.go b/internal/keycloak/users_test.go new file mode 100644 index 0000000..9921ac1 --- /dev/null +++ b/internal/keycloak/users_test.go @@ -0,0 +1,106 @@ +package keycloak + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// stubUsersKC is a users-endpoint-only KC look-alike; stubKC (client_test.go) +// covers the org/invite paths and doesn't register GET /users/{id}. +func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "test-token", "expires_in": 60}) + }) + mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, r *http.Request) { + u, ok := users[r.PathValue("id")] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"User not found"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(u) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func usersAdapter(srv *httptest.Server) *HTTPAdapter { + return NewHTTPAdapter(HTTPConfig{ + BaseURL: srv.URL, Realm: "test-realm", ClientID: "svc", ClientSecret: "secret", + }) +} + +func TestHTTPAdapter_Memberships(t *testing.T) { + srv := stubUsersKC(t, map[string]userRepresentation{ + "u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{ + "tenant_id": {"acme-001"}, + "tenant_slug": {"acme"}, + "tenant_status": {"active"}, + "plan": {"Scale"}, + "org_roles": {"IT_ADMIN", "FINANCE"}, + // the KC admin console writes multivalued attrs "##"-joined + "products": {"compliance##certifai"}, + }}, + "u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true}, + }) + a := usersAdapter(srv) + + t.Run("attribute projection becomes one membership", func(t *testing.T) { + got, err := a.Memberships(context.Background(), "u-1") + if err != nil { + t.Fatalf("memberships: %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 membership, got %d", len(got)) + } + c := got[0] + if c.TenantID != "acme-001" || c.TenantSlug != "acme" || c.Plan != "Scale" || c.TenantStatus != "active" { + t.Errorf("scalar claims wrong: %+v", c) + } + if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" { + t.Errorf("org_roles wrong: %v", c.OrgRoles) + } + if len(c.Products) != 2 || c.Products[0] != "compliance" || c.Products[1] != "certifai" { + t.Errorf("## split failed: %v", c.Products) + } + }) + + t.Run("user without tenant attributes has zero memberships", func(t *testing.T) { + got, err := a.Memberships(context.Background(), "u-2") + if err != nil { + t.Fatalf("memberships: %v", err) + } + if len(got) != 0 { + t.Fatalf("want 0 memberships, got %+v", got) + } + }) + + t.Run("unknown user is ErrUserNotFound", func(t *testing.T) { + _, err := a.Memberships(context.Background(), "nope") + if !errors.Is(err, ErrUserNotFound) { + t.Fatalf("want ErrUserNotFound, got %v", err) + } + }) +} + +func TestMock_Memberships(t *testing.T) { + m := NewMock() + if _, err := m.Memberships(context.Background(), "ghost"); !errors.Is(err, ErrUserNotFound) { + t.Fatalf("want ErrUserNotFound, got %v", err) + } + want := Claims{TenantSlug: "acme", OrgRoles: []string{"IT_ADMIN"}} + m.Claims["u-1"] = want + got, err := m.Memberships(context.Background(), "u-1") + if err != nil || len(got) != 1 || got[0].TenantSlug != "acme" { + t.Fatalf("got %+v err %v", got, err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b141e8c..ab2923c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) { diff --git a/internal/server/users.go b/internal/server/users.go new file mode 100644 index 0000000..f264d42 --- /dev/null +++ b/internal/server/users.go @@ -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", + } +} diff --git a/internal/server/users_test.go b/internal/server/users_test.go new file mode 100644 index 0000000..7acaf0b --- /dev/null +++ b/internal/server/users_test.go @@ -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) + } + }) +} diff --git a/openapi.yaml b/openapi.yaml index 88318ee..1d9d3b4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8,7 +8,10 @@ info: `PLATFORM_ARCHITECTURE.md §5c` for the schema, and `PRODUCT_INTEGRATION_SPEC.md §8.4` for the audit shape. - This API is not yet authenticated — M4.3 adds Keycloak JWT validation. + Auth (RBAC Phase 1): with AUTH_ENABLED=true every route except + /healthz and /readyz requires a Keycloak-issued bearer token whose + audience contains AUTH_EXPECTED_AUDIENCE (INTERNAL_SERVICE_ONLY + posture). With AUTH_ENABLED=false (dev default) the API is open. contact: email: oncall@breakpilot.com license: @@ -250,6 +253,49 @@ paths: description: Revoked. "404": { $ref: "#/components/responses/NotFound" } + /v1/users/{id}/memberships: + get: + summary: Resolve which tenants a user belongs to (membership authority). + description: | + The B2 membership-authority endpoint (ratified compliance auth + design). Product backends call this with the JWT `sub` instead of + trusting token claims or client-supplied headers. Keycloak supplies + the user→tenant links and org_roles; where the tenant is registered + here, tenant_status / plan / products are overridden from registry + tables (source: "registry"), otherwise the Keycloak attribute + projection is returned as-is (source: "keycloak"). + parameters: + - name: id + in: path + required: true + description: Keycloak user id (the JWT `sub`). + schema: { type: string } + responses: + "200": + description: Memberships (possibly empty) for the user. + content: + application/json: + schema: + type: object + required: [user_id, memberships] + properties: + user_id: { type: string } + memberships: + type: array + items: + allOf: + - $ref: "#/components/schemas/Claims" + - type: object + required: [source] + properties: + source: { type: string, enum: [registry, keycloak] } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "503": + description: Keycloak unreachable — membership cannot be resolved. + content: + application/json: { schema: { $ref: "#/components/schemas/Error" } } + /v1/internal/keycloak/claims: post: summary: Resolve the up-to-date claim bundle for a user/tenant. @@ -356,6 +402,15 @@ paths: "400": { $ref: "#/components/responses/BadRequest" } components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + Keycloak-issued token (client_credentials for services). Enforced + on all non-health routes when AUTH_ENABLED=true. + responses: BadRequest: description: Input failed validation.