mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
211 lines
5.5 KiB
Go
211 lines
5.5 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/broker/pkg/provider"
|
|
)
|
|
|
|
// AccountMapping links a user ID (from IAM) to a provider account ID.
|
|
type AccountMapping struct {
|
|
UserID string `json:"user_id"`
|
|
OrgID string `json:"org_id"`
|
|
Provider string `json:"provider"`
|
|
AccountID string `json:"account_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// mappingKey is the composite key for user+provider lookups.
|
|
type mappingKey struct {
|
|
userID string
|
|
provider string
|
|
}
|
|
|
|
// Resolver is a thread-safe store of user-to-provider account mappings.
|
|
type Resolver struct {
|
|
mu sync.RWMutex
|
|
byKey map[mappingKey]AccountMapping // user+provider -> mapping
|
|
byUser map[string][]AccountMapping // user -> all mappings
|
|
}
|
|
|
|
// NewResolver creates an empty Resolver.
|
|
func NewResolver() *Resolver {
|
|
return &Resolver{
|
|
byKey: make(map[mappingKey]AccountMapping),
|
|
byUser: make(map[string][]AccountMapping),
|
|
}
|
|
}
|
|
|
|
// SetMapping registers or updates a user-to-provider account mapping.
|
|
func (r *Resolver) SetMapping(userID, orgID, providerName, accountID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
key := mappingKey{userID: userID, provider: providerName}
|
|
m := AccountMapping{
|
|
UserID: userID,
|
|
OrgID: orgID,
|
|
Provider: providerName,
|
|
AccountID: accountID,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// If updating an existing mapping, replace it in the byUser slice too.
|
|
if _, exists := r.byKey[key]; exists {
|
|
r.removeFromUserSlice(userID, providerName)
|
|
}
|
|
|
|
r.byKey[key] = m
|
|
r.byUser[userID] = append(r.byUser[userID], m)
|
|
}
|
|
|
|
// RemoveMapping removes the mapping for a user+provider pair.
|
|
func (r *Resolver) RemoveMapping(userID, providerName string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
key := mappingKey{userID: userID, provider: providerName}
|
|
if _, exists := r.byKey[key]; !exists {
|
|
return
|
|
}
|
|
delete(r.byKey, key)
|
|
r.removeFromUserSlice(userID, providerName)
|
|
}
|
|
|
|
// removeFromUserSlice removes the entry for providerName from byUser[userID].
|
|
// Caller must hold mu.
|
|
func (r *Resolver) removeFromUserSlice(userID, providerName string) {
|
|
mappings := r.byUser[userID]
|
|
for i, m := range mappings {
|
|
if m.Provider == providerName {
|
|
r.byUser[userID] = append(mappings[:i], mappings[i+1:]...)
|
|
if len(r.byUser[userID]) == 0 {
|
|
delete(r.byUser, userID)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// OwnsAccount returns true if the given user owns the specified account on the provider.
|
|
func (r *Resolver) OwnsAccount(userID, providerName, accountID string) bool {
|
|
resolved, ok := r.ResolveAccount(userID, providerName)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return resolved == accountID
|
|
}
|
|
|
|
// ResolveAccount returns the provider account ID for a user+provider pair.
|
|
func (r *Resolver) ResolveAccount(userID, providerName string) (string, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
m, ok := r.byKey[mappingKey{userID: userID, provider: providerName}]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return m.AccountID, true
|
|
}
|
|
|
|
// ResolveAnyAccount returns the first available provider and account ID for a user.
|
|
func (r *Resolver) ResolveAnyAccount(userID string) (providerName, accountID string, ok bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
mappings := r.byUser[userID]
|
|
if len(mappings) == 0 {
|
|
return "", "", false
|
|
}
|
|
return mappings[0].Provider, mappings[0].AccountID, true
|
|
}
|
|
|
|
// ListMappings returns all account mappings for a user.
|
|
func (r *Resolver) ListMappings(userID string) []AccountMapping {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
mappings := r.byUser[userID]
|
|
if len(mappings) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]AccountMapping, len(mappings))
|
|
copy(out, mappings)
|
|
return out
|
|
}
|
|
|
|
// UserAccounts returns a provider->accountID map for the given user.
|
|
// Used to build the accounts parameter server-side for smart order routing.
|
|
func (r *Resolver) UserAccounts(userID string) map[string]string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
mappings := r.byUser[userID]
|
|
if len(mappings) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(mappings))
|
|
for _, m := range mappings {
|
|
out[m.Provider] = m.AccountID
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AutoDiscover queries all providers in the registry for accounts belonging to
|
|
// the given user/org and registers any discovered mappings.
|
|
func (r *Resolver) AutoDiscover(ctx context.Context, registry *provider.Registry, userID, orgID string) error {
|
|
names := registry.List()
|
|
var errs []error
|
|
|
|
for _, name := range names {
|
|
p, err := registry.Get(name)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("provider %s: %w", name, err))
|
|
continue
|
|
}
|
|
|
|
accounts, err := p.ListAccounts(ctx)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("provider %s list accounts: %w", name, err))
|
|
continue
|
|
}
|
|
|
|
for _, acct := range accounts {
|
|
if acct.UserID == userID || (orgID != "" && acct.OrgID == orgID) {
|
|
r.SetMapping(userID, orgID, name, acct.ProviderID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("auto-discover encountered %d errors: %v", len(errs), errs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ImportFromJWT extracts user identity from JWT claims and registers mappings
|
|
// for any accounts found in the token payload.
|
|
func (r *Resolver) ImportFromJWT(tokenPayload map[string]interface{}) error {
|
|
sub, _ := tokenPayload["sub"].(string)
|
|
if sub == "" {
|
|
return fmt.Errorf("JWT missing 'sub' claim")
|
|
}
|
|
|
|
orgID, _ := tokenPayload["owner"].(string)
|
|
|
|
// If the JWT carries provider account mappings (e.g. from IAM enrichment),
|
|
// register them directly.
|
|
if accts, ok := tokenPayload["accounts"].(map[string]interface{}); ok {
|
|
for providerName, v := range accts {
|
|
if accountID, ok := v.(string); ok {
|
|
r.SetMapping(sub, orgID, providerName, accountID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|