Files
2026-06-22 17:49:14 +02:00

687 lines
20 KiB
Go

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