accounts/internal/store/store.go
Haitao Pan 8b8a2aa3fa feat(agent-persistence): implement PostgreSQL persistence for agent registry
Core Changes:
- Add Agent struct and management methods to Store interface
- Implement PostgreSQL store methods (UpsertAgent, ListAgents, DeleteAgent, DeleteStaleAgents)
- Integrate persistence into Registry with async database writes
- Add Load() method to restore agents from database on startup
- Implement runAgentCleanup background task (5min interval, 10min stale threshold)

Database:
- Update agents table schema to use JSONB for groups field
- Add indexes on last_heartbeat and healthy columns
- Support health tracking and automatic cleanup of stale agents

Documentation:
- Add comprehensive DB access and upgrade guide
- Include agent persistence implementation plan
- Document diagnostic procedures and troubleshooting steps
- Add walkthrough of multi-agent support implementation

This enables:
- Persistent agent state across service restarts
- Automatic cleanup of offline agents
- Multi-agent support with shared token authentication
2026-02-05 08:34:25 +08:00

808 lines
21 KiB
Go

package store
import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// User represents an account within the account service domain.
type User struct {
ID string
Name string
Email string
Level int
Role string
Groups []string
Permissions []string
EmailVerified bool
PasswordHash string
MFATOTPSecret string
MFAEnabled bool
MFASecretIssuedAt time.Time
MFAConfirmedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
Active bool
ProxyUUID string
ProxyUUIDExpiresAt *time.Time
}
// Subscription represents a recurring or usage-based billing relationship.
type Subscription struct {
ID string
UserID string
Provider string
PaymentMethod string
PaymentQRCode string
Kind string
PlanID string
ExternalID string
Status string
Meta map[string]any
CreatedAt time.Time
UpdatedAt time.Time
CancelledAt *time.Time
}
// Identity represents a mapping between a user and a third-party authentication provider.
type Identity struct {
ID string
UserID string
Provider string
ExternalID string
CreatedAt time.Time
UpdatedAt time.Time
}
// Agent represents a registered agent instance with health tracking.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Groups []string `json:"groups"`
Healthy bool `json:"healthy"`
LastHeartbeat *time.Time `json:"lastHeartbeat,omitempty"`
ClientsCount int `json:"clientsCount"`
SyncRevision string `json:"syncRevision,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Store provides persistence operations for users.
type Store interface {
CreateUser(ctx context.Context, user *User) error
GetUserByEmail(ctx context.Context, email string) (*User, error)
GetUserByID(ctx context.Context, id string) (*User, error)
GetUserByName(ctx context.Context, name string) (*User, error)
UpdateUser(ctx context.Context, user *User) error
UpsertSubscription(ctx context.Context, subscription *Subscription) error
ListSubscriptionsByUser(ctx context.Context, userID string) ([]Subscription, error)
CancelSubscription(ctx context.Context, userID, externalID string, cancelledAt time.Time) (*Subscription, error)
CreateIdentity(ctx context.Context, identity *Identity) error
ListUsers(ctx context.Context) ([]User, error)
DeleteUser(ctx context.Context, id string) error
// Email Blacklist
AddToBlacklist(ctx context.Context, email string) error
RemoveFromBlacklist(ctx context.Context, email string) error
IsBlacklisted(ctx context.Context, email string) (bool, error)
ListBlacklist(ctx context.Context) ([]string, error)
// Agent management
UpsertAgent(ctx context.Context, agent *Agent) error
GetAgent(ctx context.Context, id string) (*Agent, error)
ListAgents(ctx context.Context) ([]*Agent, error)
DeleteAgent(ctx context.Context, id string) error
DeleteStaleAgents(ctx context.Context, staleThreshold time.Duration) (int, error)
}
// Domain level errors returned by the store implementation.
var (
ErrEmailExists = errors.New("email already exists")
ErrNameExists = errors.New("name already exists")
ErrInvalidName = errors.New("invalid user name")
ErrUserNotFound = errors.New("user not found")
ErrMFANotSupported = errors.New("mfa is not supported by the current store schema")
ErrSuperAdminCountingDisabled = errors.New("super administrator counting is disabled")
ErrSubscriptionNotFound = errors.New("subscription not found")
)
// memoryStore provides an in-memory implementation of Store. It is suitable for
// unit tests and local development where a persistent database is not yet
// configured.
type memoryStore struct {
mu sync.RWMutex
allowSuperAdminCounting bool
byID map[string]*User
byEmail map[string]*User
byName map[string]*User
subscriptions map[string]map[string]*Subscription
identities map[string]*Identity
agents map[string]*Agent
}
// NewMemoryStore creates a new in-memory store implementation with super
// administrator counting disabled by default to avoid accidental exposure of
// privileged metadata in environments where the caller has not explicitly
// opted-in.
func NewMemoryStore() Store {
return newMemoryStore(false)
}
// NewMemoryStoreWithSuperAdminCounting creates a new in-memory store with
// explicit permission to count super administrators. This is primarily used by
// internal tooling that needs to enforce singleton guarantees.
func NewMemoryStoreWithSuperAdminCounting() Store {
return newMemoryStore(true)
}
func newMemoryStore(allowSuperAdminCounting bool) Store {
return &memoryStore{
allowSuperAdminCounting: allowSuperAdminCounting,
byID: make(map[string]*User),
byEmail: make(map[string]*User),
byName: make(map[string]*User),
subscriptions: make(map[string]map[string]*Subscription),
identities: make(map[string]*Identity),
agents: make(map[string]*Agent),
}
}
// CreateUser persists a user in the in-memory store.
func (s *memoryStore) CreateUser(ctx context.Context, user *User) error {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
loweredEmail := strings.ToLower(strings.TrimSpace(user.Email))
normalizedName := strings.TrimSpace(user.Name)
if normalizedName == "" {
return ErrInvalidName
}
normalizeUserRoleFields(user)
if _, exists := s.byEmail[loweredEmail]; exists {
return ErrEmailExists
}
if _, exists := s.byName[strings.ToLower(normalizedName)]; exists {
return ErrNameExists
}
userCopy := *user
if userCopy.ID == "" {
userCopy.ID = uuid.NewString()
}
if userCopy.CreatedAt.IsZero() {
now := time.Now().UTC()
userCopy.CreatedAt = now
if userCopy.UpdatedAt.IsZero() {
userCopy.UpdatedAt = now
}
}
if userCopy.UpdatedAt.IsZero() {
userCopy.UpdatedAt = time.Now().UTC()
}
userCopy.Email = loweredEmail
userCopy.Name = normalizedName
stored := userCopy
normalizeUserRoleFields(&stored)
stored.Groups = cloneStringSlice(stored.Groups)
stored.Permissions = cloneStringSlice(stored.Permissions)
stored.Active = true
stored.ProxyUUID = uuid.NewString()
s.byID[userCopy.ID] = &stored
if loweredEmail != "" {
s.byEmail[loweredEmail] = &stored
}
s.byName[strings.ToLower(normalizedName)] = &stored
assignUser(user, &stored)
return nil
}
// GetUserByEmail fetches a user by email, returning ErrUserNotFound when the
// user does not exist.
func (s *memoryStore) GetUserByEmail(ctx context.Context, email string) (*User, error) {
_ = ctx
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.byEmail[strings.ToLower(email)]
if !ok {
return nil, ErrUserNotFound
}
return cloneUser(user), nil
}
// GetUserByID fetches a user by unique identifier, returning ErrUserNotFound
// when absent.
func (s *memoryStore) GetUserByID(ctx context.Context, id string) (*User, error) {
_ = ctx
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.byID[id]
if !ok {
return nil, ErrUserNotFound
}
return cloneUser(user), nil
}
// GetUserByName fetches a user by case-insensitive username, returning
// ErrUserNotFound when absent.
func (s *memoryStore) GetUserByName(ctx context.Context, name string) (*User, error) {
_ = ctx
normalized := strings.ToLower(strings.TrimSpace(name))
if normalized == "" {
return nil, ErrUserNotFound
}
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.byName[normalized]
if !ok {
return nil, ErrUserNotFound
}
return cloneUser(user), nil
}
// UpdateUser replaces the persisted user representation in memory.
func (s *memoryStore) UpdateUser(ctx context.Context, user *User) error {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
existing, ok := s.byID[user.ID]
if !ok {
return ErrUserNotFound
}
normalizedName := strings.TrimSpace(user.Name)
loweredEmail := strings.ToLower(strings.TrimSpace(user.Email))
if normalizedName == "" {
return ErrInvalidName
}
// Re-index username if it changed.
oldNameKey := strings.ToLower(existing.Name)
newNameKey := strings.ToLower(normalizedName)
if oldNameKey != newNameKey {
if _, exists := s.byName[newNameKey]; exists {
return ErrNameExists
}
delete(s.byName, oldNameKey)
}
// Re-index email if it changed.
oldEmailKey := strings.ToLower(existing.Email)
if oldEmailKey != loweredEmail {
if loweredEmail != "" {
if _, exists := s.byEmail[loweredEmail]; exists {
return ErrEmailExists
}
}
if oldEmailKey != "" {
delete(s.byEmail, oldEmailKey)
}
}
updated := *existing
updated.Name = normalizedName
updated.Email = loweredEmail
updated.EmailVerified = user.EmailVerified
updated.PasswordHash = user.PasswordHash
updated.MFATOTPSecret = user.MFATOTPSecret
updated.MFAEnabled = user.MFAEnabled
updated.MFASecretIssuedAt = user.MFASecretIssuedAt
updated.MFAConfirmedAt = user.MFAConfirmedAt
updated.Level = user.Level
updated.Role = user.Role
updated.Groups = cloneStringSlice(user.Groups)
updated.Permissions = cloneStringSlice(user.Permissions)
updated.Active = user.Active
updated.ProxyUUID = user.ProxyUUID
updated.ProxyUUIDExpiresAt = user.ProxyUUIDExpiresAt
normalizeUserRoleFields(&updated)
if user.CreatedAt.IsZero() {
updated.CreatedAt = existing.CreatedAt
} else {
updated.CreatedAt = user.CreatedAt
}
if user.UpdatedAt.IsZero() {
updated.UpdatedAt = time.Now().UTC()
} else {
updated.UpdatedAt = user.UpdatedAt
}
s.byID[user.ID] = &updated
s.byName[newNameKey] = &updated
if loweredEmail != "" {
s.byEmail[loweredEmail] = &updated
}
assignUser(user, &updated)
return nil
}
// UpsertSubscription creates or updates a subscription for a user.
func (s *memoryStore) UpsertSubscription(ctx context.Context, subscription *Subscription) error {
_ = ctx
if subscription == nil {
return errors.New("subscription is required")
}
s.mu.Lock()
defer s.mu.Unlock()
userID := strings.TrimSpace(subscription.UserID)
if userID == "" {
return ErrUserNotFound
}
if _, ok := s.byID[userID]; !ok {
return ErrUserNotFound
}
userSubs, ok := s.subscriptions[userID]
if !ok {
userSubs = make(map[string]*Subscription)
s.subscriptions[userID] = userSubs
}
key := strings.TrimSpace(subscription.ExternalID)
if key == "" {
return errors.New("external id is required")
}
if strings.TrimSpace(subscription.PaymentMethod) == "" {
subscription.PaymentMethod = strings.TrimSpace(subscription.Provider)
}
subscription.PaymentQRCode = strings.TrimSpace(subscription.PaymentQRCode)
now := time.Now().UTC()
stored, exists := userSubs[key]
if !exists {
stored = &Subscription{ID: uuid.NewString(), UserID: userID, ExternalID: key, CreatedAt: now}
userSubs[key] = stored
}
stored.Provider = strings.TrimSpace(subscription.Provider)
stored.PaymentMethod = strings.TrimSpace(subscription.PaymentMethod)
stored.PaymentQRCode = strings.TrimSpace(subscription.PaymentQRCode)
stored.Kind = strings.TrimSpace(subscription.Kind)
stored.PlanID = strings.TrimSpace(subscription.PlanID)
stored.Status = strings.TrimSpace(subscription.Status)
stored.Meta = cloneSubscriptionMeta(subscription.Meta)
stored.UpdatedAt = now
if subscription.CancelledAt != nil {
cancelled := subscription.CancelledAt.UTC()
stored.CancelledAt = &cancelled
}
assignSubscription(subscription, stored)
return nil
}
// ListSubscriptionsByUser returns subscriptions associated with a user.
func (s *memoryStore) ListSubscriptionsByUser(ctx context.Context, userID string) ([]Subscription, error) {
_ = ctx
s.mu.RLock()
defer s.mu.RUnlock()
normalized := strings.TrimSpace(userID)
if normalized == "" {
return nil, ErrUserNotFound
}
subs := s.subscriptions[normalized]
if len(subs) == 0 {
return []Subscription{}, nil
}
result := make([]Subscription, 0, len(subs))
for _, sub := range subs {
result = append(result, *cloneSubscription(sub))
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.After(result[j].CreatedAt)
})
return result, nil
}
// CancelSubscription marks a subscription as cancelled.
func (s *memoryStore) CancelSubscription(ctx context.Context, userID, externalID string, cancelledAt time.Time) (*Subscription, error) {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
normalizedUserID := strings.TrimSpace(userID)
if normalizedUserID == "" {
return nil, ErrUserNotFound
}
subs := s.subscriptions[normalizedUserID]
if subs == nil {
return nil, ErrSubscriptionNotFound
}
key := strings.TrimSpace(externalID)
existing, ok := subs[key]
if !ok {
return nil, ErrSubscriptionNotFound
}
cancelled := cancelledAt.UTC()
existing.Status = "cancelled"
existing.CancelledAt = &cancelled
existing.UpdatedAt = time.Now().UTC()
return cloneSubscription(existing), nil
}
// CountSuperAdmins returns the number of users configured as super administrators.
func (s *memoryStore) CountSuperAdmins(ctx context.Context) (int, error) {
_ = ctx
if !s.allowSuperAdminCounting {
return 0, ErrSuperAdminCountingDisabled
}
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, user := range s.byID {
if isSuperAdmin(user) {
count++
}
}
return count, nil
}
const (
// RootAdminEmail is the canonical email for the single root account.
RootAdminEmail = "admin@svc.plus"
)
const (
// LevelAdmin is the numeric level for administrator accounts.
LevelAdmin = 0
// LevelOperator is the numeric level for operator accounts.
LevelOperator = 10
// LevelUser is the numeric level for standard user accounts.
LevelUser = 20
)
const (
// RoleRoot identifies the single root administrator account.
RoleRoot = "root"
// RoleAdmin identifies legacy administrator accounts from earlier versions.
RoleAdmin = "admin"
// RoleOperator identifies operator accounts.
RoleOperator = "operator"
// RoleUser identifies standard user accounts.
RoleUser = "user"
// RoleReadOnly identifies read-only accounts.
RoleReadOnly = "readonly"
)
var (
roleToLevel = map[string]int{
RoleRoot: LevelAdmin,
RoleAdmin: LevelAdmin,
RoleOperator: LevelOperator,
RoleUser: LevelUser,
RoleReadOnly: LevelUser,
}
levelToRole = map[int]string{
LevelAdmin: RoleRoot,
LevelOperator: RoleOperator,
LevelUser: RoleUser,
}
)
// IsRootRole reports whether a role should be treated as root-equivalent.
func IsRootRole(role string) bool {
normalized := strings.ToLower(strings.TrimSpace(role))
return normalized == RoleRoot
}
// IsAdminRole reports whether a role is admin-like (root or legacy admin).
func IsAdminRole(role string) bool {
normalized := strings.ToLower(strings.TrimSpace(role))
return normalized == RoleRoot || normalized == RoleAdmin
}
// IsOperatorRole reports whether a role is operator.
func IsOperatorRole(role string) bool {
return strings.ToLower(strings.TrimSpace(role)) == RoleOperator
}
func normalizeUserRoleFields(user *User) {
if user == nil {
return
}
normalizedRole := strings.ToLower(strings.TrimSpace(user.Role))
if level, ok := roleToLevel[normalizedRole]; ok {
user.Role = normalizedRole
user.Level = level
} else if role, ok := levelToRole[user.Level]; ok {
user.Role = role
} else {
user.Role = RoleUser
user.Level = LevelUser
}
user.Groups = normalizeStringSlice(user.Groups)
user.Permissions = normalizeStringSlice(user.Permissions)
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
clone := make([]string, len(values))
copy(clone, values)
return clone
}
func cloneSubscription(sub *Subscription) *Subscription {
if sub == nil {
return nil
}
clone := *sub
clone.Meta = cloneSubscriptionMeta(sub.Meta)
if sub.CancelledAt != nil {
cancelled := sub.CancelledAt.UTC()
clone.CancelledAt = &cancelled
}
return &clone
}
func cloneSubscriptionMeta(meta map[string]any) map[string]any {
if len(meta) == 0 {
return map[string]any{}
}
clone := make(map[string]any, len(meta))
for key, value := range meta {
clone[key] = value
}
return clone
}
func cloneUser(user *User) *User {
if user == nil {
return nil
}
clone := *user
clone.Groups = cloneStringSlice(user.Groups)
clone.Permissions = cloneStringSlice(user.Permissions)
normalizeUserRoleFields(&clone)
return &clone
}
func assignUser(dst, src *User) {
*dst = *src
dst.Groups = cloneStringSlice(src.Groups)
dst.Permissions = cloneStringSlice(src.Permissions)
normalizeUserRoleFields(dst)
}
func assignSubscription(dst, src *Subscription) {
*dst = *src
dst.Meta = cloneSubscriptionMeta(src.Meta)
if src.CancelledAt != nil {
cancelled := src.CancelledAt.UTC()
dst.CancelledAt = &cancelled
}
}
func isSuperAdmin(user *User) bool {
if user == nil {
return false
}
if !IsAdminRole(user.Role) && user.Level != LevelAdmin {
return false
}
hasWildcard := false
for _, permission := range user.Permissions {
if strings.TrimSpace(permission) == "*" {
hasWildcard = true
break
}
}
if !hasWildcard {
return false
}
for _, group := range user.Groups {
if strings.EqualFold(strings.TrimSpace(group), "Admin") {
return true
}
}
return false
}
// CreateIdentity persists an identity record in the in-memory store.
func (s *memoryStore) CreateIdentity(ctx context.Context, identity *Identity) error {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
if identity.ID == "" {
identity.ID = uuid.NewString()
}
now := time.Now().UTC()
if identity.CreatedAt.IsZero() {
identity.CreatedAt = now
}
if identity.UpdatedAt.IsZero() {
identity.UpdatedAt = now
}
key := identity.Provider + ":" + identity.ExternalID
if _, exists := s.identities[key]; exists {
return errors.New("identity already exists")
}
stored := *identity
s.identities[key] = &stored
return nil
}
// ListUsers returns all users in the in-memory store.
func (s *memoryStore) ListUsers(ctx context.Context) ([]User, error) {
_ = ctx
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]User, 0, len(s.byID))
for _, user := range s.byID {
result = append(result, *cloneUser(user))
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.Before(result[j].CreatedAt)
})
return result, nil
}
func (s *memoryStore) DeleteUser(ctx context.Context, id string) error {
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.byID[id]
if !ok {
return nil
}
delete(s.byID, id)
delete(s.byEmail, strings.ToLower(user.Email))
delete(s.byName, strings.ToLower(user.Name))
return nil
}
func (s *memoryStore) AddToBlacklist(ctx context.Context, email string) error {
return nil
}
func (s *memoryStore) RemoveFromBlacklist(ctx context.Context, email string) error {
return nil
}
func (s *memoryStore) IsBlacklisted(ctx context.Context, email string) (bool, error) {
return false, nil
}
func (s *memoryStore) ListBlacklist(ctx context.Context) ([]string, error) {
return []string{}, nil
}
func (s *memoryStore) UpsertAgent(ctx context.Context, agent *Agent) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
existing, exists := s.agents[agent.ID]
if !exists {
existing = &Agent{
ID: agent.ID,
CreatedAt: now,
}
s.agents[agent.ID] = existing
}
existing.Name = agent.Name
existing.Groups = cloneStringSlice(agent.Groups)
existing.Healthy = agent.Healthy
existing.LastHeartbeat = agent.LastHeartbeat
existing.ClientsCount = agent.ClientsCount
existing.SyncRevision = agent.SyncRevision
existing.UpdatedAt = now
*agent = *existing
return nil
}
func (s *memoryStore) GetAgent(ctx context.Context, id string) (*Agent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
agent, ok := s.agents[id]
if !ok {
return nil, errors.New("agent not found")
}
clone := *agent
clone.Groups = cloneStringSlice(agent.Groups)
return &clone, nil
}
func (s *memoryStore) ListAgents(ctx context.Context) ([]*Agent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Agent, 0, len(s.agents))
for _, agent := range s.agents {
clone := *agent
clone.Groups = cloneStringSlice(agent.Groups)
result = append(result, &clone)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result, nil
}
func (s *memoryStore) DeleteAgent(ctx context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.agents, id)
return nil
}
func (s *memoryStore) DeleteStaleAgents(ctx context.Context, staleThreshold time.Duration) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-staleThreshold)
count := 0
for id, agent := range s.agents {
if agent.LastHeartbeat == nil || agent.LastHeartbeat.Before(cutoff) {
delete(s.agents, id)
count++
}
}
return count, nil
}