// Package keycloak adapts the Keycloak Admin API to the tenant-registry's // language of "tenants" and "IT_ADMIN invites". // // The Adapter interface is the seam: tenant-registry handlers depend on // it, never on the concrete HTTP client. Tests use Mock; production uses // HTTPAdapter against the real KC at the configured base URL. // // Required Keycloak features (verified against KC 26): // - Organizations feature enabled in the realm (organizationsEnabled: true) // - Realm roles: BREAKPILOT_ADMIN, SUPPORT_ENGINEER, SALES_REP // - Group `/IT_ADMIN` (used as the org_role marker for invited users) // // All errors are wrapped with %w so callers can errors.Is them against // ErrUnauthorized / ErrOrgConflict / ErrUserConflict. package keycloak import ( "context" "errors" ) // Sentinel errors. var ( ErrUnauthorized = errors.New("keycloak: admin auth failed") ErrOrgConflict = errors.New("keycloak: organization already exists") ErrUserConflict = errors.New("keycloak: user already exists") ErrUnavailable = errors.New("keycloak: unreachable") ) // InviteInput captures the per-tenant onboarding event from POST /v1/tenants. // The adapter creates a Keycloak organization, invites the IT_ADMIN, and // stores the (TenantID, OrganizationID) link back in the caller's Tenant. type InviteInput struct { TenantID string // the tenant_registry id; stored as KC org attribute "tenant_id" Slug string // becomes the KC org alias Name string // human-readable org name AdminEmail string // IT_ADMIN to invite (required) AdminName string // optional display name } // InviteResult is what the adapter produces. OrganizationID is what the // tenant-registry stores so it can later assert tenants.id ↔ kc.org_id 1:1. type InviteResult struct { OrganizationID string UserID string // InviteURL is what the user clicks to set their password. In dev (no // Stalwart yet) we surface it in the response so testers can use it // directly. In prod it's emailed by Keycloak and we discard it. InviteURL string } // Claims is the tenant-scoped claim bundle the protocol-mapper would push // into a JWT at token issuance. Returned by Adapter.ClaimsFor so the user- // attributes can be refreshed on subscription change. type Claims struct { TenantID string `json:"tenant_id"` TenantSlug string `json:"tenant_slug"` OrgRoles []string `json:"org_roles"` Products []string `json:"products"` Plan string `json:"plan"` TenantStatus string `json:"tenant_status"` } // Adapter is the shape tenant-registry handlers code against. HTTPAdapter // is the real one; Mock satisfies the same surface for tests. type Adapter interface { // CreateOrgAndInvite is the M4.3 happy path. Atomic from the caller's // PoV: either both org+user land or neither does. CreateOrgAndInvite(ctx context.Context, in InviteInput) (*InviteResult, error) // SyncClaims pushes the current Claims into the user's Keycloak // attributes. Called whenever entitlements change (M4.2 catalog/trial // flows, M14.x cancel, M12.x trial transitions). SyncClaims(ctx context.Context, userID string, c Claims) error // Health pings the admin endpoint. Used by readyz and the cluster cold- // start sequence (INFRASTRUCTURE.md §10 scenario F). Health(ctx context.Context) error }