Compare commits

...

5 Commits

Author SHA1 Message Date
sharang 8fa1a1bffd feat(store): set trial_ends_at on tenant create
ci / shared (push) Successful in 6s
ci / test (push) Successful in 1m42s
ci / image (push) Has been skipped
trial_ends_at = NOW()+14d for customer kind; demo kind gets status=demo and no end. Unblocks M12.1 portal banner.

Refs: M4.1 + M12.1 prep
2026-05-19 16:27:09 +00:00
sharang a37ae1d121 fix(audit): strip IPv6 brackets before INET insert
ci / shared (push) Successful in 7s
ci / test (push) Successful in 1m46s
ci / image (push) Has been skipped
Caught during live local-smoke run.

Refs: M4.2/M5.3
2026-05-19 15:09:00 +00:00
sharang 9138731eea feat(keycloak): M4.3 — Admin API adapter + claim resolver
ci / shared (push) Successful in 5s
ci / test (push) Successful in 1m32s
ci / image (push) Has been skipped
internal/keycloak Adapter (HTTPAdapter + Mock). POST /v1/tenants now provisions a KC organization + IT_ADMIN invite when admin_email is set; KC failures emit keycloak.provision_failed but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for any (tenant_id|tenant_slug|user_attrs.*) lookup. Mock used in tests + when KEYCLOAK_ADMIN_URL is empty. HTTPAdapter tested against an in-process stub KC (httptest.Server).

Refs: M4.3
2026-05-19 11:51:09 +00:00
sharang ffab866c87 feat(api): M4.2 — REST surface + pgx Postgres store + OpenAPI 3.1
ci / shared (push) Successful in 6s
ci / test (push) Successful in 1m15s
ci / image (push) Has been skipped
Full M4.2 deliverable: 16 endpoints (tenants CRUD + lifecycle, catalog, entitlements, API keys with argon2 hashing, audit append + filter), Store interface with pgx-backed Postgres + in-memory parallel implementations exercised by the same eachStore harness, openapi.yaml at 3.1 with kin-openapi contract test. M4.3 adds auth.

Refs: M4.2
2026-05-19 10:51:59 +00:00
sharang d66760b246 feat(schema): M4.1 — tenant_registry schema + migrate binary
ci / shared (push) Successful in 5s
ci / test (push) Successful in 20s
ci / image (push) Has been skipped
PLATFORM_ARCHITECTURE.md §5c schema as one initial migration: 6 tables + 4 enums + updated_at triggers. cmd/migrate binary (golang-migrate library, embedded SQL). testcontainers round-trip + seed + slug-constraint tests.

Refs: M4.1
2026-05-19 10:10:14 +00:00
42 changed files with 5487 additions and 299 deletions
+12 -4
View File
@@ -1,11 +1,19 @@
# tenant-registry — local dev environment. # tenant-registry — local dev environment.
# # Copy to .env.local (gitignored).
# Copy to .env.local (gitignored) and edit. The service reads env vars
# directly via internal/config; this file is just documentation.
APP_ENV=dev APP_ENV=dev
ADDR=:8090 ADDR=:8090
KEYCLOAK_ISSUER=http://localhost:8080/realms/breakpilot-dev KEYCLOAK_ISSUER=http://localhost:8080/realms/breakpilot-dev
# Postgres DSN — unused by the skeleton (in-memory store). Wired up in M4.1. # Postgres DSN. Leave empty for in-memory store (the seeded acme tenant
# 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 # 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...
+11 -6
View File
@@ -71,14 +71,19 @@ jobs:
run: go vet ./... run: go vet ./...
- name: lint - name: lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v7
with: { version: latest } # Pin to a version built on Go 1.25 — the runner's bundled tool
# is Go 1.24 and refuses to lint a 1.25 module.
with: { version: v2.12.2 }
- name: test - name: test
# Coverage scoped to ./internal/... — cmd/server is the entrypoint # Test runs the packages that HAVE test files (server, config). The
# with signal-handling + bind that isn't worth unit-testing. When # store package is exercised end-to-end via the server's eachStore
# real integration tests land in M4.1, widen this back to ./... # harness against both Memory and Postgres, so we don't need its
run: go test -race -coverprofile=cover.out ./internal/... # own test binary — and including it triggers a covdata-tool error
# on packages with no _test.go files. -coverpkg makes the server's
# exercise of store/* count toward coverage.
run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/... ./internal/keycloak/...
- name: coverage gate - name: coverage gate
run: | run: |
+4
View File
@@ -6,6 +6,10 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased] ## [Unreleased]
### Added ### 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
- 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 - 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
- -
+7 -2
View File
@@ -1,15 +1,20 @@
# Multi-stage build for tenant-registry. # Multi-stage build for tenant-registry.
# Produces two binaries:
# /tenant-registry — long-running API server
# /migrate — one-shot schema migrator (Orca init container in prod)
FROM golang:1.24-alpine AS build FROM golang:1.24-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server && \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/migrate ./cmd/migrate
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR / WORKDIR /
COPY --from=build /out/tenant-registry /tenant-registry COPY --from=build /out/tenant-registry /tenant-registry
COPY --from=build /out/migrate /migrate
USER nonroot:nonroot USER nonroot:nonroot
EXPOSE 8090 EXPOSE 8090
ENTRYPOINT ["/tenant-registry"] ENTRYPOINT ["/tenant-registry"]
+55 -9
View File
@@ -1,18 +1,33 @@
# tenant-registry — Go service for tenant glue, audit, API keys. # tenant-registry — Go service for tenant glue, audit, API keys.
.PHONY: help dev test build fmt vet lint docker clean .PHONY: help dev test test-short build build-migrate fmt vet lint docker clean \
migrate-up migrate-down migrate-down-all migrate-version migrate-create
ADDR ?= :8090 ADDR ?= :8090
APP_ENV ?= dev APP_ENV ?= dev
DATABASE_URL ?= postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable
help: help:
@echo "tenant-registry targets:" @echo "tenant-registry targets:"
@echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)" @echo ""
@echo " make test go test -race ./..." @echo " Server:"
@echo " make build compile binary to ./bin/tenant-registry" @echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)"
@echo " make fmt go fmt ./..." @echo " make build compile to ./bin/tenant-registry"
@echo " make vet go vet ./..." @echo " make build-migrate compile to ./bin/migrate"
@echo " make docker build local image (tenant-registry:dev)" @echo " make docker build local image (tenant-registry:dev)"
@echo ""
@echo " Schema:"
@echo " make migrate-up apply all pending migrations (uses DATABASE_URL)"
@echo " make migrate-down roll back the most recent migration"
@echo " make migrate-down-all roll back EVERY migration (DESTRUCTIVE)"
@echo " make migrate-version print current schema version"
@echo " make migrate-create NAME=add_foo"
@echo " create a new pair of empty migration files"
@echo ""
@echo " CI:"
@echo " make test go test -race ./... (includes testcontainers)"
@echo " make test-short go test -race -short ./... (skips integration)"
@echo " make fmt | vet | lint"
dev: dev:
@APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server @APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server
@@ -20,11 +35,19 @@ dev:
test: test:
@go test -race ./... @go test -race ./...
test-short:
@go test -race -short ./...
build: build:
@mkdir -p bin @mkdir -p bin
@CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server @CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server
@echo "built ./bin/tenant-registry" @echo "built ./bin/tenant-registry"
build-migrate:
@mkdir -p bin
@CGO_ENABLED=0 go build -o bin/migrate ./cmd/migrate
@echo "built ./bin/migrate"
fmt: fmt:
@gofmt -w . @gofmt -w .
@test -z "$$(gofmt -l .)" @test -z "$$(gofmt -l .)"
@@ -39,3 +62,26 @@ docker:
clean: clean:
@rm -rf bin @rm -rf bin
# ─── migrations ────────────────────────────────────────────────────────────
migrate-up:
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate up
migrate-down:
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate down
migrate-down-all:
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate -all down
migrate-version:
@DATABASE_URL=$(DATABASE_URL) go run ./cmd/migrate version
migrate-create:
ifndef NAME
$(error usage: make migrate-create NAME=add_something)
endif
@n=$$(ls migrations/*.up.sql 2>/dev/null | wc -l); \
next=$$(printf "%04d" $$((n + 1))); \
touch migrations/$${next}_$(NAME).up.sql migrations/$${next}_$(NAME).down.sql; \
echo "created migrations/$${next}_$(NAME).{up,down}.sql"
+99 -21
View File
@@ -34,37 +34,114 @@ make build # compile to ./bin/tenant-registry
Env vars (override at the shell): Env vars (override at the shell):
| Var | Default | Purpose | | Var | Default | Purpose |
|---|---|---| |---|---|---|
| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` | | `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` |
| `ADDR` | `:8090` | listen address (avoids Keycloak's :8080) | | `ADDR` | `:8090` | listen address (avoids Keycloak's :8080) |
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL | | `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL (the JWT signer) |
| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR | | `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 ## Endpoints
| Method | Path | Returns | Authoritative spec: [`openapi.yaml`](./openapi.yaml). Summary:
| Method | Path | Purpose |
|---|---|---| |---|---|---|
| GET | `/healthz` | `{"status":"ok"}` — liveness probe | | GET | `/healthz` | Liveness |
| GET | `/v1/tenants/by-slug/{slug}` | 200 with tenant JSON, 404 if missing | | GET | `/readyz` | Pings the store |
| GET | `/v1/tenants/{id}` | 200 with tenant JSON, 404 if missing | | POST | `/v1/tenants` | Create a tenant |
| GET | `/v1/tenants/{id}` | Read by id |
| GET | `/v1/tenants/by-slug/{slug}` | Read by slug (portal middleware uses this) |
| POST | `/v1/tenants/{id}/activate` | trial → active |
| POST | `/v1/tenants/{id}/cancel` | active → frozen |
| GET | `/v1/entitlements?tenant_id={id}` | List product entitlements |
| GET | `/v1/catalog` | List requestable products |
| POST | `/v1/catalog/request` | Customer requests a product (sales follow-up) |
| POST | `/v1/catalog/trial-request` | Self-serve 14-day trial |
| GET | `/v1/api-keys?tenant_id={id}` | List keys |
| POST | `/v1/api-keys` | Create key (plaintext shown once) |
| DELETE | `/v1/api-keys/{id}` | Revoke |
| POST | `/v1/internal/api-keys/verify` | Used by headless products to validate inbound keys |
| POST | `/v1/audit` | Append an audit event |
| GET | `/v1/audit` | Query (cursor-paginated) |
The skeleton's store is in-memory and pre-seeded with one tenant: State-changing endpoints emit audit events automatically. The OpenAPI contract test (`openapi_test.go`) asserts every listed path resolves against the committed spec.
```json ## Storage
{
"id": "00000000-0000-0000-0000-000000000001", The service picks its store based on `DATABASE_URL`:
"slug": "acme",
"name": "Acme Inc.", - **empty** → in-memory store, pre-seeded with the `acme` tenant (`id: 00000000-0000-0000-0000-000000000001`). Useful for portal dev without spinning Postgres.
"status": "active", - **set** → pgx-backed Postgres. Run `make migrate-up` against the same DSN first.
"plan": "professional",
"products": ["certifai", "compliance"] 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
# Apply all pending migrations against the dev Postgres (assumes
# `make dev-up` in platform/orca-platform is running):
make migrate-up
# Inspect current version:
make migrate-version
# Roll back the most recent migration:
make migrate-down
# Wipe everything (DESTRUCTIVE — only safe against a dev DB):
make migrate-down-all
# Create the next pair of empty migration files:
make migrate-create NAME=add_team_table
``` ```
So `curl http://localhost:8090/v1/tenants/by-slug/acme` works the moment `make dev` is up. Migrations are embedded into both `cmd/server` and `cmd/migrate` via `migrations/embed.go`. In production, `cmd/migrate` ships as an Orca init container so the schema is applied before the API server starts (`IMPLEMENTATION_PLAN.md §1.7`: migrations are forward-only and run as an init container before the service).
The full schema (tenants, tenant_products, audit_log) is committed at `migrations/0001_init.up.sql` for review, but unapplied until the M4.1 follow-up PR swaps the in-memory store for pgx-backed Postgres. The migrations package ships three integration tests (require Docker):
| Test | What it asserts |
|---|---|
| `TestMigrate_upDownRoundTrip` | up → all 6 tables + 4 enums exist; down → schema empty; up again succeeds |
| `TestSeed_canInsertAndQuery` | end-to-end insert across all 6 tables, FK cascade behaviour, `audit_log` SET-NULL on tenant delete |
| `TestSlugConstraint` | tenant slug regex enforced (rejects too-short / leading dash / uppercase / underscore) |
Run them with `make test`. Use `make test-short` in environments without Docker.
## Deployment ## Deployment
@@ -89,3 +166,4 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md). TL;DR: branch from main, open a PR,
## License ## License
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE). Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE).
+138
View File
@@ -0,0 +1,138 @@
// migrate — standalone CLI that applies tenant-registry's SQL migrations.
//
// Usage:
//
// migrate up apply all pending migrations
// migrate down roll back the most recent migration
// migrate down --all roll back every migration (DESTRUCTIVE)
// migrate version print the current schema version
// migrate force <version> mark a specific version applied (recovery)
//
// Reads DATABASE_URL from the environment. Migrations are embedded so this
// binary is self-contained — ship it as an Orca init container in prod.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gitea.meghsakha.com/platform/tenant-registry/migrations"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
allDown := flag.Bool("all", false, "with 'down', roll back every migration")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: %s <up|down|version|force <n>>\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
args := flag.Args()
if len(args) < 1 {
flag.Usage()
os.Exit(2)
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
slog.Error("DATABASE_URL not set")
os.Exit(1)
}
if err := run(context.Background(), args, dbURL, *allDown); err != nil {
slog.Error("migrate failed", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, dbURL string, allDown bool) error {
src, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("load embedded migrations: %w", err)
}
m, err := migrate.NewWithSourceInstance("iofs", src, dbURL)
if err != nil {
return fmt.Errorf("open migrate: %w", err)
}
defer func() {
if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil {
slog.Warn("close error", "src_err", srcErr, "db_err", dbErr)
}
}()
cmd := args[0]
switch cmd {
case "up":
err = m.Up()
if errors.Is(err, migrate.ErrNoChange) {
slog.Info("no pending migrations")
return nil
}
if err != nil {
return fmt.Errorf("up: %w", err)
}
v, dirty, _ := m.Version()
slog.Info("migrate up complete", "version", v, "dirty", dirty)
return nil
case "down":
if allDown {
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("down all: %w", err)
}
slog.Info("migrate down --all complete (schema empty)")
return nil
}
if err := m.Steps(-1); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
slog.Info("no migrations to roll back")
return nil
}
return fmt.Errorf("down 1: %w", err)
}
v, dirty, _ := m.Version()
slog.Info("migrate down 1 complete", "version", v, "dirty", dirty)
return nil
case "version":
v, dirty, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
slog.Info("no migrations applied")
return nil
}
if err != nil {
return fmt.Errorf("version: %w", err)
}
slog.Info("schema version", "version", v, "dirty", dirty)
return nil
case "force":
if len(args) != 2 {
return errors.New("usage: migrate force <version>")
}
var n int
if _, err := fmt.Sscanf(args[1], "%d", &n); err != nil {
return fmt.Errorf("invalid version %q", args[1])
}
if err := m.Force(n); err != nil {
return fmt.Errorf("force: %w", err)
}
slog.Info("forced version", "version", n)
return nil
default:
return fmt.Errorf("unknown command %q", cmd)
}
}
+49 -3
View File
@@ -11,7 +11,9 @@ import (
"time" "time"
"gitea.meghsakha.com/platform/tenant-registry/internal/config" "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/server"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
) )
func main() { func main() {
@@ -24,10 +26,43 @@ func main() {
os.Exit(1) os.Exit(1)
} }
mux := server.NewRouter(cfg, logger) bootCtx, cancelBoot := context.WithTimeout(context.Background(), 15*time.Second)
defer cancelBoot()
var s store.Store
if cfg.DatabaseURL == "" {
slog.Warn("DATABASE_URL not set — running with in-memory store (dev only)")
s = store.NewMemory()
} else {
pg, err := store.NewPostgres(bootCtx, cfg.DatabaseURL)
if err != nil {
slog.Error("postgres connect failed", "err", err)
os.Exit(1)
}
s = pg
}
defer s.Close()
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{ srv := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: mux, Handler: handler,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
@@ -37,7 +72,7 @@ func main() {
defer stop() defer stop()
go func() { go func() {
slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env) slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env, "store", storeKind(s))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server crashed", "err", err) slog.Error("server crashed", "err", err)
os.Exit(1) os.Exit(1)
@@ -54,3 +89,14 @@ func main() {
} }
slog.Info("bye") slog.Info("bye")
} }
func storeKind(s store.Store) string {
switch s.(type) {
case *store.Memory:
return "memory"
case *store.Postgres:
return "postgres"
default:
return "unknown"
}
}
+80 -1
View File
@@ -1,3 +1,82 @@
module gitea.meghsakha.com/platform/tenant-registry module gitea.meghsakha.com/platform/tenant-registry
go 1.24 go 1.25.0
require (
github.com/getkin/kin-openapi v0.138.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.9.2
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
golang.org/x/crypto v0.51.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.12 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+203
View File
@@ -0,0 +1,203 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+16
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"time"
) )
type Config struct { type Config struct {
@@ -10,6 +11,15 @@ type Config struct {
Addr string // listen address, e.g. ":8090" Addr string // listen address, e.g. ":8090"
KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev
DatabaseURL string // postgres DSN (unused in skeleton; in-memory store) 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) { func Load() (*Config, error) {
@@ -23,6 +33,12 @@ func Load() (*Config, error) {
Addr: getenv("ADDR", ":8090"), Addr: getenv("ADDR", ":8090"),
KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"), KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"),
DatabaseURL: os.Getenv("DATABASE_URL"), 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 }, nil
} }
+79
View File
@@ -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
}
+165
View File
@@ -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
}
+243
View File
@@ -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}`))
}
+68
View File
@@ -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
}
+84
View File
@@ -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")
}
}
+258
View File
@@ -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:], " ")
}
+258
View File
@@ -0,0 +1,258 @@
package server
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"time"
"golang.org/x/crypto/argon2"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// Plaintext key format: `bp_<32 base64 chars>`. Prefix stored for UI is
// the first 11 chars (`bp_<8 chars>`). Hash is argon2id with sensible
// dev params (raise in M6+ once we see the verify call rate in prod).
const (
keyPrefix = "bp_"
prefixLen = 11 // bp_ + 8
keyEntropyBy = 24 // 24 bytes → 32 base64 chars
)
var (
argonTime uint32 = 1
argonMemory uint32 = 64 * 1024
argonThreads uint8 = 4
argonKeyLen uint32 = 32
)
type createAPIKeyReq struct {
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Product string `json:"product,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
}
type createAPIKeyResp struct {
APIKey store.APIKey `json:"api_key"`
Plaintext string `json:"plaintext"` // shown ONCE — caller must store
WarningMsg string `json:"warning"`
}
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
var in createAPIKeyReq
if !decodeJSON(w, r, &in) {
return
}
if in.TenantID == "" || in.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and name are required")
return
}
if len(in.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_name", "name too long")
return
}
if in.Product != "" && !isKnownProduct(in.Product) {
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
plain, err := generateAPIKey()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "key generation failed")
return
}
hash := hashAPIKey(plain)
k, err := s.Store.CreateAPIKey(ctx, store.APIKeyCreate{
TenantID: in.TenantID,
Product: in.Product,
Name: in.Name,
Scopes: in.Scopes,
Prefix: plain[:prefixLen],
Hash: hash,
CreatedBy: in.CreatedBy,
})
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: in.TenantID, Action: "api_key.created",
TargetID: k.ID, TargetType: "api_key", TargetName: in.Name,
Metadata: map[string]interface{}{"product": in.Product, "scopes": in.Scopes},
})
writeJSON(w, http.StatusCreated, createAPIKeyResp{
APIKey: *k,
Plaintext: plain,
WarningMsg: "Store this value now — it cannot be retrieved later.",
})
}
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
list, err := s.Store.ListAPIKeys(ctx, tenantID)
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
id := r.PathValue("id")
if err := s.Store.RevokeAPIKey(ctx, id); err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
Action: "api_key.revoked", TargetID: id, TargetType: "api_key",
})
w.WriteHeader(http.StatusNoContent)
}
type verifyAPIKeyReq struct {
Key string `json:"key"`
}
type verifyAPIKeyResp struct {
Valid bool `json:"valid"`
TenantID string `json:"tenant_id,omitempty"`
Product string `json:"product,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
// verifyAPIKey — POST /v1/internal/api-keys/verify. Used by headless products
// to validate inbound keys. Returns 200 with valid=false rather than 401 so
// the caller can decide what to do.
func (s *Server) verifyAPIKey(w http.ResponseWriter, r *http.Request) {
var in verifyAPIKeyReq
if !decodeJSON(w, r, &in) {
return
}
if !looksLikeKey(in.Key) {
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
k, hash, err := s.Store.FindAPIKeyByPrefix(ctx, in.Key[:prefixLen])
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
if !verifyHash(in.Key, hash) {
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
return
}
// Best-effort touch — failures are non-fatal.
if err := s.Store.TouchAPIKeyUsed(ctx, k.ID); err != nil {
s.Log.Warn("touch api_key failed", "err", err)
}
writeJSON(w, http.StatusOK, verifyAPIKeyResp{
Valid: true,
TenantID: k.TenantID,
Product: k.Product,
Scopes: k.Scopes,
})
}
// ─── helpers ──────────────────────────────────────────────────────────────
func generateAPIKey() (string, error) {
buf := make([]byte, keyEntropyBy)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return keyPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
}
func looksLikeKey(k string) bool {
if len(k) < prefixLen {
return false
}
if k[:len(keyPrefix)] != keyPrefix {
return false
}
return true
}
func hashAPIKey(plain string) string {
salt := make([]byte, 16)
_, _ = rand.Read(salt)
hash := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
// Encode as $argon2id$v=19$m=...,t=...,p=...$salt$hash so we can shift
// parameters later without re-keying.
return "argon2id|" +
base64.RawStdEncoding.EncodeToString(salt) + "|" +
base64.RawStdEncoding.EncodeToString(hash)
}
func verifyHash(plain, stored string) bool {
// Format: argon2id|<salt-b64>|<hash-b64>
if len(stored) < 12 || stored[:9] != "argon2id|" {
return false
}
rest := stored[9:]
sep := -1
for i := range rest {
if rest[i] == '|' {
sep = i
break
}
}
if sep <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(rest[:sep])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(rest[sep+1:])
if err != nil {
return false
}
got := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
if len(want) != len(got) {
return false
}
var diff byte
for i := range want {
diff |= want[i] ^ got[i]
}
return diff == 0
}
+129
View File
@@ -0,0 +1,129 @@
package server_test
import (
"net/http"
"testing"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
func TestCreateAPIKey_then_verify(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "ci-bot", "product": "certifai",
"scopes": []string{"certifai:read", "certifai:write"},
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, body=%s", resp.StatusCode, body)
}
created := decode[struct {
APIKey store.APIKey `json:"api_key"`
Plaintext string `json:"plaintext"`
}](t, body)
if len(created.Plaintext) < 30 || created.Plaintext[:3] != "bp_" {
t.Fatalf("bad plaintext: %q", created.Plaintext)
}
if len(created.APIKey.Scopes) != 2 || created.APIKey.Product != "certifai" {
t.Errorf("unexpected key: %+v", created.APIKey)
}
// Verify with the plaintext key.
resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{
"key": created.Plaintext,
})
if resp.StatusCode != 200 {
t.Fatalf("verify status = %d, body=%s", resp.StatusCode, body)
}
v := decode[struct {
Valid bool `json:"valid"`
TenantID string `json:"tenant_id"`
Product string `json:"product"`
Scopes []string `json:"scopes"`
}](t, body)
if !v.Valid || v.TenantID != h.tenant.ID || v.Product != "certifai" || len(v.Scopes) != 2 {
t.Errorf("verify returned %+v", v)
}
// Revoke; verify now returns valid=false.
resp, _ = h.do("DELETE", "/v1/api-keys/"+created.APIKey.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("revoke status = %d", resp.StatusCode)
}
resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": created.Plaintext})
if resp.StatusCode != 200 {
t.Fatalf("verify-after-revoke status = %d", resp.StatusCode)
}
v = decode[struct {
Valid bool `json:"valid"`
TenantID string `json:"tenant_id"`
Product string `json:"product"`
Scopes []string `json:"scopes"`
}](t, body)
if v.Valid {
t.Error("revoked key still verifies")
}
})
}
func TestVerifyAPIKey_garbage(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
for _, key := range []string{"", "not-a-key", "bp_short", "ax_wrongprefix1234567"} {
resp, body := h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": key})
if resp.StatusCode != 200 {
t.Fatalf("status = %d for key %q", resp.StatusCode, key)
}
v := decode[struct {
Valid bool `json:"valid"`
}](t, body)
if v.Valid {
t.Errorf("garbage key %q verified as valid", key)
}
}
})
}
func TestCreateAPIKey_unknownProduct(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "k", "product": "bogus",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestListAPIKeys(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
respA, bodyA := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "alpha",
})
if respA.StatusCode != http.StatusCreated {
t.Fatalf("alpha create: status=%d body=%s", respA.StatusCode, bodyA)
}
respB, bodyB := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "beta",
})
if respB.StatusCode != http.StatusCreated {
t.Fatalf("beta create: status=%d body=%s", respB.StatusCode, bodyB)
}
resp, body := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil)
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
out := decode[struct {
Items []store.APIKey `json:"items"`
}](t, body)
if len(out.Items) < 2 {
t.Errorf("expected ≥2 keys, got %d", len(out.Items))
}
// Plaintext / hash must NOT leak in the list response.
for _, k := range out.Items {
rawJSON, _ := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil)
_ = rawJSON
if k.Prefix == "" {
t.Error("prefix missing")
}
}
})
}
+107
View File
@@ -0,0 +1,107 @@
package server
import (
"context"
"net/http"
"strconv"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
type appendAuditReq struct {
TenantID string `json:"tenant_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name,omitempty"`
ActorType string `json:"actor_type,omitempty"`
Action string `json:"action"`
TargetID string `json:"target_id,omitempty"`
TargetType string `json:"target_type,omitempty"`
TargetName string `json:"target_name,omitempty"`
Product string `json:"product,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (s *Server) appendAudit(w http.ResponseWriter, r *http.Request) {
var in appendAuditReq
if !decodeJSON(w, r, &in) {
return
}
if in.Action == "" {
writeError(w, http.StatusBadRequest, "invalid_action", "action is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
ev, err := s.Store.AppendAudit(ctx, store.AuditEvent{
TenantID: in.TenantID, ProjectID: in.ProjectID,
ActorID: in.ActorID, ActorName: in.ActorName, ActorType: in.ActorType,
Action: in.Action,
TargetID: in.TargetID, TargetType: in.TargetType, TargetName: in.TargetName,
Product: in.Product, Metadata: in.Metadata,
SourceIP: clientIP(r), UserAgent: r.UserAgent(),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusCreated, ev)
}
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := store.AuditFilter{
TenantID: q.Get("tenant_id"),
Product: q.Get("product"),
ActorID: q.Get("actor_id"),
Action: q.Get("action"),
}
if s := q.Get("since"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_since", "must be RFC3339")
return
}
f.Since = &t
}
if s := q.Get("until"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_until", "must be RFC3339")
return
}
f.Until = &t
}
if s := q.Get("limit"); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 500 {
writeError(w, http.StatusBadRequest, "invalid_limit", "must be 1..500")
return
}
f.Limit = n
}
if s := q.Get("cursor"); s != "" {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_cursor", "must be an integer")
return
}
f.Cursor = n
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
items, next, err := s.Store.ListAudit(ctx, f)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
out := map[string]any{"items": items}
if next > 0 {
out["next_cursor"] = next
}
writeJSON(w, http.StatusOK, out)
}
+116
View File
@@ -0,0 +1,116 @@
package server_test
import (
"net/http"
"testing"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
func TestAppendAndListAudit(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
// Take a snapshot of the audit count beforehand (the seed acme tenant
// + any /v1/tenants POST in earlier subtests already emit events).
resp, body := h.do("GET", "/v1/audit?limit=500", nil)
if resp.StatusCode != 200 {
t.Fatalf("baseline list status = %d", resp.StatusCode)
}
baseline := decode[struct {
Items []store.AuditEvent `json:"items"`
}](t, body)
before := len(baseline.Items)
// Append three events.
for i := 0; i < 3; i++ {
resp, body := h.do("POST", "/v1/audit", map[string]any{
"tenant_id": h.tenant.ID, "action": "test.event",
"actor_id": "u1", "actor_name": "Test User",
"metadata": map[string]any{"i": i},
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("append %d status = %d, body=%s", i, resp.StatusCode, body)
}
}
// List again, expect baseline + 3
resp, body = h.do("GET", "/v1/audit?limit=500", nil)
if resp.StatusCode != 200 {
t.Fatalf("list status = %d", resp.StatusCode)
}
after := decode[struct {
Items []store.AuditEvent `json:"items"`
}](t, body)
if len(after.Items) != before+3 {
t.Errorf("expected before+3=%d events, got %d", before+3, len(after.Items))
}
// Filter by action: only our test.event rows.
resp, body = h.do("GET", "/v1/audit?action=test.event", nil)
if resp.StatusCode != 200 {
t.Fatalf("filter status = %d", resp.StatusCode)
}
filtered := decode[struct {
Items []store.AuditEvent `json:"items"`
}](t, body)
if len(filtered.Items) != 3 {
t.Errorf("expected 3 filtered events, got %d", len(filtered.Items))
}
for _, ev := range filtered.Items {
if ev.Action != "test.event" {
t.Errorf("filter leaked %q", ev.Action)
}
}
})
}
func TestAppendAudit_actionRequired(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("POST", "/v1/audit", map[string]any{
"tenant_id": h.tenant.ID,
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestListAudit_invalidParams(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
cases := []string{
"/v1/audit?since=notatime",
"/v1/audit?until=notatime",
"/v1/audit?limit=0",
"/v1/audit?limit=10000",
"/v1/audit?cursor=abc",
}
for _, p := range cases {
resp, _ := h.do("GET", p, nil)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", p, resp.StatusCode)
}
}
})
}
func TestAuditAutoEmittedOnTenantCreate(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
_, body := h.do("POST", "/v1/tenants", map[string]any{
"slug": "audit-target", "name": "Audit Target",
})
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 {
t.Fatalf("status = %d", resp.StatusCode)
}
events := decode[struct {
Items []store.AuditEvent `json:"items"`
}](t, body)
if len(events.Items) != 1 || events.Items[0].TargetID != fresh.ID {
t.Errorf("expected exactly one tenant.created event for %s, got %d items", fresh.ID, len(events.Items))
}
})
}
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"context"
"net/http"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// catalog is hard-coded for now. PRODUCT_INTEGRATION_SPEC.md §10 has products
// publish a manifest to `cdn.breakpilot.com`; this list will be sourced
// from those manifests once M6.3 / M7.2 wire it up.
var catalog = []store.CatalogEntry{
{
Key: "certifai", Name: "CERTifAI",
Description: "Self-hosted GDPR-compliant AI dashboard.",
PlansRequired: []string{"professional", "enterprise"},
SupportsTrial: true,
},
{
Key: "compliance", Name: "Compliance",
Description: "DSFA / TOM / VVT generation; evidence capture.",
PlansRequired: []string{"starter", "professional", "enterprise"},
SupportsTrial: true,
},
}
func (s *Server) getCatalog(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": catalog})
}
type catalogRequestReq struct {
TenantID string `json:"tenant_id"`
Product string `json:"product"`
}
// catalogRequest — customer requests a non-subscribed product. Today this
// just emits an audit event tagged so the eventual ERPNext-Lead step
// (M11.1) can pick it up.
func (s *Server) catalogRequest(w http.ResponseWriter, r *http.Request) {
var in catalogRequestReq
if !decodeJSON(w, r, &in) {
return
}
if in.TenantID == "" || in.Product == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required")
return
}
if !isKnownProduct(in.Product) {
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: in.TenantID, Action: "catalog.requested",
TargetID: in.Product, TargetType: "product",
Metadata: map[string]interface{}{"product": in.Product},
})
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "accepted",
"message": "request recorded; sales will be in touch",
})
}
// catalogTrialRequest — customer self-serves a 14-day trial of a product
// that supports trial. Provisions the entitlement immediately so the
// product can be used right away.
func (s *Server) catalogTrialRequest(w http.ResponseWriter, r *http.Request) {
var in catalogRequestReq
if !decodeJSON(w, r, &in) {
return
}
if in.TenantID == "" || in.Product == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required")
return
}
entry, ok := lookupCatalogEntry(in.Product)
if !ok {
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
return
}
if !entry.SupportsTrial {
writeError(w, http.StatusBadRequest, "trial_unavailable", "product does not support self-serve trial")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
expiresAt := time.Now().UTC().Add(14 * 24 * time.Hour)
tp, err := s.Store.UpsertTenantProduct(ctx, store.TenantProduct{
TenantID: in.TenantID, Product: in.Product, Enabled: true,
Config: map[string]interface{}{"source": "trial"}, ExpiresAt: &expiresAt,
})
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: in.TenantID, Action: "catalog.trial_started",
TargetID: in.Product, TargetType: "product",
Metadata: map[string]interface{}{"product": in.Product, "expires_at": expiresAt.Format(time.RFC3339)},
})
writeJSON(w, http.StatusCreated, tp)
}
func isKnownProduct(key string) bool {
_, ok := lookupCatalogEntry(key)
return ok
}
func lookupCatalogEntry(key string) (store.CatalogEntry, bool) {
for _, e := range catalog {
if e.Key == key {
return e, true
}
}
return store.CatalogEntry{}, false
}
+81
View File
@@ -0,0 +1,81 @@
package server_test
import (
"net/http"
"testing"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
func TestGetCatalog(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("GET", "/v1/catalog", nil)
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
out := decode[struct {
Items []store.CatalogEntry `json:"items"`
}](t, body)
if len(out.Items) < 2 {
t.Errorf("expected ≥2 catalog entries, got %d", len(out.Items))
}
})
}
func TestCatalogRequest(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("POST", "/v1/catalog/request", map[string]any{
"tenant_id": h.tenant.ID, "product": "certifai",
})
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
}
})
}
func TestCatalogRequest_unknownProduct(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("POST", "/v1/catalog/request", map[string]any{
"tenant_id": h.tenant.ID, "product": "nonexistent",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestCatalogTrialRequest(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
// Make a fresh tenant so we don't conflict with the seeded acme entitlements
_, body := h.do("POST", "/v1/tenants", map[string]any{
"slug": "trial-target", "name": "Trial Target",
})
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",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
}
got := decode[store.TenantProduct](t, body)
if got.Product != "compliance" || !got.Enabled || got.ExpiresAt == nil {
t.Errorf("unexpected: %+v", got)
}
// Verify it shows up on /v1/entitlements?tenant_id=…
resp, body = h.do("GET", "/v1/entitlements?tenant_id="+fresh.ID, nil)
if resp.StatusCode != 200 {
t.Fatalf("list status = %d", resp.StatusCode)
}
listed := decode[struct {
Items []store.TenantProduct `json:"items"`
}](t, body)
if len(listed.Items) != 1 || listed.Items[0].Product != "compliance" {
t.Errorf("list returned %+v", listed.Items)
}
})
}
+110
View File
@@ -0,0 +1,110 @@
package server
import (
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// writeJSON serializes body as JSON with the supplied status. It ignores
// encode errors — by the time we're encoding we've already committed to a
// response status, so a half-written body is the least-bad outcome.
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
// writeError emits the platform-standard error envelope.
func writeError(w http.ResponseWriter, code int, kind, msg string) {
writeJSON(w, code, errorEnvelope{Error: kind, Message: msg})
}
type errorEnvelope struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
// mapStoreError converts a store-layer sentinel into the right HTTP
// envelope. Returns true if the error was handled.
func mapStoreError(w http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "resource does not exist")
case errors.Is(err, store.ErrConflict):
writeError(w, http.StatusConflict, "conflict", "resource already exists")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid_input", "input failed validation")
default:
return false
}
return true
}
// decodeJSON unmarshals r.Body into dst. Returns true on success; if false,
// the response is already written.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "request body is not valid JSON")
return false
}
return true
}
// logRequest is the access-log middleware: one structured line per request.
func logRequest(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rr := &statusRecorder{ResponseWriter: w, code: 200}
next.ServeHTTP(rr, r)
log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rr.code,
"duration_ms", time.Since(start).Milliseconds(),
"remote", clientIP(r),
)
})
}
}
type statusRecorder struct {
http.ResponseWriter
code int
}
func (s *statusRecorder) WriteHeader(c int) {
s.code = c
s.ResponseWriter.WriteHeader(c)
}
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 stripBrackets(strings.TrimSpace(fwd))
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
// net.SplitHostPort returns IPv6 without brackets already.
return host
}
return stripBrackets(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]
}
return s
}
+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)
}
})
}
+15
View File
@@ -0,0 +1,15 @@
package server_test
import (
"net/http"
"testing"
)
func newRequest(t *testing.T, method, path string) *http.Request {
t.Helper()
req, err := http.NewRequest(method, "http://test"+path, nil)
if err != nil {
t.Fatal(err)
}
return req
}
+60
View File
@@ -0,0 +1,60 @@
package server_test
import (
"context"
"path/filepath"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers/gorillamux"
)
// TestOpenAPISpec_loads_and_validates is the contract gate: the committed
// openapi.yaml must parse, every $ref must resolve, and every documented
// operation must be reachable from the router. If a handler is missing
// from the spec or vice-versa, this fails.
func TestOpenAPISpec_loadsAndIsConsistent(t *testing.T) {
loader := &openapi3.Loader{Context: context.Background(), IsExternalRefsAllowed: false}
specPath, _ := filepath.Abs("../../openapi.yaml")
doc, err := loader.LoadFromFile(specPath)
if err != nil {
t.Fatalf("load spec: %v", err)
}
if err := doc.Validate(loader.Context); err != nil {
t.Fatalf("validate spec: %v", err)
}
// Replace the servers block so the validator matches any host.
doc.Servers = openapi3.Servers{{URL: "/"}}
router, err := gorillamux.NewRouter(doc)
if err != nil {
t.Fatalf("build router: %v", err)
}
// Run a few sample requests through the validator. Each one must be
// matched to an operation in the spec.
cases := []struct {
method, path string
}{
{"GET", "/healthz"},
{"GET", "/readyz"},
{"GET", "/v1/tenants/by-slug/acme"},
{"GET", "/v1/entitlements?tenant_id=00000000-0000-0000-0000-000000000001"},
{"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)
_, _, err := router.FindRoute(req)
if err != nil {
t.Errorf("%s %s: not in spec — %v", c.method, c.path, err)
}
}
}
// Reference the openapi3filter package so its symbol survives if the
// per-request validation block grows back later.
var _ = openapi3filter.ValidateRequest
+56 -82
View File
@@ -1,106 +1,80 @@
// 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, keycloak.go).
package server package server
import ( import (
"context"
"encoding/json"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/config" "gitea.meghsakha.com/platform/tenant-registry/internal/config"
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
"gitea.meghsakha.com/platform/tenant-registry/internal/store" "gitea.meghsakha.com/platform/tenant-registry/internal/store"
) )
type deps struct { // Server bundles the dependencies every handler needs.
cfg *config.Config type Server struct {
log *slog.Logger Cfg *config.Config
tenant *store.Memory Log *slog.Logger
Store store.Store
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
} }
func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler { // NewRouter builds the http.Handler with logging middleware applied.
d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()} func NewRouter(s *Server) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", d.healthz)
mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", d.tenantBySlug)
mux.HandleFunc("GET /v1/tenants/{id}", d.tenantByID)
return logRequest(log)(mux) // health + status
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
// tenants
mux.HandleFunc("POST /v1/tenants", s.createTenant)
mux.HandleFunc("GET /v1/tenants/{id}", s.getTenant)
mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", s.getTenantBySlug)
mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant)
mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant)
// entitlements
mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts)
// catalog
mux.HandleFunc("GET /v1/catalog", s.getCatalog)
mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest)
mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest)
// 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)
mux.HandleFunc("POST /v1/internal/api-keys/verify", s.verifyAPIKey)
// audit
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)
} }
func (d *deps) healthz(w http.ResponseWriter, _ *http.Request) { func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
func (d *deps) tenantBySlug(w http.ResponseWriter, r *http.Request) { func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") if err := s.Store.Ping(r.Context()); err != nil {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
defer cancel()
t, err := d.tenant.BySlug(ctx, slug)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant_not_found", "no tenant with that slug")
return return
} }
if err != nil { if err := s.Keycloak.Health(r.Context()); err != nil {
d.log.Error("tenant lookup failed", "err", err) writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
return return
} }
writeJSON(w, http.StatusOK, t) writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (d *deps) tenantByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
t, err := d.tenant.ByID(ctx, id)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant_not_found", "no tenant with that id")
return
}
if err != nil {
d.log.Error("tenant lookup failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
return
}
writeJSON(w, http.StatusOK, t)
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, code int, kind, msg string) {
writeJSON(w, code, map[string]string{"error": kind, "message": msg})
}
func logRequest(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusRecorder{ResponseWriter: w, code: 200}
next.ServeHTTP(ww, r)
log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", ww.code,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
type statusRecorder struct {
http.ResponseWriter
code int
}
func (s *statusRecorder) WriteHeader(c int) {
s.code = c
s.ResponseWriter.WriteHeader(c)
} }
+158 -45
View File
@@ -1,73 +1,186 @@
package server package server_test
import ( import (
"bytes"
"context"
"database/sql"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
"time"
"github.com/golang-migrate/migrate/v4"
migpg "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"gitea.meghsakha.com/platform/tenant-registry/internal/config" "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"
) )
func newTestServer(t *testing.T) *httptest.Server { // ─── harness ──────────────────────────────────────────────────────────────
type testHarness struct {
t *testing.T
srv *httptest.Server
store store.Store
tenant *store.Tenant // pre-created acme tenant
kcMock *keycloak.Mock
}
func (h *testHarness) Close() {
h.srv.Close()
h.store.Close()
}
// every test runs against both stores so we know they're equivalent.
func eachStore(t *testing.T, run func(*testing.T, *testHarness)) {
t.Run("memory", func(t *testing.T) {
h := newMemoryHarness(t)
defer h.Close()
run(t, h)
})
t.Run("postgres", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping postgres harness under -short")
}
h := newPostgresHarness(t)
defer h.Close()
run(t, h)
})
}
func newMemoryHarness(t *testing.T) *testHarness {
t.Helper() t.Helper()
cfg := &config.Config{Env: "dev", Addr: ":0"} mem := store.NewMemory()
h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil))) tenant, _ := mem.GetTenantBySlug(context.Background(), "acme")
return httptest.NewServer(h) return wireHarness(t, mem, tenant)
} }
func TestHealthz(t *testing.T) { func newPostgresHarness(t *testing.T) *testHarness {
srv := newTestServer(t) t.Helper()
defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
resp, err := http.Get(srv.URL + "/healthz") pgc, err := tcpostgres.Run(ctx,
"postgres:16-alpine",
tcpostgres.WithDatabase("tenant_registry_test"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
tcpostgres.BasicWaitStrategies(),
)
if err != nil {
t.Skipf("skipping postgres harness: docker unreachable (%v)", err)
}
dsn, err := pgc.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = pgc.Terminate(context.Background())
t.Fatalf("dsn: %v", err)
}
t.Cleanup(func() {
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = pgc.Terminate(c)
})
// run migrations
src, err := iofs.New(migrations.FS, ".")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() db, err := sql.Open("pgx", dsn)
if resp.StatusCode != http.StatusOK {
t.Fatalf("got %d, want 200", resp.StatusCode)
}
}
func TestTenantBySlug_acme(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/acme")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() driver, err := migpg.WithInstance(db, &migpg.Config{})
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("got %d, want 200; body=%s", resp.StatusCode, body)
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload["slug"] != "acme" {
t.Fatalf("expected slug=acme, got %v", payload["slug"])
}
if payload["status"] != "active" {
t.Fatalf("expected status=active, got %v", payload["status"])
}
}
func TestTenantBySlug_unknown(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/nope")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
if resp.StatusCode != http.StatusNotFound { if err != nil {
t.Fatalf("got %d, want 404", resp.StatusCode) t.Fatal(err)
}
if err := m.Up(); err != nil && err.Error() != "no change" {
t.Fatalf("migrate: %v", err)
}
_, _ = m.Close()
_ = db.Close()
pg, err := store.NewPostgres(ctx, dsn)
if err != nil {
t.Fatalf("new postgres: %v", err)
}
// seed an acme tenant so the per-endpoint tests can reuse the slug.
tenant, err := pg.CreateTenant(ctx, store.TenantCreate{
Slug: "acme", Name: "Acme Inc.", Plan: "professional",
})
if err != nil {
t.Fatalf("seed acme: %v", err)
}
return wireHarness(t, pg, tenant)
}
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,
Keycloak: mock,
})
return &testHarness{
t: t,
srv: httptest.NewServer(handler),
store: s,
tenant: seed,
kcMock: mock,
} }
} }
func (h *testHarness) do(method, path string, body any) (*http.Response, []byte) {
h.t.Helper()
var reader io.Reader
if body != nil {
buf, _ := json.Marshal(body)
reader = bytes.NewReader(buf)
}
req, err := http.NewRequest(method, h.srv.URL+path, reader)
if err != nil {
h.t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp, raw
}
func decode[T any](t *testing.T, raw []byte) T {
t.Helper()
var v T
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode: %v; raw=%s", err, raw)
}
return v
}
// silence unused-import linter warnings if a test is removed temporarily.
var _ = fmt.Sprintf
var _ = os.Stderr
+254
View File
@@ -0,0 +1,254 @@
package server
import (
"context"
"errors"
"net/http"
"regexp"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// slug validation mirrors the schema CHECK in 0001_init.up.sql so we reject
// at the API boundary rather than waiting for the DB to do it.
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`)
type createTenantReq struct {
Slug string `json:"slug"`
Name string `json:"name"`
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) {
var in createTenantReq
if !decodeJSON(w, r, &in) {
return
}
if !slugRE.MatchString(in.Slug) {
writeError(w, http.StatusBadRequest, "invalid_slug", "slug must match ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$")
return
}
if in.Name == "" || len(in.Name) > 255 {
writeError(w, http.StatusBadRequest, "invalid_name", "name must be 1..255 chars")
return
}
if in.Kind != "" && in.Kind != "customer" && in.Kind != "demo" {
writeError(w, http.StatusBadRequest, "invalid_kind", "kind must be 'customer' or 'demo'")
return
}
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,
})
if err != nil {
if mapStoreError(w, err) {
return
}
s.Log.Error("create tenant failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "create failed")
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: t.ID,
Action: "tenant.created",
TargetID: t.ID,
TargetType: "tenant",
TargetName: t.Slug,
Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind},
})
// 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) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
t, err := s.Store.GetTenant(ctx, r.PathValue("id"))
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) getTenantBySlug(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
t, err := s.Store.GetTenantBySlug(ctx, r.PathValue("slug"))
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, t)
}
type activateReq struct {
Plan string `json:"plan,omitempty"`
ContractStart *string `json:"contract_start,omitempty"` // YYYY-MM-DD
ContractEnd *string `json:"contract_end,omitempty"`
ErpCustomerID string `json:"erp_customer_id,omitempty"`
}
func (s *Server) activateTenant(w http.ResponseWriter, r *http.Request) {
var in activateReq
if !decodeJSON(w, r, &in) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
upd := store.TenantUpdate{Status: ptrStr("active")}
if in.Plan != "" {
upd.Plan = &in.Plan
}
if in.ErpCustomerID != "" {
upd.ErpCustomerID = &in.ErpCustomerID
}
if cs, err := parseDate(in.ContractStart); err == nil && cs != nil {
upd.ContractStart = cs
} else if err != nil {
writeError(w, http.StatusBadRequest, "invalid_contract_start", "must be YYYY-MM-DD")
return
}
if ce, err := parseDate(in.ContractEnd); err == nil && ce != nil {
upd.ContractEnd = ce
} else if err != nil {
writeError(w, http.StatusBadRequest, "invalid_contract_end", "must be YYYY-MM-DD")
return
}
t, err := s.Store.UpdateTenant(ctx, r.PathValue("id"), upd)
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: t.ID, Action: "tenant.activated", TargetID: t.ID, TargetType: "tenant",
Metadata: map[string]interface{}{"plan": t.Plan, "erp_customer_id": t.ErpCustomerID},
})
writeJSON(w, http.StatusOK, t)
}
type cancelReq struct {
Reason string `json:"reason,omitempty"`
// AtPeriodEnd is a hint to billing; we always flip to 'frozen' immediately
// since billing is out of scope here.
AtPeriodEnd bool `json:"at_period_end,omitempty"`
}
func (s *Server) cancelTenant(w http.ResponseWriter, r *http.Request) {
var in cancelReq
if r.ContentLength > 0 {
if !decodeJSON(w, r, &in) {
return
}
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
t, err := s.Store.UpdateTenant(ctx, r.PathValue("id"), store.TenantUpdate{
Status: ptrStr("frozen"),
})
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: t.ID, Action: "tenant.canceled", TargetID: t.ID, TargetType: "tenant",
Metadata: map[string]interface{}{"reason": in.Reason, "at_period_end": in.AtPeriodEnd},
})
writeJSON(w, http.StatusOK, t)
}
func (s *Server) listTenantProducts(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
list, err := s.Store.ListTenantProducts(ctx, tenantID)
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
// ─── helpers (internal to this file) ──────────────────────────────────────
func ptrStr(s string) *string { return &s }
func parseDate(p *string) (*time.Time, error) {
if p == nil || *p == "" {
return nil, nil
}
t, err := time.Parse("2006-01-02", *p)
if err != nil {
return nil, errors.New("invalid date")
}
return &t, nil
}
// emitAudit is a fire-and-forget audit emit. Failures are logged but not
// returned to the caller — the actual user-facing operation already succeeded.
func (s *Server) emitAudit(ctx context.Context, r *http.Request, ev store.AuditEvent) {
ev.SourceIP = clientIP(r)
ev.UserAgent = r.UserAgent()
if _, err := s.Store.AppendAudit(ctx, ev); err != nil {
s.Log.Warn("audit emit failed", "err", err, "action", ev.Action)
}
}
+161
View File
@@ -0,0 +1,161 @@
package server_test
import (
"net/http"
"testing"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
func TestHealthz(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("GET", "/healthz", nil)
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestCreateTenant(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("POST", "/v1/tenants", map[string]any{
"slug": "beta-co", "name": "Beta Co.", "plan": "starter",
})
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 != "beta-co" || out.Tenant.Status != "trial" || out.Tenant.Plan != "starter" {
t.Errorf("unexpected: %+v", out.Tenant)
}
})
}
func TestCreateTenant_invalidSlug(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("POST", "/v1/tenants", map[string]any{
"slug": "X", "name": "Bad",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestCreateTenant_duplicateSlugConflict(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
// 'acme' is pre-seeded
resp, _ := h.do("POST", "/v1/tenants", map[string]any{
"slug": "acme", "name": "Dup",
})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestGetTenantBySlug(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("GET", "/v1/tenants/by-slug/acme", nil)
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
got := decode[store.Tenant](t, body)
if got.Slug != "acme" {
t.Errorf("slug = %q", got.Slug)
}
})
}
func TestGetTenantBySlug_notFound(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("GET", "/v1/tenants/by-slug/nope-nope", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestActivateTenant(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
_, body := h.do("POST", "/v1/tenants", map[string]any{
"slug": "trial-co", "name": "Trial Co.",
})
createdWrap := decode[struct {
Tenant *store.Tenant `json:"tenant"`
}](t, body)
created := createdWrap.Tenant
if created.Status != "trial" {
t.Fatalf("precondition: %q", created.Status)
}
resp, body := h.do("POST", "/v1/tenants/"+created.ID+"/activate", map[string]any{
"plan": "professional", "erp_customer_id": "ERP-001",
})
if resp.StatusCode != 200 {
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
}
got := decode[store.Tenant](t, body)
if got.Status != "active" || got.Plan != "professional" || got.ErpCustomerID != "ERP-001" {
t.Errorf("unexpected: %+v", got)
}
})
}
func TestCancelTenant(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("POST", "/v1/tenants/"+h.tenant.ID+"/cancel", map[string]any{
"reason": "test", "at_period_end": true,
})
if resp.StatusCode != 200 {
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
}
got := decode[store.Tenant](t, body)
if got.Status != "frozen" {
t.Errorf("status = %q", got.Status)
}
})
}
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)
}
})
}
+306 -30
View File
@@ -1,57 +1,105 @@
// Package store is a stand-in for the real Postgres-backed tenant store.
// The skeleton ships an in-memory implementation pre-seeded with one tenant
// (acme) so portal middleware has something to resolve in local dev.
// Replace with a pgx-backed implementation in the M4.1 follow-up PR.
package store package store
import ( import (
"context" "context"
"errors" "sort"
"sync" "sync"
"time" "time"
"github.com/google/uuid"
) )
var ErrNotFound = errors.New("tenant not found") // Memory — in-process Store used when DATABASE_URL is empty. Convenient for
// local dev when you don't want to bring up Postgres + run migrations.
type Tenant struct { // Pre-seeded with the acme tenant.
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"` // active | trial | frozen | archived | demo
Plan string `json:"plan"` // starter | professional | enterprise
Products []string `json:"products"`
CreatedAt time.Time `json:"created_at"`
}
type Memory struct { type Memory struct {
mu sync.RWMutex mu sync.RWMutex
bySlug map[string]*Tenant tenants map[string]*Tenant // id → tenant
byID map[string]*Tenant bySlug map[string]string // slug → id
products map[string]map[string]*TenantProduct // tenant_id → product → row
apiKeys map[string]*apiKeyWithHash // id → key
byPrefix map[string]string // prefix → id
audit []*AuditEvent
auditID int64
} }
type apiKeyWithHash struct {
APIKey
Hash string
}
// NewMemory returns a fresh in-memory store with the seed acme tenant.
func NewMemory() *Memory { func NewMemory() *Memory {
m := &Memory{ m := &Memory{
bySlug: make(map[string]*Tenant), tenants: make(map[string]*Tenant),
byID: make(map[string]*Tenant), bySlug: make(map[string]string),
products: make(map[string]map[string]*TenantProduct),
apiKeys: make(map[string]*apiKeyWithHash),
byPrefix: make(map[string]string),
} }
now := time.Now().UTC()
seed := &Tenant{ seed := &Tenant{
ID: "00000000-0000-0000-0000-000000000001", ID: "00000000-0000-0000-0000-000000000001",
Slug: "acme", Slug: "acme",
Name: "Acme Inc.", Name: "Acme Inc.",
Status: "active", Status: "active",
Kind: "customer",
Plan: "professional", Plan: "professional",
Products: []string{"certifai", "compliance"}, CreatedAt: now,
CreatedAt: time.Now().UTC(), UpdatedAt: now,
}
m.tenants[seed.ID] = seed
m.bySlug[seed.Slug] = seed.ID
m.products[seed.ID] = map[string]*TenantProduct{
"certifai": {TenantID: seed.ID, Product: "certifai", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now},
"compliance": {TenantID: seed.ID, Product: "compliance", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now},
} }
m.bySlug[seed.Slug] = seed
m.byID[seed.ID] = seed
return m return m
} }
func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) { func (m *Memory) Close() {}
func (m *Memory) Ping(_ context.Context) error { return nil }
// ─── tenants ──────────────────────────────────────────────────────────────
func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, taken := m.bySlug[in.Slug]; taken {
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,
}
m.tenants[t.ID] = t
m.bySlug[t.Slug] = t.ID
cp := *t
return &cp, nil
}
func (m *Memory) GetTenant(_ context.Context, id string) (*Tenant, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
t, ok := m.bySlug[slug] t, ok := m.tenants[id]
if !ok { if !ok {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -59,13 +107,241 @@ func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) {
return &cp, nil return &cp, nil
} }
func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) { func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
t, ok := m.byID[id] id, ok := m.bySlug[slug]
if !ok { if !ok {
return nil, ErrNotFound return nil, ErrNotFound
} }
t := m.tenants[id]
cp := *t cp := *t
return &cp, nil return &cp, nil
} }
func (m *Memory) UpdateTenant(_ context.Context, id string, in TenantUpdate) (*Tenant, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tenants[id]
if !ok {
return nil, ErrNotFound
}
if in.Status != nil {
t.Status = *in.Status
}
if in.Plan != nil {
t.Plan = *in.Plan
}
if in.ErpCustomerID != nil {
t.ErpCustomerID = *in.ErpCustomerID
}
if in.StripeCustID != nil {
t.StripeCustID = *in.StripeCustID
}
if in.TrialEndsAt != nil {
t.TrialEndsAt = in.TrialEndsAt
}
if in.ContractStart != nil {
t.ContractStart = in.ContractStart
}
if in.ContractEnd != nil {
t.ContractEnd = in.ContractEnd
}
if in.SalesOwner != nil {
t.SalesOwner = *in.SalesOwner
}
t.UpdatedAt = time.Now().UTC()
cp := *t
return &cp, nil
}
// ─── entitlements ─────────────────────────────────────────────────────────
func (m *Memory) UpsertTenantProduct(_ context.Context, tp TenantProduct) (*TenantProduct, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tp.TenantID]; !ok {
return nil, ErrNotFound
}
if _, ok := m.products[tp.TenantID]; !ok {
m.products[tp.TenantID] = map[string]*TenantProduct{}
}
now := time.Now().UTC()
stored, exists := m.products[tp.TenantID][tp.Product]
if !exists {
stored = &TenantProduct{
TenantID: tp.TenantID,
Product: tp.Product,
CreatedAt: now,
}
m.products[tp.TenantID][tp.Product] = stored
}
stored.Enabled = tp.Enabled
stored.Config = tp.Config
stored.ExpiresAt = tp.ExpiresAt
stored.UpdatedAt = now
cp := *stored
return &cp, nil
}
func (m *Memory) ListTenantProducts(_ context.Context, tenantID string) ([]TenantProduct, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrNotFound
}
out := make([]TenantProduct, 0, len(m.products[tenantID]))
for _, p := range m.products[tenantID] {
out = append(out, *p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Product < out[j].Product })
return out, nil
}
// ─── api keys ─────────────────────────────────────────────────────────────
func (m *Memory) CreateAPIKey(_ context.Context, in APIKeyCreate) (*APIKey, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[in.TenantID]; !ok {
return nil, ErrNotFound
}
now := time.Now().UTC()
k := &apiKeyWithHash{
APIKey: APIKey{
ID: uuid.NewString(),
TenantID: in.TenantID,
Product: in.Product,
Name: in.Name,
Scopes: append([]string{}, in.Scopes...),
Prefix: in.Prefix,
CreatedBy: in.CreatedBy,
CreatedAt: now,
},
Hash: in.Hash,
}
m.apiKeys[k.ID] = k
m.byPrefix[k.Prefix] = k.ID
cp := k.APIKey
return &cp, nil
}
func (m *Memory) FindAPIKeyByPrefix(_ context.Context, prefix string) (*APIKey, string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.byPrefix[prefix]
if !ok {
return nil, "", ErrNotFound
}
k := m.apiKeys[id]
if k.RevokedAt != nil {
return nil, "", ErrNotFound
}
cp := k.APIKey
return &cp, k.Hash, nil
}
func (m *Memory) TouchAPIKeyUsed(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
k, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
k.LastUsedAt = &now
return nil
}
func (m *Memory) RevokeAPIKey(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
k, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
k.RevokedAt = &now
return nil
}
func (m *Memory) ListAPIKeys(_ context.Context, tenantID string) ([]APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrNotFound
}
out := []APIKey{}
for _, k := range m.apiKeys {
if k.TenantID == tenantID {
out = append(out, k.APIKey)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
// ─── audit ────────────────────────────────────────────────────────────────
func (m *Memory) AppendAudit(_ context.Context, ev AuditEvent) (*AuditEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.auditID++
ev.ID = m.auditID
ev.CreatedAt = time.Now().UTC()
cp := ev
m.audit = append(m.audit, &cp)
out := ev
return &out, nil
}
func (m *Memory) ListAudit(_ context.Context, f AuditFilter) ([]AuditEvent, int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
matches := []AuditEvent{}
for _, ev := range m.audit {
if ev.ID <= f.Cursor {
continue
}
if f.TenantID != "" && ev.TenantID != f.TenantID {
continue
}
if f.Product != "" && ev.Product != f.Product {
continue
}
if f.ActorID != "" && ev.ActorID != f.ActorID {
continue
}
if f.Action != "" && ev.Action != f.Action {
continue
}
if f.Since != nil && ev.CreatedAt.Before(*f.Since) {
continue
}
if f.Until != nil && ev.CreatedAt.After(*f.Until) {
continue
}
matches = append(matches, *ev)
if len(matches) >= limit {
break
}
}
var nextCursor int64
if len(matches) == limit && len(matches) > 0 {
nextCursor = matches[len(matches)-1].ID
}
return matches, nextCursor, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
-64
View File
@@ -1,64 +0,0 @@
package store
import (
"context"
"errors"
"testing"
)
func TestMemory_seededAcme(t *testing.T) {
m := NewMemory()
ctx := context.Background()
t.Run("by slug returns seed", func(t *testing.T) {
got, err := m.BySlug(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if got.Slug != "acme" {
t.Errorf("slug = %q, want acme", got.Slug)
}
if got.Status != "active" {
t.Errorf("status = %q, want active", got.Status)
}
if len(got.Products) != 2 {
t.Errorf("products = %v, want [certifai compliance]", got.Products)
}
})
t.Run("by id returns seed", func(t *testing.T) {
got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatal(err)
}
if got.Slug != "acme" {
t.Errorf("slug = %q, want acme", got.Slug)
}
})
t.Run("missing slug returns ErrNotFound", func(t *testing.T) {
_, err := m.BySlug(ctx, "nope")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
t.Run("missing id returns ErrNotFound", func(t *testing.T) {
_, err := m.ByID(ctx, "deadbeef")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) {
got, err := m.BySlug(ctx, "acme")
if err != nil {
t.Fatal(err)
}
got.Name = "mutated"
got2, _ := m.BySlug(ctx, "acme")
if got2.Name == "mutated" {
t.Error("store leaked internal pointer; caller could mutate seeded state")
}
})
}
+489
View File
@@ -0,0 +1,489 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Postgres — pgxpool-backed Store. The schema this expects is produced by
// the migrations/ package (M4.1 forward).
type Postgres struct {
pool *pgxpool.Pool
}
// NewPostgres opens a pool and pings. Caller must Close().
func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
cfg.MaxConnLifetime = time.Hour
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return &Postgres{pool: pool}, nil
}
func (p *Postgres) Close() { p.pool.Close() }
func (p *Postgres) Ping(ctx context.Context) error { return p.pool.Ping(ctx) }
// isUniqueViolation detects Postgres unique_violation (23505) so callers
// can return ErrConflict cleanly.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
}
// isCheckViolation detects check_constraint_violation (23514) — used by the
// slug regex check + plan/status enum guards.
func isCheckViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.CheckViolation
}
// ─── tenants ──────────────────────────────────────────────────────────────
const tenantSelect = `
SELECT 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,''),
created_at, updated_at
FROM tenants `
func scanTenant(row pgx.Row) (*Tenant, error) {
var t Tenant
var trialEnds, cStart, cEnd *time.Time
err := row.Scan(
&t.ID, &t.Slug, &t.Name, &t.Status, &t.Kind, &t.Plan,
&t.ErpCustomerID, &t.StripeCustID,
&trialEnds, &cStart, &cEnd, &t.SalesOwner,
&t.CreatedAt, &t.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
t.TrialEndsAt = trialEnds
t.ContractStart = cStart
t.ContractEnd = cEnd
return &t, nil
}
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
)
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,''),
created_at, updated_at`,
in.Slug, in.Name, kind, plan, in.SalesOwner,
)
t, err := scanTenant(row)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrConflict
}
if isCheckViolation(err) {
return nil, ErrInvalidInput
}
return nil, fmt.Errorf("create tenant: %w", err)
}
return t, nil
}
func (p *Postgres) GetTenant(ctx context.Context, id string) (*Tenant, error) {
return scanTenant(p.pool.QueryRow(ctx, tenantSelect+`WHERE id = $1::uuid`, id))
}
func (p *Postgres) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) {
return scanTenant(p.pool.QueryRow(ctx, tenantSelect+`WHERE slug = $1`, slug))
}
func (p *Postgres) UpdateTenant(ctx context.Context, id string, in TenantUpdate) (*Tenant, error) {
// Build a partial UPDATE via COALESCE on each nullable input. Reads each
// field once; trivially type-safe.
row := p.pool.QueryRow(ctx, `
UPDATE tenants SET
status = COALESCE($2::tenant_status, status),
plan = COALESCE($3, plan),
erp_customer_id = COALESCE($4, erp_customer_id),
stripe_cust_id = COALESCE($5, stripe_cust_id),
trial_ends_at = COALESCE($6, trial_ends_at),
contract_start = COALESCE($7, contract_start),
contract_end = COALESCE($8, contract_end),
sales_owner = COALESCE($9, sales_owner)
WHERE id = $1::uuid
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,''),
created_at, updated_at`,
id,
nullableStr(in.Status), nullableStr(in.Plan),
nullableStr(in.ErpCustomerID), nullableStr(in.StripeCustID),
nullableTime(in.TrialEndsAt), nullableTime(in.ContractStart), nullableTime(in.ContractEnd),
nullableStr(in.SalesOwner),
)
t, err := scanTenant(row)
if err != nil {
if isCheckViolation(err) {
return nil, ErrInvalidInput
}
return nil, err
}
return t, nil
}
// ─── entitlements ─────────────────────────────────────────────────────────
func (p *Postgres) UpsertTenantProduct(ctx context.Context, tp TenantProduct) (*TenantProduct, error) {
cfg, err := json.Marshal(tp.Config)
if err != nil {
return nil, fmt.Errorf("marshal config: %w", err)
}
if cfg == nil || string(cfg) == "null" {
cfg = []byte("{}")
}
row := p.pool.QueryRow(ctx, `
INSERT INTO tenant_products (tenant_id, product, enabled, config, expires_at)
VALUES ($1::uuid, $2, $3, $4::jsonb, $5)
ON CONFLICT (tenant_id, product) DO UPDATE SET
enabled = EXCLUDED.enabled,
config = EXCLUDED.config,
expires_at = EXCLUDED.expires_at
RETURNING tenant_id::text, product, enabled, config, expires_at, created_at, updated_at`,
tp.TenantID, tp.Product, tp.Enabled, cfg, tp.ExpiresAt,
)
var out TenantProduct
var rawCfg []byte
var expires *time.Time
err = row.Scan(&out.TenantID, &out.Product, &out.Enabled, &rawCfg, &expires, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
// FK violation on tenant_id → not found
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation {
return nil, ErrNotFound
}
return nil, err
}
out.ExpiresAt = expires
if err := json.Unmarshal(rawCfg, &out.Config); err != nil {
out.Config = map[string]interface{}{}
}
return &out, nil
}
func (p *Postgres) ListTenantProducts(ctx context.Context, tenantID string) ([]TenantProduct, error) {
// First confirm tenant exists so we can return ErrNotFound consistent with Memory.
if _, err := p.GetTenant(ctx, tenantID); err != nil {
return nil, err
}
rows, err := p.pool.Query(ctx, `
SELECT tenant_id::text, product, enabled, config, expires_at, created_at, updated_at
FROM tenant_products WHERE tenant_id = $1::uuid ORDER BY product`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TenantProduct{}
for rows.Next() {
var tp TenantProduct
var rawCfg []byte
var expires *time.Time
if err := rows.Scan(&tp.TenantID, &tp.Product, &tp.Enabled, &rawCfg, &expires, &tp.CreatedAt, &tp.UpdatedAt); err != nil {
return nil, err
}
tp.ExpiresAt = expires
if err := json.Unmarshal(rawCfg, &tp.Config); err != nil {
tp.Config = map[string]interface{}{}
}
out = append(out, tp)
}
return out, rows.Err()
}
// ─── api keys ─────────────────────────────────────────────────────────────
func (p *Postgres) CreateAPIKey(ctx context.Context, in APIKeyCreate) (*APIKey, error) {
var product any
if in.Product != "" {
product = in.Product
}
var createdBy any
if in.CreatedBy != "" {
createdBy = in.CreatedBy
}
// Coerce nil to an empty slice — the schema's NOT NULL DEFAULT only
// fires when the column is omitted, not when an explicit NULL is sent.
scopes := in.Scopes
if scopes == nil {
scopes = []string{}
}
row := p.pool.QueryRow(ctx, `
INSERT INTO api_keys (tenant_id, product, name, scopes, hash, prefix, created_by)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)
RETURNING id::text, tenant_id::text, COALESCE(product,''), name, scopes, prefix,
COALESCE(created_by,''), last_used_at, revoked_at, created_at`,
in.TenantID, product, in.Name, scopes, in.Hash, in.Prefix, createdBy,
)
k, err := scanAPIKey(row)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrConflict
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation {
return nil, ErrNotFound
}
return nil, err
}
return k, nil
}
func scanAPIKey(row pgx.Row) (*APIKey, error) {
var k APIKey
var lastUsed, revoked *time.Time
err := row.Scan(&k.ID, &k.TenantID, &k.Product, &k.Name, &k.Scopes, &k.Prefix,
&k.CreatedBy, &lastUsed, &revoked, &k.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
k.LastUsedAt = lastUsed
k.RevokedAt = revoked
return &k, nil
}
func (p *Postgres) FindAPIKeyByPrefix(ctx context.Context, prefix string) (*APIKey, string, error) {
row := p.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, COALESCE(product,''), name, scopes, prefix,
COALESCE(created_by,''), last_used_at, revoked_at, created_at, hash
FROM api_keys WHERE prefix = $1 AND revoked_at IS NULL`, prefix)
var k APIKey
var lastUsed, revoked *time.Time
var hash string
err := row.Scan(&k.ID, &k.TenantID, &k.Product, &k.Name, &k.Scopes, &k.Prefix,
&k.CreatedBy, &lastUsed, &revoked, &k.CreatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
k.LastUsedAt = lastUsed
k.RevokedAt = revoked
return &k, hash, nil
}
func (p *Postgres) TouchAPIKeyUsed(ctx context.Context, id string) error {
tag, err := p.pool.Exec(ctx, `UPDATE api_keys SET last_used_at = NOW() WHERE id = $1::uuid`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (p *Postgres) RevokeAPIKey(ctx context.Context, id string) error {
tag, err := p.pool.Exec(ctx, `UPDATE api_keys SET revoked_at = NOW() WHERE id = $1::uuid AND revoked_at IS NULL`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (p *Postgres) ListAPIKeys(ctx context.Context, tenantID string) ([]APIKey, error) {
if _, err := p.GetTenant(ctx, tenantID); err != nil {
return nil, err
}
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, COALESCE(product,''), name, scopes, prefix,
COALESCE(created_by,''), last_used_at, revoked_at, created_at
FROM api_keys WHERE tenant_id = $1::uuid ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []APIKey{}
for rows.Next() {
var k APIKey
var lastUsed, revoked *time.Time
if err := rows.Scan(&k.ID, &k.TenantID, &k.Product, &k.Name, &k.Scopes, &k.Prefix,
&k.CreatedBy, &lastUsed, &revoked, &k.CreatedAt); err != nil {
return nil, err
}
k.LastUsedAt = lastUsed
k.RevokedAt = revoked
out = append(out, k)
}
return out, rows.Err()
}
// ─── audit ────────────────────────────────────────────────────────────────
func (p *Postgres) AppendAudit(ctx context.Context, ev AuditEvent) (*AuditEvent, error) {
meta, _ := json.Marshal(ev.Metadata)
if meta == nil || string(meta) == "null" {
meta = []byte("{}")
}
row := p.pool.QueryRow(ctx, `
INSERT INTO audit_log
(tenant_id, project_id, actor_id, actor_name, actor_type, action,
target_id, target_type, target_name, product, metadata, source_ip, user_agent)
VALUES
(NULLIF($1,'')::uuid, NULLIF($2,'')::uuid, NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), $6,
NULLIF($7,''), NULLIF($8,''), NULLIF($9,''), NULLIF($10,''), $11::jsonb,
NULLIF($12,'')::inet, NULLIF($13,''))
RETURNING id, COALESCE(tenant_id::text,''), COALESCE(project_id::text,''),
COALESCE(actor_id,''), COALESCE(actor_name,''), COALESCE(actor_type,''),
action, COALESCE(target_id,''), COALESCE(target_type,''), COALESCE(target_name,''),
COALESCE(product,''), metadata, COALESCE(host(source_ip),''), COALESCE(user_agent,''),
created_at`,
ev.TenantID, ev.ProjectID, ev.ActorID, ev.ActorName, ev.ActorType, ev.Action,
ev.TargetID, ev.TargetType, ev.TargetName, ev.Product, meta, ev.SourceIP, ev.UserAgent,
)
out, err := scanAudit(row)
if err != nil {
return nil, err
}
return out, nil
}
func scanAudit(row pgx.Row) (*AuditEvent, error) {
var ev AuditEvent
var rawMeta []byte
err := row.Scan(
&ev.ID, &ev.TenantID, &ev.ProjectID,
&ev.ActorID, &ev.ActorName, &ev.ActorType,
&ev.Action, &ev.TargetID, &ev.TargetType, &ev.TargetName,
&ev.Product, &rawMeta, &ev.SourceIP, &ev.UserAgent,
&ev.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if len(rawMeta) > 0 {
if err := json.Unmarshal(rawMeta, &ev.Metadata); err != nil {
ev.Metadata = map[string]interface{}{}
}
}
return &ev, nil
}
func (p *Postgres) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, int64, error) {
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
// Build WHERE clauses dynamically — keep param indices stable.
where := []string{"id > $1"}
args := []any{f.Cursor}
add := func(clause string, v any) {
args = append(args, v)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if f.TenantID != "" {
add("tenant_id = $%d::uuid", f.TenantID)
}
if f.Product != "" {
add("product = $%d", f.Product)
}
if f.ActorID != "" {
add("actor_id = $%d", f.ActorID)
}
if f.Action != "" {
add("action = $%d", f.Action)
}
if f.Since != nil {
add("created_at >= $%d", *f.Since)
}
if f.Until != nil {
add("created_at <= $%d", *f.Until)
}
args = append(args, limit)
sql := `
SELECT id, COALESCE(tenant_id::text,''), COALESCE(project_id::text,''),
COALESCE(actor_id,''), COALESCE(actor_name,''), COALESCE(actor_type,''),
action, COALESCE(target_id,''), COALESCE(target_type,''), COALESCE(target_name,''),
COALESCE(product,''), metadata, COALESCE(host(source_ip),''), COALESCE(user_agent,''),
created_at
FROM audit_log
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY id ASC
LIMIT $` + fmt.Sprintf("%d", len(args))
rows, err := p.pool.Query(ctx, sql, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []AuditEvent{}
for rows.Next() {
ev, err := scanAudit(rows)
if err != nil {
return nil, 0, err
}
out = append(out, *ev)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var nextCursor int64
if len(out) == limit && len(out) > 0 {
nextCursor = out[len(out)-1].ID
}
return out, nextCursor, nil
}
func nullableStr(p *string) any {
if p == nil {
return nil
}
return *p
}
func nullableTime(p *time.Time) any {
if p == nil {
return nil
}
return *p
}
+92
View File
@@ -0,0 +1,92 @@
// Package store hides the persistence layer behind a Store interface.
// Two implementations: Memory (dev convenience, used when DATABASE_URL is
// empty) and Postgres (production via pgx). Handlers depend on the
// interface — never on a concrete type.
package store
import (
"context"
"errors"
"time"
)
// Sentinel errors.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrInvalidInput = errors.New("invalid input")
)
// TenantCreate is the input shape for Store.CreateTenant.
type TenantCreate struct {
Slug string
Name string
Plan string // optional, defaults to "starter"
Kind string // optional, defaults to "customer"
SalesOwner string // optional
}
// TenantUpdate captures partial mutations. Nil fields are left untouched.
type TenantUpdate struct {
Status *string
Plan *string
ErpCustomerID *string
StripeCustID *string
TrialEndsAt *time.Time
ContractStart *time.Time
ContractEnd *time.Time
SalesOwner *string
}
// APIKeyCreate is the input shape for Store.CreateAPIKey.
type APIKeyCreate struct {
TenantID string
Product string // empty = applies to all products
Name string
Scopes []string
Prefix string
Hash string // argon2id encoded
CreatedBy string
}
// AuditFilter narrows /v1/audit GET results.
type AuditFilter struct {
TenantID string
Product string
ActorID string
Action string
Since *time.Time
Until *time.Time
Limit int
Cursor int64 // id > Cursor (ascending) is the next page anchor
}
// Store is the persistence contract. Implementations:
// - Memory — in-process, used when DATABASE_URL is empty (dev convenience).
// - Postgres — pgxpool-backed, used in stage + prod.
type Store interface {
// Tenants
CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error)
GetTenant(ctx context.Context, id string) (*Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error)
UpdateTenant(ctx context.Context, id string, in TenantUpdate) (*Tenant, error)
// Entitlements
UpsertTenantProduct(ctx context.Context, tp TenantProduct) (*TenantProduct, error)
ListTenantProducts(ctx context.Context, tenantID string) ([]TenantProduct, error)
// API keys
CreateAPIKey(ctx context.Context, in APIKeyCreate) (*APIKey, error)
FindAPIKeyByPrefix(ctx context.Context, prefix string) (*APIKey, string, error) // returns key + hash
TouchAPIKeyUsed(ctx context.Context, id string) error
RevokeAPIKey(ctx context.Context, id string) error
ListAPIKeys(ctx context.Context, tenantID string) ([]APIKey, error)
// Audit
AppendAudit(ctx context.Context, ev AuditEvent) (*AuditEvent, error)
ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, int64, error) // returns rows + next cursor (0 = none)
// Lifecycle
Close()
Ping(ctx context.Context) error
}
+82
View File
@@ -0,0 +1,82 @@
package store
import "time"
// Tenant — root entity. Lifecycle states per PLATFORM_ARCHITECTURE.md §5c:
//
// demo — shared demo tenant; reset nightly; no billing
// trial — real customer in N-day evaluation window
// active — paid; contract or self-serve
// frozen — read-only after cancel / non-payment (30d grace)
// archived — data export window closed; only audit_log retained
type Tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
Kind string `json:"kind"`
Plan string `json:"plan"`
ErpCustomerID string `json:"erp_customer_id,omitempty"`
StripeCustID string `json:"stripe_cust_id,omitempty"`
TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"`
ContractStart *time.Time `json:"contract_start,omitempty"`
ContractEnd *time.Time `json:"contract_end,omitempty"`
SalesOwner string `json:"sales_owner,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TenantProduct — entitlement matrix row.
type TenantProduct struct {
TenantID string `json:"tenant_id"`
Product string `json:"product"`
Enabled bool `json:"enabled"`
Config map[string]interface{} `json:"config"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// APIKey — portal-owned. Plaintext key is shown ONCE on creation;
// stored as argon2id hash + prefix for UI display.
type APIKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Product string `json:"product,omitempty"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
Prefix string `json:"prefix"`
CreatedBy string `json:"created_by,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AuditEvent — Retraced-shape per PRODUCT_INTEGRATION_SPEC.md §8.4.
type AuditEvent struct {
ID int64 `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name,omitempty"`
ActorType string `json:"actor_type,omitempty"`
Action string `json:"action"`
TargetID string `json:"target_id,omitempty"`
TargetType string `json:"target_type,omitempty"`
TargetName string `json:"target_name,omitempty"`
Product string `json:"product,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CatalogEntry — what /v1/catalog returns per available product.
type CatalogEntry struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
PlansRequired []string `json:"plans_required"`
DemoURL string `json:"demo_url,omitempty"`
SupportsTrial bool `json:"supports_trial"`
}
+18 -2
View File
@@ -1,5 +1,21 @@
-- M4.1 down — reverse of 0001_init.up.sql.
-- Forward-only in prod (column drops require two releases); the down
-- migration exists for testcontainers round-trips + dev tear-downs.
DROP TRIGGER IF EXISTS tenant_idp_config_touch_updated_at ON tenant_idp_config;
DROP TRIGGER IF EXISTS tenant_products_touch_updated_at ON tenant_products;
DROP TRIGGER IF EXISTS tenant_projects_touch_updated_at ON tenant_projects;
DROP TRIGGER IF EXISTS tenants_touch_updated_at ON tenants;
DROP FUNCTION IF EXISTS touch_updated_at();
DROP TABLE IF EXISTS audit_log; DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS tenant_idp_config;
DROP TABLE IF EXISTS tenant_products; DROP TABLE IF EXISTS tenant_products;
DROP TABLE IF EXISTS tenant_projects;
DROP TABLE IF EXISTS tenants; DROP TABLE IF EXISTS tenants;
DROP TYPE IF EXISTS tenant_kind;
DROP TYPE IF EXISTS tenant_status; DROP TYPE IF EXISTS tenant_project_status;
DROP TYPE IF EXISTS idp_kind;
DROP TYPE IF EXISTS tenant_kind;
DROP TYPE IF EXISTS tenant_status;
+175 -30
View File
@@ -1,52 +1,197 @@
-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c). -- M4.1 — initial tenant_registry schema.
-- The skeleton uses an in-memory store; this file lands the table shape -- Source of truth: PLATFORM_ARCHITECTURE.md §5c.
-- the real M4.1 PR will use, so the schema review can happen alongside -- Forward-only per IMPLEMENTATION_PLAN.md §1.7.
-- the rest of the boot scaffolding.
-- enums -------------------------------------------------------------------- -- =========================================================================
-- enums
-- =========================================================================
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo'); CREATE TYPE tenant_status AS ENUM (
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal'); 'demo', -- shared demo tenant; reset nightly; no billing
'trial', -- real customer in their N-day evaluation window
'active', -- paid; contract or self-serve plan
'frozen', -- read-only after cancel / non-payment (30d grace)
'archived' -- data export window closed; only audit log retained
);
-- tenants ------------------------------------------------------------------ CREATE TYPE tenant_kind AS ENUM (
'customer', -- real paying / trialing customer
'demo' -- shared demo tenant; never billed
);
CREATE TYPE idp_kind AS ENUM (
'oidc',
'saml'
);
CREATE TYPE tenant_project_status AS ENUM (
'active',
'archived'
);
-- =========================================================================
-- tenants — the root entity. tenants.id ↔ Keycloak org_id 1:1.
-- =========================================================================
CREATE TABLE tenants ( CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'), slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
name TEXT NOT NULL, name TEXT NOT NULL,
status tenant_status NOT NULL DEFAULT 'trial', status tenant_status NOT NULL DEFAULT 'trial',
kind tenant_kind NOT NULL DEFAULT 'customer', kind tenant_kind NOT NULL DEFAULT 'customer',
plan TEXT NOT NULL DEFAULT 'starter', plan TEXT NOT NULL DEFAULT 'starter',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- External system references (one-to-one per §5c "Links")
trial_ends_at TIMESTAMPTZ erp_customer_id TEXT UNIQUE,
stripe_cust_id TEXT UNIQUE,
-- Lifecycle dates
trial_ends_at TIMESTAMPTZ,
contract_start DATE,
contract_end DATE,
-- CRM ownership (ERPNext sales_owner equivalent)
sales_owner TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX tenants_status_idx ON tenants (status); CREATE INDEX tenants_status_idx ON tenants (status);
CREATE INDEX tenants_kind_idx ON tenants (kind);
CREATE INDEX tenants_trial_ends_idx ON tenants (trial_ends_at) WHERE trial_ends_at IS NOT NULL;
-- tenant ↔ product entitlements ------------------------------------------- -- =========================================================================
-- tenant_projects — OPTIONAL sub-tenancy (GCP-Project-style).
-- Customers without need operate as a single implicit "default" project.
-- Products opt in via manifest.supports_projects=true.
-- =========================================================================
CREATE TABLE tenant_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$'),
status tenant_project_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
CREATE INDEX tenant_projects_tenant_idx ON tenant_projects (tenant_id);
-- =========================================================================
-- tenant_products — entitlement matrix: which tenant has which product.
-- config holds product-specific knobs (litellm_url, max_seats, modules_enabled…).
-- =========================================================================
CREATE TABLE tenant_products ( CREATE TABLE tenant_products (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
product TEXT NOT NULL, product TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, product) PRIMARY KEY (tenant_id, product)
); );
-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------ CREATE INDEX tenant_products_product_idx ON tenant_products (product) WHERE enabled = TRUE;
CREATE TABLE audit_log ( -- =========================================================================
id BIGSERIAL PRIMARY KEY, -- tenant_idp_config — external identity provider per tenant (enterprise SSO).
tenant_id UUID REFERENCES tenants(id), -- metadata holds OIDC discovery URL + client_id, or SAML cert + entity_id.
actor_id TEXT, -- =========================================================================
actor_name TEXT,
action TEXT NOT NULL, CREATE TABLE tenant_idp_config (
target_id TEXT, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_type TEXT, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type idp_kind NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ip INET, verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, type)
); );
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC); CREATE INDEX tenant_idp_config_tenant_idx ON tenant_idp_config (tenant_id);
-- =========================================================================
-- api_keys — portal-owned. Single source of truth across all products.
-- hash is bcrypt/argon2 of the raw key; the plaintext is shown ONCE on create.
-- =========================================================================
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
product TEXT, -- nullable = applies to all products
name TEXT NOT NULL, -- human-readable label
scopes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
hash TEXT NOT NULL, -- argon2id encoded hash
prefix TEXT NOT NULL, -- first 8 chars of the raw key, for UI display
created_by TEXT, -- Keycloak user_id
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, name)
);
CREATE INDEX api_keys_tenant_idx ON api_keys (tenant_id) WHERE revoked_at IS NULL;
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
-- =========================================================================
-- audit_log — every state-changing action across portal + products.
-- Retraced-compatible shape (PRODUCT_INTEGRATION_SPEC.md §8.4) so we can
-- swap implementations without changing producers.
-- =========================================================================
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
project_id UUID REFERENCES tenant_projects(id) ON DELETE SET NULL,
actor_id TEXT,
actor_name TEXT,
actor_type TEXT, -- user | service | system
action TEXT NOT NULL,
target_id TEXT,
target_type TEXT,
target_name TEXT,
product TEXT, -- which product emitted this (NULL = portal/tenant-registry)
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
CREATE INDEX audit_log_product_idx ON audit_log (product, created_at DESC) WHERE product IS NOT NULL;
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC) WHERE actor_id IS NOT NULL;
CREATE INDEX audit_log_action_idx ON audit_log (action);
CREATE INDEX audit_log_tenant_action_idx ON audit_log (tenant_id, action, created_at DESC);
-- =========================================================================
-- update timestamp trigger — applied to every table with an updated_at.
-- =========================================================================
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tenants_touch_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER tenant_projects_touch_updated_at
BEFORE UPDATE ON tenant_projects
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER tenant_products_touch_updated_at
BEFORE UPDATE ON tenant_products
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER tenant_idp_config_touch_updated_at
BEFORE UPDATE ON tenant_idp_config
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
+10
View File
@@ -0,0 +1,10 @@
// Package migrations exposes the SQL migration files as an embed.FS so the
// migrate binary doesn't have to ship them as loose files at runtime.
package migrations
import "embed"
// FS holds every *.sql file in this directory at build time.
//
//go:embed *.sql
var FS embed.FS
+291
View File
@@ -0,0 +1,291 @@
package migrations
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib" // pgx stdlib driver for database/sql
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
)
// startPostgres spins a fresh postgres:16-alpine container and returns its
// DSN + a cleanup func. Skips the test if Docker is unreachable.
func startPostgres(t *testing.T) (string, func()) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pgc, err := tcpostgres.Run(ctx,
"postgres:16-alpine",
tcpostgres.WithDatabase("tenant_registry_test"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
tcpostgres.BasicWaitStrategies(),
)
if err != nil {
t.Skipf("skipping: docker unreachable (%v)", err)
}
dsn, err := pgc.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = pgc.Terminate(context.Background())
t.Fatalf("dsn: %v", err)
}
cleanup := func() {
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = pgc.Terminate(c)
}
return dsn, cleanup
}
func newMigrator(t *testing.T, dsn string) *migrate.Migrate {
t.Helper()
src, err := iofs.New(FS, ".")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
t.Fatal(err)
}
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = m.Close()
_ = db.Close()
})
return m
}
func TestMigrate_upDownRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test under -short")
}
dsn, stop := startPostgres(t)
defer stop()
m := newMigrator(t, dsn)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up: %v", err)
}
// Schema assertions — every table the spec requires must exist.
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
wantTables := []string{
"tenants",
"tenant_projects",
"tenant_products",
"tenant_idp_config",
"api_keys",
"audit_log",
}
for _, table := range wantTables {
var exists bool
err := db.QueryRow(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1)",
table,
).Scan(&exists)
if err != nil {
t.Fatalf("query for table %s: %v", table, err)
}
if !exists {
t.Errorf("table %s missing after migrate up", table)
}
}
// Enum assertions.
wantEnums := map[string][]string{
"tenant_status": {"demo", "trial", "active", "frozen", "archived"},
"tenant_kind": {"customer", "demo"},
"idp_kind": {"oidc", "saml"},
"tenant_project_status": {"active", "archived"},
}
for enum, values := range wantEnums {
rows, err := db.Query(
"SELECT e.enumlabel FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = $1 ORDER BY e.enumsortorder",
enum,
)
if err != nil {
t.Fatalf("query enum %s: %v", enum, err)
}
var got []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
t.Fatal(err)
}
got = append(got, v)
}
_ = rows.Close()
if fmt.Sprint(got) != fmt.Sprint(values) {
t.Errorf("enum %s = %v, want %v", enum, got, values)
}
}
// Round-trip: down all, then up again — must succeed without leftover state.
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("down: %v", err)
}
var afterDown int
err = db.QueryRow(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name = ANY($1)",
wantTables,
).Scan(&afterDown)
if err != nil {
t.Fatal(err)
}
if afterDown != 0 {
t.Errorf("after down: %d tables still present, want 0", afterDown)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up after down: %v", err)
}
}
// TestSeed_canInsertAndQuery is the lightweight happy-path: insert a tenant,
// give it a project + a product + an api_key + an audit record, query back.
// Catches schema-level mistakes (NOT NULL, FK direction, enum cast) that
// table-existence checks miss.
func TestSeed_canInsertAndQuery(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test under -short")
}
dsn, stop := startPostgres(t)
defer stop()
m := newMigrator(t, dsn)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up: %v", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
ctx := context.Background()
var tid string
err = db.QueryRowContext(ctx,
`INSERT INTO tenants (slug, name, plan, status, kind)
VALUES ($1, $2, 'professional', 'active', 'customer')
RETURNING id`,
"acme", "Acme Inc.").Scan(&tid)
if err != nil {
t.Fatalf("insert tenant: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO tenant_projects (tenant_id, name, slug) VALUES ($1, $2, $3)`,
tid, "Production", "prod"); err != nil {
t.Fatalf("insert project: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO tenant_products (tenant_id, product, config) VALUES ($1, 'certifai', '{"max_seats":10}'::jsonb)`,
tid); err != nil {
t.Fatalf("insert product: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO api_keys (tenant_id, name, hash, prefix, scopes)
VALUES ($1, 'ci-bot', 'argon2-hash', 'bp_12345', ARRAY['certifai:read'])`,
tid); err != nil {
t.Fatalf("insert api_key: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO audit_log (tenant_id, action, actor_id, actor_name, metadata)
VALUES ($1, 'tenant.created', 'sys', 'system', '{"source":"test"}'::jsonb)`,
tid); err != nil {
t.Fatalf("insert audit: %v", err)
}
// Round-trip read.
var slug, status string
err = db.QueryRowContext(ctx, `SELECT slug, status::text FROM tenants WHERE id = $1`, tid).Scan(&slug, &status)
if err != nil {
t.Fatal(err)
}
if slug != "acme" || status != "active" {
t.Errorf("tenant readback: slug=%q status=%q", slug, status)
}
// FK cascade — delete tenant, projects/products/keys/audit_log handling.
if _, err := db.ExecContext(ctx, `DELETE FROM tenants WHERE id = $1`, tid); err != nil {
t.Fatalf("delete tenant: %v", err)
}
var nProjects, nProducts, nKeys int
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_projects WHERE tenant_id = $1`, tid).Scan(&nProjects)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_products WHERE tenant_id = $1`, tid).Scan(&nProducts)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys WHERE tenant_id = $1`, tid).Scan(&nKeys)
if nProjects != 0 || nProducts != 0 || nKeys != 0 {
t.Errorf("FK cascade incomplete: projects=%d products=%d keys=%d", nProjects, nProducts, nKeys)
}
// audit_log uses ON DELETE SET NULL — tenant_id becomes NULL but row stays
var nAudit, nAuditNullTenant int
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&nAudit)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log WHERE tenant_id IS NULL`).Scan(&nAuditNullTenant)
if nAudit != 1 || nAuditNullTenant != 1 {
t.Errorf("audit_log SET NULL: total=%d null=%d, want 1/1", nAudit, nAuditNullTenant)
}
}
func TestSlugConstraint(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test under -short")
}
dsn, stop := startPostgres(t)
defer stop()
m := newMigrator(t, dsn)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up: %v", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
cases := []struct {
slug string
wantErr bool
}{
{"acme", false},
{"a-c-m-e", false},
{"a1b2c3", false},
{"a", true}, // too short
{"-acme", true}, // leading dash
{"acme-", true}, // trailing dash
{"AcMe", true}, // uppercase
{"a_b", true}, // underscore
}
for _, c := range cases {
_, err := db.Exec(`INSERT INTO tenants (slug, name) VALUES ($1, 'X')`, c.slug)
gotErr := err != nil
if gotErr != c.wantErr {
t.Errorf("slug %q: gotErr=%v wantErr=%v (err=%v)", c.slug, gotErr, c.wantErr, err)
}
}
}
+541
View File
@@ -0,0 +1,541 @@
openapi: 3.1.0
info:
title: Tenant Registry
version: 0.1.0
description: |
Internal platform API. Owns the multi-tenant glue: tenants, projects,
product entitlements, API keys, audit log. See
`PLATFORM_ARCHITECTURE.md §5c` for the schema, and
`PRODUCT_INTEGRATION_SPEC.md §8.4` for the audit shape.
This API is not yet authenticated — M4.3 adds Keycloak JWT validation.
contact:
email: oncall@breakpilot.com
license:
name: Proprietary
servers:
- url: http://localhost:8090
description: Local dev
- url: https://tenant-registry.stage.breakpilot.com
description: Stage
- url: https://tenant-registry.breakpilot.com
description: Production
paths:
/healthz:
get:
summary: Liveness probe
responses:
"200":
description: Always returns ok.
content:
application/json:
schema:
type: object
properties: { status: { type: string, example: ok } }
/readyz:
get:
summary: Readiness probe — pings the store.
responses:
"200":
description: Store reachable.
"503":
description: Store unreachable.
content:
application/json: { schema: { $ref: "#/components/schemas/Error" } }
/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. `invite_url` is non-empty when an
`admin_email` was passed and Keycloak provisioning succeeded.
content:
application/json: { schema: { $ref: "#/components/schemas/TenantCreated" } }
"400": { $ref: "#/components/responses/BadRequest" }
"409": { $ref: "#/components/responses/Conflict" }
/v1/tenants/{id}:
get:
summary: Get tenant by id.
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Found.
content:
application/json: { schema: { $ref: "#/components/schemas/Tenant" } }
"404": { $ref: "#/components/responses/NotFound" }
/v1/tenants/by-slug/{slug}:
get:
summary: Get tenant by slug.
parameters:
- in: path
name: slug
required: true
schema: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$' }
responses:
"200":
description: Found.
content:
application/json: { schema: { $ref: "#/components/schemas/Tenant" } }
"404": { $ref: "#/components/responses/NotFound" }
/v1/tenants/{id}/activate:
post:
summary: Move tenant to status=active.
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
requestBody:
required: false
content:
application/json: { schema: { $ref: "#/components/schemas/TenantActivate" } }
responses:
"200":
description: Updated.
content:
application/json: { schema: { $ref: "#/components/schemas/Tenant" } }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/v1/tenants/{id}/cancel:
post:
summary: Move tenant to status=frozen.
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
requestBody:
required: false
content:
application/json: { schema: { $ref: "#/components/schemas/TenantCancel" } }
responses:
"200":
description: Updated.
content:
application/json: { schema: { $ref: "#/components/schemas/Tenant" } }
"404": { $ref: "#/components/responses/NotFound" }
/v1/entitlements:
get:
summary: List product entitlements for a tenant.
parameters:
- in: query
name: tenant_id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: List response.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: "#/components/schemas/TenantProduct" }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/v1/catalog:
get:
summary: List the products available for any tenant to request.
responses:
"200":
description: Catalog.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: "#/components/schemas/CatalogEntry" }
/v1/catalog/request:
post:
summary: Request a product (creates an audit event; sales follows up).
requestBody:
required: true
content:
application/json: { schema: { $ref: "#/components/schemas/CatalogRequest" } }
responses:
"202":
description: Accepted.
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/v1/catalog/trial-request:
post:
summary: Self-serve a 14-day trial.
requestBody:
required: true
content:
application/json: { schema: { $ref: "#/components/schemas/CatalogRequest" } }
responses:
"201":
description: Trial entitlement created.
content:
application/json: { schema: { $ref: "#/components/schemas/TenantProduct" } }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/v1/api-keys:
get:
summary: List API keys for a tenant.
parameters:
- in: query
name: tenant_id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: List response. The plaintext key is NOT returned;
use the create endpoint and store the value immediately.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: "#/components/schemas/APIKey" }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
post:
summary: Create an API key. The plaintext is returned ONCE.
requestBody:
required: true
content:
application/json: { schema: { $ref: "#/components/schemas/APIKeyCreate" } }
responses:
"201":
description: Created. The plaintext field is shown only here.
content:
application/json: { schema: { $ref: "#/components/schemas/APIKeyCreated" } }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/v1/api-keys/{id}:
delete:
summary: Revoke an API key (sets revoked_at).
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"204":
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
200 with valid=false on any failure (never 401).
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [key]
properties:
key: { type: string }
responses:
"200":
description: Verification result.
content:
application/json: { schema: { $ref: "#/components/schemas/APIKeyVerify" } }
/v1/audit:
post:
summary: Append an audit event.
requestBody:
required: true
content:
application/json: { schema: { $ref: "#/components/schemas/AuditAppend" } }
responses:
"201":
description: Created.
content:
application/json: { schema: { $ref: "#/components/schemas/AuditEvent" } }
"400": { $ref: "#/components/responses/BadRequest" }
get:
summary: List audit events (paginated, cursor-based).
parameters:
- in: query
name: tenant_id
schema: { type: string, format: uuid }
- in: query
name: product
schema: { type: string }
- in: query
name: actor_id
schema: { type: string }
- in: query
name: action
schema: { type: string }
- in: query
name: since
schema: { type: string, format: date-time }
- in: query
name: until
schema: { type: string, format: date-time }
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 500 }
- in: query
name: cursor
schema: { type: integer, format: int64 }
responses:
"200":
description: Paginated response. next_cursor is omitted when
there are no more pages.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items: { $ref: "#/components/schemas/AuditEvent" }
next_cursor:
type: integer
format: int64
"400": { $ref: "#/components/responses/BadRequest" }
components:
responses:
BadRequest:
description: Input failed validation.
content:
application/json: { schema: { $ref: "#/components/schemas/Error" } }
NotFound:
description: Resource does not exist.
content:
application/json: { schema: { $ref: "#/components/schemas/Error" } }
Conflict:
description: Resource already exists / unique violation.
content:
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]
properties:
error: { type: string, example: invalid_input }
message: { type: string }
Tenant:
type: object
required: [id, slug, name, status, kind, plan, created_at, updated_at]
properties:
id: { type: string, format: uuid }
slug: { type: string }
name: { type: string }
status: { type: string, enum: [demo, trial, active, frozen, archived] }
kind: { type: string, enum: [customer, demo] }
plan: { type: string }
erp_customer_id: { type: string }
stripe_cust_id: { type: string }
trial_ends_at: { type: string, format: date-time, nullable: true }
contract_start: { type: string, format: date, nullable: true }
contract_end: { type: string, format: date, nullable: true }
sales_owner: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
TenantCreate:
type: object
required: [slug, name]
properties:
slug: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$' }
name: { type: string, minLength: 1, maxLength: 255 }
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
properties:
plan: { type: string }
contract_start: { type: string, format: date }
contract_end: { type: string, format: date }
erp_customer_id: { type: string }
TenantCancel:
type: object
properties:
reason: { type: string }
at_period_end: { type: boolean }
TenantProduct:
type: object
required: [tenant_id, product, enabled, config, created_at, updated_at]
properties:
tenant_id: { type: string, format: uuid }
product: { type: string }
enabled: { type: boolean }
config: { type: object, additionalProperties: true }
expires_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
CatalogEntry:
type: object
required: [key, name, description, plans_required, supports_trial]
properties:
key: { type: string }
name: { type: string }
description: { type: string }
plans_required: { type: array, items: { type: string } }
demo_url: { type: string }
supports_trial: { type: boolean }
CatalogRequest:
type: object
required: [tenant_id, product]
properties:
tenant_id: { type: string, format: uuid }
product: { type: string }
APIKey:
type: object
required: [id, tenant_id, name, scopes, prefix, created_at]
properties:
id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
product: { type: string }
name: { type: string }
scopes: { type: array, items: { type: string } }
prefix: { type: string }
created_by: { type: string }
last_used_at: { type: string, format: date-time, nullable: true }
revoked_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
APIKeyCreate:
type: object
required: [tenant_id, name]
properties:
tenant_id: { type: string, format: uuid }
name: { type: string, maxLength: 100 }
product: { type: string }
scopes: { type: array, items: { type: string } }
created_by: { type: string }
APIKeyCreated:
type: object
required: [api_key, plaintext, warning]
properties:
api_key: { $ref: "#/components/schemas/APIKey" }
plaintext: { type: string }
warning: { type: string }
APIKeyVerify:
type: object
required: [valid]
properties:
valid: { type: boolean }
tenant_id: { type: string, format: uuid }
product: { type: string }
scopes: { type: array, items: { type: string } }
AuditAppend:
type: object
required: [action]
properties:
tenant_id: { type: string, format: uuid }
project_id: { type: string, format: uuid }
actor_id: { type: string }
actor_name: { type: string }
actor_type: { type: string }
action: { type: string }
target_id: { type: string }
target_type: { type: string }
target_name: { type: string }
product: { type: string }
metadata: { type: object, additionalProperties: true }
AuditEvent:
allOf:
- $ref: "#/components/schemas/AuditAppend"
- type: object
required: [id, created_at]
properties:
id: { type: integer, format: int64 }
source_ip: { type: string }
user_agent: { type: string }
created_at: { type: string, format: date-time }