diff --git a/pkg/acl/config.go b/pkg/acl/config.go index 042c6e5..24972c0 100644 --- a/pkg/acl/config.go +++ b/pkg/acl/config.go @@ -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 { diff --git a/pkg/security/groups.go b/pkg/security/groups.go new file mode 100644 index 0000000..1dc09e5 --- /dev/null +++ b/pkg/security/groups.go @@ -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 +} diff --git a/pkg/security/policy.go b/pkg/security/policy.go new file mode 100644 index 0000000..d48b43a --- /dev/null +++ b/pkg/security/policy.go @@ -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 +} diff --git a/pkg/security/resolver.go b/pkg/security/resolver.go new file mode 100644 index 0000000..0833363 --- /dev/null +++ b/pkg/security/resolver.go @@ -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} +} diff --git a/pkg/security/security_test.go b/pkg/security/security_test.go new file mode 100644 index 0000000..b6c7c3b --- /dev/null +++ b/pkg/security/security_test.go @@ -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") + } +}