diff --git a/.env.example b/.env.example index 4605320..c0d11d5 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # tenant-registry — local dev environment. -# -# Copy to .env.local (gitignored) and edit. The service reads env vars -# directly via internal/config; this file is just documentation. +# Copy to .env.local (gitignored). APP_ENV=dev ADDR=:8090 KEYCLOAK_ISSUER=http://localhost:8080/realms/breakpilot-dev -# Postgres DSN — unused by the skeleton (in-memory store). Wired up in M4.1. +# Postgres DSN. Leave empty for in-memory store (the seeded acme tenant +# only; data lost on restart). Set to use the dev-stack Postgres + run +# `make migrate-up` first. # DATABASE_URL=postgres://platform:platform-dev-pass@localhost:5432/platform?sslmode=disable diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8ced9..afecee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl ## [Unreleased] ### Added +- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test. - feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests - feat(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant - diff --git a/README.md b/README.md index cc606aa..ba46c9c 100644 --- a/README.md +++ b/README.md @@ -43,28 +43,38 @@ Env vars (override at the shell): ## Endpoints -| Method | Path | Returns | +Authoritative spec: [`openapi.yaml`](./openapi.yaml). Summary: + +| Method | Path | Purpose | |---|---|---| -| GET | `/healthz` | `{"status":"ok"}` — liveness probe | -| GET | `/v1/tenants/by-slug/{slug}` | 200 with tenant JSON, 404 if missing | -| GET | `/v1/tenants/{id}` | 200 with tenant JSON, 404 if missing | +| GET | `/healthz` | Liveness | +| GET | `/readyz` | Pings the store | +| POST | `/v1/tenants` | Create a tenant | +| GET | `/v1/tenants/{id}` | Read by id | +| GET | `/v1/tenants/by-slug/{slug}` | Read by slug (portal middleware uses this) | +| POST | `/v1/tenants/{id}/activate` | trial → active | +| POST | `/v1/tenants/{id}/cancel` | active → frozen | +| GET | `/v1/entitlements?tenant_id={id}` | List product entitlements | +| GET | `/v1/catalog` | List requestable products | +| POST | `/v1/catalog/request` | Customer requests a product (sales follow-up) | +| POST | `/v1/catalog/trial-request` | Self-serve 14-day trial | +| GET | `/v1/api-keys?tenant_id={id}` | List keys | +| POST | `/v1/api-keys` | Create key (plaintext shown once) | +| DELETE | `/v1/api-keys/{id}` | Revoke | +| POST | `/v1/internal/api-keys/verify` | Used by headless products to validate inbound keys | +| POST | `/v1/audit` | Append an audit event | +| GET | `/v1/audit` | Query (cursor-paginated) | -The skeleton's store is in-memory and pre-seeded with one tenant: +State-changing endpoints emit audit events automatically. The OpenAPI contract test (`openapi_test.go`) asserts every listed path resolves against the committed spec. -```json -{ - "id": "00000000-0000-0000-0000-000000000001", - "slug": "acme", - "name": "Acme Inc.", - "status": "active", - "plan": "professional", - "products": ["certifai", "compliance"] -} -``` +## Storage -So `curl http://localhost:8090/v1/tenants/by-slug/acme` works the moment `make dev` is up. +The service picks its store based on `DATABASE_URL`: -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**. +- **empty** → in-memory store, pre-seeded with the `acme` tenant (`id: 00000000-0000-0000-0000-000000000001`). Useful for portal dev without spinning Postgres. +- **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`). ## Schema migrations (M4.1) diff --git a/cmd/server/main.go b/cmd/server/main.go index d6b6d40..7cc296a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "gitea.meghsakha.com/platform/tenant-registry/internal/config" "gitea.meghsakha.com/platform/tenant-registry/internal/server" + "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) func main() { @@ -24,10 +25,27 @@ func main() { os.Exit(1) } - mux := server.NewRouter(cfg, logger) + bootCtx, cancelBoot := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelBoot() + + var s store.Store + if cfg.DatabaseURL == "" { + slog.Warn("DATABASE_URL not set — running with in-memory store (dev only)") + s = store.NewMemory() + } else { + pg, err := store.NewPostgres(bootCtx, cfg.DatabaseURL) + if err != nil { + slog.Error("postgres connect failed", "err", err) + os.Exit(1) + } + s = pg + } + defer s.Close() + + handler := server.NewRouter(&server.Server{Cfg: cfg, Log: logger, Store: s}) srv := &http.Server{ Addr: cfg.Addr, - Handler: mux, + Handler: handler, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, @@ -37,7 +55,7 @@ func main() { defer stop() go func() { - slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env) + slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env, "store", storeKind(s)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server crashed", "err", err) os.Exit(1) @@ -54,3 +72,14 @@ func main() { } 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" + } +} diff --git a/go.mod b/go.mod index 4f156b8..a93ae1c 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,13 @@ module gitea.meghsakha.com/platform/tenant-registry go 1.25.0 require ( + github.com/getkin/kin-openapi v0.138.0 github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.9.2 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + golang.org/x/crypto v0.51.0 ) require ( @@ -28,14 +32,18 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect @@ -45,25 +53,30 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.12 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index edd51c8..9e757e9 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -39,6 +41,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= +github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -46,6 +50,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -55,6 +65,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -63,6 +77,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -75,6 +91,8 @@ 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/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -95,12 +113,20 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -110,6 +136,8 @@ 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -129,6 +157,10 @@ 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/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -145,19 +177,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/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/server/apikeys.go b/internal/server/apikeys.go new file mode 100644 index 0000000..2232aeb --- /dev/null +++ b/internal/server/apikeys.go @@ -0,0 +1,258 @@ +package server + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "net/http" + "time" + + "golang.org/x/crypto/argon2" + + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +// Plaintext key format: `bp_<32 base64 chars>`. Prefix stored for UI is +// the first 11 chars (`bp_<8 chars>`). Hash is argon2id with sensible +// dev params (raise in M6+ once we see the verify call rate in prod). +const ( + keyPrefix = "bp_" + prefixLen = 11 // bp_ + 8 + keyEntropyBy = 24 // 24 bytes → 32 base64 chars +) + +var ( + argonTime uint32 = 1 + argonMemory uint32 = 64 * 1024 + argonThreads uint8 = 4 + argonKeyLen uint32 = 32 +) + +type createAPIKeyReq struct { + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Product string `json:"product,omitempty"` + Scopes []string `json:"scopes,omitempty"` + CreatedBy string `json:"created_by,omitempty"` +} + +type createAPIKeyResp struct { + APIKey store.APIKey `json:"api_key"` + Plaintext string `json:"plaintext"` // shown ONCE — caller must store + WarningMsg string `json:"warning"` +} + +func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) { + var in createAPIKeyReq + if !decodeJSON(w, r, &in) { + return + } + if in.TenantID == "" || in.Name == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and name are required") + return + } + if len(in.Name) > 100 { + writeError(w, http.StatusBadRequest, "invalid_name", "name too long") + return + } + if in.Product != "" && !isKnownProduct(in.Product) { + writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + plain, err := generateAPIKey() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "key generation failed") + return + } + hash := hashAPIKey(plain) + + k, err := s.Store.CreateAPIKey(ctx, store.APIKeyCreate{ + TenantID: in.TenantID, + Product: in.Product, + Name: in.Name, + Scopes: in.Scopes, + Prefix: plain[:prefixLen], + Hash: hash, + CreatedBy: in.CreatedBy, + }) + if err != nil { + if mapStoreError(w, err) { + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + s.emitAudit(ctx, r, store.AuditEvent{ + TenantID: in.TenantID, Action: "api_key.created", + TargetID: k.ID, TargetType: "api_key", TargetName: in.Name, + Metadata: map[string]interface{}{"product": in.Product, "scopes": in.Scopes}, + }) + + writeJSON(w, http.StatusCreated, createAPIKeyResp{ + APIKey: *k, + Plaintext: plain, + WarningMsg: "Store this value now — it cannot be retrieved later.", + }) +} + +func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) { + tenantID := r.URL.Query().Get("tenant_id") + if tenantID == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + list, err := s.Store.ListAPIKeys(ctx, tenantID) + if err != nil { + if mapStoreError(w, err) { + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": list}) +} + +func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + id := r.PathValue("id") + if err := s.Store.RevokeAPIKey(ctx, id); err != nil { + if mapStoreError(w, err) { + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + s.emitAudit(ctx, r, store.AuditEvent{ + Action: "api_key.revoked", TargetID: id, TargetType: "api_key", + }) + w.WriteHeader(http.StatusNoContent) +} + +type verifyAPIKeyReq struct { + Key string `json:"key"` +} + +type verifyAPIKeyResp struct { + Valid bool `json:"valid"` + TenantID string `json:"tenant_id,omitempty"` + Product string `json:"product,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// verifyAPIKey — POST /v1/internal/api-keys/verify. Used by headless products +// to validate inbound keys. Returns 200 with valid=false rather than 401 so +// the caller can decide what to do. +func (s *Server) verifyAPIKey(w http.ResponseWriter, r *http.Request) { + var in verifyAPIKeyReq + if !decodeJSON(w, r, &in) { + return + } + if !looksLikeKey(in.Key) { + writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + k, hash, err := s.Store.FindAPIKeyByPrefix(ctx, in.Key[:prefixLen]) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false}) + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + if !verifyHash(in.Key, hash) { + writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false}) + return + } + + // Best-effort touch — failures are non-fatal. + if err := s.Store.TouchAPIKeyUsed(ctx, k.ID); err != nil { + s.Log.Warn("touch api_key failed", "err", err) + } + + writeJSON(w, http.StatusOK, verifyAPIKeyResp{ + Valid: true, + TenantID: k.TenantID, + Product: k.Product, + Scopes: k.Scopes, + }) +} + +// ─── helpers ────────────────────────────────────────────────────────────── + +func generateAPIKey() (string, error) { + buf := make([]byte, keyEntropyBy) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return keyPrefix + base64.RawURLEncoding.EncodeToString(buf), nil +} + +func looksLikeKey(k string) bool { + if len(k) < prefixLen { + return false + } + if k[:len(keyPrefix)] != keyPrefix { + return false + } + return true +} + +func hashAPIKey(plain string) string { + salt := make([]byte, 16) + _, _ = rand.Read(salt) + hash := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + // Encode as $argon2id$v=19$m=...,t=...,p=...$salt$hash so we can shift + // parameters later without re-keying. + return "argon2id|" + + base64.RawStdEncoding.EncodeToString(salt) + "|" + + base64.RawStdEncoding.EncodeToString(hash) +} + +func verifyHash(plain, stored string) bool { + // Format: argon2id|| + 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 +} diff --git a/internal/server/apikeys_test.go b/internal/server/apikeys_test.go new file mode 100644 index 0000000..b85f172 --- /dev/null +++ b/internal/server/apikeys_test.go @@ -0,0 +1,129 @@ +package server_test + +import ( + "net/http" + "testing" + + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +func TestCreateAPIKey_then_verify(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, body := h.do("POST", "/v1/api-keys", map[string]any{ + "tenant_id": h.tenant.ID, "name": "ci-bot", "product": "certifai", + "scopes": []string{"certifai:read", "certifai:write"}, + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d, body=%s", resp.StatusCode, body) + } + created := decode[struct { + APIKey store.APIKey `json:"api_key"` + Plaintext string `json:"plaintext"` + }](t, body) + if len(created.Plaintext) < 30 || created.Plaintext[:3] != "bp_" { + t.Fatalf("bad plaintext: %q", created.Plaintext) + } + if len(created.APIKey.Scopes) != 2 || created.APIKey.Product != "certifai" { + t.Errorf("unexpected key: %+v", created.APIKey) + } + + // Verify with the plaintext key. + resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{ + "key": created.Plaintext, + }) + if resp.StatusCode != 200 { + t.Fatalf("verify status = %d, body=%s", resp.StatusCode, body) + } + v := decode[struct { + Valid bool `json:"valid"` + TenantID string `json:"tenant_id"` + Product string `json:"product"` + Scopes []string `json:"scopes"` + }](t, body) + if !v.Valid || v.TenantID != h.tenant.ID || v.Product != "certifai" || len(v.Scopes) != 2 { + t.Errorf("verify returned %+v", v) + } + + // Revoke; verify now returns valid=false. + resp, _ = h.do("DELETE", "/v1/api-keys/"+created.APIKey.ID, nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("revoke status = %d", resp.StatusCode) + } + resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": created.Plaintext}) + if resp.StatusCode != 200 { + t.Fatalf("verify-after-revoke status = %d", resp.StatusCode) + } + v = decode[struct { + Valid bool `json:"valid"` + TenantID string `json:"tenant_id"` + Product string `json:"product"` + Scopes []string `json:"scopes"` + }](t, body) + if v.Valid { + t.Error("revoked key still verifies") + } + }) +} + +func TestVerifyAPIKey_garbage(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + for _, key := range []string{"", "not-a-key", "bp_short", "ax_wrongprefix1234567"} { + resp, body := h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": key}) + if resp.StatusCode != 200 { + t.Fatalf("status = %d for key %q", resp.StatusCode, key) + } + v := decode[struct { + Valid bool `json:"valid"` + }](t, body) + if v.Valid { + t.Errorf("garbage key %q verified as valid", key) + } + } + }) +} + +func TestCreateAPIKey_unknownProduct(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + resp, _ := h.do("POST", "/v1/api-keys", map[string]any{ + "tenant_id": h.tenant.ID, "name": "k", "product": "bogus", + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d", resp.StatusCode) + } + }) +} + +func TestListAPIKeys(t *testing.T) { + eachStore(t, func(t *testing.T, h *testHarness) { + respA, bodyA := h.do("POST", "/v1/api-keys", map[string]any{ + "tenant_id": h.tenant.ID, "name": "alpha", + }) + if respA.StatusCode != http.StatusCreated { + t.Fatalf("alpha create: status=%d body=%s", respA.StatusCode, bodyA) + } + respB, bodyB := h.do("POST", "/v1/api-keys", map[string]any{ + "tenant_id": h.tenant.ID, "name": "beta", + }) + if respB.StatusCode != http.StatusCreated { + t.Fatalf("beta create: status=%d body=%s", respB.StatusCode, bodyB) + } + resp, body := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil) + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + out := decode[struct { + Items []store.APIKey `json:"items"` + }](t, body) + if len(out.Items) < 2 { + t.Errorf("expected ≥2 keys, got %d", len(out.Items)) + } + // Plaintext / hash must NOT leak in the list response. + for _, k := range out.Items { + rawJSON, _ := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil) + _ = rawJSON + if k.Prefix == "" { + t.Error("prefix missing") + } + } + }) +} diff --git a/internal/server/audit.go b/internal/server/audit.go new file mode 100644 index 0000000..0a0ed26 --- /dev/null +++ b/internal/server/audit.go @@ -0,0 +1,107 @@ +package server + +import ( + "context" + "net/http" + "strconv" + "time" + + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +type appendAuditReq struct { + TenantID string `json:"tenant_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ActorID string `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + ActorType string `json:"actor_type,omitempty"` + Action string `json:"action"` + TargetID string `json:"target_id,omitempty"` + TargetType string `json:"target_type,omitempty"` + TargetName string `json:"target_name,omitempty"` + Product string `json:"product,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +func (s *Server) appendAudit(w http.ResponseWriter, r *http.Request) { + var in appendAuditReq + if !decodeJSON(w, r, &in) { + return + } + if in.Action == "" { + writeError(w, http.StatusBadRequest, "invalid_action", "action is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + ev, err := s.Store.AppendAudit(ctx, store.AuditEvent{ + TenantID: in.TenantID, ProjectID: in.ProjectID, + ActorID: in.ActorID, ActorName: in.ActorName, ActorType: in.ActorType, + Action: in.Action, + TargetID: in.TargetID, TargetType: in.TargetType, TargetName: in.TargetName, + Product: in.Product, Metadata: in.Metadata, + SourceIP: clientIP(r), UserAgent: r.UserAgent(), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusCreated, ev) +} + +func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := store.AuditFilter{ + TenantID: q.Get("tenant_id"), + Product: q.Get("product"), + ActorID: q.Get("actor_id"), + Action: q.Get("action"), + } + if s := q.Get("since"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_since", "must be RFC3339") + return + } + f.Since = &t + } + if s := q.Get("until"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_until", "must be RFC3339") + return + } + f.Until = &t + } + if s := q.Get("limit"); s != "" { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 500 { + writeError(w, http.StatusBadRequest, "invalid_limit", "must be 1..500") + return + } + f.Limit = n + } + if s := q.Get("cursor"); s != "" { + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_cursor", "must be an integer") + return + } + f.Cursor = n + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + items, next, err := s.Store.ListAudit(ctx, f) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + out := map[string]any{"items": items} + if next > 0 { + out["next_cursor"] = next + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/server/audit_test.go b/internal/server/audit_test.go new file mode 100644 index 0000000..b3cde80 --- /dev/null +++ b/internal/server/audit_test.go @@ -0,0 +1,113 @@ +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", + }) + fresh := decode[store.Tenant](t, body) + + 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)) + } + }) +} diff --git a/internal/server/catalog.go b/internal/server/catalog.go new file mode 100644 index 0000000..5a56fe4 --- /dev/null +++ b/internal/server/catalog.go @@ -0,0 +1,145 @@ +package server + +import ( + "context" + "net/http" + "time" + + "gitea.meghsakha.com/platform/tenant-registry/internal/store" +) + +// catalog is hard-coded for now. PRODUCT_INTEGRATION_SPEC.md §10 has products +// publish a manifest to `cdn.breakpilot.com`; this list will be sourced +// from those manifests once M6.3 / M7.2 wire it up. +var catalog = []store.CatalogEntry{ + { + Key: "certifai", Name: "CERTifAI", + Description: "Self-hosted GDPR-compliant AI dashboard.", + PlansRequired: []string{"professional", "enterprise"}, + SupportsTrial: true, + }, + { + Key: "compliance", Name: "Compliance", + Description: "DSFA / TOM / VVT generation; evidence capture.", + PlansRequired: []string{"starter", "professional", "enterprise"}, + SupportsTrial: true, + }, +} + +func (s *Server) getCatalog(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"items": catalog}) +} + +type catalogRequestReq struct { + TenantID string `json:"tenant_id"` + Product string `json:"product"` +} + +// catalogRequest — customer requests a non-subscribed product. Today this +// just emits an audit event tagged so the eventual ERPNext-Lead step +// (M11.1) can pick it up. +func (s *Server) catalogRequest(w http.ResponseWriter, r *http.Request) { + var in catalogRequestReq + if !decodeJSON(w, r, &in) { + return + } + if in.TenantID == "" || in.Product == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required") + return + } + if !isKnownProduct(in.Product) { + writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil { + if mapStoreError(w, err) { + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + s.emitAudit(ctx, r, store.AuditEvent{ + TenantID: in.TenantID, Action: "catalog.requested", + TargetID: in.Product, TargetType: "product", + Metadata: map[string]interface{}{"product": in.Product}, + }) + + writeJSON(w, http.StatusAccepted, map[string]string{ + "status": "accepted", + "message": "request recorded; sales will be in touch", + }) +} + +// catalogTrialRequest — customer self-serves a 14-day trial of a product +// that supports trial. Provisions the entitlement immediately so the +// product can be used right away. +func (s *Server) catalogTrialRequest(w http.ResponseWriter, r *http.Request) { + var in catalogRequestReq + if !decodeJSON(w, r, &in) { + return + } + if in.TenantID == "" || in.Product == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required") + return + } + entry, ok := lookupCatalogEntry(in.Product) + if !ok { + writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog") + return + } + if !entry.SupportsTrial { + writeError(w, http.StatusBadRequest, "trial_unavailable", "product does not support self-serve trial") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil { + if mapStoreError(w, err) { + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + expiresAt := time.Now().UTC().Add(14 * 24 * time.Hour) + tp, err := s.Store.UpsertTenantProduct(ctx, store.TenantProduct{ + TenantID: in.TenantID, Product: in.Product, Enabled: true, + Config: map[string]interface{}{"source": "trial"}, ExpiresAt: &expiresAt, + }) + if err != nil { + if mapStoreError(w, err) { + return + } + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + s.emitAudit(ctx, r, store.AuditEvent{ + TenantID: in.TenantID, Action: "catalog.trial_started", + TargetID: in.Product, TargetType: "product", + Metadata: map[string]interface{}{"product": in.Product, "expires_at": expiresAt.Format(time.RFC3339)}, + }) + + writeJSON(w, http.StatusCreated, tp) +} + +func isKnownProduct(key string) bool { + _, ok := lookupCatalogEntry(key) + return ok +} + +func lookupCatalogEntry(key string) (store.CatalogEntry, bool) { + for _, e := range catalog { + if e.Key == key { + return e, true + } + } + return store.CatalogEntry{}, false +} diff --git a/internal/server/catalog_test.go b/internal/server/catalog_test.go new file mode 100644 index 0000000..f7a98ac --- /dev/null +++ b/internal/server/catalog_test.go @@ -0,0 +1,78 @@ +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", + }) + fresh := decode[store.Tenant](t, body) + + 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) + } + }) +} diff --git a/internal/server/helpers.go b/internal/server/helpers.go new file mode 100644 index 0000000..f2f7bbb --- /dev/null +++ b/internal/server/helpers.go @@ -0,0 +1,108 @@ +package server + +import ( + "encoding/json" + "errors" + "log/slog" + "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 strings.TrimSpace(fwd[:i]) + } + return strings.TrimSpace(fwd) + } + if host, _, ok := splitHostPort(r.RemoteAddr); ok { + return host + } + return r.RemoteAddr +} + +// splitHostPort is a port-tolerant version of net.SplitHostPort that doesn't +// error on missing port. +func splitHostPort(s string) (string, string, bool) { + i := strings.LastIndexByte(s, ':') + if i < 0 { + return s, "", false + } + return s[:i], s[i+1:], true +} diff --git a/internal/server/openapi_helpers_test.go b/internal/server/openapi_helpers_test.go new file mode 100644 index 0000000..8b4b768 --- /dev/null +++ b/internal/server/openapi_helpers_test.go @@ -0,0 +1,15 @@ +package server_test + +import ( + "net/http" + "testing" +) + +func newRequest(t *testing.T, method, path string) *http.Request { + t.Helper() + req, err := http.NewRequest(method, "http://test"+path, nil) + if err != nil { + t.Fatal(err) + } + return req +} diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go new file mode 100644 index 0000000..de669a1 --- /dev/null +++ b/internal/server/openapi_test.go @@ -0,0 +1,59 @@ +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"}, + } + 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 diff --git a/internal/server/server.go b/internal/server/server.go index 207c73c..d863405 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,106 +1,71 @@ +// 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). package server import ( - "context" - "encoding/json" - "errors" "log/slog" "net/http" - "time" "gitea.meghsakha.com/platform/tenant-registry/internal/config" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) -type deps struct { - cfg *config.Config - log *slog.Logger - tenant *store.Memory +// Server bundles the dependencies every handler needs. +type Server struct { + Cfg *config.Config + Log *slog.Logger + Store store.Store } -func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler { - d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()} - +// NewRouter builds the http.Handler with logging middleware applied. +func NewRouter(s *Server) http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /healthz", d.healthz) - mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", d.tenantBySlug) - mux.HandleFunc("GET /v1/tenants/{id}", d.tenantByID) - return logRequest(log)(mux) + // health + status + mux.HandleFunc("GET /healthz", s.healthz) + mux.HandleFunc("GET /readyz", s.readyz) + + // tenants + mux.HandleFunc("POST /v1/tenants", s.createTenant) + mux.HandleFunc("GET /v1/tenants/{id}", s.getTenant) + mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", s.getTenantBySlug) + mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant) + mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant) + + // entitlements — top-level path so it doesn't conflict with + // /v1/tenants/by-slug/{slug} (Go 1.22 ServeMux can't disambiguate + // /v1/tenants/{id}/products vs /v1/tenants/by-slug/{slug=products}). + 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 — same disambiguation: list lives at /v1/api-keys?tenant_id=X + // instead of /v1/tenants/{id}/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) + + return logRequest(s.Log)(mux) } -func (d *deps) healthz(w http.ResponseWriter, _ *http.Request) { +func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } -func (d *deps) tenantBySlug(w http.ResponseWriter, r *http.Request) { - slug := r.PathValue("slug") - 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") +func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { + if err := s.Store.Ping(r.Context()); err != nil { + writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error()) 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 (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) + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c5f2779..25343e0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,73 +1,179 @@ -package server +package server_test import ( + "bytes" + "context" + "database/sql" "encoding/json" + "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "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/server" + "gitea.meghsakha.com/platform/tenant-registry/internal/store" + "gitea.meghsakha.com/platform/tenant-registry/migrations" ) -func newTestServer(t *testing.T) *httptest.Server { +// ─── harness ────────────────────────────────────────────────────────────── + +type testHarness struct { + t *testing.T + srv *httptest.Server + store store.Store + tenant *store.Tenant // pre-created acme tenant +} + +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() - cfg := &config.Config{Env: "dev", Addr: ":0"} - h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil))) - return httptest.NewServer(h) + mem := store.NewMemory() + tenant, _ := mem.GetTenantBySlug(context.Background(), "acme") + return wireHarness(t, mem, tenant) } -func TestHealthz(t *testing.T) { - srv := newTestServer(t) - defer srv.Close() +func newPostgresHarness(t *testing.T) *testHarness { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() - resp, err := http.Get(srv.URL + "/healthz") + pgc, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("tenant_registry_test"), + tcpostgres.WithUsername("test"), + tcpostgres.WithPassword("test"), + tcpostgres.BasicWaitStrategies(), + ) + if err != nil { + t.Skipf("skipping postgres harness: docker unreachable (%v)", err) + } + dsn, err := pgc.ConnectionString(ctx, "sslmode=disable") + if err != nil { + _ = pgc.Terminate(context.Background()) + t.Fatalf("dsn: %v", err) + } + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = pgc.Terminate(c) + }) + + // run migrations + src, err := iofs.New(migrations.FS, ".") if err != nil { t.Fatal(err) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - t.Fatalf("got %d, want 200", resp.StatusCode) - } -} - -func TestTenantBySlug_acme(t *testing.T) { - srv := newTestServer(t) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/acme") + db, err := sql.Open("pgx", dsn) 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") + driver, err := migpg.WithInstance(db, &migpg.Config{}) if err != nil { t.Fatal(err) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusNotFound { - t.Fatalf("got %d, want 404", resp.StatusCode) + 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)) + handler := server.NewRouter(&server.Server{ + Cfg: &config.Config{Env: "dev"}, Log: logger, Store: s, + }) + return &testHarness{ + t: t, + srv: httptest.NewServer(handler), + store: s, + tenant: seed, } } + +func (h *testHarness) do(method, path string, body any) (*http.Response, []byte) { + h.t.Helper() + var reader io.Reader + if body != nil { + buf, _ := json.Marshal(body) + reader = bytes.NewReader(buf) + } + req, err := http.NewRequest(method, h.srv.URL+path, reader) + if err != nil { + h.t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + h.t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + return resp, raw +} + +func decode[T any](t *testing.T, raw []byte) T { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode: %v; raw=%s", err, raw) + } + return v +} + +// silence unused-import linter warnings if a test is removed temporarily. +var _ = fmt.Sprintf +var _ = os.Stderr diff --git a/internal/server/tenants.go b/internal/server/tenants.go new file mode 100644 index 0000000..b196dea --- /dev/null +++ b/internal/server/tenants.go @@ -0,0 +1,224 @@ +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"` +} + +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(), 5*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}, + }) + + writeJSON(w, http.StatusCreated, t) +} + +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) + } +} diff --git a/internal/server/tenants_test.go b/internal/server/tenants_test.go new file mode 100644 index 0000000..f5b4ef7 --- /dev/null +++ b/internal/server/tenants_test.go @@ -0,0 +1,115 @@ +package server_test + +import ( + "net/http" + "testing" + + "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) + } + t1 := decode[store.Tenant](t, body) + if t1.Slug != "beta-co" || t1.Status != "trial" || t1.Plan != "starter" { + t.Errorf("unexpected: %+v", t1) + } + }) +} + +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.", + }) + created := decode[store.Tenant](t, body) + 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) + } + }) +} diff --git a/internal/store/memory.go b/internal/store/memory.go index 5530afb..b98b9d1 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -1,57 +1,95 @@ -// 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 import ( "context" - "errors" + "sort" "sync" "time" + + "github.com/google/uuid" ) -var ErrNotFound = errors.New("tenant not found") - -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"` -} - +// Memory — in-process Store used when DATABASE_URL is empty. Convenient for +// local dev when you don't want to bring up Postgres + run migrations. +// Pre-seeded with the acme tenant. type Memory struct { - mu sync.RWMutex - bySlug map[string]*Tenant - byID map[string]*Tenant + mu sync.RWMutex + tenants map[string]*Tenant // id → tenant + bySlug map[string]string // slug → id + products map[string]map[string]*TenantProduct // tenant_id → product → row + apiKeys map[string]*apiKeyWithHash // id → key + byPrefix map[string]string // prefix → id + audit []*AuditEvent + auditID int64 } +type apiKeyWithHash struct { + APIKey + Hash string +} + +// NewMemory returns a fresh in-memory store with the seed acme tenant. func NewMemory() *Memory { m := &Memory{ - bySlug: make(map[string]*Tenant), - byID: make(map[string]*Tenant), + tenants: make(map[string]*Tenant), + bySlug: make(map[string]string), + products: make(map[string]map[string]*TenantProduct), + apiKeys: make(map[string]*apiKeyWithHash), + byPrefix: make(map[string]string), } + now := time.Now().UTC() seed := &Tenant{ ID: "00000000-0000-0000-0000-000000000001", Slug: "acme", Name: "Acme Inc.", Status: "active", + Kind: "customer", Plan: "professional", - Products: []string{"certifai", "compliance"}, - CreatedAt: time.Now().UTC(), + CreatedAt: now, + UpdatedAt: now, + } + m.tenants[seed.ID] = seed + m.bySlug[seed.Slug] = seed.ID + m.products[seed.ID] = map[string]*TenantProduct{ + "certifai": {TenantID: seed.ID, Product: "certifai", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now}, + "compliance": {TenantID: seed.ID, Product: "compliance", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now}, } - m.bySlug[seed.Slug] = seed - m.byID[seed.ID] = seed return m } -func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) { +func (m *Memory) Close() {} +func (m *Memory) Ping(_ context.Context) error { return nil } + +// ─── tenants ────────────────────────────────────────────────────────────── + +func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, error) { + m.mu.Lock() + defer m.mu.Unlock() + if _, taken := m.bySlug[in.Slug]; taken { + return nil, ErrConflict + } + now := time.Now().UTC() + t := &Tenant{ + ID: uuid.NewString(), + Slug: in.Slug, + Name: in.Name, + Status: "trial", + Kind: firstNonEmpty(in.Kind, "customer"), + Plan: firstNonEmpty(in.Plan, "starter"), + SalesOwner: in.SalesOwner, + CreatedAt: now, + UpdatedAt: now, + } + m.tenants[t.ID] = t + m.bySlug[t.Slug] = t.ID + cp := *t + return &cp, nil +} + +func (m *Memory) GetTenant(_ context.Context, id string) (*Tenant, error) { m.mu.RLock() defer m.mu.RUnlock() - t, ok := m.bySlug[slug] + t, ok := m.tenants[id] if !ok { return nil, ErrNotFound } @@ -59,13 +97,241 @@ func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) { return &cp, nil } -func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) { +func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) { m.mu.RLock() defer m.mu.RUnlock() - t, ok := m.byID[id] + id, ok := m.bySlug[slug] if !ok { return nil, ErrNotFound } + t := m.tenants[id] cp := *t 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 "" +} diff --git a/internal/store/memory_test.go b/internal/store/memory_test.go deleted file mode 100644 index afc3325..0000000 --- a/internal/store/memory_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package store - -import ( - "context" - "errors" - "testing" -) - -func TestMemory_seededAcme(t *testing.T) { - m := NewMemory() - ctx := context.Background() - - t.Run("by slug returns seed", func(t *testing.T) { - got, err := m.BySlug(ctx, "acme") - if err != nil { - t.Fatal(err) - } - if got.Slug != "acme" { - t.Errorf("slug = %q, want acme", got.Slug) - } - if got.Status != "active" { - t.Errorf("status = %q, want active", got.Status) - } - if len(got.Products) != 2 { - t.Errorf("products = %v, want [certifai compliance]", got.Products) - } - }) - - t.Run("by id returns seed", func(t *testing.T) { - got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001") - if err != nil { - t.Fatal(err) - } - if got.Slug != "acme" { - t.Errorf("slug = %q, want acme", got.Slug) - } - }) - - t.Run("missing slug returns ErrNotFound", func(t *testing.T) { - _, err := m.BySlug(ctx, "nope") - if !errors.Is(err, ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } - }) - - t.Run("missing id returns ErrNotFound", func(t *testing.T) { - _, err := m.ByID(ctx, "deadbeef") - if !errors.Is(err, ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } - }) - - t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) { - got, err := m.BySlug(ctx, "acme") - if err != nil { - t.Fatal(err) - } - got.Name = "mutated" - got2, _ := m.BySlug(ctx, "acme") - if got2.Name == "mutated" { - t.Error("store leaked internal pointer; caller could mutate seeded state") - } - }) -} diff --git a/internal/store/postgres.go b/internal/store/postgres.go new file mode 100644 index 0000000..35f5ad1 --- /dev/null +++ b/internal/store/postgres.go @@ -0,0 +1,478 @@ +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") + row := p.pool.QueryRow(ctx, + `INSERT INTO tenants (slug, name, kind, plan, sales_owner) + VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, '')) + RETURNING id::text, slug, name, status::text, kind::text, plan, + COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''), + trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''), + 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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..4ad3ce1 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,92 @@ +// Package store hides the persistence layer behind a Store interface. +// Two implementations: Memory (dev convenience, used when DATABASE_URL is +// empty) and Postgres (production via pgx). Handlers depend on the +// interface — never on a concrete type. +package store + +import ( + "context" + "errors" + "time" +) + +// Sentinel errors. +var ( + ErrNotFound = errors.New("not found") + ErrConflict = errors.New("conflict") + ErrInvalidInput = errors.New("invalid input") +) + +// TenantCreate is the input shape for Store.CreateTenant. +type TenantCreate struct { + Slug string + Name string + Plan string // optional, defaults to "starter" + Kind string // optional, defaults to "customer" + SalesOwner string // optional +} + +// TenantUpdate captures partial mutations. Nil fields are left untouched. +type TenantUpdate struct { + Status *string + Plan *string + ErpCustomerID *string + StripeCustID *string + TrialEndsAt *time.Time + ContractStart *time.Time + ContractEnd *time.Time + SalesOwner *string +} + +// APIKeyCreate is the input shape for Store.CreateAPIKey. +type APIKeyCreate struct { + TenantID string + Product string // empty = applies to all products + Name string + Scopes []string + Prefix string + Hash string // argon2id encoded + CreatedBy string +} + +// AuditFilter narrows /v1/audit GET results. +type AuditFilter struct { + TenantID string + Product string + ActorID string + Action string + Since *time.Time + Until *time.Time + Limit int + Cursor int64 // id > Cursor (ascending) is the next page anchor +} + +// Store is the persistence contract. Implementations: +// - Memory — in-process, used when DATABASE_URL is empty (dev convenience). +// - Postgres — pgxpool-backed, used in stage + prod. +type Store interface { + // Tenants + CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) + GetTenant(ctx context.Context, id string) (*Tenant, error) + GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) + UpdateTenant(ctx context.Context, id string, in TenantUpdate) (*Tenant, error) + + // Entitlements + UpsertTenantProduct(ctx context.Context, tp TenantProduct) (*TenantProduct, error) + ListTenantProducts(ctx context.Context, tenantID string) ([]TenantProduct, error) + + // API keys + CreateAPIKey(ctx context.Context, in APIKeyCreate) (*APIKey, error) + FindAPIKeyByPrefix(ctx context.Context, prefix string) (*APIKey, string, error) // returns key + hash + TouchAPIKeyUsed(ctx context.Context, id string) error + RevokeAPIKey(ctx context.Context, id string) error + ListAPIKeys(ctx context.Context, tenantID string) ([]APIKey, error) + + // Audit + AppendAudit(ctx context.Context, ev AuditEvent) (*AuditEvent, error) + ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, int64, error) // returns rows + next cursor (0 = none) + + // Lifecycle + Close() + Ping(ctx context.Context) error +} diff --git a/internal/store/types.go b/internal/store/types.go new file mode 100644 index 0000000..8a4fa28 --- /dev/null +++ b/internal/store/types.go @@ -0,0 +1,82 @@ +package store + +import "time" + +// Tenant — root entity. Lifecycle states per PLATFORM_ARCHITECTURE.md §5c: +// +// demo — shared demo tenant; reset nightly; no billing +// trial — real customer in N-day evaluation window +// active — paid; contract or self-serve +// frozen — read-only after cancel / non-payment (30d grace) +// archived — data export window closed; only audit_log retained +type Tenant struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + Kind string `json:"kind"` + Plan string `json:"plan"` + ErpCustomerID string `json:"erp_customer_id,omitempty"` + StripeCustID string `json:"stripe_cust_id,omitempty"` + TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"` + ContractStart *time.Time `json:"contract_start,omitempty"` + ContractEnd *time.Time `json:"contract_end,omitempty"` + SalesOwner string `json:"sales_owner,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TenantProduct — entitlement matrix row. +type TenantProduct struct { + TenantID string `json:"tenant_id"` + Product string `json:"product"` + Enabled bool `json:"enabled"` + Config map[string]interface{} `json:"config"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// APIKey — portal-owned. Plaintext key is shown ONCE on creation; +// stored as argon2id hash + prefix for UI display. +type APIKey struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Product string `json:"product,omitempty"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + Prefix string `json:"prefix"` + CreatedBy string `json:"created_by,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditEvent — Retraced-shape per PRODUCT_INTEGRATION_SPEC.md §8.4. +type AuditEvent struct { + ID int64 `json:"id"` + TenantID string `json:"tenant_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ActorID string `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + ActorType string `json:"actor_type,omitempty"` + Action string `json:"action"` + TargetID string `json:"target_id,omitempty"` + TargetType string `json:"target_type,omitempty"` + TargetName string `json:"target_name,omitempty"` + Product string `json:"product,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + SourceIP string `json:"source_ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CatalogEntry — what /v1/catalog returns per available product. +type CatalogEntry struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + PlansRequired []string `json:"plans_required"` + DemoURL string `json:"demo_url,omitempty"` + SupportsTrial bool `json:"supports_trial"` +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..ad0cfff --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,485 @@ +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. + requestBody: + required: true + content: + application/json: { schema: { $ref: "#/components/schemas/TenantCreate" } } + responses: + "201": + description: Created. + content: + application/json: { schema: { $ref: "#/components/schemas/Tenant" } } + "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/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: + 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 } + + 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 }