bb2c638fb4
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
259 lines
8.2 KiB
Go
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:], " ")
|
|
}
|