Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+602 -136
View File
@@ -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{}