Compare commits
1 Commits
main
..
f9e9f0e21b
| Author | SHA1 | Date | |
|---|---|---|---|
| f9e9f0e21b |
+4
-12
@@ -1,19 +1,11 @@
|
|||||||
# 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. Leave empty for in-memory store (the seeded acme tenant
|
# Postgres DSN — unused by the skeleton (in-memory store). Wired up in M4.1.
|
||||||
# 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...
|
|
||||||
|
|||||||
@@ -77,13 +77,10 @@ jobs:
|
|||||||
with: { version: v2.12.2 }
|
with: { version: v2.12.2 }
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
# Test runs the packages that HAVE test files (server, config). The
|
# Coverage scoped to ./internal/... — cmd/server is the entrypoint
|
||||||
# store package is exercised end-to-end via the server's eachStore
|
# with signal-handling + bind that isn't worth unit-testing. When
|
||||||
# harness against both Memory and Postgres, so we don't need its
|
# real integration tests land in M4.1, widen this back to ./...
|
||||||
# own test binary — and including it triggers a covdata-tool error
|
run: go test -race -coverprofile=cover.out ./internal/...
|
||||||
# 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: |
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ 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(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
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -34,82 +34,37 @@ 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 (the JWT signer) |
|
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL |
|
||||||
| `DATABASE_URL` | empty (in-memory store fallback) | Postgres DSN; service uses Memory when empty |
|
| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR |
|
||||||
| `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
|
||||||
|
|
||||||
Authoritative spec: [`openapi.yaml`](./openapi.yaml). Summary:
|
| Method | Path | Returns |
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/healthz` | Liveness |
|
| GET | `/healthz` | `{"status":"ok"}` — liveness probe |
|
||||||
| GET | `/readyz` | Pings the store |
|
| GET | `/v1/tenants/by-slug/{slug}` | 200 with tenant JSON, 404 if missing |
|
||||||
| POST | `/v1/tenants` | Create a tenant |
|
| GET | `/v1/tenants/{id}` | 200 with tenant JSON, 404 if missing |
|
||||||
| 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) |
|
|
||||||
|
|
||||||
State-changing endpoints emit audit events automatically. The OpenAPI contract test (`openapi_test.go`) asserts every listed path resolves against the committed spec.
|
The skeleton's store is in-memory and pre-seeded with one tenant:
|
||||||
|
|
||||||
## Storage
|
```json
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"slug": "acme",
|
||||||
|
"name": "Acme Inc.",
|
||||||
|
"status": "active",
|
||||||
|
"plan": "professional",
|
||||||
|
"products": ["certifai", "compliance"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The service picks its store based on `DATABASE_URL`:
|
So `curl http://localhost:8090/v1/tenants/by-slug/acme` works the moment `make dev` is up.
|
||||||
|
|
||||||
- **empty** → in-memory store, pre-seeded with the `acme` tenant (`id: 00000000-0000-0000-0000-000000000001`). Useful for portal dev without spinning Postgres.
|
The full schema (6 tables: `tenants`, `tenant_projects`, `tenant_products`, `tenant_idp_config`, `api_keys`, `audit_log` — per `PLATFORM_ARCHITECTURE.md §5c`) lives at `migrations/0001_init.up.sql`. The handler-layer in-memory store is still wired in by default; the pgx-backed store + the full REST surface lands in **M4.2**.
|
||||||
- **set** → pgx-backed Postgres. Run `make migrate-up` against the same DSN first.
|
|
||||||
|
|
||||||
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)
|
## Schema migrations (M4.1)
|
||||||
|
|
||||||
@@ -166,4 +121,3 @@ 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).
|
||||||
|
|
||||||
|
|||||||
+3
-49
@@ -11,9 +11,7 @@ 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() {
|
||||||
@@ -26,43 +24,10 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
bootCtx, cancelBoot := context.WithTimeout(context.Background(), 15*time.Second)
|
mux := server.NewRouter(cfg, logger)
|
||||||
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: handler,
|
Handler: mux,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
@@ -72,7 +37,7 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env, "store", storeKind(s))
|
slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env)
|
||||||
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)
|
||||||
@@ -89,14 +54,3 @@ 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,13 +3,9 @@ module gitea.meghsakha.com/platform/tenant-registry
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/getkin/kin-openapi v0.138.0
|
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
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/jackc/pgx/v5 v5.9.2
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||||
golang.org/x/crypto v0.51.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -32,18 +28,14 @@ require (
|
|||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/google/uuid v1.6.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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // 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/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
github.com/moby/moby/api v1.54.1 // indirect
|
github.com/moby/moby/api v1.54.1 // indirect
|
||||||
@@ -53,30 +45,25 @@ require (
|
|||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // 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/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
|
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // 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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
|||||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
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 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
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 h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
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 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
@@ -41,8 +39,6 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/
|
|||||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -50,12 +46,6 @@ 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-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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
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 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
@@ -65,10 +55,6 @@ 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -77,8 +63,6 @@ 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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -91,8 +75,6 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
|||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
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 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -113,20 +95,12 @@ 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/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 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
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 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
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 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -136,8 +110,6 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
|||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
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 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
@@ -157,10 +129,6 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
|
|||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
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 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
@@ -177,19 +145,19 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
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 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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-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.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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -11,15 +10,6 @@ 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) {
|
||||||
@@ -33,12 +23,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
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}`))
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
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:], " ")
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/keycloak"
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateTenant_provisionsKeycloak(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
|
||||||
"slug": "kc-co",
|
|
||||||
"name": "KC Co.",
|
|
||||||
"admin_email": "owner@kc-co.test",
|
|
||||||
"admin_name": "Pat Owner",
|
|
||||||
})
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
out := decode[struct {
|
|
||||||
Tenant *store.Tenant `json:"tenant"`
|
|
||||||
InviteURL string `json:"invite_url"`
|
|
||||||
}](t, body)
|
|
||||||
if out.Tenant.Slug != "kc-co" {
|
|
||||||
t.Errorf("slug = %q", out.Tenant.Slug)
|
|
||||||
}
|
|
||||||
if out.InviteURL == "" {
|
|
||||||
t.Error("invite_url missing in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mock recorded the call.
|
|
||||||
if _, ok := h.kcMock.Orgs[out.Tenant.ID]; !ok {
|
|
||||||
t.Errorf("kc mock did not record org for tenant %s", out.Tenant.ID)
|
|
||||||
}
|
|
||||||
if _, ok := h.kcMock.Users["owner@kc-co.test"]; !ok {
|
|
||||||
t.Error("kc mock did not record user for owner@kc-co.test")
|
|
||||||
}
|
|
||||||
|
|
||||||
// And we emitted a keycloak.invite_sent audit event.
|
|
||||||
resp, body = h.do("GET",
|
|
||||||
"/v1/audit?action=keycloak.invite_sent&tenant_id="+out.Tenant.ID, nil)
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
t.Fatalf("audit list status = %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
listed := decode[struct {
|
|
||||||
Items []store.AuditEvent `json:"items"`
|
|
||||||
}](t, body)
|
|
||||||
if len(listed.Items) != 1 {
|
|
||||||
t.Errorf("expected 1 invite_sent event, got %d", len(listed.Items))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateTenant_kcFailure_doesNotRollback(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
// Force the mock to fail the next call.
|
|
||||||
h.kcMock.FailNext = keycloak.ErrUnavailable
|
|
||||||
|
|
||||||
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
|
||||||
"slug": "kc-fail", "name": "KC Fail", "admin_email": "x@y.test",
|
|
||||||
})
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
t.Fatalf("expected tenant still created despite kc fail; status=%d body=%s",
|
|
||||||
resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
out := decode[struct {
|
|
||||||
Tenant *store.Tenant `json:"tenant"`
|
|
||||||
}](t, body)
|
|
||||||
// Tenant landed in the DB.
|
|
||||||
if out.Tenant.ID == "" {
|
|
||||||
t.Error("tenant id missing")
|
|
||||||
}
|
|
||||||
// And there's a provision_failed audit event for it.
|
|
||||||
_, body = h.do("GET",
|
|
||||||
"/v1/audit?action=keycloak.provision_failed&tenant_id="+out.Tenant.ID, nil)
|
|
||||||
listed := decode[struct {
|
|
||||||
Items []store.AuditEvent `json:"items"`
|
|
||||||
}](t, body)
|
|
||||||
if len(listed.Items) != 1 {
|
|
||||||
t.Errorf("expected 1 provision_failed event, got %d", len(listed.Items))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKcClaims_returnsCurrentEntitlements(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
|
||||||
"tenant_slug": h.tenant.Slug,
|
|
||||||
})
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
got := decode[keycloak.Claims](t, body)
|
|
||||||
if got.TenantID != h.tenant.ID || got.TenantSlug != h.tenant.Slug {
|
|
||||||
t.Errorf("tenant fields off: %+v", got)
|
|
||||||
}
|
|
||||||
if got.Plan != h.tenant.Plan {
|
|
||||||
t.Errorf("plan = %q, want %q", got.Plan, h.tenant.Plan)
|
|
||||||
}
|
|
||||||
if got.TenantStatus != h.tenant.Status {
|
|
||||||
t.Errorf("status = %q, want %q", got.TenantStatus, h.tenant.Status)
|
|
||||||
}
|
|
||||||
// acme is seeded with certifai + compliance entitlements (memory)
|
|
||||||
// or one or zero (postgres, depending on prior subtest ordering).
|
|
||||||
// At minimum the field is present.
|
|
||||||
if got.Products == nil {
|
|
||||||
t.Error("products is nil; should be at least empty slice")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKcClaims_lookupByUserAttrs(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
resp, body := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
|
||||||
"user_attrs": map[string]string{"tenant_slug": h.tenant.Slug},
|
|
||||||
})
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
got := decode[keycloak.Claims](t, body)
|
|
||||||
if got.TenantID != h.tenant.ID {
|
|
||||||
t.Errorf("did not resolve via user_attrs; got %+v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKcClaims_missingTenant404(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{
|
|
||||||
"tenant_slug": "nope-nope",
|
|
||||||
})
|
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
|
||||||
t.Errorf("status = %d, want 404", resp.StatusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKcClaims_requiresInput(t *testing.T) {
|
|
||||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
|
||||||
resp, _ := h.do("POST", "/v1/internal/keycloak/claims", map[string]any{})
|
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
|
||||||
t.Errorf("status = %d, want 400", resp.StatusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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
|
|
||||||
+82
-56
@@ -1,80 +1,106 @@
|
|||||||
// 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server bundles the dependencies every handler needs.
|
type deps struct {
|
||||||
type Server struct {
|
cfg *config.Config
|
||||||
Cfg *config.Config
|
log *slog.Logger
|
||||||
Log *slog.Logger
|
tenant *store.Memory
|
||||||
Store store.Store
|
|
||||||
Keycloak keycloak.Adapter // never nil — main wires Mock when KC env is unset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter builds the http.Handler with logging middleware applied.
|
func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler {
|
||||||
func NewRouter(s *Server) http.Handler {
|
d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
// health + status
|
return logRequest(log)(mux)
|
||||||
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 (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
|
func (d *deps) 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 (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
func (d *deps) tenantBySlug(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.Store.Ping(r.Context()); err != nil {
|
slug := r.PathValue("slug")
|
||||||
writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
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 := s.Keycloak.Health(r.Context()); err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "keycloak_unavailable", err.Error())
|
d.log.Error("tenant lookup failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-162
@@ -1,186 +1,73 @@
|
|||||||
package server_test
|
package server
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── harness ──────────────────────────────────────────────────────────────
|
func newTestServer(t *testing.T) *httptest.Server {
|
||||||
|
|
||||||
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()
|
||||||
mem := store.NewMemory()
|
cfg := &config.Config{Env: "dev", Addr: ":0"}
|
||||||
tenant, _ := mem.GetTenantBySlug(context.Background(), "acme")
|
h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
return wireHarness(t, mem, tenant)
|
return httptest.NewServer(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPostgresHarness(t *testing.T) *testHarness {
|
func TestHealthz(t *testing.T) {
|
||||||
t.Helper()
|
srv := newTestServer(t)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
defer srv.Close()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
pgc, err := tcpostgres.Run(ctx,
|
resp, err := http.Get(srv.URL + "/healthz")
|
||||||
"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)
|
||||||
}
|
}
|
||||||
db, err := sql.Open("pgx", dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
driver, err := migpg.WithInstance(db, &migpg.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
|
|
||||||
if err != nil {
|
|
||||||
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() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
if resp.StatusCode != http.StatusOK {
|
||||||
return resp, raw
|
t.Fatalf("got %d, want 200", resp.StatusCode)
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
func TestTenantBySlug_acme(t *testing.T) {
|
||||||
var _ = fmt.Sprintf
|
srv := newTestServer(t)
|
||||||
var _ = os.Stderr
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("got %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+30
-306
@@ -1,105 +1,57 @@
|
|||||||
|
// 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"
|
||||||
"sort"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Memory — in-process Store used when DATABASE_URL is empty. Convenient for
|
var ErrNotFound = errors.New("tenant not found")
|
||||||
// local dev when you don't want to bring up Postgres + run migrations.
|
|
||||||
// Pre-seeded with the acme tenant.
|
type Tenant struct {
|
||||||
|
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
|
||||||
tenants map[string]*Tenant // id → tenant
|
bySlug map[string]*Tenant
|
||||||
bySlug map[string]string // slug → id
|
byID map[string]*Tenant
|
||||||
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{
|
||||||
tenants: make(map[string]*Tenant),
|
bySlug: make(map[string]*Tenant),
|
||||||
bySlug: make(map[string]string),
|
byID: make(map[string]*Tenant),
|
||||||
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",
|
||||||
CreatedAt: now,
|
Products: []string{"certifai", "compliance"},
|
||||||
UpdatedAt: now,
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
|
||||||
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) Close() {}
|
func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) {
|
||||||
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.tenants[id]
|
t, ok := m.bySlug[slug]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -107,241 +59,13 @@ func (m *Memory) GetTenant(_ context.Context, id string) (*Tenant, error) {
|
|||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) {
|
func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
id, ok := m.bySlug[slug]
|
t, ok := m.byID[id]
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
-541
@@ -1,541 +0,0 @@
|
|||||||
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 }
|
|
||||||
Reference in New Issue
Block a user