// 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. // // 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" ) // 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. Never produced by the role model, but kept so // existing switch statements remain exhaustive. LevelNone Level = iota // LevelRead permits reads only. LevelRead // LevelWrite permits full write access. LevelWrite ) // String renders the level using the tokens surfaced to the frontend via // /api/v1/me. func (l Level) String() string { switch l { case LevelNone: return "none" case LevelRead: return "readonly" default: return "write" } } func roleToLevel(r Role) Level { if r >= RoleOperator { return LevelWrite } return LevelRead } // 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 { mu sync.RWMutex path string // access.json path; "" disables persistence defaultUser string // immutable after construction/load groups map[string]*group } // 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), groups: make(map[string]*group), } for _, s := range specs { name := strings.TrimSpace(s.Name) if name == "" { 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.ensureGroupLocked(PublicGroup).parent = "" p.normalizeParentsLocked() return p } // 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 { u := strings.TrimSpace(headerValue) if u == "" { return p.defaultUser } return u } // Level returns the coarse global access level for a user. func (p *Policy) Level(user string) Level { p.mu.RLock() defer p.mu.RUnlock() return roleToLevel(p.effectiveRoleLocked(user)) } // 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 and control logic // (logic-editor role or higher). func (p *Policy) CanEditLogic(user string) bool { p.mu.RLock() defer p.mu.RUnlock() return p.effectiveRoleLocked(user) >= RoleLogic } // CanViewAudit reports whether a user may view the audit log (auditor or admin). func (p *Policy) CanViewAudit(user string) bool { p.mu.RLock() defer p.mu.RUnlock() return p.effectiveRoleLocked(user) >= RoleAuditor } // 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 } // 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{} // WithUser returns a copy of ctx carrying the resolved end-user identity. func WithUser(ctx context.Context, user string) context.Context { return context.WithValue(ctx, ctxKey{}, user) } // UserFrom returns the identity stored by WithUser, or "" if none. func UserFrom(ctx context.Context) string { u, _ := ctx.Value(ctxKey{}).(string) return u }