Files
zeekay 00609ecedb feat(platform): multi-org service with KMS creds, customer identity, and API routes
- OrgService: per-org config cache, KMS credential resolution with env fallback,
  customer identity provisioning with sequential IDs
- org_configs + org_customers system collections auto-created on boot
- REST routes under /api/platform/org with IAM-based access control
- Remove tenant-authorization header forwarding (unused)
- Simplify auth proxy to forward only Authorization header
2026-03-02 00:37:26 -07:00

333 lines
8.8 KiB
Go

// OrgService provides per-org configuration, credential resolution, and
// customer identity management. Registered in app.Store() as "org" so
// Base Functions (Goja JS) can call methods directly:
//
// var org = $app.store().get("org")
// var creds = org.getCreds(orgId, "commerce")
// var config = org.getConfig(orgId)
// var customer = org.getCustomer(orgId, userId)
package platform
import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/hanzoai/base/core"
)
const orgCacheTTL = 5 * time.Minute
// OrgService provides per-org configuration, credential resolution, and
// customer identity management.
type OrgService struct {
app core.App
kms *KMSClient
config PlatformConfig
configCache sync.Map // orgId -> *orgConfigEntry
credsCache sync.Map // "orgId/provider" -> *credsCacheEntry
}
type orgConfigEntry struct {
data map[string]any
expires time.Time
}
type credsCacheEntry struct {
creds map[string]string
expires time.Time
}
// GetConfig returns the org_configs record for an org. Cached 5min.
func (s *OrgService) GetConfig(orgId string) map[string]any {
if orgId == "" {
return nil
}
// Check cache.
if v, ok := s.configCache.Load(orgId); ok {
entry := v.(*orgConfigEntry)
if time.Now().Before(entry.expires) {
return entry.data
}
s.configCache.Delete(orgId)
}
// Query the collection.
record, err := s.app.FindFirstRecordByFilter(
collectionOrgConfigs,
"org_id = {:orgId}",
map[string]any{"orgId": orgId},
)
if err != nil {
s.app.Logger().Debug("org config not found",
slog.String("org_id", orgId),
slog.String("error", err.Error()),
)
return nil
}
data := map[string]any{
"id": record.Id,
"org_id": record.GetString("org_id"),
"display_name": record.GetString("display_name"),
"status": record.GetString("status"),
"kms_project_id": record.GetString("kms_project_id"),
"fee_schedule": record.Get("fee_schedule"),
"features": record.Get("features"),
"providers": record.Get("providers"),
"chain_config": record.Get("chain_config"),
"metadata": record.Get("metadata"),
}
s.configCache.Store(orgId, &orgConfigEntry{
data: data,
expires: time.Now().Add(orgCacheTTL),
})
return data
}
// GetCreds fetches per-org credentials from KMS.
// Path convention: /orgs/{orgId}/{provider}/{key}
// Returns map like {"api_key": "...", "api_secret": "...", "base_url": "..."}
// Cached 5min per (orgId, provider) pair.
// Falls back to env vars if KMS not configured or secret not found (dev mode).
func (s *OrgService) GetCreds(orgId, provider string) map[string]string {
if orgId == "" || provider == "" {
return nil
}
cacheKey := orgId + "/" + provider
// Check cache.
if v, ok := s.credsCache.Load(cacheKey); ok {
entry := v.(*credsCacheEntry)
if time.Now().Before(entry.expires) {
return entry.creds
}
s.credsCache.Delete(cacheKey)
}
creds := s.fetchCredsFromKMS(orgId, provider)
if creds == nil {
creds = s.fetchCredsFromEnv(provider)
}
if creds != nil {
s.credsCache.Store(cacheKey, &credsCacheEntry{
creds: creds,
expires: time.Now().Add(orgCacheTTL),
})
}
return creds
}
// fetchCredsFromKMS tries to fetch credentials from KMS for known key names.
func (s *OrgService) fetchCredsFromKMS(orgId, provider string) map[string]string {
if s.kms == nil || s.kms.baseURL == "" {
return nil
}
keys := []string{"api_key", "api_secret", "base_url", "webhook_secret"}
creds := make(map[string]string)
for _, key := range keys {
secretPath := provider + "/" + key
val, err := s.kms.GetSecret(orgId, secretPath)
if err != nil {
continue
}
if val != "" {
creds[key] = val
}
}
if len(creds) == 0 {
return nil
}
return creds
}
// fetchCredsFromEnv falls back to environment variables.
// Pattern: {PROVIDER}_API_KEY, {PROVIDER}_API_SECRET, etc.
func (s *OrgService) fetchCredsFromEnv(provider string) map[string]string {
prefix := strings.ToUpper(provider) + "_"
keys := []string{"API_KEY", "API_SECRET", "BASE_URL", "WEBHOOK_SECRET"}
creds := make(map[string]string)
for _, key := range keys {
val := os.Getenv(prefix + key)
if val != "" {
creds[strings.ToLower(key)] = val
}
}
if len(creds) == 0 {
return nil
}
return creds
}
// SetCreds stores credentials in KMS for an org+provider.
func (s *OrgService) SetCreds(orgId, provider string, creds map[string]string) error {
if orgId == "" || provider == "" {
return fmt.Errorf("org: orgId and provider are required")
}
if s.kms == nil || s.kms.baseURL == "" {
return fmt.Errorf("org: KMS not configured")
}
for key, val := range creds {
secretPath := provider + "/" + key
if err := s.kms.SetSecret(orgId, secretPath, val); err != nil {
return fmt.Errorf("org: set cred %s/%s: %w", provider, key, err)
}
}
// Invalidate cache for this org+provider.
s.credsCache.Delete(orgId + "/" + provider)
return nil
}
// InvalidateCreds clears the credential cache for an org (all providers).
func (s *OrgService) InvalidateCreds(orgId string) {
s.credsCache.Range(func(key, _ any) bool {
k := key.(string)
if strings.HasPrefix(k, orgId+"/") {
s.credsCache.Delete(key)
}
return true
})
}
// GetCustomer looks up the org_customers record for (orgId, userId).
func (s *OrgService) GetCustomer(orgId, userId string) map[string]any {
if orgId == "" || userId == "" {
return nil
}
record, err := s.app.FindFirstRecordByFilter(
collectionOrgCustomers,
"org_id = {:orgId} && user_id = {:userId}",
map[string]any{"orgId": orgId, "userId": userId},
)
if err != nil {
return nil
}
return customerRecordToMap(record)
}
// ProvisionCustomer creates a new customer identity for a user in an org.
// Generates a sequential customer_id, creates the record.
func (s *OrgService) ProvisionCustomer(orgId, userId string, opts map[string]any) (map[string]any, error) {
if orgId == "" || userId == "" {
return nil, fmt.Errorf("org: orgId and userId are required")
}
// Check if already exists.
existing := s.GetCustomer(orgId, userId)
if existing != nil {
return nil, fmt.Errorf("org: customer already exists for org=%s user=%s", orgId, userId)
}
// Generate sequential customer ID within org.
custId, err := s.nextCustomerId(orgId)
if err != nil {
return nil, fmt.Errorf("org: generate customer_id: %w", err)
}
col, err := s.app.FindCollectionByNameOrId(collectionOrgCustomers)
if err != nil {
return nil, fmt.Errorf("org: %s collection not found: %w", collectionOrgCustomers, err)
}
record := core.NewRecord(col)
record.Set("org_id", orgId)
record.Set("user_id", userId)
record.Set("customer_id", custId)
record.Set("status", "active")
// Apply optional fields from opts.
if opts != nil {
for _, field := range []string{"display_name", "broker_account_id", "commerce_customer_id", "mpc_vault_id"} {
if v, ok := opts[field]; ok {
record.Set(field, v)
}
}
if v, ok := opts["metadata"]; ok {
record.Set("metadata", v)
}
if v, ok := opts["status"]; ok {
record.Set("status", v)
}
}
if err := s.app.Save(record); err != nil {
return nil, fmt.Errorf("org: save customer: %w", err)
}
s.app.Logger().Info("provisioned org customer",
slog.String("org_id", orgId),
slog.String("user_id", userId),
slog.String("customer_id", custId),
)
return customerRecordToMap(record), nil
}
// GetOrProvisionCustomer returns existing customer or creates one.
func (s *OrgService) GetOrProvisionCustomer(orgId, userId string) (map[string]any, error) {
existing := s.GetCustomer(orgId, userId)
if existing != nil {
return existing, nil
}
return s.ProvisionCustomer(orgId, userId, nil)
}
// nextCustomerId generates the next sequential customer ID for an org.
// Format: zero-padded 6 digits like "000001".
func (s *OrgService) nextCustomerId(orgId string) (string, error) {
// Find the current max customer_id for this org.
records, err := s.app.FindRecordsByFilter(
collectionOrgCustomers,
"org_id = {:orgId}",
"-customer_id",
1, 0,
map[string]any{"orgId": orgId},
)
if err != nil || len(records) == 0 {
return "000001", nil
}
maxId := records[0].GetString("customer_id")
var num int
_, _ = fmt.Sscanf(maxId, "%d", &num)
num++
return fmt.Sprintf("%06d", num), nil
}
// customerRecordToMap converts a customer record to a plain map.
func customerRecordToMap(record *core.Record) map[string]any {
return map[string]any{
"id": record.Id,
"org_id": record.GetString("org_id"),
"user_id": record.GetString("user_id"),
"customer_id": record.GetString("customer_id"),
"status": record.GetString("status"),
"display_name": record.GetString("display_name"),
"broker_account_id": record.GetString("broker_account_id"),
"commerce_customer_id": record.GetString("commerce_customer_id"),
"mpc_vault_id": record.GetString("mpc_vault_id"),
"metadata": record.Get("metadata"),
}
}