diff --git a/internal/keycloak/client_test.go b/internal/keycloak/client_test.go index 08ad32d..8452ace 100644 --- a/internal/keycloak/client_test.go +++ b/internal/keycloak/client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -24,6 +25,7 @@ type stubKC struct { emailCalls atomic.Int32 healthCalls atomic.Int32 syncCalls atomic.Int32 + lastOrgBody string 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) { s.orgCalls.Add(1) 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.WriteHeader(http.StatusCreated) return @@ -135,6 +139,12 @@ func TestHTTPAdapter_createOrgAndInvite(t *testing.T) { t.Errorf("call counts: org=%d user=%d member=%d email=%d", 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) { diff --git a/internal/keycloak/orgs.go b/internal/keycloak/orgs.go index d4fb4cd..9fc0f8b 100644 --- a/internal/keycloak/orgs.go +++ b/internal/keycloak/orgs.go @@ -13,6 +13,11 @@ import ( // ─── organizations API ─────────────────────────────────────────────────── +// orgDomainSuffix namespaces the synthetic org domain. The slug is unique in +// the registry, so ".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 { Name string `json:"name"` Alias string `json:"alias"` @@ -50,6 +55,16 @@ func (a *HTTPAdapter) CreateOrgAndInvite(ctx context.Context, in InviteInput) (* Name: in.Name, Alias: in.Slug, 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{ "tenant_id": {in.TenantID}, },