feat(keycloak): organizations become the membership authority source (#19)
ci / shared (push) Successful in 11s
ci / test (push) Successful in 21m22s
ci / image (push) Successful in 19s

This commit was merged in pull request #19.
This commit is contained in:
2026-09-01 09:03:23 +00:00
parent 4eaee521d4
commit 84516e9b4f
3 changed files with 150 additions and 43 deletions
+92 -21
View File
@@ -9,9 +9,13 @@ import (
"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 {
// stubUsersKC is a users+organizations KC look-alike; stubKC (client_test.go)
// covers the org-create/invite paths and doesn't register these reads.
func stubUsersKC(
t *testing.T,
users map[string]userRepresentation,
memberOrgs map[string][]memberOrgRepresentation,
) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
@@ -28,6 +32,14 @@ func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Se
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
})
mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, r *http.Request) {
orgs, ok := memberOrgs[r.PathValue("id")]
if !ok {
orgs = []memberOrgRepresentation{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(orgs)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
@@ -40,10 +52,11 @@ func usersAdapter(srv *httptest.Server) *HTTPAdapter {
}
func TestHTTPAdapter_Memberships(t *testing.T) {
srv := stubUsersKC(t, map[string]userRepresentation{
users := 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"},
// legacy projection attrs — MUST NOT grant membership on their own
"tenant_id": {"stale-legacy-001"},
"tenant_slug": {"stale"},
"tenant_status": {"active"},
"plan": {"Scale"},
"org_roles": {"IT_ADMIN", "FINANCE"},
@@ -51,10 +64,30 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
"products": {"compliance##certifai"},
}},
"u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true},
})
"u-3": {ID: "u-3", Username: "multi@breakpilot.com", Enabled: true},
"u-4": {ID: "u-4", Username: "attrs-only@breakpilot.com", Enabled: true, Attributes: map[string][]string{
"tenant_id": {"acme-001"},
"tenant_slug": {"acme"},
}},
}
memberOrgs := map[string][]memberOrgRepresentation{
"u-1": {{
ID: "org-1", Alias: "acme", Enabled: true,
Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}},
}},
"u-3": {
{ID: "org-1", Alias: "acme", Enabled: true,
Attributes: map[string][]string{"tenant_id": {"2f875d6a-1d94-433a-b2ec-8529451a2d89"}}},
{ID: "org-2", Alias: "globex", Enabled: true,
Attributes: map[string][]string{"tenant_id": {"7c3f2b10-0000-4000-8000-000000000042"}}},
{ID: "org-3", Alias: "disabled-co", Enabled: false,
Attributes: map[string][]string{"tenant_id": {"9e9e9e9e-0000-4000-8000-000000000099"}}},
},
}
srv := stubUsersKC(t, users, memberOrgs)
a := usersAdapter(srv)
t.Run("attribute projection becomes one membership", func(t *testing.T) {
t.Run("org membership becomes the claim, org attrs are authoritative", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-1")
if err != nil {
t.Fatalf("memberships: %v", err)
@@ -63,8 +96,14 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
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)
// tenant identity comes from the ORG (alias + tenant_id attribute),
// never from the user's legacy projection attributes
if c.TenantID != "2f875d6a-1d94-433a-b2ec-8529451a2d89" || c.TenantSlug != "acme" {
t.Errorf("org identity wrong: %+v", c)
}
// per-user claim attrs still ride along
if c.Plan != "Scale" || c.TenantStatus != "active" {
t.Errorf("user claim attrs 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)
@@ -74,7 +113,7 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
}
})
t.Run("user without tenant attributes has zero memberships", func(t *testing.T) {
t.Run("user in no org has zero memberships", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-2")
if err != nil {
t.Fatalf("memberships: %v", err)
@@ -84,6 +123,29 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
}
})
t.Run("legacy tenant attributes alone grant NO membership", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-4")
if err != nil {
t.Fatalf("memberships: %v", err)
}
if len(got) != 0 {
t.Fatalf("attribute projection must not grant membership, got %+v", got)
}
})
t.Run("multiple orgs give multiple memberships, disabled org skipped", func(t *testing.T) {
got, err := a.Memberships(context.Background(), "u-3")
if err != nil {
t.Fatalf("memberships: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 memberships (disabled org skipped), got %d", len(got))
}
if got[0].TenantSlug != "acme" || got[1].TenantSlug != "globex" {
t.Errorf("slugs wrong: %+v", got)
}
})
t.Run("unknown user is ErrUserNotFound", func(t *testing.T) {
_, err := a.Memberships(context.Background(), "nope")
if !errors.Is(err, ErrUserNotFound) {
@@ -92,15 +154,24 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
})
}
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)
func TestHTTPAdapter_Memberships_OrgQueryFailure(t *testing.T) {
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": "t", "expires_in": 60})
})
mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(userRepresentation{ID: "u-1", Enabled: true})
})
mux.HandleFunc("GET /admin/realms/test-realm/organizations/members/{id}/organizations", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
_, err := usersAdapter(srv).Memberships(context.Background(), "u-1")
if err == nil {
t.Fatal("want error when the org query fails, got nil")
}
}