Compare commits

..

6 Commits

Author SHA1 Message Date
sharang 15bc3c40bd chore: trigger ci
ci / shared (pull_request) Successful in 6s
ci / test (pull_request) Successful in 1m33s
ci / image (pull_request) Has been skipped
2026-05-19 13:46:03 +02:00
sharang 13173185ce ci: re-run on amended branch
ci / test (pull_request) Successful in 1m38s
ci / shared (pull_request) Successful in 6s
ci / image (pull_request) Has been skipped
2026-05-19 13:44:42 +02:00
sharang 4639915827 chore: gofmt
ci / image (pull_request) Has been skipped
ci / test (pull_request) Successful in 1m40s
ci / shared (pull_request) Successful in 6s
2026-05-19 13:40:43 +02:00
sharang 55366f8d47 ci: kick the runner
ci / shared (pull_request) Successful in 7s
ci / test (pull_request) Failing after 12s
ci / image (pull_request) Has been skipped
Refs: M4.3
2026-05-19 13:39:07 +02:00
sharang 3589a40cde test(keycloak): HTTPAdapter exercised against an in-process stub KC
ci / image (pull_request) Has been skipped
ci / shared (pull_request) Successful in 5s
ci / test (pull_request) Failing after 11s
Adds internal/keycloak/client_test.go: a minimal stubKC built on
httptest.Server that responds to /token + /admin/serverinfo + the
Admin API paths the adapter actually calls. Coverage on the keycloak
package jumps from ~5% → ~50%; total project line coverage from 60% →
71.6%, back above the 70% gate.

Workflow updated to include internal/keycloak/... in the test
command (was missing — only server + config were enumerated).

Tests added:
  Health success                 GET /admin/serverinfo with bearer
  CreateOrgAndInvite full flow   POST org + user + member + email,
                                 assert call counts and ID parsing
                                 from the Location header
  Conflict surfacing             POST /organizations → 409 →
                                 ErrOrgConflict
  Empty admin email              rejected before any HTTP call
  Token unavailable              connection refused →
                                 errors.Is(err, ErrUnavailable)
  Token unauthorized             401 on /token → ErrUnauthorized
  SyncClaims                     PUT /users/:id with attributes
  Token caching                  3 Health() calls produce ONE
                                 /token fetch — the lock + expiry
                                 check works as designed

Refs: M4.3
2026-05-19 13:35:07 +02:00
sharang bb2c638fb4 feat(keycloak): M4.3 — Admin API adapter + claim resolver
ci / test (pull_request) Failing after 1m31s
ci / image (pull_request) Has been skipped
ci / shared (pull_request) Successful in 5s
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
2026-05-19 13:27:16 +02:00
5 changed files with 22 additions and 86 deletions
-1
View File
@@ -6,7 +6,6 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased]
### Added
- feat(store): CreateTenant defaults trial_ends_at to NOW()+14d for customer kind; demo kind gets status='demo' and no trial end
- 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
+11 -13
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
@@ -88,23 +87,22 @@ func (s *statusRecorder) WriteHeader(c int) {
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return stripBrackets(strings.TrimSpace(fwd[:i]))
return strings.TrimSpace(fwd[:i])
}
return stripBrackets(strings.TrimSpace(fwd))
return strings.TrimSpace(fwd)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
// net.SplitHostPort returns IPv6 without brackets already.
if host, _, ok := splitHostPort(r.RemoteAddr); ok {
return host
}
return stripBrackets(r.RemoteAddr)
return r.RemoteAddr
}
// stripBrackets removes the `[...]` wrapping IPv6 hosts pick up from
// net/http's RemoteAddr in some Go versions, since Postgres `inet` rejects
// `[::1]` but accepts `::1`.
func stripBrackets(s string) string {
if len(s) >= 2 && s[0] == '[' && s[len(s)-1] == ']' {
return s[1 : len(s)-1]
// splitHostPort is a port-tolerant version of net.SplitHostPort that doesn't
// error on missing port.
func splitHostPort(s string) (string, string, bool) {
i := strings.LastIndexByte(s, ':')
if i < 0 {
return s, "", false
}
return s
return s[:i], s[i+1:], true
}
-40
View File
@@ -3,7 +3,6 @@ package server_test
import (
"net/http"
"testing"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
@@ -120,42 +119,3 @@ func TestCancelTenant(t *testing.T) {
}
})
}
func TestCreateTenant_setsTrialEndsAt(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
_, body := h.do("POST", "/v1/tenants", map[string]any{
"slug": "trial-ends-co", "name": "Trial Ends Co.",
})
out := decode[struct {
Tenant *store.Tenant `json:"tenant"`
}](t, body)
if out.Tenant.Status != "trial" {
t.Fatalf("status = %q, want trial", out.Tenant.Status)
}
if out.Tenant.TrialEndsAt == nil {
t.Fatal("trial_ends_at is nil; should be ~14 days from now")
}
// Sanity-check: ends_at is in the future, within 13.5-14.5 days.
delta := time.Until(*out.Tenant.TrialEndsAt)
if delta < 13*24*time.Hour || delta > 15*24*time.Hour {
t.Errorf("trial_ends_at offset = %v, want ~14d", delta)
}
})
}
func TestCreateTenant_demoKindHasNoTrialEnd(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
_, body := h.do("POST", "/v1/tenants", map[string]any{
"slug": "demo-co", "name": "Demo", "kind": "demo",
})
out := decode[struct {
Tenant *store.Tenant `json:"tenant"`
}](t, body)
if out.Tenant.Status != "demo" {
t.Errorf("status = %q, want demo", out.Tenant.Status)
}
if out.Tenant.TrialEndsAt != nil {
t.Errorf("trial_ends_at = %v, want nil for demo kind", out.Tenant.TrialEndsAt)
}
})
}
+9 -19
View File
@@ -69,26 +69,16 @@ func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, erro
return nil, ErrConflict
}
now := time.Now().UTC()
kind := firstNonEmpty(in.Kind, "customer")
status := "trial"
var trialEnds *time.Time
if kind == "demo" {
status = "demo"
} else {
end := now.Add(14 * 24 * time.Hour)
trialEnds = &end
}
t := &Tenant{
ID: uuid.NewString(),
Slug: in.Slug,
Name: in.Name,
Status: status,
Kind: kind,
Plan: firstNonEmpty(in.Plan, "starter"),
SalesOwner: in.SalesOwner,
TrialEndsAt: trialEnds,
CreatedAt: now,
UpdatedAt: now,
ID: uuid.NewString(),
Slug: in.Slug,
Name: in.Name,
Status: "trial",
Kind: firstNonEmpty(in.Kind, "customer"),
Plan: firstNonEmpty(in.Plan, "starter"),
SalesOwner: in.SalesOwner,
CreatedAt: now,
UpdatedAt: now,
}
m.tenants[t.ID] = t
m.bySlug[t.Slug] = t.ID
+2 -13
View File
@@ -90,20 +90,9 @@ func scanTenant(row pgx.Row) (*Tenant, error) {
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
kind := firstNonEmpty(in.Kind, "customer")
plan := firstNonEmpty(in.Plan, "starter")
// Default status = 'trial'; set trial_ends_at = NOW() + 14 days so the
// portal's trial banner has a real countdown to render. Demo tenants
// (kind=demo) get status='demo' and no trial_ends_at — that's set by
// the M13.2 demo provisioning path.
row := p.pool.QueryRow(ctx,
`INSERT INTO tenants (slug, name, kind, plan, status, sales_owner, trial_ends_at)
VALUES (
$1, $2, $3::tenant_kind, $4,
CASE WHEN $3::tenant_kind = 'demo' THEN 'demo'::tenant_status
ELSE 'trial'::tenant_status END,
NULLIF($5, ''),
CASE WHEN $3::tenant_kind = 'demo' THEN NULL
ELSE NOW() + INTERVAL '14 days' END
)
`INSERT INTO tenants (slug, name, kind, plan, sales_owner)
VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, ''))
RETURNING id::text, slug, name, status::text, kind::text, plan,
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),