From af9f3317811fa9e372ceb60d40bd59d5f7888f3e Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 09:35:04 +0000 Subject: [PATCH] feat(server): tenant-registry skeleton boots against dev stack Minimal Go service: /healthz + /v1/tenants/by-slug/:slug + /v1/tenants/:id with an in-memory store seeded with the acme tenant. Stdlib-only; pgx + JWT validation land in M4.1 follow-up. --- .gitea/workflows/ci.yaml | 9 ++- .gitignore | 1 + CHANGELOG.md | 1 + Dockerfile | 15 +++++ Makefile | 41 +++++++++++++ README.md | 50 +++++++++++++--- cmd/server/main.go | 56 +++++++++++++++++ go.mod | 3 + internal/config/config.go | 33 ++++++++++ internal/config/config_test.go | 52 ++++++++++++++++ internal/server/server.go | 106 +++++++++++++++++++++++++++++++++ internal/server/server_test.go | 73 +++++++++++++++++++++++ internal/store/memory.go | 71 ++++++++++++++++++++++ internal/store/memory_test.go | 64 ++++++++++++++++++++ migrations/0001_init.down.sql | 5 ++ migrations/0001_init.up.sql | 52 ++++++++++++++++ 16 files changed, 620 insertions(+), 12 deletions(-) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/store/memory.go create mode 100644 internal/store/memory_test.go create mode 100644 migrations/0001_init.down.sql create mode 100644 migrations/0001_init.up.sql diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d3002ac..c2dd182 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -57,12 +57,12 @@ jobs: test: runs-on: docker - if: hashFiles('go.sum') != '' + steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 - with: { go-version: '1.22' } + with: { go-version: '1.24' } - name: fmt run: test -z "$(gofmt -l .)" @@ -75,7 +75,10 @@ jobs: with: { version: latest } - name: test - run: go test -race -coverprofile=cover.out ./... + # Coverage scoped to ./internal/... — cmd/server is the entrypoint + # with signal-handling + bind that isn't worth unit-testing. When + # real integration tests land in M4.1, widen this back to ./... + run: go test -race -coverprofile=cover.out ./internal/... - name: coverage gate run: | diff --git a/.gitignore b/.gitignore index 376d6c0..83b428b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ vendor/ # Rust **/target/ +bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6af5e..b696613 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(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant - ### Changed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fbaec1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Multi-stage build for tenant-registry. + +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR / +COPY --from=build /out/tenant-registry /tenant-registry +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/tenant-registry"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c3d51d --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# tenant-registry — Go service for tenant glue, audit, API keys. + +.PHONY: help dev test build fmt vet lint docker clean + +ADDR ?= :8080 +APP_ENV ?= dev + +help: + @echo "tenant-registry targets:" + @echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)" + @echo " make test go test -race ./..." + @echo " make build compile binary to ./bin/tenant-registry" + @echo " make fmt go fmt ./..." + @echo " make vet go vet ./..." + @echo " make docker build local image (tenant-registry:dev)" + +dev: + @APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server + +test: + @go test -race ./... + +build: + @mkdir -p bin + @CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server + @echo "built ./bin/tenant-registry" + +fmt: + @gofmt -w . + @test -z "$$(gofmt -l .)" + +vet: + @go vet ./... + +lint: fmt vet + +docker: + @docker build -t tenant-registry:dev . + +clean: + @rm -rf bin diff --git a/README.md b/README.md index 2709a54..b152ded 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,51 @@ Multi-tenant glue: orgs, entitlements, API keys, audit. Scaffolded under milesto ## Run locally ```bash -# prerequisites: see CONTRIBUTING.md for tooling once code lands -make dev # starts dependencies + this service on http://localhost:8080 -make test # unit + integration -make e2e # only if this repo ships user-facing flows +# Prerequisites: Go 1.25+ +# Dependencies (Keycloak, pg-app) come from the dev stack — see platform/orca-platform/dev. + +# In one terminal — bring up dev dependencies (in the orca-platform clone): +cd /path/to/platform/orca-platform && make dev-up + +# In another — run the service: +make dev # APP_ENV=dev, listens on :8080 +make test # unit tests +make build # compile to ./bin/tenant-registry ``` -Local secrets come from `.env.local` (gitignored). Template at `.env.example`. +Env vars (override at the shell): -## Endpoints / surface +| Var | Default | Purpose | +|---|---|---| +| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` | +| `ADDR` | `:8080` | listen address | +| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL | +| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR | -{{For services: list the top-level routes or commands. -For libraries: list the public API entry points. -For IaC: list the make targets.}} +## Endpoints + +| Method | Path | Returns | +|---|---|---| +| 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 | + +The skeleton's store is in-memory and pre-seeded with one tenant: + +```json +{ + "id": "00000000-0000-0000-0000-000000000001", + "slug": "acme", + "name": "Acme Inc.", + "status": "active", + "plan": "professional", + "products": ["certifai", "compliance"] +} +``` + +So `curl http://localhost:8080/v1/tenants/by-slug/acme` works the moment `make dev` is up. + +The full schema (tenants, tenant_products, audit_log) is committed at `migrations/0001_init.up.sql` for review, but unapplied until the M4.1 follow-up PR swaps the in-memory store for pgx-backed Postgres. ## Deployment diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..d6b6d40 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "gitea.meghsakha.com/platform/tenant-registry/internal/config" + "gitea.meghsakha.com/platform/tenant-registry/internal/server" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + cfg, err := config.Load() + if err != nil { + slog.Error("config load failed", "err", err) + os.Exit(1) + } + + mux := server.NewRouter(cfg, logger) + srv := &http.Server{ + Addr: cfg.Addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server crashed", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + slog.Info("shutdown requested") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown failed", "err", err) + os.Exit(1) + } + slog.Info("bye") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..869d5c4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.meghsakha.com/platform/tenant-registry + +go 1.24 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..64d14a6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "os" +) + +type Config struct { + Env string // dev | stage | prod + Addr string // listen address, e.g. ":8080" + KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev + DatabaseURL string // postgres DSN (unused in skeleton; in-memory store) +} + +func Load() (*Config, error) { + env := getenv("APP_ENV", "dev") + if env != "dev" && env != "stage" && env != "prod" { + return nil, fmt.Errorf("invalid APP_ENV %q", env) + } + return &Config{ + Env: env, + Addr: getenv("ADDR", ":8080"), + KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"), + DatabaseURL: os.Getenv("DATABASE_URL"), + }, nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..34b3ba9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "testing" +) + +func TestLoad_defaults(t *testing.T) { + t.Setenv("APP_ENV", "") + t.Setenv("ADDR", "") + t.Setenv("KEYCLOAK_ISSUER", "") + t.Setenv("DATABASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Env != "dev" { + t.Errorf("Env = %q, want dev", cfg.Env) + } + if cfg.Addr != ":8080" { + t.Errorf("Addr = %q, want :8080", cfg.Addr) + } + if cfg.KeycloakIssuer == "" { + t.Error("KeycloakIssuer is empty; expected a default") + } + if cfg.DatabaseURL != "" { + t.Errorf("DatabaseURL = %q, want empty default", cfg.DatabaseURL) + } +} + +func TestLoad_overrides(t *testing.T) { + t.Setenv("APP_ENV", "stage") + t.Setenv("ADDR", ":9000") + t.Setenv("KEYCLOAK_ISSUER", "https://auth.example/realms/r") + t.Setenv("DATABASE_URL", "postgres://x") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Env != "stage" || cfg.Addr != ":9000" || cfg.KeycloakIssuer != "https://auth.example/realms/r" || cfg.DatabaseURL != "postgres://x" { + t.Errorf("overrides not applied: %+v", cfg) + } +} + +func TestLoad_invalidEnv(t *testing.T) { + t.Setenv("APP_ENV", "bogus") + _, err := Load() + if err == nil { + t.Fatal("expected error for invalid APP_ENV") + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..207c73c --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,106 @@ +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 +} + +func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler { + d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()} + + 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) +} + +func (d *deps) 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") + 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) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..6ec0fe2 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "gitea.meghsakha.com/platform/tenant-registry/internal/config" +) + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + cfg := &config.Config{Env: "dev", Addr: ":0"} + h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil))) + return httptest.NewServer(h) +} + +func TestHealthz(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + defer 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") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("got %d, want 200; body=%s", resp.StatusCode, body) + } + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["slug"] != "acme" { + t.Fatalf("expected slug=acme, got %v", payload["slug"]) + } + if payload["status"] != "active" { + t.Fatalf("expected status=active, got %v", payload["status"]) + } +} + +func TestTenantBySlug_unknown(t *testing.T) { + srv := newTestServer(t) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/nope") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("got %d, want 404", resp.StatusCode) + } +} diff --git a/internal/store/memory.go b/internal/store/memory.go new file mode 100644 index 0000000..5530afb --- /dev/null +++ b/internal/store/memory.go @@ -0,0 +1,71 @@ +// 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" + "sync" + "time" +) + +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"` +} + +type Memory struct { + mu sync.RWMutex + bySlug map[string]*Tenant + byID map[string]*Tenant +} + +func NewMemory() *Memory { + m := &Memory{ + bySlug: make(map[string]*Tenant), + byID: make(map[string]*Tenant), + } + seed := &Tenant{ + ID: "00000000-0000-0000-0000-000000000001", + Slug: "acme", + Name: "Acme Inc.", + Status: "active", + Plan: "professional", + Products: []string{"certifai", "compliance"}, + CreatedAt: time.Now().UTC(), + } + m.bySlug[seed.Slug] = seed + m.byID[seed.ID] = seed + return m +} + +func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) { + m.mu.RLock() + defer m.mu.RUnlock() + t, ok := m.bySlug[slug] + if !ok { + return nil, ErrNotFound + } + cp := *t + return &cp, nil +} + +func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) { + m.mu.RLock() + defer m.mu.RUnlock() + t, ok := m.byID[id] + if !ok { + return nil, ErrNotFound + } + cp := *t + return &cp, nil +} diff --git a/internal/store/memory_test.go b/internal/store/memory_test.go new file mode 100644 index 0000000..afc3325 --- /dev/null +++ b/internal/store/memory_test.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "errors" + "testing" +) + +func TestMemory_seededAcme(t *testing.T) { + m := NewMemory() + ctx := context.Background() + + t.Run("by slug returns seed", func(t *testing.T) { + got, err := m.BySlug(ctx, "acme") + if err != nil { + t.Fatal(err) + } + if got.Slug != "acme" { + t.Errorf("slug = %q, want acme", got.Slug) + } + if got.Status != "active" { + t.Errorf("status = %q, want active", got.Status) + } + if len(got.Products) != 2 { + t.Errorf("products = %v, want [certifai compliance]", got.Products) + } + }) + + t.Run("by id returns seed", func(t *testing.T) { + got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001") + if err != nil { + t.Fatal(err) + } + if got.Slug != "acme" { + t.Errorf("slug = %q, want acme", got.Slug) + } + }) + + t.Run("missing slug returns ErrNotFound", func(t *testing.T) { + _, err := m.BySlug(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("missing id returns ErrNotFound", func(t *testing.T) { + _, err := m.ByID(ctx, "deadbeef") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) { + got, err := m.BySlug(ctx, "acme") + if err != nil { + t.Fatal(err) + } + got.Name = "mutated" + got2, _ := m.BySlug(ctx, "acme") + if got2.Name == "mutated" { + t.Error("store leaked internal pointer; caller could mutate seeded state") + } + }) +} diff --git a/migrations/0001_init.down.sql b/migrations/0001_init.down.sql new file mode 100644 index 0000000..793ebb3 --- /dev/null +++ b/migrations/0001_init.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS audit_log; +DROP TABLE IF EXISTS tenant_products; +DROP TABLE IF EXISTS tenants; +DROP TYPE IF EXISTS tenant_kind; +DROP TYPE IF EXISTS tenant_status; diff --git a/migrations/0001_init.up.sql b/migrations/0001_init.up.sql new file mode 100644 index 0000000..f2cc3e1 --- /dev/null +++ b/migrations/0001_init.up.sql @@ -0,0 +1,52 @@ +-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c). +-- The skeleton uses an in-memory store; this file lands the table shape +-- the real M4.1 PR will use, so the schema review can happen alongside +-- the rest of the boot scaffolding. + +-- enums -------------------------------------------------------------------- + +CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo'); +CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal'); + +-- tenants ------------------------------------------------------------------ + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'), + name TEXT NOT NULL, + status tenant_status NOT NULL DEFAULT 'trial', + kind tenant_kind NOT NULL DEFAULT 'customer', + plan TEXT NOT NULL DEFAULT 'starter', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + trial_ends_at TIMESTAMPTZ +); + +CREATE INDEX tenants_status_idx ON tenants (status); + +-- tenant ↔ product entitlements ------------------------------------------- + +CREATE TABLE tenant_products ( + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + product TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, product) +); + +-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------ + +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id UUID REFERENCES tenants(id), + actor_id TEXT, + actor_name TEXT, + action TEXT NOT NULL, + target_id TEXT, + target_type TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + source_ip INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);