The realm enabled Keycloak Organizations (prod shape): Memberships now
queries GET /organizations/members/{id}/organizations and emits one
claim per enabled org — alias = tenant slug, org attribute tenant_id =
registry UUID (both already written by CreateOrgAndInvite). Per-user
claim attributes (org_roles/products/plan/tenant_status) still ride
along from the user record, and ErrUserNotFound semantics are kept.
Deliberate behavior change, pinned by test: the legacy user-attribute
tenant projection no longer grants membership on its own, so a stale
tenant_id attribute cannot resurrect access an org removal revoked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
178 lines
6.3 KiB
Go
178 lines
6.3 KiB
Go
package keycloak
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// 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) {
|
|
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)
|
|
})
|
|
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
|
|
}
|
|
|
|
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) {
|
|
users := map[string]userRepresentation{
|
|
"u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{
|
|
// 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"},
|
|
// the KC admin console writes multivalued attrs "##"-joined
|
|
"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("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)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 membership, got %d", len(got))
|
|
}
|
|
c := got[0]
|
|
// 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)
|
|
}
|
|
if len(c.Products) != 2 || c.Products[0] != "compliance" || c.Products[1] != "certifai" {
|
|
t.Errorf("## split failed: %v", c.Products)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("want 0 memberships, got %+v", got)
|
|
}
|
|
})
|
|
|
|
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) {
|
|
t.Fatalf("want ErrUserNotFound, got %v", 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")
|
|
}
|
|
}
|