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
+6
View File
@@ -236,6 +236,9 @@ A signal defined by composing one or more input signals through a chain of proce
processing node, set parameters, Create.
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
It supports undo/redo (Ctrl+Z / Ctrl+Shift+Z) and copy/paste (Ctrl+C / Ctrl+V) of the
selection; paste remaps ids and preserves internal wires (the permanent output node is
never duplicated).
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
that created it), *user*, or *global* (shared with everyone).
@@ -303,6 +306,9 @@ managed through the REST API.
config* makes a new instance for a set, optionally seeded from another instance. Both
mutating nodes are audited.
- Each graph can be enabled/disabled independently; saving reloads the engine live.
- The editor has its own undo/redo and copy/paste (Ctrl+Z / Ctrl+Shift+Z, Ctrl+C / Ctrl+V;
also ↩/↪ toolbar buttons). Copy/paste is multi-select aware and preserves wires internal
to the selection.
- Editing is gated by the logic-edit restriction (§2).
---
+89 -22
View File
@@ -164,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Request historical data
{ "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 }
// Start a control-logic live-debug session (one per client; replaces any prior).
// mode "live" — observe the running, enabled graph identified by graphId.
// mode "simulate" — dry-run the unsaved `graph` in a server sandbox (no real
// writes/config/dialogs); re-send on each edit to refresh it.
{ "type": "debugSubscribe", "mode": "live", "graphId": "g1" }
{ "type": "debugSubscribe", "mode": "simulate", "graph": { /* unsaved control-logic graph */ } }
// Stop the current debug session (tears down any simulate sandbox).
{ "type": "debugUnsubscribe" }
// Force a trigger node of the current debug session (live or simulate) to run
// now, as if it had fired. nodeId must be a trigger node of the watched graph;
// best-effort (dropped if the session is gone). Drives the editor's
// double-click-to-fire gesture on trigger nodes in debug mode.
{ "type": "fireTrigger", "nodeId": "t" }
```
**Server → Client messages:**
@@ -178,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Historical data response
{ "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] }
// Control-logic node execution during a live-debug session. Emitted ~per node
// run for the watched graph (live) or sandbox (simulate); value is meaningful
// only when hasValue is true (e.g. an action.write's value, a flow.if branch 0/1).
{ "type": "debugNode", "graphId": "g1", "nodeId": "w", "value": 42, "hasValue": true, "ts": 1750000000000 }
// Error
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
```
@@ -205,6 +226,7 @@ Base path: `/api/v1`
| GET, PUT | `/groups` | Read or set group definitions |
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
| POST | `/synthetic/trace` | Stateless single-shot trace of an unsaved graph for the live-debug view — every node's value; stateful ops flagged `approx` |
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
| GET, POST | `/config/sets` | List or create configuration sets (schemas) |
@@ -360,14 +382,48 @@ instance's `setId` in its `Meta`.
### 3.10 Access Control
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
(`canEditLogic`) so the UI can hide logic-editing affordances.
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
identity is read per request from `server.trusted_user_header` (with a `default_user`
fallback) and stored on the request context. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
capability is the highest role across all their memberships. Roles map to capabilities as:
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
logiceditor+ → `CanEditLogic`; auditor+ → `CanViewAudit`; admin → `CanAdmin`.
`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel
ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed
by the `acl.json` sidecar.
A built-in **public** group is always present: every user (and anonymous) is an implicit
viewer member, so an identified caller is at least read-only. Groups may **nest** via a
`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits
its role on every descendant group too, unless overridden lower. Cycles are rejected
(offending parent dropped to root), and the public group is protected from rename, reparent,
and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is
treated as unconfigured → fully open (everyone is admin), matching trusted-LAN/dev use;
assigning any role switches to strict mode where unlisted users are read-only viewers.
The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the
frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can
hide affordances. The `Policy` is seeded from the TOML config at startup but is
**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points
it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp +
atomic rename) and, on a later startup, supersedes the TOML access config (which then only
bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share,
mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact.
The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated):
`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group
roles, groups with members and parents, the role ladder, and the configured flag);
`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body
`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]`
create (name + optional parent), rename + set parent and member roles, and delete groups;
and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via
`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime
stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal
opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role
assignment with an effective-role badge), Groups (nesting + per-member roles), and
Server-stats tabs.
### 3.11 Configuration Manager
@@ -565,15 +621,25 @@ storage_dir = "./interfaces"
# Access control (all optional)
trusted_user_header = "" # header carrying the proxy-authenticated user
default_user = "" # identity when the header is absent (LAN/dev)
logic_editors = [] # users/groups allowed to edit panel & control logic
# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess"
# user = "guest"
# level = "readonly"
# [[groups]] # named user sets for per-panel sharing
# name = "operators"
# members = ["alice", "bob"]
# Role-based access through group memberships. Roles (low→high):
# viewer < operator < logiceditor < auditor < admin
# Effective capability = highest role across all memberships. The built-in
# "public" group makes every user an implicit viewer. Groups may nest via
# "parent" (members inherit their role on descendants). No roles anywhere = open
# (everyone admin); once set, unlisted users are read-only viewers.
# [[groups]]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
@@ -586,7 +652,7 @@ enabled = true
```
All settings can also be overridden with `UOPI_*` environment variables (e.g.
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
`UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
---
@@ -611,11 +677,12 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g.
- Interface XML parsing: use strict schema validation to prevent XXE.
- **Identity & access control:** the end-user identity is taken from a header set by a
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
unproxied/SSH-tunnel deployment model.
values — the proxy MUST strip any inbound copy of that header or it can be spoofed.
Authorisation is role-based through group memberships (viewer/operator/logiceditor/
auditor/admin), with the highest role across memberships deciding global capability;
per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere
the deployment is fully open (everyone admin), preserving the unproxied/SSH-tunnel/dev
model; once any role is set, unlisted and anonymous callers are read-only viewers.
---