From bb2c638fb401898587d4ace6528f642b466cf1e1 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 13:24:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(keycloak):=20M4.3=20=E2=80=94=20Admin=20AP?= =?UTF-8?q?I=20adapter=20+=20claim=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 8 + CHANGELOG.md | 1 + README.md | 45 +++++- cmd/server/main.go | 19 ++- internal/config/config.go | 16 ++ internal/keycloak/adapter.go | 79 ++++++++++ internal/keycloak/client.go | 165 ++++++++++++++++++++ internal/keycloak/mock.go | 68 ++++++++ internal/keycloak/mock_test.go | 84 ++++++++++ internal/keycloak/orgs.go | 258 +++++++++++++++++++++++++++++++ internal/server/audit_test.go | 5 +- internal/server/catalog_test.go | 5 +- internal/server/keycloak.go | 115 ++++++++++++++ internal/server/keycloak_test.go | 147 ++++++++++++++++++ internal/server/openapi_test.go | 1 + internal/server/server.go | 27 ++-- internal/server/server_test.go | 9 +- internal/server/tenants.go | 34 +++- internal/server/tenants_test.go | 14 +- openapi.yaml | 60 ++++++- 20 files changed, 1134 insertions(+), 26 deletions(-) create mode 100644 internal/keycloak/adapter.go create mode 100644 internal/keycloak/client.go create mode 100644 internal/keycloak/mock.go create mode 100644 internal/keycloak/mock_test.go create mode 100644 internal/keycloak/orgs.go create mode 100644 internal/server/keycloak.go create mode 100644 internal/server/keycloak_test.go diff --git a/.env.example b/.env.example index c0d11d5..b4589b9 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,11 @@ KEYCLOAK_ISSUER=http://localhost:8080/realms/breakpilot-dev # only; data lost on restart). Set to use the dev-stack Postgres + run # `make migrate-up` first. # DATABASE_URL=postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable + +# Keycloak Admin API — when these are set, tenant-registry calls the real KC +# Admin API to provision orgs + invite IT_ADMINs on POST /v1/tenants. Leave +# empty to use the in-process Mock adapter (no real KC writes). +# KEYCLOAK_ADMIN_URL=http://localhost:8080 +# KEYCLOAK_REALM=breakpilot-dev +# KEYCLOAK_CLIENT_ID=tenant-registry-admin +# KEYCLOAK_CLIENT_SECRET=...from infisical... diff --git a/CHANGELOG.md b/CHANGELOG.md index afecee7..7d133a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl ## [Unreleased] ### Added +- feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant. - feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test. - feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests - feat(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant diff --git a/README.md b/README.md index ba46c9c..70e7b49 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,16 @@ make build # compile to ./bin/tenant-registry Env vars (override at the shell): -| Var | Default | Purpose | +| Var | Default | Purpose | |---|---|---| -| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` | -| `ADDR` | `:8090` | listen address (avoids Keycloak's :8080) | -| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL | -| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR | +| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` | +| `ADDR` | `:8090` | listen address (avoids Keycloak's :8080) | +| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL (the JWT signer) | +| `DATABASE_URL` | empty (in-memory store fallback) | Postgres DSN; service uses Memory when empty | +| `KEYCLOAK_ADMIN_URL` | empty (Mock adapter used in dev) | KC base URL for the Admin API | +| `KEYCLOAK_REALM` | `breakpilot-dev` | Realm name for Admin API calls | +| `KEYCLOAK_CLIENT_ID` | empty | Service-account client id (Admin) | +| `KEYCLOAK_CLIENT_SECRET` | empty | Service-account client secret | ## Endpoints @@ -76,6 +80,37 @@ The service picks its store based on `DATABASE_URL`: Both implementations pass the same test harness (`internal/server/server_test.go` → `eachStore`). + + +## Keycloak adapter (M4.3) + +`internal/keycloak` is the seam between tenant-registry and Keycloak. The +`Adapter` interface has two implementations: + +| Implementation | When used | +|---|---| +| `Mock` | Default in dev when `KEYCLOAK_ADMIN_URL` is empty | +| `HTTPAdapter` | Real KC Admin API client; activated when KC env vars are populated | + +`POST /v1/tenants` now accepts `admin_email` and `admin_name`. When set, the +adapter creates a Keycloak organization (alias = the tenant slug), invites +the user as the IT_ADMIN, and triggers the verify-email + set-password +flow. The response body includes `invite_url` so dev testers can use it +without waiting for the email — production discards it. + +**KC failures are non-fatal.** The tenant row still lands; a +`keycloak.provision_failed` audit event captures the error so the operator +can resend the invite from the KC UI. + +`POST /v1/internal/keycloak/claims` resolves a tenant's current entitlement +bundle (tenant_id, slug, products, plan, status). The realm's protocol +mapper calls this at token-issuance time (or whenever user attributes +need a refresh). + +For production, provision a service-account client in the realm with the +`realm-management:manage-users` + `manage-organizations` roles. Drop its +credentials in Infisical at `/{env}/tenant-registry/KEYCLOAK_CLIENT_*`. + ## Schema migrations (M4.1) ```bash diff --git a/cmd/server/main.go b/cmd/server/main.go index 7cc296a..58bb386 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "time" "gitea.meghsakha.com/platform/tenant-registry/internal/config" + "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/server" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) @@ -42,7 +43,23 @@ func main() { } defer s.Close() - handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s}) + var kc keycloak.Adapter + if cfg.KeycloakAdminURL != "" && cfg.KeycloakClientID != "" { + kc = keycloak.NewHTTPAdapter(keycloak.HTTPConfig{ + BaseURL: cfg.KeycloakAdminURL, + Realm: cfg.KeycloakRealm, + ClientID: cfg.KeycloakClientID, + ClientSecret: cfg.KeycloakClientSecret, + Timeout: cfg.KeycloakTimeout, + }) + slog.Info("keycloak adapter configured", + "url", cfg.KeycloakAdminURL, "realm", cfg.KeycloakRealm, "client_id", cfg.KeycloakClientID) + } else { + slog.Warn("KEYCLOAK_ADMIN_URL not set — using mock adapter (dev only; no real KC writes)") + kc = keycloak.NewMock() + } + + handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s, Keycloak: kc}) srv := &http.Server{ Addr: cfg.Addr, Handler: handler, diff --git a/internal/config/config.go b/internal/config/config.go index d9cf47b..0e05e2a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "time" ) type Config struct { @@ -10,6 +11,15 @@ type Config struct { Addr string // listen address, e.g. ":8090" KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev DatabaseURL string // postgres DSN (unused in skeleton; in-memory store) + + // Keycloak Admin API — only used if KeycloakAdminURL is set. Empty + // values disable the adapter and tenant-registry falls back to the + // Mock (dev convenience). + KeycloakAdminURL string + KeycloakRealm string + KeycloakClientID string + KeycloakClientSecret string + KeycloakTimeout time.Duration } func Load() (*Config, error) { @@ -23,6 +33,12 @@ func Load() (*Config, error) { Addr: getenv("ADDR", ":8090"), KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"), DatabaseURL: os.Getenv("DATABASE_URL"), + + KeycloakAdminURL: os.Getenv("KEYCLOAK_ADMIN_URL"), + KeycloakRealm: getenv("KEYCLOAK_REALM", "breakpilot-dev"), + KeycloakClientID: os.Getenv("KEYCLOAK_CLIENT_ID"), + KeycloakClientSecret: os.Getenv("KEYCLOAK_CLIENT_SECRET"), + KeycloakTimeout: 10 * time.Second, }, nil } diff --git a/internal/keycloak/adapter.go b/internal/keycloak/adapter.go new file mode 100644 index 0000000..afb6fbd --- /dev/null +++ b/internal/keycloak/adapter.go @@ -0,0 +1,79 @@ +// 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 +} diff --git a/internal/keycloak/client.go b/internal/keycloak/client.go new file mode 100644 index 0000000..bce1887 --- /dev/null +++ b/internal/keycloak/client.go @@ -0,0 +1,165 @@ +package keycloak + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// HTTPAdapter implements Adapter against the real Keycloak Admin REST API. +// Uses client-credentials grant; an admin-role'd service account on the +// realm should be configured. Token is cached and refreshed before expiry. +type HTTPAdapter struct { + cfg HTTPConfig + hc *http.Client + + // token cache + mu sync.Mutex + tokenStr string + tokenExp time.Time +} + +// HTTPConfig — every value read from env via internal/config. +type HTTPConfig struct { + BaseURL string // e.g. http://localhost:8080 + Realm string // breakpilot-dev | breakpilot-prod + ClientID string // service account client id + ClientSecret string // service account client secret + AdminEmail string // platform admin email — used to gate the BREAKPILOT_ADMIN realm role check + Timeout time.Duration +} + +func NewHTTPAdapter(cfg HTTPConfig) *HTTPAdapter { + if cfg.Timeout == 0 { + cfg.Timeout = 10 * time.Second + } + return &HTTPAdapter{cfg: cfg, hc: &http.Client{Timeout: cfg.Timeout}} +} + +// ─── auth ──────────────────────────────────────────────────────────────── + +func (a *HTTPAdapter) token(ctx context.Context) (string, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.tokenStr != "" && time.Now().Before(a.tokenExp.Add(-30*time.Second)) { + return a.tokenStr, nil + } + + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {a.cfg.ClientID}, + "client_secret": {a.cfg.ClientSecret}, + } + tokenURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", a.cfg.BaseURL, a.cfg.Realm) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := a.hc.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusUnauthorized { + return "", ErrUnauthorized + } + if resp.StatusCode/100 != 2 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("keycloak token: %d %s", resp.StatusCode, body) + } + var tr struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return "", fmt.Errorf("keycloak token decode: %w", err) + } + a.tokenStr = tr.AccessToken + a.tokenExp = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) + return a.tokenStr, nil +} + +// adminCall is the common request shape against /admin/realms/{realm}/... +// On 401/403 it clears the token and tries once more. +func (a *HTTPAdapter) adminCall(ctx context.Context, method, path string, body any, into any) (resp *http.Response, err error) { + tok, err := a.token(ctx) + if err != nil { + return nil, err + } + resp, err = a.doAdmin(ctx, method, path, body, tok) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized { + _ = resp.Body.Close() + a.mu.Lock() + a.tokenStr = "" // force refresh + a.mu.Unlock() + if tok, err = a.token(ctx); err != nil { + return nil, err + } + resp, err = a.doAdmin(ctx, method, path, body, tok) + if err != nil { + return nil, err + } + } + if into != nil && resp.StatusCode/100 == 2 && resp.ContentLength != 0 { + defer func() { _ = resp.Body.Close() }() + if err := json.NewDecoder(resp.Body).Decode(into); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("decode response: %w", err) + } + } + return resp, nil +} + +func (a *HTTPAdapter) doAdmin(ctx context.Context, method, path string, body any, tok string) (*http.Response, error) { + u := fmt.Sprintf("%s/admin/realms/%s%s", a.cfg.BaseURL, a.cfg.Realm, path) + var bodyR io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyR = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, u, bodyR) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + resp, err := a.hc.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return resp, nil +} + +// Health pings /admin/serverinfo (cheap, returns 200 on a working install). +func (a *HTTPAdapter) Health(ctx context.Context) error { + tok, err := a.token(ctx) + if err != nil { + return err + } + u := fmt.Sprintf("%s/admin/serverinfo", a.cfg.BaseURL) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := a.hc.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("keycloak health: %d", resp.StatusCode) + } + return nil +} diff --git a/internal/keycloak/mock.go b/internal/keycloak/mock.go new file mode 100644 index 0000000..50e8709 --- /dev/null +++ b/internal/keycloak/mock.go @@ -0,0 +1,68 @@ +package keycloak + +import ( + "context" + "errors" + "sync" +) + +// Mock is the test-friendly Adapter. Records every call; predictable IDs. +// Use in unit tests + as the default adapter when KEYCLOAK_BASE_URL is empty +// (dev convenience). +type Mock struct { + mu sync.Mutex + Orgs map[string]string // tenantID → orgID + Users map[string]string // email → userID + Claims map[string]Claims // userID → last synced + FailNext error // set to force the next call to fail +} + +func NewMock() *Mock { + return &Mock{ + Orgs: map[string]string{}, + Users: map[string]string{}, + Claims: map[string]Claims{}, + } +} + +func (m *Mock) Health(_ context.Context) error { return nil } + +func (m *Mock) CreateOrgAndInvite(_ context.Context, in InviteInput) (*InviteResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.FailNext != nil { + err := m.FailNext + m.FailNext = nil + return nil, err + } + if _, taken := m.Orgs[in.TenantID]; taken { + return nil, ErrOrgConflict + } + if _, taken := m.Users[in.AdminEmail]; taken { + return nil, ErrUserConflict + } + orgID := "mock-org-" + in.Slug + userID := "mock-user-" + in.AdminEmail + m.Orgs[in.TenantID] = orgID + m.Users[in.AdminEmail] = userID + return &InviteResult{ + OrganizationID: orgID, + UserID: userID, + InviteURL: "http://mock-keycloak/invite/" + userID, + }, nil +} + +func (m *Mock) SyncClaims(_ context.Context, userID string, c Claims) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.FailNext != nil { + err := m.FailNext + m.FailNext = nil + return err + } + if userID == "" { + return errors.New("mock: user_id required") + } + m.Claims[userID] = c + return nil +} diff --git a/internal/keycloak/mock_test.go b/internal/keycloak/mock_test.go new file mode 100644 index 0000000..a572c92 --- /dev/null +++ b/internal/keycloak/mock_test.go @@ -0,0 +1,84 @@ +package keycloak + +import ( + "context" + "errors" + "testing" +) + +func TestMock_createOrgAndInvite(t *testing.T) { + m := NewMock() + ctx := context.Background() + + res, err := m.CreateOrgAndInvite(ctx, InviteInput{ + TenantID: "t1", Slug: "acme", Name: "Acme", + AdminEmail: "a@acme.test", AdminName: "Alice", + }) + if err != nil { + t.Fatal(err) + } + if res.OrganizationID == "" || res.UserID == "" { + t.Errorf("ids missing: %+v", res) + } + if m.Orgs["t1"] != res.OrganizationID { + t.Errorf("Orgs map not updated") + } + if m.Users["a@acme.test"] != res.UserID { + t.Errorf("Users map not updated") + } +} + +func TestMock_orgConflict(t *testing.T) { + m := NewMock() + ctx := context.Background() + _, _ = m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"}) + _, err := m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "b@y.test"}) + if !errors.Is(err, ErrOrgConflict) { + t.Errorf("err = %v, want ErrOrgConflict", err) + } +} + +func TestMock_userConflict(t *testing.T) { + m := NewMock() + ctx := context.Background() + _, _ = m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"}) + _, err := m.CreateOrgAndInvite(ctx, InviteInput{TenantID: "t2", Slug: "z", AdminEmail: "a@y.test"}) + if !errors.Is(err, ErrUserConflict) { + t.Errorf("err = %v, want ErrUserConflict", err) + } +} + +func TestMock_failNextHook(t *testing.T) { + m := NewMock() + m.FailNext = ErrUnavailable + _, err := m.CreateOrgAndInvite(context.Background(), InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"}) + if !errors.Is(err, ErrUnavailable) { + t.Errorf("err = %v, want ErrUnavailable", err) + } + // Subsequent call recovers + _, err = m.CreateOrgAndInvite(context.Background(), InviteInput{TenantID: "t1", Slug: "x", AdminEmail: "a@y.test"}) + if err != nil { + t.Errorf("FailNext should clear after one use; err=%v", err) + } +} + +func TestMock_syncClaims(t *testing.T) { + m := NewMock() + err := m.SyncClaims(context.Background(), "user-1", Claims{ + TenantID: "t1", Plan: "professional", Products: []string{"certifai"}, + }) + if err != nil { + t.Fatal(err) + } + if m.Claims["user-1"].Plan != "professional" { + t.Errorf("claims not stored") + } +} + +func TestMock_syncClaimsRequiresUserID(t *testing.T) { + m := NewMock() + err := m.SyncClaims(context.Background(), "", Claims{}) + if err == nil { + t.Error("expected error for empty user id") + } +} diff --git a/internal/keycloak/orgs.go b/internal/keycloak/orgs.go new file mode 100644 index 0000000..d4fb4cd --- /dev/null +++ b/internal/keycloak/orgs.go @@ -0,0 +1,258 @@ +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:], " ") +} diff --git a/internal/server/audit_test.go b/internal/server/audit_test.go index b3cde80..3208d54 100644 --- a/internal/server/audit_test.go +++ b/internal/server/audit_test.go @@ -97,7 +97,10 @@ func TestAuditAutoEmittedOnTenantCreate(t *testing.T) { _, body := h.do("POST", "/v1/tenants", map[string]any{ "slug": "audit-target", "name": "Audit Target", }) - fresh := decode[store.Tenant](t, body) + freshWrap := decode[struct { + Tenant *store.Tenant `json:"tenant"` + }](t, body) + fresh := freshWrap.Tenant resp, body := h.do("GET", "/v1/audit?action=tenant.created&tenant_id="+fresh.ID, nil) if resp.StatusCode != 200 { diff --git a/internal/server/catalog_test.go b/internal/server/catalog_test.go index f7a98ac..57c96e4 100644 --- a/internal/server/catalog_test.go +++ b/internal/server/catalog_test.go @@ -50,7 +50,10 @@ func TestCatalogTrialRequest(t *testing.T) { _, body := h.do("POST", "/v1/tenants", map[string]any{ "slug": "trial-target", "name": "Trial Target", }) - fresh := decode[store.Tenant](t, body) + freshWrap := decode[struct { + Tenant *store.Tenant `json:"tenant"` + }](t, body) + fresh := freshWrap.Tenant resp, body := h.do("POST", "/v1/catalog/trial-request", map[string]any{ "tenant_id": fresh.ID, "product": "compliance", diff --git a/internal/server/keycloak.go b/internal/server/keycloak.go new file mode 100644 index 0000000..6d24363 --- /dev/null +++ b/internal/server/keycloak.go @@ -0,0 +1,115 @@ +package server + +import ( + "context" + "errors" + "net/http" + "time" + + "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +// provisionKeycloak is called inside createTenant after the DB insert +// succeeds. Best-effort: a failure does NOT roll the tenant back. The +// audit_log captures the error so the operator can heal it later +// (resending the invite is a one-click in the KC admin UI). +// +// Returns the InviteURL so the API response can surface it for dev. +func (s *Server) provisionKeycloak(ctx context.Context, t *store.Tenant, adminEmail, adminName string) (string, error) { + if adminEmail == "" { + // Skip silently — caller chose not to invite anyone yet (sales-led + // flow, demo tenant, test fixture, etc.). + return "", nil + } + res, err := s.Keycloak.CreateOrgAndInvite(ctx, keycloak.InviteInput{ + TenantID: t.ID, + Slug: t.Slug, + Name: t.Name, + AdminEmail: adminEmail, + AdminName: adminName, + }) + if err != nil { + s.Log.Error("keycloak provision failed", + "tenant_id", t.ID, "slug", t.Slug, "err", err) + return "", err + } + s.Log.Info("keycloak provisioned", + "tenant_id", t.ID, "kc_org_id", res.OrganizationID, "kc_user_id", res.UserID) + return res.InviteURL, nil +} + +// kcClaims is POST /v1/internal/keycloak/claims. Called by Keycloak's +// protocol mapper (or by a dev tester) to fetch the current entitlement +// bundle for a user. Lookup chain: +// 1. body.tenant_slug → tenant +// 2. body.tenant_id → tenant +// 3. body.user_attrs.tenant_id → tenant +// +// At least one must be present. +type kcClaimsReq struct { + TenantID string `json:"tenant_id,omitempty"` + TenantSlug string `json:"tenant_slug,omitempty"` + UserAttrs map[string]string `json:"user_attrs,omitempty"` +} + +func (s *Server) kcClaims(w http.ResponseWriter, r *http.Request) { + var in kcClaimsReq + if !decodeJSON(w, r, &in) { + return + } + id := in.TenantID + if id == "" { + id = in.UserAttrs["tenant_id"] + } + slug := in.TenantSlug + if slug == "" { + slug = in.UserAttrs["tenant_slug"] + } + if id == "" && slug == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id or tenant_slug required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + var ( + t *store.Tenant + err error + ) + if id != "" { + t, err = s.Store.GetTenant(ctx, id) + } else { + t, err = s.Store.GetTenantBySlug(ctx, slug) + } + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "tenant does not exist") + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + products, err := s.Store.ListTenantProducts(ctx, t.ID) + if err != nil && !errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + productKeys := make([]string, 0, len(products)) + for _, p := range products { + if p.Enabled { + productKeys = append(productKeys, p.Product) + } + } + + writeJSON(w, http.StatusOK, keycloak.Claims{ + TenantID: t.ID, + TenantSlug: t.Slug, + OrgRoles: []string{}, // populated by /v1/users/:id role lookup — out of scope until M5.2 + Products: productKeys, + Plan: t.Plan, + TenantStatus: t.Status, + }) +} diff --git a/internal/server/keycloak_test.go b/internal/server/keycloak_test.go new file mode 100644 index 0000000..84db846 --- /dev/null +++ b/internal/server/keycloak_test.go @@ -0,0 +1,147 @@ +package server_test + +import ( + "net/http" + "testing" + + "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +func TestCreateTenant_provisionsKeycloak(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, body := h.do("POST", "/v1/tenants", map[string]any{ + "slug": "kc-co", + "name": "KC Co.", + "admin_email": "owner@kc-co.test", + "admin_name": "Pat Owner", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, body=%s", resp.StatusCode, body) + } + out := decode[struct { + Tenant *store.Tenant `json:"tenant"` + InviteURL string `json:"invite_url"` + }](t, body) + if out.Tenant.Slug != "kc-co" { + t.Errorf("slug = %q", out.Tenant.Slug) + } + if out.InviteURL == "" { + t.Error("invite_url missing in response") + } + + // The mock recorded the call. + if _, ok := h.kcMock.Orgs[out.Tenant.ID]; !ok { + t.Errorf("kc mock did not record org for tenant %s", out.Tenant.ID) + } + if _, ok := h.kcMock.Users["owner@kc-co.test"]; !ok { + t.Error("kc mock did not record user for owner@kc-co.test") + } + + // And we emitted a keycloak.invite_sent audit event. + resp, body = h.do("GET", + "/v1/audit?action=keycloak.invite_sent&tenant_id="+out.Tenant.ID, nil) + if resp.StatusCode != 200 { + t.Fatalf("audit list status = %d", resp.StatusCode) + } + listed := decode[struct { + Items []store.AuditEvent `json:"items"` + }](t, body) + if len(listed.Items) != 1 { + t.Errorf("expected 1 invite_sent event, got %d", len(listed.Items)) + } + }) +} + +func TestCreateTenant_kcFailure_doesNotRollback(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + // Force the mock to fail the next call. + h.kcMock.FailNext = keycloak.ErrUnavailable + + resp, body := h.do("POST", "/v1/tenants", map[string]any{ + "slug": "kc-fail", "name": "KC Fail", "admin_email": "x@y.test", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected tenant still created despite kc fail; status=%d body=%s", + resp.StatusCode, body) + } + out := decode[struct { + Tenant *store.Tenant `json:"tenant"` + }](t, body) + // Tenant landed in the DB. + if out.Tenant.ID == "" { + t.Error("tenant id missing") + } + // And there's a provision_failed audit event for it. + _, body = h.do("GET", + "/v1/audit?action=keycloak.provision_failed&tenant_id="+out.Tenant.ID, nil) + listed := decode[struct { + Items []store.AuditEvent `json:"items"` + }](t, body) + if len(listed.Items) != 1 { + t.Errorf("expected 1 provision_failed event, got %d", len(listed.Items)) + } + }) +} + +func TestKcClaims_returnsCurrentEntitlements(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{ + "tenant_slug": h.tenant.Slug, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, body) + } + got := decode[keycloak.Claims](t, body) + if got.TenantID != h.tenant.ID || got.TenantSlug != h.tenant.Slug { + t.Errorf("tenant fields off: %+v", got) + } + if got.Plan != h.tenant.Plan { + t.Errorf("plan = %q, want %q", got.Plan, h.tenant.Plan) + } + if got.TenantStatus != h.tenant.Status { + t.Errorf("status = %q, want %q", got.TenantStatus, h.tenant.Status) + } + // acme is seeded with certifai + compliance entitlements (memory) + // or one or zero (postgres, depending on prior subtest ordering). + // At minimum the field is present. + if got.Products == nil { + t.Error("products is nil; should be at least empty slice") + } + }) +} + +func TestKcClaims_lookupByUserAttrs(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{ + "user_attrs": map[string]string{"tenant_slug": h.tenant.Slug}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body=%s", resp.StatusCode, body) + } + got := decode[keycloak.Claims](t, body) + if got.TenantID != h.tenant.ID { + t.Errorf("did not resolve via user_attrs; got %+v", got) + } + }) +} + +func TestKcClaims_missingTenant404(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{ + "tenant_slug": "nope-nope", + }) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } + }) +} + +func TestKcClaims_requiresInput(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want 400", resp.StatusCode) + } + }) +} diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index de669a1..5d2be49 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -44,6 +44,7 @@ func TestOpenAPISpec_loadsAndIsConsistent(t *testing.T) { {"GET", "/v1/api-keys?tenant_id=00000000-0000-0000-0000-000000000001"}, {"GET", "/v1/catalog"}, {"GET", "/v1/audit?limit=10"}, + {"POST", "/v1/internal/keycloak/claims"}, } for _, c := range cases { req := newRequest(t, c.method, c.path) diff --git a/internal/server/server.go b/internal/server/server.go index d863405..b141e8c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,7 +1,7 @@ // Package server wires the HTTP surface for tenant-registry. // // All routes are registered in NewRouter; per-concern handlers live in -// peer files (tenants.go, catalog.go, apikeys.go, audit.go). +// peer files (tenants.go, catalog.go, apikeys.go, audit.go, keycloak.go). package server import ( @@ -9,14 +9,16 @@ import ( "net/http" "gitea.meghsakha.com/platform/tenant-registry/internal/config" + "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // Server bundles the dependencies every handler needs. type Server struct { - Cfg *config.Config - Log *slog.Logger - Store store.Store + Cfg *config.Config + Log *slog.Logger + Store store.Store + Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset } // NewRouter builds the http.Handler with logging middleware applied. @@ -34,9 +36,7 @@ func NewRouter(s *Server) http.Handler { mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant) mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant) - // entitlements — top-level path so it doesn't conflict with - // /v1/tenants/by-slug/{slug} (Go 1.22 ServeMux can't disambiguate - // /v1/tenants/{id}/products vs /v1/tenants/by-slug/{slug=products}). + // entitlements mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts) // catalog @@ -44,8 +44,7 @@ func NewRouter(s *Server) http.Handler { mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest) mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest) - // api keys — same disambiguation: list lives at /v1/api-keys?tenant_id=X - // instead of /v1/tenants/{id}/api-keys. + // api keys mux.HandleFunc("POST /v1/api-keys", s.createAPIKey) mux.HandleFunc("GET /v1/api-keys", s.listAPIKeys) mux.HandleFunc("DELETE /v1/api-keys/{id}", s.revokeAPIKey) @@ -55,6 +54,12 @@ func NewRouter(s *Server) http.Handler { mux.HandleFunc("POST /v1/audit", s.appendAudit) mux.HandleFunc("GET /v1/audit", s.listAudit) + // keycloak claims refresh — the URL the protocol mapper would call at + // token issuance to grab the up-to-date entitlement bundle. Today the + // dev realm projects user attributes (set by SyncClaims) — this is + // the "pull" complement for when the realm is reconfigured to fetch. + mux.HandleFunc("POST /v1/internal/keycloak/claims", s.kcClaims) + return logRequest(s.Log)(mux) } @@ -67,5 +72,9 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error()) return } + if err := s.Keycloak.Health(r.Context()); err != nil { + writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error()) + return + } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 25343e0..c5c8fc1 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -21,6 +21,7 @@ import ( tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "gitea.meghsakha.com/platform/tenant-registry/internal/config" + "gitea.meghsakha.com/platform/tenant-registry/internal/keycloak" "gitea.meghsakha.com/platform/tenant-registry/internal/server" "gitea.meghsakha.com/platform/tenant-registry/internal/store" "gitea.meghsakha.com/platform/tenant-registry/migrations" @@ -33,6 +34,7 @@ type testHarness struct { srv *httptest.Server store store.Store tenant *store.Tenant // pre-created acme tenant + kcMock *keycloak.Mock } func (h *testHarness) Close() { @@ -131,14 +133,19 @@ func newPostgresHarness(t *testing.T) *testHarness { func wireHarness(t *testing.T, s store.Store, seed *store.Tenant) *testHarness { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + mock := keycloak.NewMock() handler := server.NewRouter(&server.Server{ - Cfg: &config.Config{Env: "dev"}, Log: logger, Store: s, + Cfg: &config.Config{Env: "dev"}, + Log: logger, + Store: s, + Keycloak: mock, }) return &testHarness{ t: t, srv: httptest.NewServer(handler), store: s, tenant: seed, + kcMock: mock, } } diff --git a/internal/server/tenants.go b/internal/server/tenants.go index b196dea..3ad895d 100644 --- a/internal/server/tenants.go +++ b/internal/server/tenants.go @@ -20,6 +20,18 @@ type createTenantReq struct { Plan string `json:"plan,omitempty"` Kind string `json:"kind,omitempty"` SalesOwner string `json:"sales_owner,omitempty"` + // AdminEmail is optional. When set, the Keycloak adapter provisions + // an organization + invites this user as IT_ADMIN. Omitted for + // sales-led flows that invite the admin later via the portal. + AdminEmail string `json:"admin_email,omitempty"` + AdminName string `json:"admin_name,omitempty"` +} + +// createTenantResp wraps the tenant with the optional KC invite URL so +// dev testers can use it without waiting for the email. +type createTenantResp struct { + Tenant *store.Tenant `json:"tenant"` + InviteURL string `json:"invite_url,omitempty"` } func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) { @@ -40,7 +52,7 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() t, err := s.Store.CreateTenant(ctx, store.TenantCreate{ Slug: in.Slug, Name: in.Name, Plan: in.Plan, Kind: in.Kind, SalesOwner: in.SalesOwner, @@ -63,7 +75,25 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) { Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind}, }) - writeJSON(w, http.StatusCreated, t) + // Best-effort Keycloak provisioning. A failure here doesn't roll the + // tenant back — the operator can resend the invite via the KC admin UI. + // We emit an audit event regardless so the failure is traceable. + inviteURL, kcErr := s.provisionKeycloak(ctx, t, in.AdminEmail, in.AdminName) + if kcErr != nil { + s.emitAudit(ctx, r, store.AuditEvent{ + TenantID: t.ID, Action: "keycloak.provision_failed", + TargetID: t.ID, TargetType: "tenant", + Metadata: map[string]interface{}{"err": kcErr.Error(), "admin_email": in.AdminEmail}, + }) + } else if in.AdminEmail != "" { + s.emitAudit(ctx, r, store.AuditEvent{ + TenantID: t.ID, Action: "keycloak.invite_sent", + TargetID: in.AdminEmail, TargetType: "user", TargetName: in.AdminEmail, + Metadata: map[string]interface{}{"role": "IT_ADMIN"}, + }) + } + + writeJSON(w, http.StatusCreated, createTenantResp{Tenant: t, InviteURL: inviteURL}) } func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/tenants_test.go b/internal/server/tenants_test.go index f5b4ef7..0e5e246 100644 --- a/internal/server/tenants_test.go +++ b/internal/server/tenants_test.go @@ -24,9 +24,12 @@ func TestCreateTenant(t *testing.T) { if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, body=%s", resp.StatusCode, body) } - t1 := decode[store.Tenant](t, body) - if t1.Slug != "beta-co" || t1.Status != "trial" || t1.Plan != "starter" { - t.Errorf("unexpected: %+v", t1) + out := decode[struct { + Tenant *store.Tenant `json:"tenant"` + InviteURL string `json:"invite_url"` + }](t, body) + if out.Tenant.Slug != "beta-co" || out.Tenant.Status != "trial" || out.Tenant.Plan != "starter" { + t.Errorf("unexpected: %+v", out.Tenant) } }) } @@ -81,7 +84,10 @@ func TestActivateTenant(t *testing.T) { _, body := h.do("POST", "/v1/tenants", map[string]any{ "slug": "trial-co", "name": "Trial Co.", }) - created := decode[store.Tenant](t, body) + createdWrap := decode[struct { + Tenant *store.Tenant `json:"tenant"` + }](t, body) + created := createdWrap.Tenant if created.Status != "trial" { t.Fatalf("precondition: %q", created.Status) } diff --git a/openapi.yaml b/openapi.yaml index ad0cfff..88318ee 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -48,15 +48,22 @@ paths: /v1/tenants: post: summary: Create a tenant. + description: | + Creates the tenant row, and if `admin_email` is provided, also + creates a Keycloak organization + invites the user as IT_ADMIN. + Keycloak failures DO NOT roll the tenant back — they emit a + `keycloak.provision_failed` audit event so the operator can resend + the invite from the KC admin UI. requestBody: required: true content: application/json: { schema: { $ref: "#/components/schemas/TenantCreate" } } responses: "201": - description: Created. + description: Created. `invite_url` is non-empty when an + `admin_email` was passed and Keycloak provisioning succeeded. content: - application/json: { schema: { $ref: "#/components/schemas/Tenant" } } + application/json: { schema: { $ref: "#/components/schemas/TenantCreated" } } "400": { $ref: "#/components/responses/BadRequest" } "409": { $ref: "#/components/responses/Conflict" } @@ -243,6 +250,35 @@ paths: description: Revoked. "404": { $ref: "#/components/responses/NotFound" } + /v1/internal/keycloak/claims: + post: + summary: Resolve the up-to-date claim bundle for a user/tenant. + description: | + Called by Keycloak's protocol mapper at token issuance (or by + any operator on demand) to fetch the current tenant_id / + tenant_slug / org_roles / products / plan / tenant_status + claims. Lookup tries tenant_id, then tenant_slug, then + user_attrs.tenant_id, then user_attrs.tenant_slug. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tenant_id: { type: string, format: uuid } + tenant_slug: { type: string } + user_attrs: + type: object + additionalProperties: { type: string } + responses: + "200": + description: Resolved claim bundle. + content: + application/json: { schema: { $ref: "#/components/schemas/Claims" } } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + /v1/internal/api-keys/verify: post: summary: Verify an API key. Used by headless products. Returns @@ -335,6 +371,17 @@ components: application/json: { schema: { $ref: "#/components/schemas/Error" } } schemas: + Claims: + type: object + required: [tenant_id, tenant_slug, plan, tenant_status] + properties: + tenant_id: { type: string, format: uuid } + tenant_slug: { type: string } + org_roles: { type: array, items: { type: string } } + products: { type: array, items: { type: string } } + plan: { type: string } + tenant_status: { type: string, enum: [demo, trial, active, frozen, archived] } + Error: type: object required: [error] @@ -370,6 +417,15 @@ components: plan: { type: string, default: starter } kind: { type: string, enum: [customer, demo], default: customer } sales_owner: { type: string } + admin_email: { type: string, format: email, description: "IT_ADMIN to invite via Keycloak" } + admin_name: { type: string } + + TenantCreated: + type: object + required: [tenant] + properties: + tenant: { $ref: "#/components/schemas/Tenant" } + invite_url: { type: string, description: "KC action-token URL — present only when admin_email was set and KC provisioning succeeded" } TenantActivate: type: object