From 386e4d3dcb688ce8c9c329a0f519402758c3cb40 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sat, 7 Mar 2026 17:59:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20paquete=20pkg/acl=20para?= =?UTF-8?q?=20control=20de=20acceso=20puro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/acl/acl.go | 103 ++++++++++++++++++++++++++ pkg/acl/acl_test.go | 171 ++++++++++++++++++++++++++++++++++++++++++++ pkg/acl/config.go | 21 ++++++ 3 files changed, 295 insertions(+) create mode 100644 pkg/acl/acl.go create mode 100644 pkg/acl/acl_test.go create mode 100644 pkg/acl/config.go diff --git a/pkg/acl/acl.go b/pkg/acl/acl.go new file mode 100644 index 0000000..a34e267 --- /dev/null +++ b/pkg/acl/acl.go @@ -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 +} diff --git a/pkg/acl/acl_test.go b/pkg/acl/acl_test.go new file mode 100644 index 0000000..c39f5da --- /dev/null +++ b/pkg/acl/acl_test.go @@ -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") + } +} diff --git a/pkg/acl/config.go b/pkg/acl/config.go new file mode 100644 index 0000000..042c6e5 --- /dev/null +++ b/pkg/acl/config.go @@ -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} +}