Files
chenhg5-cc-connect/core/user_roles.go
sean2077 5be00c702f fix: case-insensitive duplicate detection, custom command ACL, and audit logging
- Normalize user IDs to lowercase in ValidateRoleInputs and
  validateUsersConfig to catch case-insensitive duplicates (e.g.
  "User1" in admin + "user1" in member)
- Apply disabled_commands ACL check to custom commands — previously
  only builtin commands were checked, so explicitly disabling a custom
  command name had no effect
- Add audit: log entries for custom command and skill execution
- Add 2 new tests for case-insensitive validation and custom command ACL
2026-03-16 17:04:19 +08:00

250 lines
6.5 KiB
Go

package core
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
// UserRole holds the resolved policy for a single role.
type UserRole struct {
Name string
DisabledCmds map[string]bool // resolved command IDs (including "*" wildcard)
RateLimitCfg *RateLimitCfg // nil = no role-specific limit; use global fallback
}
// RoleInput is the configuration data used to build a UserRoleManager.
type RoleInput struct {
Name string
UserIDs []string
DisabledCommands []string
RateLimit *RateLimitCfg
}
// UserRoleManager resolves user IDs to roles and manages per-role rate limiters.
type UserRoleManager struct {
mu sync.RWMutex
roles []roleEntry // ordered list for iteration
defaultRole string // fallback role name
roleMap map[string]*UserRole // role name → resolved policy
limiters map[string]*RateLimiter // role name → shared per-role rate limiter
}
type roleEntry struct {
roleName string
userIDs map[string]bool // normalized user IDs; nil when wildcard
wildcard bool // true if user_ids contains "*"
}
// NewUserRoleManager creates an empty manager. Call Configure() to populate.
func NewUserRoleManager() *UserRoleManager {
return &UserRoleManager{
roleMap: make(map[string]*UserRole),
limiters: make(map[string]*RateLimiter),
}
}
// Configure replaces the role configuration. Should be called on a fresh manager
// before passing to Engine.SetUserRoles().
func (m *UserRoleManager) Configure(defaultRole string, roles []RoleInput) {
m.mu.Lock()
defer m.mu.Unlock()
// Stop any existing limiters
for _, rl := range m.limiters {
rl.Stop()
}
m.defaultRole = defaultRole
m.roleMap = make(map[string]*UserRole, len(roles))
m.limiters = make(map[string]*RateLimiter, len(roles))
m.roles = make([]roleEntry, 0, len(roles))
// Sort roles by name for deterministic iteration order
sorted := make([]RoleInput, len(roles))
copy(sorted, roles)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
for _, ri := range sorted {
role := &UserRole{
Name: ri.Name,
DisabledCmds: resolveDisabledCmds(ri.DisabledCommands),
RateLimitCfg: ri.RateLimit,
}
m.roleMap[ri.Name] = role
entry := roleEntry{roleName: ri.Name}
for _, uid := range ri.UserIDs {
if uid == "*" {
entry.wildcard = true
} else {
if entry.userIDs == nil {
entry.userIDs = make(map[string]bool)
}
entry.userIDs[strings.ToLower(uid)] = true
}
}
m.roles = append(m.roles, entry)
// Create per-role rate limiter if configured
if ri.RateLimit != nil && ri.RateLimit.MaxMessages > 0 {
m.limiters[ri.Name] = NewRateLimiter(ri.RateLimit.MaxMessages, ri.RateLimit.Window)
}
}
}
// ResolveRole returns the role for a given user ID.
// Resolution order: explicit match → default role → wildcard → nil.
// Nil-receiver safe.
func (m *UserRoleManager) ResolveRole(userID string) *UserRole {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
uid := strings.ToLower(userID)
// 1. Explicit match in non-wildcard roles
for _, entry := range m.roles {
if !entry.wildcard && entry.userIDs[uid] {
return m.roleMap[entry.roleName]
}
}
// 2. Default role
if m.defaultRole != "" {
if role, ok := m.roleMap[m.defaultRole]; ok {
return role
}
}
// 3. Wildcard role
for _, entry := range m.roles {
if entry.wildcard {
return m.roleMap[entry.roleName]
}
}
return nil
}
// AllowRate checks the per-user rate limit based on the user's role.
// Returns (allowed, handled). handled=false means no role-specific limit
// was found; the caller should fall back to the global limiter.
// Nil-receiver safe.
func (m *UserRoleManager) AllowRate(userID string) (allowed, handled bool) {
if m == nil {
return true, false
}
role := m.ResolveRole(userID)
if role == nil || role.RateLimitCfg == nil {
return true, false
}
m.mu.RLock()
rl := m.limiters[role.Name]
m.mu.RUnlock()
if rl == nil {
return true, false
}
return rl.Allow(userID), true
}
// Snapshot returns a serializable representation of the current role configuration.
func (m *UserRoleManager) Snapshot() map[string]any {
if m == nil {
return map[string]any{"configured": false}
}
m.mu.RLock()
defer m.mu.RUnlock()
roles := make(map[string]any, len(m.roles))
for _, entry := range m.roles {
role := m.roleMap[entry.roleName]
userIDs := make([]string, 0)
if entry.wildcard {
userIDs = append(userIDs, "*")
}
for id := range entry.userIDs {
userIDs = append(userIDs, id)
}
sort.Strings(userIDs)
disabledCmds := make([]string, 0, len(role.DisabledCmds))
for cmd := range role.DisabledCmds {
disabledCmds = append(disabledCmds, cmd)
}
sort.Strings(disabledCmds)
roleData := map[string]any{
"user_ids": userIDs,
"disabled_commands": disabledCmds,
}
if role.RateLimitCfg != nil {
roleData["rate_limit"] = map[string]any{
"max_messages": role.RateLimitCfg.MaxMessages,
"window_secs": int(role.RateLimitCfg.Window / time.Second),
}
}
roles[entry.roleName] = roleData
}
return map[string]any{
"configured": true,
"default_role": m.defaultRole,
"roles": roles,
}
}
// ValidateRoleInputs checks role inputs for consistency: duplicate user IDs,
// multiple wildcards, empty user_ids, and default_role existence.
func ValidateRoleInputs(defaultRole string, roles []RoleInput) error {
if len(roles) == 0 {
return fmt.Errorf("no roles defined")
}
wildcardCount := 0
seenUserIDs := make(map[string]string) // userID → role name
roleNames := make(map[string]bool, len(roles))
for _, ri := range roles {
roleNames[ri.Name] = true
if len(ri.UserIDs) == 0 {
return fmt.Errorf("role %q has empty user_ids", ri.Name)
}
for _, uid := range ri.UserIDs {
if uid == "*" {
wildcardCount++
continue
}
lower := strings.ToLower(uid)
if prev, dup := seenUserIDs[lower]; dup {
return fmt.Errorf("user %q appears in both role %q and %q", uid, prev, ri.Name)
}
seenUserIDs[lower] = ri.Name
}
}
if wildcardCount > 1 {
return fmt.Errorf("wildcard user_ids=[\"*\"] appears in multiple roles")
}
if defaultRole != "" {
if !roleNames[defaultRole] {
return fmt.Errorf("default_role %q does not match any defined role", defaultRole)
}
}
return nil
}
// Stop terminates all per-role rate limiter goroutines. Nil-receiver safe.
func (m *UserRoleManager) Stop() {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
for _, rl := range m.limiters {
rl.Stop()
}
}