mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
- 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
333 lines
8.8 KiB
Go
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"),
|
|
}
|
|
}
|