Author SHA1 Message Date
Sharang ParnerkarandClaude Fable 5 67264d57ed fix(keycloak): org create must send a domain (KC 26 rejects domainless)
ci / shared (pull_request) Successful in 11s
ci / test (pull_request) Successful in 21m24s
ci / image (pull_request) Skipped
Onboarding provisioning failed at CreateOrgAndInvite with
create org: 400 "You must provide at least one domain" — Keycloak 26
requires every organization to carry at least one domain, and the
payload sent none. Membership is registry-authoritative (model B2), so
we do not use Keycloak email-domain auto-join; a synthetic per-tenant
domain <slug>.tenant.breakpilot.com satisfies the constraint, is unique
(slug is unique in the registry), and never clashes with a real
deliverable mail domain. Discovered live: with the service account now
holding manage-realm the 403 is gone and this is the next real blocker
to onboarding every customer's org on KC 26.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
2026-09-01 11:16:19 +02:00
sharang 84516e9b4f 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
2026-09-01 09:03:23 +00:00
sharang 4eaee521d4 fix(build): builder image Go 1.25, go.mod requires >= 1.25.0 (#18)
ci / shared (push) Successful in 14s
ci / test (push) Successful in 21m24s
ci / image (push) Successful in 27s
2026-08-31 17:18:10 +00:00
sharang 03b479ab6f fix(ci): image job needs a docker CLI, build in docker:27-cli (#17)
ci / shared (push) Successful in 12s
ci / test (push) Successful in 21m18s
ci / image (push) Failing after 6s
2026-08-31 15:51:36 +00:00
6 changed files with 176 additions and 44 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
# /tenant-registry — long-running API server # /tenant-registry — long-running API server
# /migrate — one-shot schema migrator (Orca init container in prod) # /migrate — one-shot schema migrator (Orca init container in prod)
FROM golang:1.24-alpine AS build FROM golang:1.25-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
+6 -5
View File
@@ -75,11 +75,12 @@ type Adapter interface {
SyncClaims(ctx context.Context, userID string, c Claims) error SyncClaims(ctx context.Context, userID string, c Claims) error
// Memberships resolves the tenants user userID (the Keycloak user id, // Memberships resolves the tenants user userID (the Keycloak user id,
// i.e. the JWT `sub`) belongs to, as Keycloak records them. The realm // i.e. the JWT `sub`) belongs to, as Keycloak records them. Since the
// has no Organizations yet, so this reads the user's attribute // realm enabled Organizations (2026-09-01) this is an org-membership
// projection — zero or one memberships. When the realm migrates to // query: one membership per enabled org the user belongs to (alias =
// Organizations this becomes an org-membership query and callers keep // tenant slug, org attribute tenant_id = registry UUID). The legacy
// working unchanged. Returns ErrUserNotFound for an unknown user id. // user-attribute projection no longer grants membership on its own.
// Returns ErrUserNotFound for an unknown user id.
Memberships(ctx context.Context, userID string) ([]Claims, error) Memberships(ctx context.Context, userID string) ([]Claims, error)
// Health pings the admin endpoint. Used by readyz and the cluster cold- // Health pings the admin endpoint. Used by readyz and the cluster cold-
+10
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -24,6 +25,7 @@ type stubKC struct {
emailCalls atomic.Int32 emailCalls atomic.Int32
healthCalls atomic.Int32 healthCalls atomic.Int32
syncCalls atomic.Int32 syncCalls atomic.Int32
lastOrgBody string
tokenFails atomic.Bool // when true, /token returns 401 once tokenFails atomic.Bool // when true, /token returns 401 once
} }
@@ -53,6 +55,8 @@ func newStubKC(t *testing.T) *stubKC {
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
s.orgCalls.Add(1) s.orgCalls.Add(1)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
body, _ := io.ReadAll(r.Body)
s.lastOrgBody = string(body)
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz") w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
return return
@@ -135,6 +139,12 @@ func TestHTTPAdapter_createOrgAndInvite(t *testing.T) {
t.Errorf("call counts: org=%d user=%d member=%d email=%d", t.Errorf("call counts: org=%d user=%d member=%d email=%d",
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load()) s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
} }
// KC 26 rejects a domainless org; the adapter must send a synthetic
// per-slug domain so onboarding actually provisions.
if !strings.Contains(s.lastOrgBody, `"domains"`) ||
!strings.Contains(s.lastOrgBody, "acme.tenant.breakpilot.com") {
t.Errorf("org create body missing synthetic domain: %s", s.lastOrgBody)
}
} }
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) { func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
+15
View File
@@ -13,6 +13,11 @@ import (
// ─── organizations API ─────────────────────────────────────────────────── // ─── organizations API ───────────────────────────────────────────────────
// orgDomainSuffix namespaces the synthetic org domain. The slug is unique in
// the registry, so "<slug>.tenant.breakpilot.com" is unique per organization
// and never a real deliverable mail domain we might clash with.
const orgDomainSuffix = ".tenant.breakpilot.com"
type orgCreate struct { type orgCreate struct {
Name string `json:"name"` Name string `json:"name"`
Alias string `json:"alias"` Alias string `json:"alias"`
@@ -50,6 +55,16 @@ func (a *HTTPAdapter) CreateOrgAndInvite(ctx context.Context, in InviteInput) (*
Name: in.Name, Name: in.Name,
Alias: in.Slug, Alias: in.Slug,
Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID), Description: fmt.Sprintf("Auto-provisioned from tenant-registry %s", in.TenantID),
// Keycloak 26 rejects an organization with no domain ("You must
// provide at least one domain"). Membership is registry-authoritative
// (model B2), so we do NOT use Keycloak's email-domain auto-join; a
// synthetic per-tenant domain derived from the unique slug satisfies
// the constraint without depending on the customer's real mail domain
// (which may be a shared public domain and would collide across
// tenants). Unverified is fine — verification only gates auto-join.
Domains: []map[string]any{
{"name": in.Slug + orgDomainSuffix, "verified": false},
},
Attributes: map[string][]string{ Attributes: map[string][]string{
"tenant_id": {in.TenantID}, "tenant_id": {in.TenantID},
}, },
+51 -16
View File
@@ -18,7 +18,33 @@ type userRepresentation struct {
Attributes map[string][]string `json:"attributes"` 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) { func (a *HTTPAdapter) Memberships(ctx context.Context, userID string) ([]Claims, error) {
var u userRepresentation var u userRepresentation
resp, err := a.adminCall(ctx, http.MethodGet, "/users/"+userID, nil, &u) 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() _ = resp.Body.Close()
return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode) return nil, fmt.Errorf("keycloak get user: %d", resp.StatusCode)
} }
return claimsFromAttributes(u.Attributes), nil
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)
} }
// claimsFromAttributes builds the membership list from a user's attribute claims := []Claims{}
// projection. A user with no tenant_id and no tenant_slug attribute simply for _, org := range orgs {
// has no memberships — that is a valid state, not an error. if !org.Enabled {
func claimsFromAttributes(attrs map[string][]string) []Claims { continue
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 == "" { claims = append(claims, Claims{
return []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{c} return claims, nil
} }
func attrValue(attrs map[string][]string, key string) string { func attrValue(attrs map[string][]string, key string) string {
+92 -21
View File
@@ -9,9 +9,13 @@ import (
"testing" "testing"
) )
// stubUsersKC is a users-endpoint-only KC look-alike; stubKC (client_test.go) // stubUsersKC is a users+organizations KC look-alike; stubKC (client_test.go)
// covers the org/invite paths and doesn't register GET /users/{id}. // covers the org-create/invite paths and doesn't register these reads.
func stubUsersKC(t *testing.T, users map[string]userRepresentation) *httptest.Server { func stubUsersKC(
t *testing.T,
users map[string]userRepresentation,
memberOrgs map[string][]memberOrgRepresentation,
) *httptest.Server {
t.Helper() t.Helper()
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) { 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") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u) _ = 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
return srv return srv
@@ -40,10 +52,11 @@ func usersAdapter(srv *httptest.Server) *HTTPAdapter {
} }
func TestHTTPAdapter_Memberships(t *testing.T) { 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{ "u-1": {ID: "u-1", Username: "test@breakpilot.com", Enabled: true, Attributes: map[string][]string{
"tenant_id": {"acme-001"}, // legacy projection attrs — MUST NOT grant membership on their own
"tenant_slug": {"acme"}, "tenant_id": {"stale-legacy-001"},
"tenant_slug": {"stale"},
"tenant_status": {"active"}, "tenant_status": {"active"},
"plan": {"Scale"}, "plan": {"Scale"},
"org_roles": {"IT_ADMIN", "FINANCE"}, "org_roles": {"IT_ADMIN", "FINANCE"},
@@ -51,10 +64,30 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
"products": {"compliance##certifai"}, "products": {"compliance##certifai"},
}}, }},
"u-2": {ID: "u-2", Username: "bare@breakpilot.com", Enabled: true}, "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) 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") got, err := a.Memberships(context.Background(), "u-1")
if err != nil { if err != nil {
t.Fatalf("memberships: %v", err) t.Fatalf("memberships: %v", err)
@@ -63,8 +96,14 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
t.Fatalf("want 1 membership, got %d", len(got)) t.Fatalf("want 1 membership, got %d", len(got))
} }
c := got[0] c := got[0]
if c.TenantID != "acme-001" || c.TenantSlug != "acme" || c.Plan != "Scale" || c.TenantStatus != "active" { // tenant identity comes from the ORG (alias + tenant_id attribute),
t.Errorf("scalar claims wrong: %+v", c) // 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" { if len(c.OrgRoles) != 2 || c.OrgRoles[0] != "IT_ADMIN" || c.OrgRoles[1] != "FINANCE" {
t.Errorf("org_roles wrong: %v", c.OrgRoles) 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") got, err := a.Memberships(context.Background(), "u-2")
if err != nil { if err != nil {
t.Fatalf("memberships: %v", err) 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) { t.Run("unknown user is ErrUserNotFound", func(t *testing.T) {
_, err := a.Memberships(context.Background(), "nope") _, err := a.Memberships(context.Background(), "nope")
if !errors.Is(err, ErrUserNotFound) { if !errors.Is(err, ErrUserNotFound) {
@@ -92,15 +154,24 @@ func TestHTTPAdapter_Memberships(t *testing.T) {
}) })
} }
func TestMock_Memberships(t *testing.T) { func TestHTTPAdapter_Memberships_OrgQueryFailure(t *testing.T) {
m := NewMock() mux := http.NewServeMux()
if _, err := m.Memberships(context.Background(), "ghost"); !errors.Is(err, ErrUserNotFound) { mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, _ *http.Request) {
t.Fatalf("want ErrUserNotFound, got %v", err) w.Header().Set("Content-Type", "application/json")
} _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "t", "expires_in": 60})
want := Claims{TenantSlug: "acme", OrgRoles: []string{"IT_ADMIN"}} })
m.Claims["u-1"] = want mux.HandleFunc("GET /admin/realms/test-realm/users/{id}", func(w http.ResponseWriter, _ *http.Request) {
got, err := m.Memberships(context.Background(), "u-1") w.Header().Set("Content-Type", "application/json")
if err != nil || len(got) != 1 || got[0].TenantSlug != "acme" { _ = json.NewEncoder(w).Encode(userRepresentation{ID: "u-1", Enabled: true})
t.Fatalf("got %+v err %v", got, err) })
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")
} }
} }