ffab866c87
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
338 lines
8.5 KiB
Go
338 lines
8.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// 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
|
|
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{
|
|
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",
|
|
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},
|
|
}
|
|
return m
|
|
}
|
|
|
|
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.tenants[id]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
cp := *t
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
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 ""
|
|
}
|