418 lines
12 KiB
Go
418 lines
12 KiB
Go
// Package panelacl implements per-panel ownership and sharing (Phase 2) plus a
|
|
// nested folder hierarchy for organising panels (Phase 3). It persists a single
|
|
// sidecar index, {storageDir}/acl.json, alongside the XML interface files.
|
|
//
|
|
// The effective permission a user has on a panel is the maximum of: ownership
|
|
// (always write), the panel's public level, any direct user grant, any grant to
|
|
// a user-group the user belongs to, and the permissions inherited from the
|
|
// folder chain the panel lives in. Callers are expected to further cap this by
|
|
// the user's global access level (see internal/access).
|
|
//
|
|
// Panels that have no record in the index are treated as fully open
|
|
// (PermWrite). This preserves access to interfaces created before ACLs existed;
|
|
// panels created through the ACL-aware API are recorded as private to their
|
|
// owner.
|
|
package panelacl
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// ErrNotFound is returned when a folder does not exist.
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
// Perm is an effective access level on a panel or folder.
|
|
type Perm int
|
|
|
|
const (
|
|
// PermNone denies access.
|
|
PermNone Perm = iota
|
|
// PermRead permits viewing only.
|
|
PermRead
|
|
// PermWrite permits viewing and modification.
|
|
PermWrite
|
|
)
|
|
|
|
// String renders a Perm as the token used in the JSON index and HTTP API.
|
|
func (p Perm) String() string {
|
|
switch p {
|
|
case PermRead:
|
|
return "read"
|
|
case PermWrite:
|
|
return "write"
|
|
default:
|
|
return "none"
|
|
}
|
|
}
|
|
|
|
// parsePerm maps a config/API token to a Perm. Unknown/empty → PermNone.
|
|
func parsePerm(s string) Perm {
|
|
switch s {
|
|
case "read":
|
|
return PermRead
|
|
case "write", "readwrite", "rw":
|
|
return PermWrite
|
|
default:
|
|
return PermNone
|
|
}
|
|
}
|
|
|
|
// Grant shares a panel or folder with a single user or a named user-group.
|
|
type Grant struct {
|
|
Kind string `json:"kind"` // "user" | "group"
|
|
Name string `json:"name"` // username or user-group name
|
|
Perm string `json:"perm"` // "read" | "write"
|
|
}
|
|
|
|
// PanelACL is the ownership + sharing record for one panel.
|
|
type PanelACL struct {
|
|
Owner string `json:"owner"`
|
|
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
|
|
Public string `json:"public,omitempty"` // "" | "read" | "write"
|
|
Grants []Grant `json:"grants,omitempty"`
|
|
Order float64 `json:"order,omitempty"` // sort position within its folder
|
|
}
|
|
|
|
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
|
|
// folder are inherited by every panel and subfolder beneath it.
|
|
type Folder struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Parent string `json:"parent,omitempty"` // parent folder id ("" = root)
|
|
Owner string `json:"owner"`
|
|
Public string `json:"public,omitempty"`
|
|
Grants []Grant `json:"grants,omitempty"`
|
|
}
|
|
|
|
type index struct {
|
|
Panels map[string]*PanelACL `json:"panels"`
|
|
Folders map[string]*Folder `json:"folders"`
|
|
}
|
|
|
|
// Store persists and resolves the panel ACL index. It is safe for concurrent
|
|
// use.
|
|
type Store struct {
|
|
path string
|
|
mu sync.RWMutex
|
|
idx index
|
|
}
|
|
|
|
// New opens (loading if present) the acl.json index in storageDir.
|
|
func New(storageDir string) (*Store, error) {
|
|
s := &Store{
|
|
path: filepath.Join(storageDir, "acl.json"),
|
|
idx: index{Panels: map[string]*PanelACL{}, Folders: map[string]*Folder{}},
|
|
}
|
|
data, err := os.ReadFile(s.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(data, &s.idx); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.idx.Panels == nil {
|
|
s.idx.Panels = map[string]*PanelACL{}
|
|
}
|
|
if s.idx.Folders == nil {
|
|
s.idx.Folders = map[string]*Folder{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// saveLocked atomically persists the index. The caller must hold s.mu.
|
|
func (s *Store) saveLocked() error {
|
|
data, err := json.MarshalIndent(s.idx, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
// ── Panel records ────────────────────────────────────────────────────────────
|
|
|
|
// GetPanel returns a copy of a panel's ACL record, or nil if it is unmanaged.
|
|
func (s *Store) GetPanel(id string) *PanelACL {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
acl, ok := s.idx.Panels[id]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return clonePanel(acl)
|
|
}
|
|
|
|
// CreatePanel records ownership for a newly created panel, defaulting to
|
|
// private (owner-only) access. Existing records are left untouched.
|
|
func (s *Store) CreatePanel(id, owner string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.idx.Panels[id]; ok {
|
|
return nil
|
|
}
|
|
s.idx.Panels[id] = &PanelACL{Owner: owner}
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// SetPanel replaces a panel's sharing settings (folder, public level, grants)
|
|
// while preserving its existing owner (or adopting the supplied owner when the
|
|
// panel was previously unmanaged).
|
|
func (s *Store) SetPanel(id, owner, folder, public string, grants []Grant) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
acl := s.idx.Panels[id]
|
|
if acl == nil {
|
|
acl = &PanelACL{Owner: owner}
|
|
s.idx.Panels[id] = acl
|
|
}
|
|
acl.Folder = folder
|
|
acl.Public = public
|
|
acl.Grants = grants
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// PlacePanel sets only a panel's organizational fields — its folder and sort
|
|
// order — preserving ownership and sharing. A previously-unmanaged panel gets a
|
|
// record with no owner, which PanelPerm still treats as fully open (so dragging
|
|
// a legacy panel into a folder does not lock it).
|
|
func (s *Store) PlacePanel(id, folder string, order float64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
acl := s.idx.Panels[id]
|
|
if acl == nil {
|
|
acl = &PanelACL{}
|
|
s.idx.Panels[id] = acl
|
|
}
|
|
acl.Folder = folder
|
|
acl.Order = order
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// DeletePanel removes a panel's ACL record (called when the panel is deleted).
|
|
func (s *Store) DeletePanel(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.idx.Panels[id]; !ok {
|
|
return nil
|
|
}
|
|
delete(s.idx.Panels, id)
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// ── Folders ──────────────────────────────────────────────────────────────────
|
|
|
|
// Folders returns a copy of every folder, keyed by id.
|
|
func (s *Store) Folders() map[string]Folder {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make(map[string]Folder, len(s.idx.Folders))
|
|
for id, f := range s.idx.Folders {
|
|
out[id] = *cloneFolder(f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetFolder returns a copy of a folder, or ErrNotFound.
|
|
func (s *Store) GetFolder(id string) (Folder, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
f, ok := s.idx.Folders[id]
|
|
if !ok {
|
|
return Folder{}, ErrNotFound
|
|
}
|
|
return *cloneFolder(f), nil
|
|
}
|
|
|
|
// CreateFolder adds a new folder owned by owner and returns it.
|
|
func (s *Store) CreateFolder(id, name, parent, owner string) (Folder, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if parent != "" {
|
|
if _, ok := s.idx.Folders[parent]; !ok {
|
|
return Folder{}, ErrNotFound
|
|
}
|
|
}
|
|
f := &Folder{ID: id, Name: name, Parent: parent, Owner: owner}
|
|
s.idx.Folders[id] = f
|
|
if err := s.saveLocked(); err != nil {
|
|
return Folder{}, err
|
|
}
|
|
return *cloneFolder(f), nil
|
|
}
|
|
|
|
// UpdateFolder replaces a folder's mutable fields (name, parent, public,
|
|
// grants). It rejects parent changes that would create a cycle.
|
|
func (s *Store) UpdateFolder(id, name, parent, public string, grants []Grant) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
f, ok := s.idx.Folders[id]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
if parent != "" {
|
|
if _, ok := s.idx.Folders[parent]; !ok {
|
|
return ErrNotFound
|
|
}
|
|
if s.createsCycleLocked(id, parent) {
|
|
return errors.New("folder parent change would create a cycle")
|
|
}
|
|
}
|
|
f.Name = name
|
|
f.Parent = parent
|
|
f.Public = public
|
|
f.Grants = grants
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// DeleteFolder removes a folder, reparenting its child folders and panels to the
|
|
// deleted folder's parent (so nothing is orphaned).
|
|
func (s *Store) DeleteFolder(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
f, ok := s.idx.Folders[id]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
newParent := f.Parent
|
|
for _, child := range s.idx.Folders {
|
|
if child.Parent == id {
|
|
child.Parent = newParent
|
|
}
|
|
}
|
|
for _, acl := range s.idx.Panels {
|
|
if acl.Folder == id {
|
|
acl.Folder = newParent
|
|
}
|
|
}
|
|
delete(s.idx.Folders, id)
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// createsCycleLocked reports whether setting folder id's parent to newParent
|
|
// would introduce a cycle. The caller must hold s.mu.
|
|
func (s *Store) createsCycleLocked(id, newParent string) bool {
|
|
for cur := newParent; cur != ""; {
|
|
if cur == id {
|
|
return true
|
|
}
|
|
f, ok := s.idx.Folders[cur]
|
|
if !ok {
|
|
return false
|
|
}
|
|
cur = f.Parent
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Permission resolution ────────────────────────────────────────────────────
|
|
|
|
// PanelPerm returns the effective permission user has on panel id, given the
|
|
// user-groups the user belongs to. Unmanaged panels are fully open.
|
|
func (s *Store) PanelPerm(id, user string, groups []string) Perm {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
acl, ok := s.idx.Panels[id]
|
|
if !ok {
|
|
return PermWrite // legacy/unmanaged panel: fully open
|
|
}
|
|
// A record carrying only organizational metadata (folder/order) with no
|
|
// owner, public level, or grants is still open — keeps an unmanaged panel
|
|
// accessible after it has merely been moved into a folder or reordered.
|
|
if acl.Owner == "" && acl.Public == "" && len(acl.Grants) == 0 {
|
|
return PermWrite
|
|
}
|
|
best := PermNone
|
|
if user != "" && acl.Owner == user {
|
|
return PermWrite
|
|
}
|
|
best = maxPerm(best, parsePerm(acl.Public))
|
|
best = maxPerm(best, grantsPerm(acl.Grants, user, groups))
|
|
if acl.Folder != "" {
|
|
best = maxPerm(best, s.folderPermLocked(acl.Folder, user, groups, map[string]bool{}))
|
|
}
|
|
return best
|
|
}
|
|
|
|
// FolderPerm returns the effective permission user has on folder id.
|
|
func (s *Store) FolderPerm(id, user string, groups []string) Perm {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.folderPermLocked(id, user, groups, map[string]bool{})
|
|
}
|
|
|
|
func (s *Store) folderPermLocked(id, user string, groups []string, seen map[string]bool) Perm {
|
|
if id == "" || seen[id] {
|
|
return PermNone
|
|
}
|
|
seen[id] = true
|
|
f, ok := s.idx.Folders[id]
|
|
if !ok {
|
|
return PermNone
|
|
}
|
|
if user != "" && f.Owner == user {
|
|
return PermWrite
|
|
}
|
|
best := PermNone
|
|
best = maxPerm(best, parsePerm(f.Public))
|
|
best = maxPerm(best, grantsPerm(f.Grants, user, groups))
|
|
best = maxPerm(best, s.folderPermLocked(f.Parent, user, groups, seen))
|
|
return best
|
|
}
|
|
|
|
func grantsPerm(grants []Grant, user string, groups []string) Perm {
|
|
best := PermNone
|
|
for _, g := range grants {
|
|
switch g.Kind {
|
|
case "user":
|
|
if user != "" && g.Name == user {
|
|
best = maxPerm(best, parsePerm(g.Perm))
|
|
}
|
|
case "group":
|
|
if contains(groups, g.Name) {
|
|
best = maxPerm(best, parsePerm(g.Perm))
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func maxPerm(a, b Perm) Perm {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func contains(xs []string, v string) bool {
|
|
for _, x := range xs {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clonePanel(a *PanelACL) *PanelACL {
|
|
c := *a
|
|
c.Grants = append([]Grant(nil), a.Grants...)
|
|
return &c
|
|
}
|
|
|
|
func cloneFolder(f *Folder) *Folder {
|
|
c := *f
|
|
c.Grants = append([]Grant(nil), f.Grants...)
|
|
return &c
|
|
}
|