// 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 }