feat: pkg/security/ — tipos puros y resolución ACL (issue 0024a)

Crea el paquete puro pkg/security/ con los tipos base del sistema
centralizado de permisos y la función ResolveACL.

Cambios:
- pkg/acl/config.go: añade FromRoles([]Role) ACL como constructor directo
- pkg/security/groups.go: UserGroup, AgentGroup
- pkg/security/policy.go: Permission, AgentPolicy, SecurityPolicy
- pkg/security/resolver.go: ResolveACL(agentID, SecurityPolicy) → acl.ACL
  * soporte wildcard de agente ("*") y de usuario ("*")
  * políticas acumulativas: unión de permisos entre grupos
  * referencia directa por agentID sin definir grupo
- pkg/security/security_test.go: 7 tests cubriendo todos los casos del issue

El paquete es pure core: cero I/O, cero side effects.
Mergeado con feature flag centralized-security-groups = false (no wired).
This commit is contained in:
2026-03-08 20:20:05 +00:00
parent 9c1bc8b5a6
commit c7531e2b4d
5 changed files with 275 additions and 0 deletions
+5
View File
@@ -6,6 +6,11 @@ type RoleDef struct {
Actions []string
}
// FromRoles builds an ACL directly from a slice of Role values.
func FromRoles(roles []Role) ACL {
return ACL{roles: roles}
}
// 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 {
+17
View File
@@ -0,0 +1,17 @@
// Package security provides pure types and functions for centralized permission management.
// No I/O, no side effects — only data transformations.
package security
// UserGroup is a named set of Matrix user IDs.
// Members may contain "*" to represent all users.
type UserGroup struct {
Name string
Members []string
}
// AgentGroup is a named set of agent IDs.
// Agents may contain "*" to represent all agents.
type AgentGroup struct {
Name string
Agents []string
}
+23
View File
@@ -0,0 +1,23 @@
package security
// Permission grants a set of actions to all members of a UserGroup.
type Permission struct {
UserGroup string
Actions []string
}
// AgentPolicy assigns a set of permissions to all agents in an AgentGroup.
// AgentGroup may be a group name defined in SecurityPolicy.AgentGroups,
// or a direct agent ID (without defining a group).
type AgentPolicy struct {
AgentGroup string
Permissions []Permission
}
// SecurityPolicy is the top-level pure data structure that describes
// who can do what across which agents.
type SecurityPolicy struct {
UserGroups []UserGroup
AgentGroups []AgentGroup
Policies []AgentPolicy
}
+68
View File
@@ -0,0 +1,68 @@
package security
import "github.com/enmanuel/agents/pkg/acl"
// ResolveACL computes the ACL for a given agentID from a SecurityPolicy.
//
// Resolution rules:
// - An AgentPolicy applies to agentID if its AgentGroup field is:
// (a) a group name in p.AgentGroups whose Agents list contains agentID or "*", or
// (b) directly equal to agentID (individual assignment without a named group).
// - If multiple AgentPolicies apply, their permissions are accumulated (union).
// - For each Permission, the UserGroup is resolved to members in p.UserGroups.
// A UserGroup with Members = ["*"] grants the actions to all users.
// - If no policy applies, an empty ACL is returned (open access per acl semantics).
func ResolveACL(agentID string, p SecurityPolicy) acl.ACL {
// Build a lookup: group name → members.
userGroupMembers := make(map[string][]string, len(p.UserGroups))
for _, ug := range p.UserGroups {
userGroupMembers[ug.Name] = ug.Members
}
// Collect all roles from every AgentPolicy that applies to this agent.
var roles []acl.Role
for _, ap := range p.Policies {
if !agentPolicyApplies(agentID, ap.AgentGroup, p.AgentGroups) {
continue
}
for _, perm := range ap.Permissions {
members := resolveMembers(perm.UserGroup, userGroupMembers)
roles = append(roles, acl.Role{
Name: perm.UserGroup,
Users: members,
Actions: perm.Actions,
})
}
}
return acl.FromRoles(roles)
}
// agentPolicyApplies returns true if an AgentPolicy with the given agentGroupRef
// should apply to agentID.
func agentPolicyApplies(agentID, agentGroupRef string, groups []AgentGroup) bool {
// Try to find a named group first.
for _, ag := range groups {
if ag.Name != agentGroupRef {
continue
}
for _, a := range ag.Agents {
if a == "*" || a == agentID {
return true
}
}
return false // group found but agent not in it
}
// No matching group found — treat agentGroupRef as a direct agent ID.
return agentGroupRef == agentID
}
// resolveMembers returns the member list for a user group name.
// If the group is not defined, it falls back to treating the name as a literal user ID.
func resolveMembers(userGroupName string, lookup map[string][]string) []string {
if members, ok := lookup[userGroupName]; ok {
return members
}
// Fallback: treat as a direct user ID.
return []string{userGroupName}
}
+162
View File
@@ -0,0 +1,162 @@
package security_test
import (
"testing"
"github.com/enmanuel/agents/pkg/security"
)
// helpers
func makePolicy(userGroups []security.UserGroup, agentGroups []security.AgentGroup, policies []security.AgentPolicy) security.SecurityPolicy {
return security.SecurityPolicy{
UserGroups: userGroups,
AgentGroups: agentGroups,
Policies: policies,
}
}
// 2.1 — sin política → ACL vacía → todo permitido (acl.Empty() == true)
func TestResolveACL_EmptyPolicy(t *testing.T) {
a := security.ResolveACL("assistant-bot", security.SecurityPolicy{})
if !a.Empty() {
t.Fatal("expected empty ACL for empty policy")
}
if !a.CanDo("@anyone:matrix.org", "anything") {
t.Fatal("empty ACL should allow everything")
}
}
// 2.2 — agente en grupo → recibe los permisos del grupo
func TestResolveACL_AgentInGroup(t *testing.T) {
p := makePolicy(
[]security.UserGroup{{Name: "admins", Members: []string{"@alice:matrix.org"}}},
[]security.AgentGroup{{Name: "bots", Agents: []string{"assistant-bot"}}},
[]security.AgentPolicy{{
AgentGroup: "bots",
Permissions: []security.Permission{
{UserGroup: "admins", Actions: []string{"ask"}},
},
}},
)
a := security.ResolveACL("assistant-bot", p)
if a.Empty() {
t.Fatal("ACL should not be empty")
}
if !a.CanDo("@alice:matrix.org", "ask") {
t.Fatal("alice should be able to ask")
}
if a.CanDo("@bob:matrix.org", "ask") {
t.Fatal("bob should not be able to ask")
}
}
// 2.3 — agente NO en ningún grupo → ACL vacía
func TestResolveACL_AgentNotInGroup(t *testing.T) {
p := makePolicy(
[]security.UserGroup{{Name: "admins", Members: []string{"@alice:matrix.org"}}},
[]security.AgentGroup{{Name: "bots", Agents: []string{"other-bot"}}},
[]security.AgentPolicy{{
AgentGroup: "bots",
Permissions: []security.Permission{{UserGroup: "admins", Actions: []string{"ask"}}},
}},
)
a := security.ResolveACL("assistant-bot", p)
if !a.Empty() {
t.Fatal("expected empty ACL for agent not in any group")
}
}
// 2.4 — wildcard de agente "*" → todos los agentes reciben los permisos
func TestResolveACL_AgentWildcard(t *testing.T) {
p := makePolicy(
[]security.UserGroup{{Name: "everyone", Members: []string{"*"}}},
[]security.AgentGroup{{Name: "all", Agents: []string{"*"}}},
[]security.AgentPolicy{{
AgentGroup: "all",
Permissions: []security.Permission{{UserGroup: "everyone", Actions: []string{"ask"}}},
}},
)
for _, agentID := range []string{"assistant-bot", "asistente-2", "any-random-bot"} {
a := security.ResolveACL(agentID, p)
if a.Empty() {
t.Fatalf("ACL for %q should not be empty", agentID)
}
if !a.CanDo("@whoever:matrix.org", "ask") {
t.Fatalf("any user should be able to ask via agent %q", agentID)
}
}
}
// 2.5 — wildcard de usuario "*" → todos los usuarios reciben la acción
func TestResolveACL_UserWildcard(t *testing.T) {
p := makePolicy(
[]security.UserGroup{{Name: "everyone", Members: []string{"*"}}},
[]security.AgentGroup{{Name: "bots", Agents: []string{"assistant-bot"}}},
[]security.AgentPolicy{{
AgentGroup: "bots",
Permissions: []security.Permission{{UserGroup: "everyone", Actions: []string{"ask"}}},
}},
)
a := security.ResolveACL("assistant-bot", p)
for _, user := range []string{"@alice:matrix.org", "@bob:example.com", "@unknown:other.server"} {
if !a.CanDo(user, "ask") {
t.Fatalf("user %q should be able to ask (wildcard user group)", user)
}
}
}
// 2.6 — múltiples grupos que incluyen al agente → permisos acumulados
func TestResolveACL_AccumulatedPermissions(t *testing.T) {
p := makePolicy(
[]security.UserGroup{
{Name: "admins", Members: []string{"@alice:matrix.org"}},
{Name: "users", Members: []string{"@bob:matrix.org"}},
},
[]security.AgentGroup{
{Name: "premium", Agents: []string{"assistant-bot"}},
{Name: "all", Agents: []string{"*"}},
},
[]security.AgentPolicy{
{
AgentGroup: "premium",
Permissions: []security.Permission{{UserGroup: "admins", Actions: []string{"deploy"}}},
},
{
AgentGroup: "all",
Permissions: []security.Permission{{UserGroup: "users", Actions: []string{"ask"}}},
},
},
)
a := security.ResolveACL("assistant-bot", p)
if !a.CanDo("@alice:matrix.org", "deploy") {
t.Fatal("alice should be able to deploy (via premium group)")
}
if !a.CanDo("@bob:matrix.org", "ask") {
t.Fatal("bob should be able to ask (via all group)")
}
if a.CanDo("@bob:matrix.org", "deploy") {
t.Fatal("bob should not be able to deploy")
}
}
// 2.7 — agente referenciado directamente por ID en AgentPolicy.AgentGroup → recibe permisos
func TestResolveACL_DirectAgentID(t *testing.T) {
p := makePolicy(
[]security.UserGroup{{Name: "admins", Members: []string{"@alice:matrix.org"}}},
nil, // no named groups
[]security.AgentPolicy{{
AgentGroup: "assistant-bot", // direct agent ID, no group defined
Permissions: []security.Permission{{UserGroup: "admins", Actions: []string{"*"}}},
}},
)
a := security.ResolveACL("assistant-bot", p)
if !a.CanDo("@alice:matrix.org", "anything") {
t.Fatal("alice should have full access via direct agent ID assignment")
}
// other agents should not be affected
b := security.ResolveACL("asistente-2", p)
if !b.Empty() {
t.Fatal("asistente-2 should not receive permissions from direct assignment to assistant-bot")
}
}