package keycloak import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "path" "strings" ) // ─── organizations API ─────────────────────────────────────────────────── 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), 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:], " ") }