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"` } // memberOrgRepresentation is the slice of OrganizationRepresentation the // membership query needs: the alias IS the tenant slug and the org // attribute "tenant_id" carries the registry tenant UUID (both written by // CreateOrgAndInvite, or provisioned by the realm admin for pre-existing // tenants). type memberOrgRepresentation struct { ID string `json:"id"` Alias string `json:"alias"` Enabled bool `json:"enabled"` Attributes map[string][]string `json:"attributes"` } // Memberships implements Adapter with Keycloak Organizations as the // authoritative membership source (prod shape, realm orgs enabled // 2026-09-01): // // 1. GET /users/{id} — preserves ErrUserNotFound semantics and supplies // the per-user claim attributes (org_roles / products / plan / // tenant_status) that SyncClaims maintains. // 2. GET /organizations/members/{id}/organizations — the memberships. // // One Claims entry per ENABLED organization: TenantID = org attribute // "tenant_id" (registry UUID), TenantSlug = org alias. A disabled org // grants no membership. A user in no organization has zero memberships — // the legacy user-attribute tenant projection is NO LONGER consulted for // membership, so a stale tenant_id/tenant_slug user attribute cannot // grant access the org model has revoked. 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) } var orgs []memberOrgRepresentation resp, err = a.adminCall( ctx, http.MethodGet, "/organizations/members/"+userID+"/organizations", nil, &orgs, ) if err != nil { return nil, err } if resp.StatusCode/100 != 2 { _ = resp.Body.Close() return nil, fmt.Errorf("keycloak member organizations: %d", resp.StatusCode) } claims := []Claims{} for _, org := range orgs { if !org.Enabled { continue } claims = append(claims, Claims{ TenantID: attrValue(org.Attributes, "tenant_id"), TenantSlug: org.Alias, OrgRoles: attrValues(u.Attributes, "org_roles"), Products: attrValues(u.Attributes, "products"), Plan: attrValue(u.Attributes, "plan"), TenantStatus: attrValue(u.Attributes, "tenant_status"), }) } return claims, nil } 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 }