Files
tenant-registry/internal/keycloak/orgs.go
T
sharang 9138731eea
ci / shared (push) Successful in 5s
ci / test (push) Successful in 1m32s
ci / image (push) Has been skipped
feat(keycloak): M4.3 — Admin API adapter + claim resolver
internal/keycloak Adapter (HTTPAdapter + Mock). POST /v1/tenants now provisions a KC organization + IT_ADMIN invite when admin_email is set; KC failures emit keycloak.provision_failed but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for any (tenant_id|tenant_slug|user_attrs.*) lookup. Mock used in tests + when KEYCLOAK_ADMIN_URL is empty. HTTPAdapter tested against an in-process stub KC (httptest.Server).

Refs: M4.3
2026-05-19 11:51:09 +00:00

259 lines
8.2 KiB
Go

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:], " ")
}