Implemented admin pane + user permission
This commit is contained in:
+602
-136
@@ -1,32 +1,114 @@
|
||||
// Package access implements uopi's global user-access policy: every user is
|
||||
// trusted (full write) by default, while a configured blacklist can downgrade
|
||||
// specific users to read-only or no access. It also resolves the per-request
|
||||
// user identity and the user→group memberships defined in config.
|
||||
// Package access implements uopi's role-based access policy. Access is granted
|
||||
// through group memberships: every user belongs implicitly to the built-in
|
||||
// "public" group (granting the baseline viewer role), and may additionally be
|
||||
// assigned a higher role in any number of named groups. Roles form a cumulative
|
||||
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
|
||||
// includes all powers below it. A user's effective capability is the highest
|
||||
// role they hold across all their groups.
|
||||
//
|
||||
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
|
||||
// layered on top of this in a later phase.
|
||||
// Groups can be nested: a member of a parent group holds that role on every
|
||||
// descendant group too (unless the descendant assigns them a different role),
|
||||
// so an admin of an organisation group administers its sub-teams.
|
||||
//
|
||||
// As a bootstrap convenience, a policy with no role assignments at all is treated
|
||||
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
|
||||
// Assigning any role switches to strict mode where unlisted users are viewers.
|
||||
//
|
||||
// The policy is seeded from the TOML config at startup but is runtime-mutable
|
||||
// through the admin pane: once EnablePersistence is called, every mutation is
|
||||
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
|
||||
// later startup, becomes the source of truth (the TOML config is then only a
|
||||
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
|
||||
// use; the *Policy pointer is stable so existing shared-pointer wiring is
|
||||
// preserved.
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Level is a global access level. Higher levels include lower ones.
|
||||
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
|
||||
// be renamed or deleted and is always a top-level (parentless) group.
|
||||
const PublicGroup = "public"
|
||||
|
||||
// Role is a cumulative access level granted by a group membership. Higher roles
|
||||
// include every power of the lower ones.
|
||||
type Role int
|
||||
|
||||
const (
|
||||
// RoleViewer permits reads only (no signal writes). It is the baseline every
|
||||
// user receives via the public group.
|
||||
RoleViewer Role = iota
|
||||
// RoleOperator adds signal writes (full HMI interaction).
|
||||
RoleOperator
|
||||
// RoleLogic adds editing panel and server-side control logic.
|
||||
RoleLogic
|
||||
// RoleAuditor adds viewing the audit log.
|
||||
RoleAuditor
|
||||
// RoleAdmin adds managing users, groups and access, and viewing server stats.
|
||||
RoleAdmin
|
||||
)
|
||||
|
||||
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
|
||||
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
|
||||
|
||||
// String renders the role using the tokens accepted by ParseRole.
|
||||
func (r Role) String() string {
|
||||
switch r {
|
||||
case RoleOperator:
|
||||
return "operator"
|
||||
case RoleLogic:
|
||||
return "logiceditor"
|
||||
case RoleAuditor:
|
||||
return "auditor"
|
||||
case RoleAdmin:
|
||||
return "admin"
|
||||
default:
|
||||
return "viewer"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
|
||||
// least-privileged viewer.
|
||||
func ParseRole(s string) Role {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "operator", "write", "operate", "rw":
|
||||
return RoleOperator
|
||||
case "logiceditor", "logic", "logic_editor", "editor":
|
||||
return RoleLogic
|
||||
case "auditor", "audit":
|
||||
return RoleAuditor
|
||||
case "admin", "administrator":
|
||||
return RoleAdmin
|
||||
default:
|
||||
return RoleViewer
|
||||
}
|
||||
}
|
||||
|
||||
// Level is a coarse global access level retained for the rest of the codebase
|
||||
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
|
||||
// user's effective role: viewer → read-only, operator and above → write.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelNone denies all access.
|
||||
// LevelNone denies all access. Never produced by the role model, but kept so
|
||||
// existing switch statements remain exhaustive.
|
||||
LevelNone Level = iota
|
||||
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
|
||||
// LevelRead permits reads only.
|
||||
LevelRead
|
||||
// LevelWrite permits full access. This is the default for any user not blacklisted.
|
||||
// LevelWrite permits full write access.
|
||||
LevelWrite
|
||||
)
|
||||
|
||||
// String renders the level using the same tokens accepted by ParseLevel and
|
||||
// surfaced to the frontend via /api/v1/me.
|
||||
// String renders the level using the tokens surfaced to the frontend via
|
||||
// /api/v1/me.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelNone:
|
||||
@@ -38,88 +120,218 @@ func (l Level) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel maps a config string to a Level. Unknown values restrict to
|
||||
// read-only, since the only reason to list a user is to limit them.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "noaccess", "none", "no":
|
||||
return LevelNone
|
||||
case "readonly", "read", "ro":
|
||||
return LevelRead
|
||||
case "write", "readwrite", "rw", "full":
|
||||
func roleToLevel(r Role) Level {
|
||||
if r >= RoleOperator {
|
||||
return LevelWrite
|
||||
default:
|
||||
return LevelRead
|
||||
}
|
||||
return LevelRead
|
||||
}
|
||||
|
||||
// Policy holds the resolved global access configuration. It is immutable after
|
||||
// construction and safe for concurrent use.
|
||||
// group holds a group's parent (for nesting) and explicit per-user role
|
||||
// assignments.
|
||||
type group struct {
|
||||
parent string
|
||||
members map[string]Role
|
||||
}
|
||||
|
||||
// Policy holds the resolved role-based access configuration. It is safe for
|
||||
// concurrent use; reads take a shared lock and admin mutations take an exclusive
|
||||
// lock and persist to disk.
|
||||
type Policy struct {
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
logicEditors map[string]bool // users + group names allowed to edit logic
|
||||
auditReaders map[string]bool // users + group names allowed to view the audit log
|
||||
mu sync.RWMutex
|
||||
path string // access.json path; "" disables persistence
|
||||
defaultUser string // immutable after construction/load
|
||||
groups map[string]*group
|
||||
}
|
||||
|
||||
// New builds a Policy. blacklist maps a username to a config level string;
|
||||
// groups maps a group name to its member usernames. logicEditors optionally
|
||||
// restricts who may edit panel/control logic (usernames or group names); empty
|
||||
// means no restriction.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy {
|
||||
// GroupSpec seeds one group at construction time: its name, optional parent, and
|
||||
// explicit user→role assignments.
|
||||
type GroupSpec struct {
|
||||
Name string
|
||||
Parent string
|
||||
Members map[string]Role
|
||||
}
|
||||
|
||||
// New builds a Policy from group specs. The built-in public group is always
|
||||
// created. A spec listing the same user in multiple roles keeps the last one;
|
||||
// callers should pass the highest intended role.
|
||||
func New(defaultUser string, specs []GroupSpec) *Policy {
|
||||
p := &Policy{
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
userGroups: make(map[string][]string),
|
||||
logicEditors: make(map[string]bool),
|
||||
auditReaders: make(map[string]bool),
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
groups: make(map[string]*group),
|
||||
}
|
||||
for _, e := range logicEditors {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.logicEditors[e] = true
|
||||
}
|
||||
}
|
||||
for _, e := range auditReaders {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.auditReaders[e] = true
|
||||
}
|
||||
}
|
||||
for user, lvl := range blacklist {
|
||||
u := strings.TrimSpace(user)
|
||||
if u == "" {
|
||||
for _, s := range specs {
|
||||
name := strings.TrimSpace(s.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
p.blacklist[u] = ParseLevel(lvl)
|
||||
}
|
||||
for g, members := range groups {
|
||||
g = strings.TrimSpace(g)
|
||||
if g == "" {
|
||||
continue
|
||||
}
|
||||
p.groupNames = append(p.groupNames, g)
|
||||
for _, m := range members {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = strings.TrimSpace(s.Parent)
|
||||
for u, r := range s.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = r
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
}
|
||||
}
|
||||
sort.Strings(p.groupNames)
|
||||
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||
p.normalizeParentsLocked()
|
||||
return p
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every configured user-group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
out := make([]string, len(p.groupNames))
|
||||
copy(out, p.groupNames)
|
||||
return out
|
||||
// ensureGroupLocked returns the named group, creating an empty one if needed.
|
||||
// The caller must hold p.mu for writing (or be in construction).
|
||||
func (p *Policy) ensureGroupLocked(name string) *group {
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
g = &group{members: make(map[string]Role)}
|
||||
p.groups[name] = g
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// normalizeParentsLocked drops parent references to missing groups or that would
|
||||
// form a cycle, and forces the public group to be a root.
|
||||
func (p *Policy) normalizeParentsLocked() {
|
||||
for name, g := range p.groups {
|
||||
if name == PublicGroup {
|
||||
g.parent = ""
|
||||
continue
|
||||
}
|
||||
if g.parent == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
|
||||
g.parent = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasCycleLocked reports whether following parent links from start loops back.
|
||||
func (p *Policy) hasCycleLocked(start string) bool {
|
||||
seen := make(map[string]bool)
|
||||
for cur := start; cur != ""; {
|
||||
if seen[cur] {
|
||||
return true
|
||||
}
|
||||
seen[cur] = true
|
||||
g, ok := p.groups[cur]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur = g.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── effective role ─────────────────────────────────────────────────────────
|
||||
|
||||
// configuredLocked reports whether any explicit role is assigned anywhere. An
|
||||
// unconfigured policy is treated as fully open (bootstrap-safe).
|
||||
func (p *Policy) configuredLocked() bool {
|
||||
for _, g := range p.groups {
|
||||
if len(g.members) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
|
||||
// users (and anonymous callers) get the viewer baseline once the policy is
|
||||
// configured; an unconfigured policy grants everyone admin.
|
||||
func (p *Policy) effectiveRoleLocked(user string) Role {
|
||||
if !p.configuredLocked() {
|
||||
return RoleAdmin
|
||||
}
|
||||
best := RoleViewer
|
||||
if user = strings.TrimSpace(user); user == "" {
|
||||
return best
|
||||
}
|
||||
for _, g := range p.groups {
|
||||
if r, ok := g.members[user]; ok && r > best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ── persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
// persisted is the on-disk schema for access.json.
|
||||
type persisted struct {
|
||||
DefaultUser string `json:"defaultUser"`
|
||||
Groups map[string]persistedGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type persistedGroup struct {
|
||||
Parent string `json:"parent"`
|
||||
Members map[string]string `json:"members"` // user → role token
|
||||
}
|
||||
|
||||
// EnablePersistence points the policy at {storageDir}/access.json. If the file
|
||||
// exists its contents replace the TOML-seeded state (the file is the source of
|
||||
// truth once written); otherwise the current state is kept and the file is only
|
||||
// created on the first mutation.
|
||||
func (p *Policy) EnablePersistence(storageDir string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.path = filepath.Join(storageDir, "access.json")
|
||||
data, err := os.ReadFile(p.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ps persisted
|
||||
if err := json.Unmarshal(data, &ps); err != nil {
|
||||
return err
|
||||
}
|
||||
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
|
||||
p.groups = make(map[string]*group)
|
||||
for name, pg := range ps.Groups {
|
||||
if name = strings.TrimSpace(name); name == "" {
|
||||
continue
|
||||
}
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = strings.TrimSpace(pg.Parent)
|
||||
for u, token := range pg.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = ParseRole(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||
p.normalizeParentsLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked atomically persists the current state when persistence is enabled.
|
||||
func (p *Policy) saveLocked() error {
|
||||
if p.path == "" {
|
||||
return nil
|
||||
}
|
||||
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
|
||||
for name, g := range p.groups {
|
||||
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
|
||||
for u, r := range g.members {
|
||||
pg.Members[u] = r.String()
|
||||
}
|
||||
ps.Groups[name] = pg
|
||||
}
|
||||
data, err := json.MarshalIndent(ps, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p.path)
|
||||
}
|
||||
|
||||
// ── reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ResolveUser trims the proxy-provided header value and falls back to the
|
||||
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
||||
func (p *Policy) ResolveUser(headerValue string) string {
|
||||
@@ -130,81 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string {
|
||||
return u
|
||||
}
|
||||
|
||||
// Level returns the global access level for a user. Users that are neither
|
||||
// blacklisted nor anonymous get full write access.
|
||||
// Level returns the coarse global access level for a user.
|
||||
func (p *Policy) Level(user string) Level {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
// No identity at all (no proxy header, no default_user): trusted LAN.
|
||||
return LevelWrite
|
||||
}
|
||||
if lvl, ok := p.blacklist[user]; ok {
|
||||
return lvl
|
||||
}
|
||||
return LevelWrite
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return roleToLevel(p.effectiveRoleLocked(user))
|
||||
}
|
||||
|
||||
// LogicRestricted reports whether a logic-editor allowlist is configured. When
|
||||
// false, any write-capable user may edit panel/control logic.
|
||||
func (p *Policy) LogicRestricted() bool {
|
||||
return len(p.logicEditors) > 0
|
||||
// EffectiveRole returns the highest role a user holds across all groups.
|
||||
func (p *Policy) EffectiveRole(user string) Role {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user)
|
||||
}
|
||||
|
||||
// CanEditLogic reports whether a user may add or edit panel logic and
|
||||
// server-side control logic. When no allowlist is configured everyone with
|
||||
// write access qualifies; otherwise the user (or one of their groups) must be
|
||||
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
|
||||
// CanEditLogic reports whether a user may add or edit panel and control logic
|
||||
// (logic-editor role or higher).
|
||||
func (p *Policy) CanEditLogic(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if !p.LogicRestricted() {
|
||||
return true
|
||||
}
|
||||
if p.logicEditors[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.logicEditors[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleLogic
|
||||
}
|
||||
|
||||
// CanViewAudit reports whether a user may view the audit log. When no reader
|
||||
// allowlist is configured everyone with read access qualifies; otherwise the
|
||||
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
|
||||
// (user=="") are always permitted.
|
||||
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
|
||||
func (p *Policy) CanViewAudit(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if len(p.auditReaders) == 0 {
|
||||
return true
|
||||
}
|
||||
if p.auditReaders[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.auditReaders[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAuditor
|
||||
}
|
||||
|
||||
// GroupsOf returns a copy of the groups a user belongs to.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
src := p.userGroups[strings.TrimSpace(user)]
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
// CanAdmin reports whether a user may use the admin pane (admin role).
|
||||
func (p *Policy) CanAdmin(user string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAdmin
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.groupNamesLocked()
|
||||
}
|
||||
|
||||
func (p *Policy) groupNamesLocked() []string {
|
||||
out := make([]string, 0, len(p.groups))
|
||||
for n := range p.groups {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ────────────────────────────────────────────
|
||||
// GroupsOf returns the groups a user effectively belongs to: every group they
|
||||
// are an explicit member of, plus that group's descendants (membership flows
|
||||
// parent→child). The implicit public-group viewer baseline is not included.
|
||||
// Used by per-panel/folder sharing rules.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if user = strings.TrimSpace(user); user == "" {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
for name, g := range p.groups {
|
||||
if _, ok := g.members[user]; ok {
|
||||
set[name] = true
|
||||
p.addDescendantsLocked(name, set)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// addDescendantsLocked adds every transitive child of parent into set.
|
||||
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
|
||||
for name, g := range p.groups {
|
||||
if g.parent == parent && !set[name] {
|
||||
set[name] = true
|
||||
p.addDescendantsLocked(name, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── admin snapshot ─────────────────────────────────────────────────────────
|
||||
|
||||
// MemberInfo is one user's role within a group.
|
||||
type MemberInfo struct {
|
||||
User string `json:"user"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// GroupInfo describes one group for the admin pane.
|
||||
type GroupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Parent string `json:"parent"`
|
||||
Members []MemberInfo `json:"members"`
|
||||
}
|
||||
|
||||
// UserInfo describes one user's memberships and resulting effective role.
|
||||
type UserInfo struct {
|
||||
Name string `json:"name"`
|
||||
EffectiveRole string `json:"effectiveRole"`
|
||||
Roles map[string]string `json:"roles"` // group → role token
|
||||
}
|
||||
|
||||
// AccessSnapshot is the full mutable access state, rendered for the admin pane.
|
||||
type AccessSnapshot struct {
|
||||
DefaultUser string `json:"defaultUser"`
|
||||
PublicGroup string `json:"publicGroup"`
|
||||
Roles []string `json:"roles"` // role ladder, low→high
|
||||
Configured bool `json:"configured"`
|
||||
Users []UserInfo `json:"users"`
|
||||
Groups []GroupInfo `json:"groups"`
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the full access configuration for the admin pane.
|
||||
func (p *Policy) Snapshot() AccessSnapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
snap := AccessSnapshot{
|
||||
DefaultUser: p.defaultUser,
|
||||
PublicGroup: PublicGroup,
|
||||
Roles: append([]string(nil), RoleNames...),
|
||||
Configured: p.configuredLocked(),
|
||||
}
|
||||
|
||||
userSet := make(map[string]bool)
|
||||
for _, name := range p.groupNamesLocked() {
|
||||
g := p.groups[name]
|
||||
gi := GroupInfo{Name: name, Parent: g.parent}
|
||||
users := make([]string, 0, len(g.members))
|
||||
for u := range g.members {
|
||||
users = append(users, u)
|
||||
userSet[u] = true
|
||||
}
|
||||
sort.Strings(users)
|
||||
for _, u := range users {
|
||||
gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
|
||||
}
|
||||
snap.Groups = append(snap.Groups, gi)
|
||||
}
|
||||
|
||||
users := make([]string, 0, len(userSet))
|
||||
for u := range userSet {
|
||||
users = append(users, u)
|
||||
}
|
||||
sort.Strings(users)
|
||||
for _, u := range users {
|
||||
ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
|
||||
for name, g := range p.groups {
|
||||
if r, ok := g.members[u]; ok {
|
||||
ui.Roles[name] = r.String()
|
||||
}
|
||||
}
|
||||
snap.Users = append(snap.Users, ui)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// ── mutations (admin pane) ─────────────────────────────────────────────────
|
||||
|
||||
// ErrNotFound is returned when a named group does not exist.
|
||||
var ErrNotFound = errors.New("group not found")
|
||||
|
||||
// SetMemberRole assigns a user a role within an existing group.
|
||||
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
|
||||
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
|
||||
if groupName == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
if user == "" {
|
||||
return errors.New("empty user")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[groupName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
g.members[user] = role
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// RemoveMember drops a user's explicit role in a group.
|
||||
func (p *Policy) RemoveMember(groupName, user string) error {
|
||||
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[groupName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(g.members, user)
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// SetUserRoles replaces a user's full set of memberships: the user is placed in
|
||||
// exactly the listed groups with the given roles and removed from all others.
|
||||
// Listed groups that do not exist are created.
|
||||
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return errors.New("empty user")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
want := make(map[string]Role, len(roles))
|
||||
for g, r := range roles {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
want[g] = r
|
||||
p.ensureGroupLocked(g)
|
||||
}
|
||||
}
|
||||
for name, g := range p.groups {
|
||||
if r, ok := want[name]; ok {
|
||||
g.members[user] = r
|
||||
} else {
|
||||
delete(g.members, user)
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// CreateGroup adds an empty group with an optional parent if it does not exist.
|
||||
func (p *Policy) CreateGroup(name, parent string) error {
|
||||
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
|
||||
if name == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if _, ok := p.groups[name]; ok {
|
||||
return nil
|
||||
}
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = parent
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// SetGroup replaces an existing group's parent and members. The public group's
|
||||
// parent is always forced to root.
|
||||
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
|
||||
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
|
||||
if name == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if name != PublicGroup {
|
||||
g.parent = parent
|
||||
}
|
||||
g.members = make(map[string]Role, len(members))
|
||||
for u, r := range members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = r
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// RenameGroup renames a group, re-pointing any children at the new name. The
|
||||
// public group cannot be renamed.
|
||||
func (p *Policy) RenameGroup(oldName, newName string) error {
|
||||
oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
if oldName == PublicGroup {
|
||||
return errors.New("cannot rename the public group")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[oldName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if oldName == newName {
|
||||
return nil
|
||||
}
|
||||
if _, exists := p.groups[newName]; exists {
|
||||
return errors.New("group already exists: " + newName)
|
||||
}
|
||||
delete(p.groups, oldName)
|
||||
p.groups[newName] = g
|
||||
for _, other := range p.groups {
|
||||
if other.parent == oldName {
|
||||
other.parent = newName
|
||||
}
|
||||
}
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// DeleteGroup removes a group, reparenting its children to the deleted group's
|
||||
// parent. The public group cannot be deleted; member users keep other groups.
|
||||
func (p *Policy) DeleteGroup(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == PublicGroup {
|
||||
return errors.New("cannot delete the public group")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
parent := g.parent
|
||||
delete(p.groups, name)
|
||||
for _, other := range p.groups {
|
||||
if other.parent == name {
|
||||
other.parent = parent
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ───────────────────────────────────────────
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
|
||||
+257
-26
@@ -1,35 +1,266 @@
|
||||
package access
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanEditLogic(t *testing.T) {
|
||||
groups := map[string][]string{"ops": {"carol"}}
|
||||
// spec is a small helper to build a GroupSpec.
|
||||
func spec(name, parent string, members map[string]Role) GroupSpec {
|
||||
return GroupSpec{Name: name, Parent: parent, Members: members}
|
||||
}
|
||||
|
||||
// No allowlist configured: everyone with write access may edit logic.
|
||||
open := New("", nil, groups, nil, nil)
|
||||
func TestUnconfiguredIsOpen(t *testing.T) {
|
||||
// No roles assigned anywhere → fully open (everyone is admin), matching a
|
||||
// fresh/dev deployment.
|
||||
p := New("", nil)
|
||||
for _, u := range []string{"", "alice", "carol"} {
|
||||
if !open.CanEditLogic(u) {
|
||||
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
|
||||
if !p.CanAdmin(u) {
|
||||
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
if open.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = true with no allowlist")
|
||||
}
|
||||
|
||||
// Allowlist by username and by group name.
|
||||
p := New("", nil, groups, []string{"alice", "ops"}, nil)
|
||||
if !p.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = false with allowlist set")
|
||||
}
|
||||
cases := map[string]bool{
|
||||
"": true, // anonymous / trusted LAN
|
||||
"alice": true, // listed user
|
||||
"carol": true, // member of listed group "ops"
|
||||
"bob": false, // not listed
|
||||
}
|
||||
for u, want := range cases {
|
||||
if got := p.CanEditLogic(u); got != want {
|
||||
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
||||
if p.Level(u) != LevelWrite {
|
||||
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleLadder(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"adm": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{
|
||||
"viewer": RoleViewer,
|
||||
"op": RoleOperator,
|
||||
"logic": RoleLogic,
|
||||
"aud": RoleAuditor,
|
||||
}),
|
||||
})
|
||||
|
||||
// Configured now → unlisted users (and anonymous) are viewers (read-only).
|
||||
if p.Level("") != LevelRead {
|
||||
t.Errorf("anonymous Level = %v, want read", p.Level(""))
|
||||
}
|
||||
if p.Level("nobody") != LevelRead {
|
||||
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
user string
|
||||
level Level
|
||||
editLogic bool
|
||||
audit bool
|
||||
admin bool
|
||||
}{
|
||||
{"viewer", LevelRead, false, false, false},
|
||||
{"op", LevelWrite, false, false, false},
|
||||
{"logic", LevelWrite, true, false, false},
|
||||
{"aud", LevelWrite, true, true, false},
|
||||
{"adm", LevelWrite, true, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if p.Level(c.user) != c.level {
|
||||
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
|
||||
}
|
||||
if p.CanEditLogic(c.user) != c.editLogic {
|
||||
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
|
||||
}
|
||||
if p.CanViewAudit(c.user) != c.audit {
|
||||
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
|
||||
}
|
||||
if p.CanAdmin(c.user) != c.admin {
|
||||
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"x": RoleViewer}),
|
||||
spec("a", "", map[string]Role{"x": RoleOperator}),
|
||||
spec("b", "", map[string]Role{"x": RoleAuditor}),
|
||||
})
|
||||
if got := p.EffectiveRole("x"); got != RoleAuditor {
|
||||
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestingInheritsMembership(t *testing.T) {
|
||||
// org → team-a → squad-1. A member of org is effectively in the descendants
|
||||
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
|
||||
p := New("", []GroupSpec{
|
||||
spec("org", "", map[string]Role{"boss": RoleAdmin}),
|
||||
spec("team-a", "org", nil),
|
||||
spec("squad-1", "team-a", nil),
|
||||
})
|
||||
groups := p.GroupsOf("boss")
|
||||
for _, want := range []string{"org", "team-a", "squad-1"} {
|
||||
if !containsStr(groups, want) {
|
||||
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationsRoundTrip(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
||||
})
|
||||
|
||||
// Set a user's full membership set: dave operator in ops, admin in eng (new).
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
|
||||
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
|
||||
}
|
||||
if !p.CanAdmin("dave") { // admin in eng
|
||||
t.Error("CanAdmin(dave) = false after admin role in eng")
|
||||
}
|
||||
|
||||
// Replacing membership removes dave from ops.
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if containsStr(p.GroupsOf("dave"), "ops") {
|
||||
t.Error("dave still in ops after membership replaced")
|
||||
}
|
||||
|
||||
// Rename carries dave's admin grant from eng → engineering.
|
||||
if err := p.RenameGroup("eng", "engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.CanAdmin("dave") {
|
||||
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
|
||||
}
|
||||
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
|
||||
t.Errorf("GroupNames after rename = %v", p.GroupNames())
|
||||
}
|
||||
|
||||
// Delete drops the group; dave loses admin but root (in public) keeps it.
|
||||
if err := p.DeleteGroup("engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.CanAdmin("dave") {
|
||||
t.Error("CanAdmin(dave) = true after engineering deleted")
|
||||
}
|
||||
if !p.CanAdmin("root") {
|
||||
t.Error("CanAdmin(root) = false; root grant in public should survive")
|
||||
}
|
||||
if err := p.DeleteGroup("nope"); err != ErrNotFound {
|
||||
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicGroupProtected(t *testing.T) {
|
||||
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
|
||||
if err := p.DeleteGroup(PublicGroup); err == nil {
|
||||
t.Error("DeleteGroup(public) = nil, want error")
|
||||
}
|
||||
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
|
||||
t.Error("RenameGroup(public) = nil, want error")
|
||||
}
|
||||
if !containsStr(p.GroupNames(), PublicGroup) {
|
||||
t.Error("public group missing after protected mutations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentCycleRejected(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("a", "", map[string]Role{"x": RoleViewer}),
|
||||
spec("b", "a", nil),
|
||||
})
|
||||
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
|
||||
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := p.Snapshot()
|
||||
for _, g := range snap.Groups {
|
||||
if g.Name == "a" && g.Parent != "" {
|
||||
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
p := New("admin", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"alice": RoleLogic}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
|
||||
})
|
||||
if err := p.EnablePersistence(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A mutation triggers the first write of access.json.
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
|
||||
t.Fatalf("access.json not written: %v", err)
|
||||
}
|
||||
|
||||
// A fresh policy loading the same dir should see the persisted state, not the
|
||||
// (different) seed it was constructed with.
|
||||
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
|
||||
if err := p2.EnablePersistence(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p2.ResolveUser("") != "admin" {
|
||||
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
|
||||
}
|
||||
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
|
||||
t.Error("logic-editor role not persisted")
|
||||
}
|
||||
if !p2.CanAdmin("carol") {
|
||||
t.Error("admin role not persisted")
|
||||
}
|
||||
if !containsStr(p2.GroupsOf("dave"), "ops") {
|
||||
t.Error("dave membership not persisted")
|
||||
}
|
||||
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
|
||||
// seeduser came from the discarded seed; should be an unlisted viewer now.
|
||||
t.Error("persisted state did not supersede the seed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
||||
})
|
||||
if err := p.EnablePersistence(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
p.CanAdmin("carol")
|
||||
p.Level("carol")
|
||||
p.GroupsOf("carol")
|
||||
p.Snapshot()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
p.SetMemberRole("ops", "carol", RoleAuditor)
|
||||
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
|
||||
p.RemoveMember("ops", "carol")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func containsStr(xs []string, v string) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user