feat: añadir paquete pkg/acl para control de acceso puro
Nuevo paquete puro (sin I/O) que implementa RBAC basado en roles.
Incluye: ACL, Role, RoleDef, FromMap constructor, CanDo para verificar
permisos, RoleFor para resolver rol de un usuario, y soporte para
wildcards tanto en usuarios ("*") como en acciones ("command:*").
Incluye tests completos cubriendo: ACL vacío, admin wildcard, acciones
específicas, prefix wildcards, prioridad exacto>wildcard, y múltiples
roles por usuario.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
// Package acl provides pure access control types and functions.
|
||||
// No I/O, no side effects — only data transformations.
|
||||
package acl
|
||||
|
||||
// Role represents a named role with its users and allowed actions.
|
||||
type Role struct {
|
||||
Name string
|
||||
Users []string // Matrix user IDs; "*" means everyone
|
||||
Actions []string // allowed actions; "*" means all
|
||||
}
|
||||
|
||||
// ACL is the resolved access control list.
|
||||
type ACL struct {
|
||||
roles []Role
|
||||
}
|
||||
|
||||
// Empty returns true if no roles are configured (ACL is inactive).
|
||||
func (a ACL) Empty() bool {
|
||||
return len(a.roles) == 0
|
||||
}
|
||||
|
||||
// RoleFor returns the name of the first role that matches the given userID.
|
||||
// Specific user entries are checked before wildcard ("*") entries.
|
||||
// Returns "" if no role matches.
|
||||
func (a ACL) RoleFor(userID string) string {
|
||||
// First pass: exact match
|
||||
for _, r := range a.roles {
|
||||
for _, u := range r.Users {
|
||||
if u == userID {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: wildcard
|
||||
for _, r := range a.roles {
|
||||
for _, u := range r.Users {
|
||||
if u == "*" {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CanDo checks if a userID is allowed to perform an action.
|
||||
// If no roles are defined, returns true (open access).
|
||||
// If roles exist but the user has none, returns false.
|
||||
func (a ACL) CanDo(userID string, action string) bool {
|
||||
if a.Empty() {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, r := range a.roles {
|
||||
if !matchesUser(r.Users, userID) {
|
||||
continue
|
||||
}
|
||||
if matchesAction(r.Actions, action) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowedUsers returns the deduplicated list of all explicit user IDs
|
||||
// (excluding "*") that have at least one role.
|
||||
func (a ACL) AllowedUsers() []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, r := range a.roles {
|
||||
for _, u := range r.Users {
|
||||
if u != "*" && !seen[u] {
|
||||
seen[u] = true
|
||||
result = append(result, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func matchesUser(users []string, userID string) bool {
|
||||
for _, u := range users {
|
||||
if u == userID || u == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesAction(actions []string, action string) bool {
|
||||
for _, a := range actions {
|
||||
if a == "*" || a == action {
|
||||
return true
|
||||
}
|
||||
// Wildcard prefix: "command:*" matches "command:deploy"
|
||||
if len(a) > 1 && a[len(a)-1] == '*' {
|
||||
prefix := a[:len(a)-1]
|
||||
if len(action) >= len(prefix) && action[:len(prefix)] == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmptyACL_AllowsEverything(t *testing.T) {
|
||||
a := FromMap(nil)
|
||||
if !a.Empty() {
|
||||
t.Fatal("expected empty ACL")
|
||||
}
|
||||
if !a.CanDo("@anyone:server", "anything") {
|
||||
t.Fatal("empty ACL should allow everything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanDo_AdminWildcard(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"admin": {
|
||||
Users: []string{"@admin:server"},
|
||||
Actions: []string{"*"},
|
||||
},
|
||||
})
|
||||
|
||||
if !a.CanDo("@admin:server", "command:deploy") {
|
||||
t.Fatal("admin should be able to do anything")
|
||||
}
|
||||
if a.CanDo("@user:server", "command:deploy") {
|
||||
t.Fatal("unknown user should be denied when roles are defined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanDo_SpecificActions(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"admin": {
|
||||
Users: []string{"@admin:server"},
|
||||
Actions: []string{"*"},
|
||||
},
|
||||
"user": {
|
||||
Users: []string{"*"},
|
||||
Actions: []string{"ask", "help", "command:help", "command:ping"},
|
||||
},
|
||||
})
|
||||
|
||||
// Admin can do anything
|
||||
if !a.CanDo("@admin:server", "tool:ssh_command") {
|
||||
t.Fatal("admin should have wildcard access")
|
||||
}
|
||||
|
||||
// Regular user can ask and use help
|
||||
if !a.CanDo("@random:server", "ask") {
|
||||
t.Fatal("wildcard user should be able to ask")
|
||||
}
|
||||
if !a.CanDo("@random:server", "command:help") {
|
||||
t.Fatal("wildcard user should be able to use help command")
|
||||
}
|
||||
|
||||
// Regular user cannot use restricted tools
|
||||
if a.CanDo("@random:server", "tool:ssh_command") {
|
||||
t.Fatal("wildcard user should not access ssh tool")
|
||||
}
|
||||
if a.CanDo("@random:server", "command:deploy") {
|
||||
t.Fatal("wildcard user should not access deploy command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanDo_PrefixWildcard(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"ops": {
|
||||
Users: []string{"@ops:server"},
|
||||
Actions: []string{"command:*", "tool:*"},
|
||||
},
|
||||
})
|
||||
|
||||
if !a.CanDo("@ops:server", "command:deploy") {
|
||||
t.Fatal("command:* should match command:deploy")
|
||||
}
|
||||
if !a.CanDo("@ops:server", "tool:ssh_command") {
|
||||
t.Fatal("tool:* should match tool:ssh_command")
|
||||
}
|
||||
if a.CanDo("@ops:server", "ask") {
|
||||
t.Fatal("command:*/tool:* should not match 'ask'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleFor_ExactBeforeWildcard(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"admin": {
|
||||
Users: []string{"@admin:server"},
|
||||
Actions: []string{"*"},
|
||||
},
|
||||
"user": {
|
||||
Users: []string{"*"},
|
||||
Actions: []string{"ask"},
|
||||
},
|
||||
})
|
||||
|
||||
if role := a.RoleFor("@admin:server"); role != "admin" {
|
||||
t.Fatalf("expected admin, got %q", role)
|
||||
}
|
||||
if role := a.RoleFor("@random:server"); role != "user" {
|
||||
t.Fatalf("expected user, got %q", role)
|
||||
}
|
||||
if role := a.RoleFor("@nobody:other"); role != "user" {
|
||||
t.Fatalf("expected user for wildcard, got %q", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleFor_NoMatch(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"admin": {
|
||||
Users: []string{"@admin:server"},
|
||||
Actions: []string{"*"},
|
||||
},
|
||||
})
|
||||
|
||||
if role := a.RoleFor("@nobody:server"); role != "" {
|
||||
t.Fatalf("expected empty role, got %q", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedUsers(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"admin": {
|
||||
Users: []string{"@admin:server", "@root:server"},
|
||||
Actions: []string{"*"},
|
||||
},
|
||||
"user": {
|
||||
Users: []string{"*", "@admin:server"}, // admin appears in both
|
||||
Actions: []string{"ask"},
|
||||
},
|
||||
})
|
||||
|
||||
users := a.AllowedUsers()
|
||||
// Should contain @admin:server and @root:server, deduplicated, no "*"
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d: %v", len(users), users)
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
for _, u := range users {
|
||||
found[u] = true
|
||||
}
|
||||
if !found["@admin:server"] || !found["@root:server"] {
|
||||
t.Fatalf("unexpected users: %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanDo_MultipleRolesForSameUser(t *testing.T) {
|
||||
a := FromMap(map[string]RoleDef{
|
||||
"viewer": {
|
||||
Users: []string{"@user:server"},
|
||||
Actions: []string{"ask", "help"},
|
||||
},
|
||||
"deployer": {
|
||||
Users: []string{"@user:server"},
|
||||
Actions: []string{"command:deploy"},
|
||||
},
|
||||
})
|
||||
|
||||
// User has both roles, should be able to do actions from either
|
||||
if !a.CanDo("@user:server", "ask") {
|
||||
t.Fatal("user should be able to ask via viewer role")
|
||||
}
|
||||
if !a.CanDo("@user:server", "command:deploy") {
|
||||
t.Fatal("user should be able to deploy via deployer role")
|
||||
}
|
||||
if a.CanDo("@user:server", "tool:ssh_command") {
|
||||
t.Fatal("user should not have ssh access")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package acl
|
||||
|
||||
// RoleDef is the input shape for building an ACL — matches config.RoleCfg.
|
||||
type RoleDef struct {
|
||||
Users []string
|
||||
Actions []string
|
||||
}
|
||||
|
||||
// FromMap builds an ACL from a map of role name → RoleDef.
|
||||
// This is the primary constructor used from the runtime.
|
||||
func FromMap(roles map[string]RoleDef) ACL {
|
||||
var rs []Role
|
||||
for name, def := range roles {
|
||||
rs = append(rs, Role{
|
||||
Name: name,
|
||||
Users: def.Users,
|
||||
Actions: def.Actions,
|
||||
})
|
||||
}
|
||||
return ACL{roles: rs}
|
||||
}
|
||||
Reference in New Issue
Block a user