feat(keycloak): M4.3 — Admin API adapter + claim resolver
ci / image (pull_request) Has been skipped
ci / shared (pull_request) Successful in 6s
ci / test (pull_request) Successful in 1m36s

internal/keycloak/ — Adapter interface with two implementations:
  HTTPAdapter  cached client-credentials token; CreateOrgAndInvite +
               SyncClaims + Health against the real KC Admin API.
  Mock         in-process map for unit tests + dev convenience when
               KEYCLOAK_ADMIN_URL is empty. Used by the eachStore harness.

POST /v1/tenants now accepts admin_email + admin_name. When set, the
adapter creates a KC organization, invites the user as IT_ADMIN, and
triggers VERIFY_EMAIL + UPDATE_PASSWORD. Response wraps the tenant
with TenantCreated{tenant, invite_url}. KC failures DO NOT roll the
tenant back — they emit a keycloak.provision_failed audit event.
Successful invites emit keycloak.invite_sent.

POST /v1/internal/keycloak/claims resolves a tenant's current claim
bundle (tenant_id, slug, products, plan, status). Lookup chain:
body.tenant_id → body.tenant_slug → user_attrs.tenant_id →
user_attrs.tenant_slug.

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

Tests:
  internal/keycloak/mock_test.go     conflict surfacing, FailNext hook,
                                     SyncClaims persistence.
  internal/keycloak/client_test.go   HTTPAdapter against an in-process
                                     stub KC: health, full create-org-
                                     and-invite, conflict, token-cache,
                                     401 retry, ErrUnavailable.
  internal/server/keycloak_test.go   eachStore integration: provisions
                                     via mock; failure path emits
                                     provision_failed audit; claims
                                     endpoint via every lookup variant
                                     + 404 + 400.

OpenAPI extended with TenantCreated + Claims schemas and the new
claims endpoint. Contract test asserts the new path.

CI: include internal/keycloak/... in the test package list so
HTTPAdapter coverage counts. Total project line coverage: 71.6%.

Refs: M4.3
This commit is contained in:
2026-05-19 13:47:03 +02:00
parent ffab866c87
commit d4e8042b94
22 changed files with 1379 additions and 27 deletions
+4 -1
View File
@@ -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 {
+4 -1
View File
@@ -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",
+115
View File
@@ -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,
})
}
+147
View File
@@ -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)
}
})
}
+1
View File
@@ -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)
+18 -9
View File
@@ -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"})
}
+8 -1
View File
@@ -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,
}
}
+32 -2
View File
@@ -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) {
+10 -4
View File
@@ -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)
}