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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user