feat(keycloak): M4.3 — Admin API adapter + claim resolver
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package keycloak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubKC builds a minimal KC look-alike: token endpoint + the Admin API
|
||||
// paths the HTTPAdapter actually calls. Each path is a single handler that
|
||||
// asserts the request shape and returns the bare-minimum valid response.
|
||||
type stubKC struct {
|
||||
srv *httptest.Server
|
||||
tokenCalls atomic.Int32
|
||||
orgCalls atomic.Int32
|
||||
userCalls atomic.Int32
|
||||
memberCalls atomic.Int32
|
||||
emailCalls atomic.Int32
|
||||
healthCalls atomic.Int32
|
||||
syncCalls atomic.Int32
|
||||
|
||||
tokenFails atomic.Bool // when true, /token returns 401 once
|
||||
}
|
||||
|
||||
func newStubKC(t *testing.T) *stubKC {
|
||||
t.Helper()
|
||||
s := &stubKC{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.tokenCalls.Add(1)
|
||||
if s.tokenFails.Swap(false) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-token", "expires_in": 60,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/serverinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.healthCalls.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"systemInfo":{"version":"26.0.0"}}`))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.orgCalls.Add(1)
|
||||
if r.Method == http.MethodPost {
|
||||
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/realms/test-realm/organizations/org-xyz/members", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.memberCalls.Add(1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/realms/test-realm/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.userCalls.Add(1)
|
||||
if r.Method == http.MethodPost {
|
||||
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/users/user-abc")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/realms/test-realm/users/user-abc/execute-actions-email", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.emailCalls.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/admin/realms/test-realm/users/user-abc", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.syncCalls.Add(1)
|
||||
if r.Method == http.MethodPut {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
s.srv = httptest.NewServer(mux)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *stubKC) close() { s.srv.Close() }
|
||||
|
||||
func newTestAdapter(srv *httptest.Server) *HTTPAdapter {
|
||||
return NewHTTPAdapter(HTTPConfig{
|
||||
BaseURL: srv.URL,
|
||||
Realm: "test-realm",
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_health(t *testing.T) {
|
||||
s := newStubKC(t)
|
||||
defer s.close()
|
||||
a := newTestAdapter(s.srv)
|
||||
if err := a.Health(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.healthCalls.Load() != 1 {
|
||||
t.Errorf("health calls = %d", s.healthCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_createOrgAndInvite(t *testing.T) {
|
||||
s := newStubKC(t)
|
||||
defer s.close()
|
||||
a := newTestAdapter(s.srv)
|
||||
|
||||
res, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||
TenantID: "t1", Slug: "acme", Name: "Acme Inc.",
|
||||
AdminEmail: "owner@acme.test", AdminName: "Alice Owner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.OrganizationID != "org-xyz" || res.UserID != "user-abc" {
|
||||
t.Errorf("unexpected ids: %+v", res)
|
||||
}
|
||||
if s.orgCalls.Load() != 1 || s.userCalls.Load() != 1 ||
|
||||
s.memberCalls.Load() != 1 || s.emailCalls.Load() != 1 {
|
||||
t.Errorf("call counts: org=%d user=%d member=%d email=%d",
|
||||
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
|
||||
s := newStubKC(t)
|
||||
defer s.close()
|
||||
a := newTestAdapter(s.srv)
|
||||
|
||||
_, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||
TenantID: "t1", Slug: "x", Name: "X",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty admin email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_orgConflict(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", tokenOK)
|
||||
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
a := newTestAdapter(srv)
|
||||
|
||||
_, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||
TenantID: "t1", Slug: "x", Name: "X", AdminEmail: "a@b.test",
|
||||
})
|
||||
if !errors.Is(err, ErrOrgConflict) {
|
||||
t.Errorf("err = %v, want ErrOrgConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_tokenUnavailable(t *testing.T) {
|
||||
// No KC server at all — adapter should surface ErrUnavailable.
|
||||
a := NewHTTPAdapter(HTTPConfig{
|
||||
BaseURL: "http://127.0.0.1:1", Realm: "test", ClientID: "x", ClientSecret: "y", Timeout: 1 * time.Second,
|
||||
})
|
||||
err := a.Health(context.Background())
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_tokenUnauthorized(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
a := newTestAdapter(srv)
|
||||
err := a.Health(context.Background())
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Errorf("err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_syncClaims(t *testing.T) {
|
||||
s := newStubKC(t)
|
||||
defer s.close()
|
||||
a := newTestAdapter(s.srv)
|
||||
|
||||
err := a.SyncClaims(context.Background(), "user-abc", Claims{
|
||||
TenantID: "t1", TenantSlug: "acme", Plan: "professional",
|
||||
Products: []string{"certifai"}, TenantStatus: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.syncCalls.Load() != 1 {
|
||||
t.Errorf("sync calls = %d", s.syncCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAdapter_tokenIsCached(t *testing.T) {
|
||||
s := newStubKC(t)
|
||||
defer s.close()
|
||||
a := newTestAdapter(s.srv)
|
||||
|
||||
// Three Health calls should produce ONE token fetch (cached).
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := a.Health(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.tokenCalls.Load() != 1 {
|
||||
t.Errorf("token fetches = %d, want 1 (cache miss)", s.tokenCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// tokenOK is a reusable handler that always returns a working token.
|
||||
func tokenOK(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
if !strings.Contains(string(b), "client_credentials") {
|
||||
http.Error(w, "grant_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"t","expires_in":60}`))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:], " ")
|
||||
}
|
||||
Reference in New Issue
Block a user