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
@@ -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