feat(keycloak): organizations become the membership authority source (#19)
This commit was merged in pull request #19.
This commit is contained in:
+52
-17
@@ -18,7 +18,33 @@ type userRepresentation struct {
|
||||
Attributes map[string][]string `json:"attributes"`
|
||||
}
|
||||
|
||||
// Memberships implements Adapter against GET /admin/realms/{realm}/users/{id}.
|
||||
// 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)
|
||||
@@ -33,25 +59,34 @@ func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims,
|
||||
_ = 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"),
|
||||
var orgs []memberOrgRepresentation
|
||||
resp, err = a.adminCall(
|
||||
ctx, http.MethodGet, "/organizations/members/"+userID+"/organizations", nil, &orgs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.TenantID == "" && c.TenantSlug == "" {
|
||||
return []Claims{}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("keycloak member organizations: %d", resp.StatusCode)
|
||||
}
|
||||
return []Claims{c}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user