feat(api): M4.2 — REST surface + pgx Postgres store + OpenAPI 3.1
Full M4.2 deliverable: 16 endpoints (tenants CRUD + lifecycle, catalog, entitlements, API keys with argon2 hashing, audit append + filter), Store interface with pgx-backed Postgres + in-memory parallel implementations exercised by the same eachStore harness, openapi.yaml at 3.1 with kin-openapi contract test. M4.3 adds auth. Refs: M4.2
This commit was merged in pull request #7.
This commit is contained in:
+296
-30
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user