Files
tenant-registry/internal/keycloak/orgs.go
Sharang ParnerkarandClaude Fable 5 67264d57ed
ci / shared (pull_request) Successful in 11s
ci / test (pull_request) Successful in 21m24s
ci / image (pull_request) Skipped
fix(keycloak): org create must send a domain (KC 26 rejects domainless)
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

274 lines
9.1 KiB
Go

package keycloak
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strings"
)
// ─── 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 {
Name string `json:"name"`
Alias string `json:"alias"`
Description string `json:"description,omitempty"`
Domains []map[string]any `json:"domains,omitempty"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
type userCreate struct {
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Enabled bool `json:"enabled"`
EmailVerified bool `json:"emailVerified"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
// CreateOrgAndInvite creates the organization, creates the IT_ADMIN user,
// adds them as org member, and triggers the verify-email-and-set-password
// flow (Keycloak's native "invite via email" path).
//
// Best-effort atomicity: on partial failure we leave KC in whatever state
// it's in and surface the error. A follow-up reconciler (M4.x or M14.x)
// can heal divergence. For local dev where everything either succeeds or
// the test surfaces the exact failure, this is fine.
func (a *HTTPAdapter) CreateOrgAndInvite(ctx context.Context, in InviteInput) (*InviteResult, error) {
if in.AdminEmail == "" {
return nil, fmt.Errorf("keycloak: admin email required")
}
// 1. Create org with tenant_id baked in as an attribute so we can
// correlate the two systems with a single Admin API call later.
orgPayload := orgCreate{
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},
},
}
resp, err := a.adminCall(ctx, http.MethodPost, "/organizations", orgPayload, nil)
if err != nil {
return nil, err
}
switch resp.StatusCode {
case http.StatusCreated:
// keep going
case http.StatusConflict:
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: alias=%s", ErrOrgConflict, in.Slug)
default:
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("create org: %d %s", resp.StatusCode, body)
}
// Keycloak returns the id in the Location header.
orgID := lastSegment(resp.Header.Get("Location"))
_ = resp.Body.Close()
if orgID == "" {
// Fallback: query by alias.
orgID, err = a.findOrgByAlias(ctx, in.Slug)
if err != nil {
return nil, fmt.Errorf("create org: missing Location and lookup failed: %w", err)
}
}
// 2. Create the user (disabled until they set a password).
first, last := splitName(in.AdminName)
userPayload := userCreate{
Username: in.AdminEmail,
Email: in.AdminEmail,
FirstName: first,
LastName: last,
Enabled: true,
EmailVerified: false,
Attributes: map[string][]string{
"tenant_id": {in.TenantID},
"tenant_slug": {in.Slug},
"org_roles": {"IT_ADMIN"},
"tenant_status": {"trial"},
},
}
resp, err = a.adminCall(ctx, http.MethodPost, "/users", userPayload, nil)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
switch resp.StatusCode {
case http.StatusCreated:
// keep going
case http.StatusConflict:
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: email=%s", ErrUserConflict, in.AdminEmail)
default:
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("create user: %d %s", resp.StatusCode, body)
}
userID := lastSegment(resp.Header.Get("Location"))
_ = resp.Body.Close()
if userID == "" {
userID, err = a.findUserByEmail(ctx, in.AdminEmail)
if err != nil {
return nil, fmt.Errorf("create user: missing Location and lookup failed: %w", err)
}
}
// 3. Add user to organization (member).
addBody := map[string]string{"id": userID}
resp, err = a.adminCall(ctx, http.MethodPost,
fmt.Sprintf("/organizations/%s/members", orgID), addBody, nil)
if err != nil {
return nil, fmt.Errorf("add member: %w", err)
}
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("add member: %d %s", resp.StatusCode, body)
}
_ = resp.Body.Close()
// 4. Trigger the verify-email + set-password execute-actions email.
// In dev (no Stalwart) we also surface the action-token URL to
// the caller so they can hit it directly.
inviteURL, err := a.executeActionsEmail(ctx, userID,
[]string{"VERIFY_EMAIL", "UPDATE_PASSWORD"},
"https://breakpilot.com/onboard")
if err != nil {
// Non-fatal — admin can resend from the KC UI.
return &InviteResult{OrganizationID: orgID, UserID: userID, InviteURL: ""}, nil
}
return &InviteResult{OrganizationID: orgID, UserID: userID, InviteURL: inviteURL}, nil
}
// SyncClaims pushes the up-to-date claim bundle into the user's KC
// attributes. Called by tenant-registry whenever entitlements change.
func (a *HTTPAdapter) SyncClaims(ctx context.Context, userID string, c Claims) error {
attrs := map[string][]string{
"tenant_id": {c.TenantID},
"tenant_slug": {c.TenantSlug},
"org_roles": c.OrgRoles,
"products": c.Products,
"plan": {c.Plan},
"tenant_status": {c.TenantStatus},
}
resp, err := a.adminCall(ctx, http.MethodPut, "/users/"+userID,
map[string]any{"attributes": attrs}, nil)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("sync claims: %d %s", resp.StatusCode, body)
}
return nil
}
// ─── helpers ─────────────────────────────────────────────────────────────
func (a *HTTPAdapter) findOrgByAlias(ctx context.Context, alias string) (string, error) {
resp, err := a.adminCall(ctx, http.MethodGet,
fmt.Sprintf("/organizations?search=%s&exact=true", alias), nil, nil)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("find org: %d", resp.StatusCode)
}
var orgs []struct {
ID string `json:"id"`
Alias string `json:"alias"`
}
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
return "", err
}
for _, o := range orgs {
if o.Alias == alias {
return o.ID, nil
}
}
return "", errors.New("org not found")
}
func (a *HTTPAdapter) findUserByEmail(ctx context.Context, email string) (string, error) {
resp, err := a.adminCall(ctx, http.MethodGet,
fmt.Sprintf("/users?email=%s&exact=true", email), nil, nil)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
var users []struct {
ID string `json:"id"`
Email string `json:"email"`
}
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
return "", err
}
for _, u := range users {
if strings.EqualFold(u.Email, email) {
return u.ID, nil
}
}
return "", errors.New("user not found")
}
func (a *HTTPAdapter) executeActionsEmail(ctx context.Context, userID string, actions []string, redirectURI string) (string, error) {
resp, err := a.adminCall(ctx, http.MethodPut,
fmt.Sprintf("/users/%s/execute-actions-email?client_id=dev-portal&redirect_uri=%s", userID, redirectURI),
actions, nil)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("execute-actions: %d", resp.StatusCode)
}
// KC doesn't return the action-token URL via this endpoint — it sends
// the email. For dev we surface an admin-portal pointer so the tester
// has somewhere to land.
return fmt.Sprintf("%s/realms/%s/account", a.cfg.BaseURL, a.cfg.Realm), nil
}
func lastSegment(loc string) string {
if loc == "" {
return ""
}
return path.Base(loc)
}
func splitName(full string) (first, last string) {
full = strings.TrimSpace(full)
if full == "" {
return "", ""
}
parts := strings.Fields(full)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}