Files
tenant-registry/internal/keycloak/orgs.go
T
sharang bb2c638fb4
ci / test (pull_request) Failing after 1m31s
ci / image (pull_request) Has been skipped
ci / shared (pull_request) Successful in 5s
feat(keycloak): M4.3 — Admin API adapter + claim resolver
internal/keycloak/ — Adapter interface with two implementations:
  HTTPAdapter  pgxpool-style real Admin API client with cached client-
               credentials token (auto-refresh, 401 retry).
  Mock         in-process map for unit tests + dev convenience when
               KEYCLOAK_ADMIN_URL is empty. Used by the eachStore harness.

Adapter contract (adapter.go):
  CreateOrgAndInvite(ctx, InviteInput) (*InviteResult, error)
    Creates a KC organization, an IT_ADMIN user, adds the user as a
    member, triggers VERIFY_EMAIL + UPDATE_PASSWORD execute-actions
    email. Atomic from the caller's PoV; partial failures surface as
    typed errors (ErrOrgConflict, ErrUserConflict, ErrUnauthorized,
    ErrUnavailable).
  SyncClaims(ctx, userID, Claims) error
    Pushes tenant_id / tenant_slug / org_roles / products / plan /
    tenant_status into the user's KC attributes — the same shape the
    realm's protocol mappers project into JWTs.
  Health(ctx) error
    Pings /admin/serverinfo; wired into readyz.

Wiring:
  POST /v1/tenants now accepts admin_email + admin_name. When set, the
  adapter creates the org and invites the user. Response wraps the
  tenant with the new TenantCreated{tenant, invite_url} shape so dev
  testers can use the action-token URL without waiting for the email.
  KC failures DO NOT roll the tenant back — they emit a
  keycloak.provision_failed audit event so the operator can resend.
  Successful invites emit keycloak.invite_sent.

  POST /v1/internal/keycloak/claims resolves a tenant's current claim
  bundle. Lookup chain: body.tenant_id → body.tenant_slug →
  body.user_attrs.tenant_id → body.user_attrs.tenant_slug. The realm's
  protocol mapper calls this at token issuance, or operators on demand.

Config: KEYCLOAK_ADMIN_URL / REALM / CLIENT_ID / CLIENT_SECRET; empty
URL falls back to Mock for dev.

OpenAPI: TenantCreated + Claims schemas added; /v1/internal/keycloak/claims
documented. Contract test extended to cover the new endpoint.

Tests:
  internal/keycloak/mock_test.go    Mock semantics: conflict surfacing,
                                    FailNext hook, SyncClaims persistence.
  internal/server/keycloak_test.go  KC provisioning end-to-end via
                                    eachStore: invite_url returned,
                                    mock records, invite_sent audit;
                                    failure path emits provision_failed
                                    but tenant still lands; claims
                                    endpoint resolves via tenant_id /
                                    tenant_slug / user_attrs / 404 / 400.

The real-KC integration test (against a testcontainers-spun KC 26)
lands in a follow-up — gating it behind KEYCLOAK_INTEGRATION=1 + a
slower nightly CI is cleaner than baking 30s+ of KC boot into every PR.

Refs: M4.3
2026-05-19 13:27:16 +02: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:], " ")
}