687 lines
20 KiB
Go
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
|
|
}
|