feat(api): M4.2 — full REST surface + pgx-backed Postgres store
ci / shared (pull_request) Successful in 5s
ci / test (pull_request) Failing after 1m30s
ci / image (pull_request) Has been skipped

Replaces the M5.1-skeleton handler set with the M4.2 spec from
IMPLEMENTATION_PLAN.md:

Endpoints (authoritative shape in openapi.yaml):
  POST   /v1/tenants
  GET    /v1/tenants/{id}
  GET    /v1/tenants/by-slug/{slug}
  POST   /v1/tenants/{id}/activate
  POST   /v1/tenants/{id}/cancel
  GET    /v1/entitlements?tenant_id=...
  GET    /v1/catalog
  POST   /v1/catalog/request
  POST   /v1/catalog/trial-request
  POST   /v1/api-keys                       returns plaintext ONCE
  GET    /v1/api-keys?tenant_id=...
  DELETE /v1/api-keys/{id}
  POST   /v1/internal/api-keys/verify       always 200; valid: bool
  POST   /v1/audit
  GET    /v1/audit?{tenant_id,product,actor_id,action,since,until,limit,cursor}

Architecture:
  internal/store/store.go        Store interface (CRUD + audit + ping)
  internal/store/memory.go       in-process impl, used when DATABASE_URL
                                 is empty (seed acme tenant, no migrations)
  internal/store/postgres.go     pgxpool impl against the M4.1 schema
  internal/server/server.go      router + healthz/readyz
  internal/server/{tenants,catalog,apikeys,audit}.go
                                 per-concern handlers (≤250 LoC each)
  internal/server/helpers.go     writeJSON/writeError/error mapping/log mw
  openapi.yaml                   3.1 spec; openapi_test.go is the contract gate

API keys:
  Plaintext format 'bp_<22-char base64>'. Prefix bp_<8> stored for UI.
  Hash is argon2id(salt, time=1, mem=64MB, threads=4, len=32) encoded as
  'argon2id|<salt-b64>|<hash-b64>'. Format-tagged so we can rotate
  parameters without re-keying. Verify is constant-time.

Store selection:
  cmd/server picks Postgres when DATABASE_URL is set, otherwise Memory.
  Both implementations are exercised by the same eachStore test harness —
  parity is enforced.

Audit:
  Every state-changing endpoint emits via s.emitAudit() (fire-and-forget).
  audit_log uses ON DELETE SET NULL on tenant_id so forensic history
  outlives tenant deletes (per M4.1 schema).

Routing constraint:
  Go 1.22 ServeMux can't disambiguate /v1/tenants/{id}/products from
  /v1/tenants/by-slug/{slug=products}. Per-tenant subresources moved to
  query-param top-level paths: /v1/entitlements?tenant_id=… and
  /v1/api-keys?tenant_id=….

Tests:
  Every endpoint exercised against both Memory and Postgres via the
  eachStore harness. Includes happy paths, validation errors, conflicts,
  404s, auto-audit-emit assertion. testcontainers-go for the postgres
  harness; gated by -short.

  TestOpenAPISpec is the contract gate: every documented operation must
  resolve against the router. (kin-openapi v0.138.0.)

Refs: M4.2
This commit is contained in:
2026-05-19 12:44:43 +02:00
parent d66760b246
commit 4c46d673fb
25 changed files with 3108 additions and 262 deletions
+258
View File
@@ -0,0 +1,258 @@
package server
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"time"
"golang.org/x/crypto/argon2"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// Plaintext key format: `bp_<32 base64 chars>`. Prefix stored for UI is
// the first 11 chars (`bp_<8 chars>`). Hash is argon2id with sensible
// dev params (raise in M6+ once we see the verify call rate in prod).
const (
keyPrefix = "bp_"
prefixLen = 11 // bp_ + 8
keyEntropyBy = 24 // 24 bytes → 32 base64 chars
)
var (
argonTime uint32 = 1
argonMemory uint32 = 64 * 1024
argonThreads uint8 = 4
argonKeyLen uint32 = 32
)
type createAPIKeyReq struct {
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Product string `json:"product,omitempty"`
Scopes []string `json:"scopes,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
}
type createAPIKeyResp struct {
APIKey store.APIKey `json:"api_key"`
Plaintext string `json:"plaintext"` // shown ONCE — caller must store
WarningMsg string `json:"warning"`
}
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
var in createAPIKeyReq
if !decodeJSON(w, r, &in) {
return
}
if in.TenantID == "" || in.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and name are required")
return
}
if len(in.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_name", "name too long")
return
}
if in.Product != "" && !isKnownProduct(in.Product) {
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
plain, err := generateAPIKey()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "key generation failed")
return
}
hash := hashAPIKey(plain)
k, err := s.Store.CreateAPIKey(ctx, store.APIKeyCreate{
TenantID: in.TenantID,
Product: in.Product,
Name: in.Name,
Scopes: in.Scopes,
Prefix: plain[:prefixLen],
Hash: hash,
CreatedBy: in.CreatedBy,
})
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: in.TenantID, Action: "api_key.created",
TargetID: k.ID, TargetType: "api_key", TargetName: in.Name,
Metadata: map[string]interface{}{"product": in.Product, "scopes": in.Scopes},
})
writeJSON(w, http.StatusCreated, createAPIKeyResp{
APIKey: *k,
Plaintext: plain,
WarningMsg: "Store this value now — it cannot be retrieved later.",
})
}
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
list, err := s.Store.ListAPIKeys(ctx, tenantID)
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": list})
}
func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
id := r.PathValue("id")
if err := s.Store.RevokeAPIKey(ctx, id); err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
Action: "api_key.revoked", TargetID: id, TargetType: "api_key",
})
w.WriteHeader(http.StatusNoContent)
}
type verifyAPIKeyReq struct {
Key string `json:"key"`
}
type verifyAPIKeyResp struct {
Valid bool `json:"valid"`
TenantID string `json:"tenant_id,omitempty"`
Product string `json:"product,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
// verifyAPIKey — POST /v1/internal/api-keys/verify. Used by headless products
// to validate inbound keys. Returns 200 with valid=false rather than 401 so
// the caller can decide what to do.
func (s *Server) verifyAPIKey(w http.ResponseWriter, r *http.Request) {
var in verifyAPIKeyReq
if !decodeJSON(w, r, &in) {
return
}
if !looksLikeKey(in.Key) {
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
k, hash, err := s.Store.FindAPIKeyByPrefix(ctx, in.Key[:prefixLen])
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
if !verifyHash(in.Key, hash) {
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
return
}
// Best-effort touch — failures are non-fatal.
if err := s.Store.TouchAPIKeyUsed(ctx, k.ID); err != nil {
s.Log.Warn("touch api_key failed", "err", err)
}
writeJSON(w, http.StatusOK, verifyAPIKeyResp{
Valid: true,
TenantID: k.TenantID,
Product: k.Product,
Scopes: k.Scopes,
})
}
// ─── helpers ──────────────────────────────────────────────────────────────
func generateAPIKey() (string, error) {
buf := make([]byte, keyEntropyBy)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return keyPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
}
func looksLikeKey(k string) bool {
if len(k) < prefixLen {
return false
}
if k[:len(keyPrefix)] != keyPrefix {
return false
}
return true
}
func hashAPIKey(plain string) string {
salt := make([]byte, 16)
_, _ = rand.Read(salt)
hash := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
// Encode as $argon2id$v=19$m=...,t=...,p=...$salt$hash so we can shift
// parameters later without re-keying.
return "argon2id|" +
base64.RawStdEncoding.EncodeToString(salt) + "|" +
base64.RawStdEncoding.EncodeToString(hash)
}
func verifyHash(plain, stored string) bool {
// Format: argon2id|<salt-b64>|<hash-b64>
if len(stored) < 12 || stored[:9] != "argon2id|" {
return false
}
rest := stored[9:]
sep := -1
for i := range rest {
if rest[i] == '|' {
sep = i
break
}
}
if sep <= 0 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(rest[:sep])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(rest[sep+1:])
if err != nil {
return false
}
got := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
if len(want) != len(got) {
return false
}
var diff byte
for i := range want {
diff |= want[i] ^ got[i]
}
return diff == 0
}
+129
View File
@@ -0,0 +1,129 @@
package server_test
import (
"net/http"
"testing"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
func TestCreateAPIKey_then_verify(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, body := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "ci-bot", "product": "certifai",
"scopes": []string{"certifai:read", "certifai:write"},
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, body=%s", resp.StatusCode, body)
}
created := decode[struct {
APIKey store.APIKey `json:"api_key"`
Plaintext string `json:"plaintext"`
}](t, body)
if len(created.Plaintext) < 30 || created.Plaintext[:3] != "bp_" {
t.Fatalf("bad plaintext: %q", created.Plaintext)
}
if len(created.APIKey.Scopes) != 2 || created.APIKey.Product != "certifai" {
t.Errorf("unexpected key: %+v", created.APIKey)
}
// Verify with the plaintext key.
resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{
"key": created.Plaintext,
})
if resp.StatusCode != 200 {
t.Fatalf("verify status = %d, body=%s", resp.StatusCode, body)
}
v := decode[struct {
Valid bool `json:"valid"`
TenantID string `json:"tenant_id"`
Product string `json:"product"`
Scopes []string `json:"scopes"`
}](t, body)
if !v.Valid || v.TenantID != h.tenant.ID || v.Product != "certifai" || len(v.Scopes) != 2 {
t.Errorf("verify returned %+v", v)
}
// Revoke; verify now returns valid=false.
resp, _ = h.do("DELETE", "/v1/api-keys/"+created.APIKey.ID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("revoke status = %d", resp.StatusCode)
}
resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": created.Plaintext})
if resp.StatusCode != 200 {
t.Fatalf("verify-after-revoke status = %d", resp.StatusCode)
}
v = decode[struct {
Valid bool `json:"valid"`
TenantID string `json:"tenant_id"`
Product string `json:"product"`
Scopes []string `json:"scopes"`
}](t, body)
if v.Valid {
t.Error("revoked key still verifies")
}
})
}
func TestVerifyAPIKey_garbage(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
for _, key := range []string{"", "not-a-key", "bp_short", "ax_wrongprefix1234567"} {
resp, body := h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": key})
if resp.StatusCode != 200 {
t.Fatalf("status = %d for key %q", resp.StatusCode, key)
}
v := decode[struct {
Valid bool `json:"valid"`
}](t, body)
if v.Valid {
t.Errorf("garbage key %q verified as valid", key)
}
}
})
}
func TestCreateAPIKey_unknownProduct(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
resp, _ := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "k", "product": "bogus",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", resp.StatusCode)
}
})
}
func TestListAPIKeys(t *testing.T) {
eachStore(t, func(t *testing.T, h *testHarness) {
respA, bodyA := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "alpha",
})
if respA.StatusCode != http.StatusCreated {
t.Fatalf("alpha create: status=%d body=%s", respA.StatusCode, bodyA)
}
respB, bodyB := h.do("POST", "/v1/api-keys", map[string]any{
"tenant_id": h.tenant.ID, "name": "beta",
})
if respB.StatusCode != http.StatusCreated {
t.Fatalf("beta create: status=%d body=%s", respB.StatusCode, bodyB)
}
resp, body := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil)
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
out := decode[struct {
Items []store.APIKey `json:"items"`
}](t, body)
if len(out.Items) < 2 {
t.Errorf("expected ≥2 keys, got %d", len(out.Items))
}
// Plaintext / hash must NOT leak in the list response.
for _, k := range out.Items {
rawJSON, _ := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil)
_ = rawJSON
if k.Prefix == "" {
t.Error("prefix missing")
}
}
})
}
+107
View File
@@ -0,0 +1,107 @@
package server
import (
"context"
"net/http"
"strconv"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
type appendAuditReq struct {
TenantID string `json:"tenant_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name,omitempty"`
ActorType string `json:"actor_type,omitempty"`
Action string `json:"action"`
TargetID string `json:"target_id,omitempty"`
TargetType string `json:"target_type,omitempty"`
TargetName string `json:"target_name,omitempty"`
Product string `json:"product,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (s *Server) appendAudit(w http.ResponseWriter, r *http.Request) {
var in appendAuditReq
if !decodeJSON(w, r, &in) {
return
}
if in.Action == "" {
writeError(w, http.StatusBadRequest, "invalid_action", "action is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
ev, err := s.Store.AppendAudit(ctx, store.AuditEvent{
TenantID: in.TenantID, ProjectID: in.ProjectID,
ActorID: in.ActorID, ActorName: in.ActorName, ActorType: in.ActorType,
Action: in.Action,
TargetID: in.TargetID, TargetType: in.TargetType, TargetName: in.TargetName,
Product: in.Product, Metadata: in.Metadata,
SourceIP: clientIP(r), UserAgent: r.UserAgent(),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusCreated, ev)
}
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := store.AuditFilter{
TenantID: q.Get("tenant_id"),
Product: q.Get("product"),
ActorID: q.Get("actor_id"),
Action: q.Get("action"),
}
if s := q.Get("since"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_since", "must be RFC3339")
return
}
f.Since = &t
}
if s := q.Get("until"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_until", "must be RFC3339")
return
}
f.Until = &t
}
if s := q.Get("limit"); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 500 {
writeError(w, http.StatusBadRequest, "invalid_limit", "must be 1..500")
return
}
f.Limit = n
}
if s := q.Get("cursor"); s != "" {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_cursor", "must be an integer")
return
}
f.Cursor = n
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
items, next, err := s.Store.ListAudit(ctx, f)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
out := map[string]any{"items": items}
if next > 0 {
out["next_cursor"] = next
}
writeJSON(w, http.StatusOK, out)
}
+113
View File
@@ -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))
}
})
}
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"context"
"net/http"
"time"
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
)
// catalog is hard-coded for now. PRODUCT_INTEGRATION_SPEC.md §10 has products
// publish a manifest to `cdn.breakpilot.com`; this list will be sourced
// from those manifests once M6.3 / M7.2 wire it up.
var catalog = []store.CatalogEntry{
{
Key: "certifai", Name: "CERTifAI",
Description: "Self-hosted GDPR-compliant AI dashboard.",
PlansRequired: []string{"professional", "enterprise"},
SupportsTrial: true,
},
{
Key: "compliance", Name: "Compliance",
Description: "DSFA / TOM / VVT generation; evidence capture.",
PlansRequired: []string{"starter", "professional", "enterprise"},
SupportsTrial: true,
},
}
func (s *Server) getCatalog(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": catalog})
}
type catalogRequestReq struct {
TenantID string `json:"tenant_id"`
Product string `json:"product"`
}
// catalogRequest — customer requests a non-subscribed product. Today this
// just emits an audit event tagged so the eventual ERPNext-Lead step
// (M11.1) can pick it up.
func (s *Server) catalogRequest(w http.ResponseWriter, r *http.Request) {
var in catalogRequestReq
if !decodeJSON(w, r, &in) {
return
}
if in.TenantID == "" || in.Product == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required")
return
}
if !isKnownProduct(in.Product) {
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: in.TenantID, Action: "catalog.requested",
TargetID: in.Product, TargetType: "product",
Metadata: map[string]interface{}{"product": in.Product},
})
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "accepted",
"message": "request recorded; sales will be in touch",
})
}
// catalogTrialRequest — customer self-serves a 14-day trial of a product
// that supports trial. Provisions the entitlement immediately so the
// product can be used right away.
func (s *Server) catalogTrialRequest(w http.ResponseWriter, r *http.Request) {
var in catalogRequestReq
if !decodeJSON(w, r, &in) {
return
}
if in.TenantID == "" || in.Product == "" {
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required")
return
}
entry, ok := lookupCatalogEntry(in.Product)
if !ok {
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
return
}
if !entry.SupportsTrial {
writeError(w, http.StatusBadRequest, "trial_unavailable", "product does not support self-serve trial")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
expiresAt := time.Now().UTC().Add(14 * 24 * time.Hour)
tp, err := s.Store.UpsertTenantProduct(ctx, store.TenantProduct{
TenantID: in.TenantID, Product: in.Product, Enabled: true,
Config: map[string]interface{}{"source": "trial"}, ExpiresAt: &expiresAt,
})
if err != nil {
if mapStoreError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
s.emitAudit(ctx, r, store.AuditEvent{
TenantID: in.TenantID, Action: "catalog.trial_started",
TargetID: in.Product, TargetType: "product",
Metadata: map[string]interface{}{"product": in.Product, "expires_at": expiresAt.Format(time.RFC3339)},
})
writeJSON(w, http.StatusCreated, tp)
}
func isKnownProduct(key string) bool {
_, ok := lookupCatalogEntry(key)
return ok
}
func lookupCatalogEntry(key string) (store.CatalogEntry, bool) {
for _, e := range catalog {
if e.Key == key {
return e, true
}
}
return store.CatalogEntry{}, false
}
+78
View File
@@ -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)
}
})
}
+108
View File
@@ -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
}
+15
View File
@@ -0,0 +1,15 @@
package server_test
import (
"net/http"
"testing"
)
func newRequest(t *testing.T, method, path string) *http.Request {
t.Helper()
req, err := http.NewRequest(method, "http://test"+path, nil)
if err != nil {
t.Fatal(err)
}
return req
}
+59
View File
@@ -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
+49 -84
View File
@@ -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"})
}
+151 -45
View File
@@ -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
+224
View File
@@ -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)
}
}
+115
View File
@@ -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)
}
})
}
+296 -30
View File
@@ -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 ""
}
-64
View File
@@ -1,64 +0,0 @@
package store
import (
"context"
"errors"
"testing"
)
func TestMemory_seededAcme(t *testing.T) {
m := NewMemory()
ctx := context.Background()
t.Run("by slug returns seed", func(t *testing.T) {
got, err := m.BySlug(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if got.Slug != "acme" {
t.Errorf("slug = %q, want acme", got.Slug)
}
if got.Status != "active" {
t.Errorf("status = %q, want active", got.Status)
}
if len(got.Products) != 2 {
t.Errorf("products = %v, want [certifai compliance]", got.Products)
}
})
t.Run("by id returns seed", func(t *testing.T) {
got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatal(err)
}
if got.Slug != "acme" {
t.Errorf("slug = %q, want acme", got.Slug)
}
})
t.Run("missing slug returns ErrNotFound", func(t *testing.T) {
_, err := m.BySlug(ctx, "nope")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
t.Run("missing id returns ErrNotFound", func(t *testing.T) {
_, err := m.ByID(ctx, "deadbeef")
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
})
t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) {
got, err := m.BySlug(ctx, "acme")
if err != nil {
t.Fatal(err)
}
got.Name = "mutated"
got2, _ := m.BySlug(ctx, "acme")
if got2.Name == "mutated" {
t.Error("store leaked internal pointer; caller could mutate seeded state")
}
})
}
+478
View File
@@ -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
}
+92
View File
@@ -0,0 +1,92 @@
// Package store hides the persistence layer behind a Store interface.
// Two implementations: Memory (dev convenience, used when DATABASE_URL is
// empty) and Postgres (production via pgx). Handlers depend on the
// interface — never on a concrete type.
package store
import (
"context"
"errors"
"time"
)
// Sentinel errors.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrInvalidInput = errors.New("invalid input")
)
// TenantCreate is the input shape for Store.CreateTenant.
type TenantCreate struct {
Slug string
Name string
Plan string // optional, defaults to "starter"
Kind string // optional, defaults to "customer"
SalesOwner string // optional
}
// TenantUpdate captures partial mutations. Nil fields are left untouched.
type TenantUpdate struct {
Status *string
Plan *string
ErpCustomerID *string
StripeCustID *string
TrialEndsAt *time.Time
ContractStart *time.Time
ContractEnd *time.Time
SalesOwner *string
}
// APIKeyCreate is the input shape for Store.CreateAPIKey.
type APIKeyCreate struct {
TenantID string
Product string // empty = applies to all products
Name string
Scopes []string
Prefix string
Hash string // argon2id encoded
CreatedBy string
}
// AuditFilter narrows /v1/audit GET results.
type AuditFilter struct {
TenantID string
Product string
ActorID string
Action string
Since *time.Time
Until *time.Time
Limit int
Cursor int64 // id > Cursor (ascending) is the next page anchor
}
// Store is the persistence contract. Implementations:
// - Memory — in-process, used when DATABASE_URL is empty (dev convenience).
// - Postgres — pgxpool-backed, used in stage + prod.
type Store interface {
// Tenants
CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error)
GetTenant(ctx context.Context, id string) (*Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error)
UpdateTenant(ctx context.Context, id string, in TenantUpdate) (*Tenant, error)
// Entitlements
UpsertTenantProduct(ctx context.Context, tp TenantProduct) (*TenantProduct, error)
ListTenantProducts(ctx context.Context, tenantID string) ([]TenantProduct, error)
// API keys
CreateAPIKey(ctx context.Context, in APIKeyCreate) (*APIKey, error)
FindAPIKeyByPrefix(ctx context.Context, prefix string) (*APIKey, string, error) // returns key + hash
TouchAPIKeyUsed(ctx context.Context, id string) error
RevokeAPIKey(ctx context.Context, id string) error
ListAPIKeys(ctx context.Context, tenantID string) ([]APIKey, error)
// Audit
AppendAudit(ctx context.Context, ev AuditEvent) (*AuditEvent, error)
ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, int64, error) // returns rows + next cursor (0 = none)
// Lifecycle
Close()
Ping(ctx context.Context) error
}
+82
View File
@@ -0,0 +1,82 @@
package store
import "time"
// Tenant — root entity. Lifecycle states per PLATFORM_ARCHITECTURE.md §5c:
//
// demo — shared demo tenant; reset nightly; no billing
// trial — real customer in N-day evaluation window
// active — paid; contract or self-serve
// frozen — read-only after cancel / non-payment (30d grace)
// archived — data export window closed; only audit_log retained
type Tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
Kind string `json:"kind"`
Plan string `json:"plan"`
ErpCustomerID string `json:"erp_customer_id,omitempty"`
StripeCustID string `json:"stripe_cust_id,omitempty"`
TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"`
ContractStart *time.Time `json:"contract_start,omitempty"`
ContractEnd *time.Time `json:"contract_end,omitempty"`
SalesOwner string `json:"sales_owner,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TenantProduct — entitlement matrix row.
type TenantProduct struct {
TenantID string `json:"tenant_id"`
Product string `json:"product"`
Enabled bool `json:"enabled"`
Config map[string]interface{} `json:"config"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// APIKey — portal-owned. Plaintext key is shown ONCE on creation;
// stored as argon2id hash + prefix for UI display.
type APIKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Product string `json:"product,omitempty"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
Prefix string `json:"prefix"`
CreatedBy string `json:"created_by,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AuditEvent — Retraced-shape per PRODUCT_INTEGRATION_SPEC.md §8.4.
type AuditEvent struct {
ID int64 `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name,omitempty"`
ActorType string `json:"actor_type,omitempty"`
Action string `json:"action"`
TargetID string `json:"target_id,omitempty"`
TargetType string `json:"target_type,omitempty"`
TargetName string `json:"target_name,omitempty"`
Product string `json:"product,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CatalogEntry — what /v1/catalog returns per available product.
type CatalogEntry struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
PlansRequired []string `json:"plans_required"`
DemoURL string `json:"demo_url,omitempty"`
SupportsTrial bool `json:"supports_trial"`
}