Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package panelacl
|
||||
|
||||
import "testing"
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("New:", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestUnmanagedPanelIsOpen(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if got := s.PanelPerm("legacy", "alice", nil); got != PermWrite {
|
||||
t.Fatalf("unmanaged panel perm = %v, want write", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerAlwaysWrites(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.PanelPerm("p1", "alice", nil); got != PermWrite {
|
||||
t.Fatalf("owner perm = %v, want write", got)
|
||||
}
|
||||
// A freshly created panel is private to its owner.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermNone {
|
||||
t.Fatalf("non-owner perm on private panel = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAndGrants(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants := []Grant{
|
||||
{Kind: "user", Name: "bob", Perm: "write"},
|
||||
{Kind: "group", Name: "ops", Perm: "read"},
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", "", "read", grants); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Public read applies to anyone.
|
||||
if got := s.PanelPerm("p1", "carol", nil); got != PermRead {
|
||||
t.Fatalf("public perm = %v, want read", got)
|
||||
}
|
||||
// Direct user grant raises bob to write.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermWrite {
|
||||
t.Fatalf("granted user perm = %v, want write", got)
|
||||
}
|
||||
// Group membership grants read.
|
||||
if got := s.PanelPerm("p1", "dave", []string{"ops"}); got != PermRead {
|
||||
t.Fatalf("group perm = %v, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderInheritance(t *testing.T) {
|
||||
s := newStore(t)
|
||||
f, err := s.CreateFolder("f1", "Shared", "", "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateFolder(f.ID, f.Name, "", "read", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", f.ID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The panel inherits the folder's public-read level.
|
||||
if got := s.PanelPerm("p1", "bob", nil); got != PermRead {
|
||||
t.Fatalf("inherited perm = %v, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderCycleRejected(t *testing.T) {
|
||||
s := newStore(t)
|
||||
a, _ := s.CreateFolder("a", "A", "", "alice")
|
||||
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
|
||||
// Re-parenting A under its own descendant B must be rejected.
|
||||
if err := s.UpdateFolder(a.ID, a.Name, b.ID, "", nil); err == nil {
|
||||
t.Fatal("expected cycle rejection, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFolderReparents(t *testing.T) {
|
||||
s := newStore(t)
|
||||
a, _ := s.CreateFolder("a", "A", "", "alice")
|
||||
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
|
||||
if err := s.CreatePanel("p1", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPanel("p1", "alice", b.ID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeleteFolder(b.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// B's panel should reparent to A (B's former parent), not orphan.
|
||||
if acl := s.GetPanel("p1"); acl == nil || acl.Folder != a.ID {
|
||||
t.Fatalf("panel folder after delete = %+v, want %s", acl, a.ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user