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