Merge pull request #150 from sean2077/feat/multi-user
feat: multi-user mode — per-user rate limits, role-based ACL, audit logging
This commit is contained in:
8 files changed
+1092
-12
No files matched your search
@@ -235,6 +235,11 @@ func main() {
|
||||
// Wire admin allowlist for privileged commands
|
||||
engine.SetAdminFrom(proj.AdminFrom)
|
||||
|
||||
// Wire per-user role-based policies
|
||||
if proj.Users != nil {
|
||||
engine.SetUserRoles(buildUserRoleManager(proj.Users))
|
||||
}
|
||||
|
||||
// Wire display truncation settings
|
||||
{
|
||||
dcfg := core.DisplayCfg{
|
||||
@@ -917,10 +922,50 @@ func reloadConfig(configPath, projName string, engine *core.Engine) (*core.Confi
|
||||
// Reload admin allowlist
|
||||
engine.SetAdminFrom(proj.AdminFrom)
|
||||
|
||||
// Reload per-user role-based policies
|
||||
if proj.Users != nil {
|
||||
engine.SetUserRoles(buildUserRoleManager(proj.Users))
|
||||
} else {
|
||||
engine.SetUserRoles(nil)
|
||||
}
|
||||
|
||||
slog.Info("config reloaded", "project", projName)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildUserRoleManager(uc *config.UsersConfig) *core.UserRoleManager {
|
||||
var roles []core.RoleInput
|
||||
for name, rc := range uc.Roles {
|
||||
var rlCfg *core.RateLimitCfg
|
||||
if rc.RateLimit != nil {
|
||||
maxMsg, windowSecs := 20, 60
|
||||
if rc.RateLimit.MaxMessages != nil {
|
||||
maxMsg = *rc.RateLimit.MaxMessages
|
||||
}
|
||||
if rc.RateLimit.WindowSecs != nil {
|
||||
windowSecs = *rc.RateLimit.WindowSecs
|
||||
}
|
||||
rlCfg = &core.RateLimitCfg{
|
||||
MaxMessages: maxMsg,
|
||||
Window: time.Duration(windowSecs) * time.Second,
|
||||
}
|
||||
}
|
||||
roles = append(roles, core.RoleInput{
|
||||
Name: name,
|
||||
UserIDs: rc.UserIDs,
|
||||
DisabledCommands: rc.DisabledCommands,
|
||||
RateLimit: rlCfg,
|
||||
})
|
||||
}
|
||||
defaultRole := "member"
|
||||
if uc.DefaultRole != "" {
|
||||
defaultRole = uc.DefaultRole
|
||||
}
|
||||
urm := core.NewUserRoleManager()
|
||||
urm.Configure(defaultRole, roles)
|
||||
return urm
|
||||
}
|
||||
|
||||
func buildHeartbeatConfig(hc config.HeartbeatConfig) core.HeartbeatConfig {
|
||||
cfg := core.HeartbeatConfig{
|
||||
IntervalMins: 30,
|
||||
|
||||
@@ -415,6 +415,29 @@ name = "my-backend"
|
||||
# 仅在个人单用户部署中使用此配置,确保所有允许的用户均可信任。
|
||||
# admin_from = "*"
|
||||
|
||||
# Multi-user mode — per-user rate limits and role-based command ACL
|
||||
# 多用户模式 — 按用户速率限制和基于角色的命令权限控制
|
||||
#
|
||||
# When [projects.users] is configured, each user is matched to a role.
|
||||
# Roles control which commands are available and the rate limit for that user.
|
||||
# If not configured, behavior is unchanged (backward compatible).
|
||||
#
|
||||
# 配置 [projects.users] 后,每个用户会被分配到一个角色。
|
||||
# 角色控制该用户可用的命令和速率限制。未配置时行为不变(向后兼容)。
|
||||
#
|
||||
# [projects.users]
|
||||
# default_role = "member"
|
||||
#
|
||||
# [projects.users.roles.admin]
|
||||
# user_ids = ["platform_user_id_1", "platform_user_id_2"]
|
||||
# disabled_commands = [] # no restrictions
|
||||
# rate_limit = { max_messages = 50, window_secs = 60 }
|
||||
#
|
||||
# [projects.users.roles.member]
|
||||
# user_ids = ["*"] # matches all unassigned users
|
||||
# disabled_commands = ["*"] # disable all built-in commands
|
||||
# rate_limit = { max_messages = 10, window_secs = 60 }
|
||||
|
||||
# Heartbeat — periodic awareness check / 心跳 — 周期性巡检
|
||||
# The agent wakes up at a fixed interval to check the environment.
|
||||
# Agent 会按固定间隔唤醒,检查环境状态。
|
||||
|
||||
@@ -88,6 +88,19 @@ type RateLimitConfig struct {
|
||||
WindowSecs *int `toml:"window_secs"` // window size in seconds; default 60
|
||||
}
|
||||
|
||||
// UsersConfig controls per-user role assignments and policies within a project.
|
||||
type UsersConfig struct {
|
||||
DefaultRole string `toml:"default_role,omitempty"` // role for unmatched users; default "member"
|
||||
Roles map[string]RoleConfig `toml:"roles,omitempty"`
|
||||
}
|
||||
|
||||
// RoleConfig defines policies for a user role.
|
||||
type RoleConfig struct {
|
||||
UserIDs []string `toml:"user_ids"`
|
||||
DisabledCommands []string `toml:"disabled_commands,omitempty"`
|
||||
RateLimit *RateLimitConfig `toml:"rate_limit,omitempty"` // nil = inherit global
|
||||
}
|
||||
|
||||
// SpeechConfig configures speech-to-text for voice messages.
|
||||
type SpeechConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
@@ -156,6 +169,7 @@ type ProjectConfig struct {
|
||||
InjectSender *bool `toml:"inject_sender,omitempty"` // prepend sender identity (platform + user ID) to each message sent to the agent
|
||||
DisabledCommands []string `toml:"disabled_commands,omitempty"` // commands to disable for this project (e.g. ["restart", "upgrade"])
|
||||
AdminFrom string `toml:"admin_from,omitempty"` // comma-separated user IDs allowed to run privileged commands; "*" = all allowed users
|
||||
Users *UsersConfig `toml:"users,omitempty"` // per-user role config; nil = legacy behavior
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
@@ -252,6 +266,46 @@ func (c *Config) validate() error {
|
||||
return fmt.Errorf("project %q: multi-workspace mode conflicts with agent work_dir (use base_dir instead)", proj.Name)
|
||||
}
|
||||
}
|
||||
if err := validateUsersConfig(prefix, proj.Users); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateUsersConfig checks the [projects.users] section for consistency.
|
||||
func validateUsersConfig(prefix string, u *UsersConfig) error {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
if len(u.Roles) == 0 {
|
||||
return fmt.Errorf("config: %s.users has no roles defined", prefix)
|
||||
}
|
||||
wildcardCount := 0
|
||||
seenUserIDs := make(map[string]string) // userID → role name
|
||||
for roleName, rc := range u.Roles {
|
||||
if len(rc.UserIDs) == 0 {
|
||||
return fmt.Errorf("config: %s.users.roles.%s has empty user_ids", prefix, roleName)
|
||||
}
|
||||
for _, uid := range rc.UserIDs {
|
||||
if uid == "*" {
|
||||
wildcardCount++
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(uid)
|
||||
if prev, dup := seenUserIDs[lower]; dup {
|
||||
return fmt.Errorf("config: %s.users: user %q appears in both role %q and %q", prefix, uid, prev, roleName)
|
||||
}
|
||||
seenUserIDs[lower] = roleName
|
||||
}
|
||||
}
|
||||
if wildcardCount > 1 {
|
||||
return fmt.Errorf("config: %s.users: wildcard user_ids=[\"*\"] appears in multiple roles", prefix)
|
||||
}
|
||||
if u.DefaultRole != "" {
|
||||
if _, ok := u.Roles[u.DefaultRole]; !ok {
|
||||
return fmt.Errorf("config: %s.users.default_role %q does not match any defined role", prefix, u.DefaultRole)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+117
-12
@@ -161,6 +161,8 @@ type Engine struct {
|
||||
|
||||
disabledCmds map[string]bool
|
||||
adminFrom string // comma-separated user IDs for privileged commands; "*" = all allowed users; "" = deny
|
||||
userRoles *UserRoleManager // nil = legacy mode (no per-user policies)
|
||||
userRolesMu sync.RWMutex // protects userRoles and disabledCmds
|
||||
|
||||
rateLimiter *RateLimiter
|
||||
streamPreview StreamPreviewCfg
|
||||
@@ -432,20 +434,42 @@ func (e *Engine) ClearAliases() {
|
||||
e.aliases = make(map[string]string)
|
||||
}
|
||||
|
||||
// SetDisabledCommands sets the list of command IDs that are disabled for this project.
|
||||
func (e *Engine) SetDisabledCommands(cmds []string) {
|
||||
// resolveDisabledCmds resolves a list of command names (including "*" wildcard)
|
||||
// to a set of canonical command IDs.
|
||||
func resolveDisabledCmds(cmds []string) map[string]bool {
|
||||
m := make(map[string]bool, len(cmds))
|
||||
for _, c := range cmds {
|
||||
c = strings.ToLower(strings.TrimPrefix(c, "/"))
|
||||
// Resolve alias names to canonical IDs
|
||||
id := matchPrefix(c, builtinCommands)
|
||||
if id != "" {
|
||||
if c == "*" {
|
||||
for _, bc := range builtinCommands {
|
||||
m[bc.id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
if id := matchPrefix(c, builtinCommands); id != "" {
|
||||
m[id] = true
|
||||
} else {
|
||||
m[c] = true
|
||||
}
|
||||
}
|
||||
e.disabledCmds = m
|
||||
return m
|
||||
}
|
||||
|
||||
// SetDisabledCommands sets the list of command IDs that are disabled for this project.
|
||||
func (e *Engine) SetDisabledCommands(cmds []string) {
|
||||
e.userRolesMu.Lock()
|
||||
defer e.userRolesMu.Unlock()
|
||||
e.disabledCmds = resolveDisabledCmds(cmds)
|
||||
}
|
||||
|
||||
// SetUserRoles configures per-user role-based policies. Pass nil to disable.
|
||||
func (e *Engine) SetUserRoles(urm *UserRoleManager) {
|
||||
e.userRolesMu.Lock()
|
||||
defer e.userRolesMu.Unlock()
|
||||
if e.userRoles != nil {
|
||||
e.userRoles.Stop()
|
||||
}
|
||||
e.userRoles = urm
|
||||
}
|
||||
|
||||
// SetAdminFrom sets the admin allowlist for privileged commands.
|
||||
@@ -453,7 +477,10 @@ func (e *Engine) SetDisabledCommands(cmds []string) {
|
||||
// Empty string means privileged commands are denied for everyone.
|
||||
func (e *Engine) SetAdminFrom(adminFrom string) {
|
||||
e.adminFrom = strings.TrimSpace(adminFrom)
|
||||
if e.adminFrom == "" && !e.disabledCmds["shell"] {
|
||||
e.userRolesMu.RLock()
|
||||
shellDisabled := e.disabledCmds["shell"]
|
||||
e.userRolesMu.RUnlock()
|
||||
if e.adminFrom == "" && !shellDisabled {
|
||||
slog.Warn("admin_from is not set — privileged commands (/shell, /dir, /restart, /upgrade) are blocked. "+
|
||||
"Set admin_from in config to enable them, or use disabled_commands to hide them.",
|
||||
"project", e.name)
|
||||
@@ -506,6 +533,38 @@ func (e *Engine) SetRateLimitCfg(cfg RateLimitCfg) {
|
||||
e.rateLimiter = NewRateLimiter(cfg.MaxMessages, cfg.Window)
|
||||
}
|
||||
|
||||
// checkRateLimit returns true if the message is allowed, false if rate-limited.
|
||||
// It checks per-user role-based limits first, then falls back to the global limiter.
|
||||
func (e *Engine) checkRateLimit(msg *Message) bool {
|
||||
e.userRolesMu.RLock()
|
||||
urm := e.userRoles
|
||||
e.userRolesMu.RUnlock()
|
||||
|
||||
// Try role-specific rate limit first
|
||||
if urm != nil {
|
||||
// Use userID if available, else fall back to sessionKey for unidentified users
|
||||
rateKey := msg.UserID
|
||||
if rateKey == "" {
|
||||
rateKey = msg.SessionKey
|
||||
}
|
||||
allowed, handled := urm.AllowRate(rateKey)
|
||||
if handled {
|
||||
return allowed
|
||||
}
|
||||
// Role has no rate_limit config — fall through to global, keyed by user
|
||||
}
|
||||
// Global rate limiter
|
||||
if e.rateLimiter == nil {
|
||||
return true
|
||||
}
|
||||
// When users config active: key by userID (per-user); otherwise sessionKey (legacy)
|
||||
key := msg.SessionKey
|
||||
if urm != nil && msg.UserID != "" {
|
||||
key = msg.UserID
|
||||
}
|
||||
return e.rateLimiter.Allow(key)
|
||||
}
|
||||
|
||||
// SetStreamPreviewCfg configures the streaming preview behavior.
|
||||
func (e *Engine) SetStreamPreviewCfg(cfg StreamPreviewCfg) {
|
||||
e.streamPreview = cfg
|
||||
@@ -779,6 +838,11 @@ func (e *Engine) Stop() error {
|
||||
if e.rateLimiter != nil {
|
||||
e.rateLimiter.Stop()
|
||||
}
|
||||
e.userRolesMu.Lock()
|
||||
if e.userRoles != nil {
|
||||
e.userRoles.Stop()
|
||||
}
|
||||
e.userRolesMu.Unlock()
|
||||
|
||||
if err := e.agent.Stop(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("stop agent %s: %w", e.agent.Name(), err))
|
||||
@@ -853,9 +917,10 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
|
||||
content = e.resolveAlias(content)
|
||||
msg.Content = content
|
||||
|
||||
// Rate limit check
|
||||
if e.rateLimiter != nil && !e.rateLimiter.Allow(msg.SessionKey) {
|
||||
slog.Info("message rate limited", "session", msg.SessionKey, "user", msg.UserName)
|
||||
// Rate limit check (per-user role-based, then global fallback)
|
||||
if !e.checkRateLimit(msg) {
|
||||
slog.Info("message rate limited",
|
||||
"session", msg.SessionKey, "user_id", msg.UserID, "user", msg.UserName)
|
||||
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgRateLimited))
|
||||
return
|
||||
}
|
||||
@@ -1960,16 +2025,39 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool {
|
||||
|
||||
cmdID := matchPrefix(cmd, builtinCommands)
|
||||
|
||||
if cmdID != "" && e.disabledCmds[cmdID] {
|
||||
// Resolve effective disabled commands: role-based if available, else project-level
|
||||
e.userRolesMu.RLock()
|
||||
disabledCmds := e.disabledCmds
|
||||
urm := e.userRoles
|
||||
e.userRolesMu.RUnlock()
|
||||
if urm != nil {
|
||||
if role := urm.ResolveRole(msg.UserID); role != nil {
|
||||
disabledCmds = role.DisabledCmds
|
||||
}
|
||||
}
|
||||
|
||||
if cmdID != "" && disabledCmds[cmdID] {
|
||||
slog.Info("audit: command_blocked",
|
||||
"user_id", msg.UserID, "platform", msg.Platform,
|
||||
"project", e.name, "command", cmdID, "reason", "disabled")
|
||||
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandDisabled), "/"+cmdID))
|
||||
return true
|
||||
}
|
||||
|
||||
if cmdID != "" && privilegedCommands[cmdID] && !e.isAdmin(msg.UserID) {
|
||||
slog.Info("audit: command_blocked",
|
||||
"user_id", msg.UserID, "platform", msg.Platform,
|
||||
"project", e.name, "command", cmdID, "reason", "unauthorized")
|
||||
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgAdminRequired), "/"+cmdID))
|
||||
return true
|
||||
}
|
||||
|
||||
if cmdID != "" {
|
||||
slog.Info("audit: command_executed",
|
||||
"user_id", msg.UserID, "platform", msg.Platform,
|
||||
"project", e.name, "command", cmdID)
|
||||
}
|
||||
|
||||
switch cmdID {
|
||||
case "new":
|
||||
e.cmdNew(p, msg, args)
|
||||
@@ -2050,10 +2138,23 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool {
|
||||
return true
|
||||
default:
|
||||
if custom, ok := e.commands.Resolve(cmd); ok {
|
||||
if disabledCmds[strings.ToLower(custom.Name)] {
|
||||
slog.Info("audit: command_blocked",
|
||||
"user_id", msg.UserID, "platform", msg.Platform,
|
||||
"project", e.name, "command", custom.Name, "reason", "disabled")
|
||||
e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgCommandDisabled), "/"+custom.Name))
|
||||
return true
|
||||
}
|
||||
slog.Info("audit: command_executed",
|
||||
"user_id", msg.UserID, "platform", msg.Platform,
|
||||
"project", e.name, "command", custom.Name, "type", "custom")
|
||||
e.executeCustomCommand(p, msg, custom, args)
|
||||
return true
|
||||
}
|
||||
if skill := e.skills.Resolve(cmd); skill != nil {
|
||||
slog.Info("audit: command_executed",
|
||||
"user_id", msg.UserID, "platform", msg.Platform,
|
||||
"project", e.name, "command", skill.Name, "type", "skill")
|
||||
e.executeSkill(p, msg, skill, args)
|
||||
return true
|
||||
}
|
||||
@@ -3476,6 +3577,10 @@ func (e *Engine) renderHelpGroupCard(groupKey string) *Card {
|
||||
func (e *Engine) GetAllCommands() []BotCommandInfo {
|
||||
var commands []BotCommandInfo
|
||||
|
||||
e.userRolesMu.RLock()
|
||||
disabledCmds := e.disabledCmds
|
||||
e.userRolesMu.RUnlock()
|
||||
|
||||
// Collect built-in commands (use primary name, first in names list)
|
||||
seenCmds := make(map[string]bool)
|
||||
for _, c := range builtinCommands {
|
||||
@@ -3490,7 +3595,7 @@ func (e *Engine) GetAllCommands() []BotCommandInfo {
|
||||
seenCmds[primaryName] = true
|
||||
|
||||
// Skip disabled commands
|
||||
if e.disabledCmds[c.id] {
|
||||
if disabledCmds[c.id] {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +380,58 @@ func TestEngine_DisabledCommandsWithSlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDisabledCmds_Wildcard(t *testing.T) {
|
||||
m := resolveDisabledCmds([]string{"*"})
|
||||
for _, bc := range builtinCommands {
|
||||
if !m[bc.id] {
|
||||
t.Errorf("wildcard should disable %q", bc.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDisabledCmds_Specific(t *testing.T) {
|
||||
m := resolveDisabledCmds([]string{"upgrade", "/restart", "Help"})
|
||||
if !m["upgrade"] {
|
||||
t.Error("upgrade should be disabled")
|
||||
}
|
||||
if !m["restart"] {
|
||||
t.Error("restart should be disabled (slash stripped)")
|
||||
}
|
||||
if !m["help"] {
|
||||
t.Error("help should be disabled (case insensitive)")
|
||||
}
|
||||
if m["shell"] {
|
||||
t.Error("shell should not be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDisabledCmds_Empty(t *testing.T) {
|
||||
m1 := resolveDisabledCmds(nil)
|
||||
if len(m1) != 0 {
|
||||
t.Errorf("nil input should produce empty map, got %d entries", len(m1))
|
||||
}
|
||||
m2 := resolveDisabledCmds([]string{})
|
||||
if len(m2) != 0 {
|
||||
t.Errorf("empty input should produce empty map, got %d entries", len(m2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_DisabledCommandsWildcard(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.SetDisabledCommands([]string{"*"})
|
||||
|
||||
p := &stubPlatformEngine{n: "test"}
|
||||
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
||||
|
||||
e.handleCommand(p, msg, "/help")
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
if !strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用") {
|
||||
t.Errorf("expected disabled message, got: %s", p.sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin_from tests ---
|
||||
|
||||
func TestEngine_AdminFrom_DenyByDefault(t *testing.T) {
|
||||
@@ -514,6 +566,204 @@ func TestEngine_AdminFrom_AdminCanRunShell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- role-based ACL tests ---
|
||||
|
||||
func TestEngine_RoleBasedACL_AdminCanRunAll(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.SetDisabledCommands([]string{"help", "status"}) // project-level disables
|
||||
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure("member", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
||||
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"*"}},
|
||||
})
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
p := &stubPlatformEngine{n: "test"}
|
||||
msg := &Message{SessionKey: "test:a1", UserID: "admin1", ReplyCtx: "ctx"}
|
||||
e.handleCommand(p, msg, "/help")
|
||||
|
||||
// Admin role has disabled_commands=[], so /help should NOT be blocked
|
||||
for _, s := range p.sent {
|
||||
if strings.Contains(s, "disabled") || strings.Contains(s, "禁用") {
|
||||
t.Errorf("admin should not have /help disabled, got: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RoleBasedACL_MemberBlocked(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure("member", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
||||
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"*"}},
|
||||
})
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
p := &stubPlatformEngine{n: "test"}
|
||||
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
||||
e.handleCommand(p, msg, "/help")
|
||||
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
if !strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用") {
|
||||
t.Errorf("member should have /help disabled, got: %s", p.sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RoleBasedACL_NoUserID_UsesDefaultRole(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.SetDisabledCommands([]string{"help"}) // project-level disables /help
|
||||
|
||||
// Default role "member" has wildcard with disabled_commands=["*"]
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure("member", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
||||
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"*"}},
|
||||
})
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
p := &stubPlatformEngine{n: "test"}
|
||||
msg := &Message{SessionKey: "test:anon", UserID: "", ReplyCtx: "ctx"} // no UserID
|
||||
e.handleCommand(p, msg, "/help")
|
||||
|
||||
// Empty UserID resolves to default/wildcard role, which disables all commands
|
||||
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
||||
t.Errorf("empty UserID should resolve to default role ACL, got: %v", p.sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RoleBasedACL_NoUsersConfig_Legacy(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.SetDisabledCommands([]string{"help"})
|
||||
// No SetUserRoles — legacy mode
|
||||
|
||||
p := &stubPlatformEngine{n: "test"}
|
||||
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
||||
e.handleCommand(p, msg, "/help")
|
||||
|
||||
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
||||
t.Errorf("legacy mode should use project-level disabled_commands, got: %v", p.sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_CustomCommand_DisabledByRole(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.commands.Add("deploy", "deploy command", "deploy it", "", "", "test")
|
||||
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure("member", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
||||
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"deploy"}},
|
||||
})
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
// Member should be blocked from custom command
|
||||
p := &stubPlatformEngine{n: "test"}
|
||||
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
||||
e.handleCommand(p, msg, "/deploy")
|
||||
|
||||
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
||||
t.Errorf("custom command should be blocked for member, got: %v", p.sent)
|
||||
}
|
||||
|
||||
// Admin should be allowed
|
||||
p2 := &stubPlatformEngine{n: "test"}
|
||||
msg2 := &Message{SessionKey: "test:a1", UserID: "admin1", ReplyCtx: "ctx"}
|
||||
e.handleCommand(p2, msg2, "/deploy")
|
||||
|
||||
if len(p2.sent) > 0 && (strings.Contains(p2.sent[0], "disabled") || strings.Contains(p2.sent[0], "禁用")) {
|
||||
t.Errorf("custom command should be allowed for admin, got: %v", p2.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// --- role-based rate limit tests ---
|
||||
|
||||
func TestEngine_RateLimit_RoleSpecific(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure("member", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{},
|
||||
RateLimit: &RateLimitCfg{MaxMessages: 50, Window: time.Minute}},
|
||||
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{},
|
||||
RateLimit: &RateLimitCfg{MaxMessages: 2, Window: time.Minute}},
|
||||
})
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
// Member should be limited after 2 messages
|
||||
msg := &Message{SessionKey: "test:u1", UserID: "user1"}
|
||||
if !e.checkRateLimit(msg) {
|
||||
t.Error("1st message should be allowed")
|
||||
}
|
||||
if !e.checkRateLimit(msg) {
|
||||
t.Error("2nd message should be allowed")
|
||||
}
|
||||
if e.checkRateLimit(msg) {
|
||||
t.Error("3rd message should be rate-limited")
|
||||
}
|
||||
|
||||
// Admin should still be allowed
|
||||
adminMsg := &Message{SessionKey: "test:a1", UserID: "admin1"}
|
||||
if !e.checkRateLimit(adminMsg) {
|
||||
t.Error("admin should not be rate-limited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RateLimit_NoUsersConfig_Legacy(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.SetRateLimitCfg(RateLimitCfg{MaxMessages: 2, Window: time.Minute})
|
||||
|
||||
msg := &Message{SessionKey: "test:session1", UserID: "user1"}
|
||||
if !e.checkRateLimit(msg) {
|
||||
t.Error("1st should be allowed")
|
||||
}
|
||||
if !e.checkRateLimit(msg) {
|
||||
t.Error("2nd should be allowed")
|
||||
}
|
||||
if e.checkRateLimit(msg) {
|
||||
t.Error("3rd should be rate-limited")
|
||||
}
|
||||
|
||||
// Different session key should be independent (legacy keying)
|
||||
msg2 := &Message{SessionKey: "test:session2", UserID: "user1"}
|
||||
if !e.checkRateLimit(msg2) {
|
||||
t.Error("different session key should have independent bucket in legacy mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RateLimit_GlobalFallback(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.SetRateLimitCfg(RateLimitCfg{MaxMessages: 2, Window: time.Minute})
|
||||
|
||||
// User roles configured but role has no rate_limit
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure("member", []RoleInput{
|
||||
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{}},
|
||||
// No RateLimit on this role
|
||||
})
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
msg := &Message{SessionKey: "test:s1", UserID: "user1"}
|
||||
if !e.checkRateLimit(msg) {
|
||||
t.Error("1st should be allowed")
|
||||
}
|
||||
if !e.checkRateLimit(msg) {
|
||||
t.Error("2nd should be allowed")
|
||||
}
|
||||
if e.checkRateLimit(msg) {
|
||||
t.Error("3rd should be rate-limited by global limiter")
|
||||
}
|
||||
|
||||
// Same user, different session → should share limit (keyed by userID when users config active)
|
||||
msg2 := &Message{SessionKey: "test:s2", UserID: "user1"}
|
||||
if e.checkRateLimit(msg2) {
|
||||
t.Error("same user from different session should still be rate-limited")
|
||||
}
|
||||
}
|
||||
|
||||
// --- permission prompt card tests ---
|
||||
|
||||
func TestSendPermissionPrompt_CardPlatform(t *testing.T) {
|
||||
|
||||
@@ -363,6 +363,8 @@ func (m *ManagementServer) handleProjectRoutes(w http.ResponseWriter, r *http.Re
|
||||
m.handleProjectModel(w, r, engine)
|
||||
case "heartbeat":
|
||||
m.handleProjectHeartbeat(w, r, projName, rest)
|
||||
case "users":
|
||||
m.handleProjectUsers(w, r, engine)
|
||||
default:
|
||||
mgmtError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
@@ -461,6 +463,75 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
|
||||
mgmtError(w, http.StatusMethodNotAllowed, "GET or PATCH only")
|
||||
}
|
||||
|
||||
// ── Users endpoints ──────────────────────────────────────────
|
||||
|
||||
func (m *ManagementServer) handleProjectUsers(w http.ResponseWriter, r *http.Request, e *Engine) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
e.userRolesMu.RLock()
|
||||
urm := e.userRoles
|
||||
e.userRolesMu.RUnlock()
|
||||
mgmtJSON(w, http.StatusOK, urm.Snapshot())
|
||||
|
||||
case http.MethodPatch:
|
||||
var body struct {
|
||||
DefaultRole string `json:"default_role"`
|
||||
Roles map[string]json.RawMessage `json:"roles"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var roles []RoleInput
|
||||
for name, raw := range body.Roles {
|
||||
var rc struct {
|
||||
UserIDs []string `json:"user_ids"`
|
||||
DisabledCommands []string `json:"disabled_commands"`
|
||||
RateLimit *struct {
|
||||
MaxMessages int `json:"max_messages"`
|
||||
WindowSecs int `json:"window_secs"`
|
||||
} `json:"rate_limit"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rc); err != nil {
|
||||
mgmtError(w, http.StatusBadRequest, fmt.Sprintf("invalid role %q: %s", name, err))
|
||||
return
|
||||
}
|
||||
ri := RoleInput{
|
||||
Name: name,
|
||||
UserIDs: rc.UserIDs,
|
||||
DisabledCommands: rc.DisabledCommands,
|
||||
}
|
||||
if rc.RateLimit != nil {
|
||||
ri.RateLimit = &RateLimitCfg{
|
||||
MaxMessages: rc.RateLimit.MaxMessages,
|
||||
Window: time.Duration(rc.RateLimit.WindowSecs) * time.Second,
|
||||
}
|
||||
}
|
||||
roles = append(roles, ri)
|
||||
}
|
||||
|
||||
defaultRole := body.DefaultRole
|
||||
if defaultRole == "" {
|
||||
defaultRole = "member"
|
||||
}
|
||||
|
||||
if err := ValidateRoleInputs(defaultRole, roles); err != nil {
|
||||
mgmtError(w, http.StatusBadRequest, "invalid users config: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
urm := NewUserRoleManager()
|
||||
urm.Configure(defaultRole, roles)
|
||||
e.SetUserRoles(urm)
|
||||
|
||||
mgmtOK(w, "users config updated")
|
||||
|
||||
default:
|
||||
mgmtError(w, http.StatusMethodNotAllowed, "GET or PATCH only")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session endpoints ─────────────────────────────────────────
|
||||
|
||||
func (m *ManagementServer) handleProjectSessions(w http.ResponseWriter, r *http.Request, projName string, e *Engine, rest string) {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testRoles() []RoleInput {
|
||||
return []RoleInput{
|
||||
{
|
||||
Name: "admin",
|
||||
UserIDs: []string{"admin1", "admin2"},
|
||||
DisabledCommands: []string{},
|
||||
RateLimit: &RateLimitCfg{MaxMessages: 50, Window: time.Minute},
|
||||
},
|
||||
{
|
||||
Name: "member",
|
||||
UserIDs: []string{"*"},
|
||||
DisabledCommands: []string{"*"},
|
||||
RateLimit: &RateLimitCfg{MaxMessages: 3, Window: time.Minute},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_ResolveRole_ExactMatch(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
role := m.ResolveRole("admin1")
|
||||
if role == nil || role.Name != "admin" {
|
||||
t.Errorf("expected admin role, got %+v", role)
|
||||
}
|
||||
role = m.ResolveRole("admin2")
|
||||
if role == nil || role.Name != "admin" {
|
||||
t.Errorf("expected admin role for admin2, got %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_ResolveRole_CaseInsensitive(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
role := m.ResolveRole("ADMIN1")
|
||||
if role == nil || role.Name != "admin" {
|
||||
t.Errorf("expected case-insensitive match to admin, got %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_ResolveRole_WildcardFallback(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
role := m.ResolveRole("unknown_user")
|
||||
if role == nil || role.Name != "member" {
|
||||
t.Errorf("expected member (wildcard) role, got %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_ResolveRole_DefaultRole(t *testing.T) {
|
||||
// No wildcard role; use defaultRole
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("viewer", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
||||
{Name: "viewer", UserIDs: []string{"viewer1"}, DisabledCommands: []string{"shell"}},
|
||||
})
|
||||
|
||||
role := m.ResolveRole("unknown_user")
|
||||
if role == nil || role.Name != "viewer" {
|
||||
t.Errorf("expected default role 'viewer', got %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_ResolveRole_NoMatch(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
||||
})
|
||||
|
||||
role := m.ResolveRole("unknown_user")
|
||||
if role != nil {
|
||||
t.Errorf("expected nil for unmatched user, got %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_DisabledCmdsWildcard(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
role := m.ResolveRole("regular_user")
|
||||
if role == nil {
|
||||
t.Fatal("expected member role")
|
||||
}
|
||||
// Member has disabled_commands = ["*"], should disable all builtins
|
||||
for _, bc := range builtinCommands {
|
||||
if !role.DisabledCmds[bc.id] {
|
||||
t.Errorf("member role should disable %q", bc.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_DisabledCmdsEmpty(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
role := m.ResolveRole("admin1")
|
||||
if role == nil {
|
||||
t.Fatal("expected admin role")
|
||||
}
|
||||
if len(role.DisabledCmds) != 0 {
|
||||
t.Errorf("admin role should have no disabled commands, got %d", len(role.DisabledCmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_AllowRate_RoleSpecific(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
// Member has max_messages=3
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, handled := m.AllowRate("user1")
|
||||
if !allowed || !handled {
|
||||
t.Errorf("request %d should be allowed, got allowed=%v handled=%v", i+1, allowed, handled)
|
||||
}
|
||||
}
|
||||
// 4th should be blocked
|
||||
allowed, handled := m.AllowRate("user1")
|
||||
if allowed || !handled {
|
||||
t.Errorf("4th request should be blocked, got allowed=%v handled=%v", allowed, handled)
|
||||
}
|
||||
|
||||
// Admin has max_messages=50, should still be allowed
|
||||
allowed, handled = m.AllowRate("admin1")
|
||||
if !allowed || !handled {
|
||||
t.Errorf("admin should be allowed, got allowed=%v handled=%v", allowed, handled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_AllowRate_NoRoleLimit(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("viewer", []RoleInput{
|
||||
{Name: "viewer", UserIDs: []string{"*"}, DisabledCommands: []string{}},
|
||||
// No RateLimit on viewer role
|
||||
})
|
||||
|
||||
allowed, handled := m.AllowRate("anyone")
|
||||
if !allowed || handled {
|
||||
t.Errorf("expected allowed=true handled=false for role without rate_limit, got %v %v", allowed, handled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_AllowRate_Concurrent(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m.AllowRate("concurrent_user")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestUserRoleManager_NilReceiver(t *testing.T) {
|
||||
var m *UserRoleManager
|
||||
|
||||
if role := m.ResolveRole("anyone"); role != nil {
|
||||
t.Error("nil manager should return nil role")
|
||||
}
|
||||
allowed, handled := m.AllowRate("anyone")
|
||||
if !allowed || handled {
|
||||
t.Error("nil manager should return allowed=true handled=false")
|
||||
}
|
||||
m.Stop() // should not panic
|
||||
}
|
||||
|
||||
func TestUserRoleManager_Stop(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
m.Stop()
|
||||
m.Stop() // idempotent
|
||||
|
||||
// AllowRate should still work (just no cleanup goroutine)
|
||||
allowed, handled := m.AllowRate("admin1")
|
||||
if !allowed || !handled {
|
||||
t.Errorf("should still work after Stop, got allowed=%v handled=%v", allowed, handled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_Snapshot(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
snap := m.Snapshot()
|
||||
if snap["configured"] != true {
|
||||
t.Error("expected configured=true")
|
||||
}
|
||||
if snap["default_role"] != "member" {
|
||||
t.Errorf("expected default_role=member, got %v", snap["default_role"])
|
||||
}
|
||||
roles, ok := snap["roles"].(map[string]any)
|
||||
if !ok || len(roles) != 2 {
|
||||
t.Errorf("expected 2 roles, got %v", snap["roles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_Snapshot_Nil(t *testing.T) {
|
||||
var m *UserRoleManager
|
||||
snap := m.Snapshot()
|
||||
if snap["configured"] != false {
|
||||
t.Error("nil manager snapshot should have configured=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRoleManager_ResolveRole_EmptyUserID(t *testing.T) {
|
||||
m := NewUserRoleManager()
|
||||
m.Configure("member", testRoles())
|
||||
|
||||
// Empty userID should still resolve to default/wildcard role
|
||||
role := m.ResolveRole("")
|
||||
if role == nil || role.Name != "member" {
|
||||
t.Errorf("empty userID should resolve to default role, got %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleInputs_DuplicateUserIDs(t *testing.T) {
|
||||
err := ValidateRoleInputs("admin", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"user1"}},
|
||||
{Name: "member", UserIDs: []string{"user1"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate user IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleInputs_MultipleWildcards(t *testing.T) {
|
||||
err := ValidateRoleInputs("admin", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"*"}},
|
||||
{Name: "member", UserIDs: []string{"*"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for multiple wildcards")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleInputs_InvalidDefaultRole(t *testing.T) {
|
||||
err := ValidateRoleInputs("nonexistent", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"user1"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid default_role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleInputs_EmptyUserIDs(t *testing.T) {
|
||||
err := ValidateRoleInputs("admin", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty user_ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleInputs_CaseInsensitiveDuplicate(t *testing.T) {
|
||||
err := ValidateRoleInputs("admin", []RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"User1"}},
|
||||
{Name: "member", UserIDs: []string{"user1"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for case-insensitive duplicate user IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRoleInputs_Valid(t *testing.T) {
|
||||
err := ValidateRoleInputs("member", testRoles())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for valid config, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user