Skip to content

Latest commit

 

History

History
121 lines (79 loc) · 42.6 KB

File metadata and controls

121 lines (79 loc) · 42.6 KB

pyobs-web-admin: ACL matrix page — v0.11 (2026-07-04)

Status

Core matrix (view + edit + hub aggregation, Work Plan items 1–8) is done and in daily-usable shape — see Progress log below for exactly what's done; that section is kept current so work can resume from a different machine without re-deriving state from this conversation. Groups is a separate topic now — see DEV_ACL_GROUPS.md. It was designed, fully implemented (storage, a standalone management page, expand-on-save into both ACL editing surfaces), and then reverted at explicit request; that doc has the complete design and implementation record. v0.2 added the groups/profiles concept (now moved to that doc); v0.3 added interface-name shorthand handling and the live-XMPP-discovery idea; v0.4 decided against XMPP discovery (custom wire protocol, would need real pyobs-core dependency) in favor of literal/badged shorthand display; v0.5 started implementing the Work Plan below, in order; v0.6 added the matrix page itself (Work Plan item 6); v0.7 was a first pass at Work Plan item 7 that only routed to existing raw-YAML editors; v0.8 replaces that with the actual structured point-and-click editor item 7 was meant to deliver (see "Editing from the matrix" for why v0.7 undersized the item); v0.9 adds hub-mode aggregation (Work Plan item 8) — the matrix and its edit endpoint now span every configured host, not just localhost; v0.10 adds a second editing surface not in the original Work Plan — a per-module "ACL" tab on module_detail itself, requested after using v0.9's matrix-only modal (see "Editing from the matrix" for the new "Two editing surfaces" note); v0.11 reworks the matrix table itself for mobile-friendliness — rotated column headers, icon-only cells, module names linking straight to that module's own ACL tab — not a Work Plan item either, requested after the same mobile-friendliness pass already done for the Users page (DEV_EJABBERD_USER_MANAGEMENT.md) surfaced the matrix as the other page with the same problem.

Progress log

Work Plan items are implemented top-to-bottom; check the item list at the bottom for the authoritative state, this log just narrates it.

  • Done — Work Plan items 1–2. pyyaml>=6.0 added to pyproject.toml (uv sync run, uv.lock updated). pre_process_yaml/include_parts/reload_anchors/replace_aliases vendored verbatim into modules/pyobs_config.py, with a header comment recording the exact pyobs-core commit/version synced against. Unit tests in modules/tests.py (IncludePartsTests, ReloadAnchorsTests, PreProcessYamlTests) port pyobs-core's own fixtures from tests/utils/test_config.py plus one ACL-specific case (test_acl_block_via_include). Uses plain unittest.TestCase, not Django's TestCase — this app has no DATABASES configured (sessions are signed-cookie based), so DB-wrapped test cases aren't appropriate here. Run with python manage.py test modules — 17/17 passing as of this entry.

  • Done — Work Plan item 3. modules/services.py: get_resolved_acl(name) -> tuple[dict | None, str | None] returns the module's effective acl: block (via pyobs_config.pre_process_yaml + yaml.safe_load) and its provenance. Provenance detection (_acl_source_file, private helper) only recognizes the two patterns pyobs-web-admin's own editor can produce — a bare top-level {include x.shared.yaml} whose target defines acl: itself, or an acl: key whose entire value is one {include x.shared.yaml} — and conservatively falls back to reporting "own file" for anything more deeply nested (documented as a known simplifying assumption in the function's docstring, not silently). Tests: GetResolvedAclTests in modules/tests.py, using django.test.override_settings to point PYOBS_CONFIG_DIR at a temp dir (safe with plain unittest.TestCase, no DB involved). python manage.py test modules — 22/22 passing as of this entry.

  • Done — Work Plan items 4–5. modules/services.py: build_acl_matrix() -> dict returns {"targets": [...], "callers": [...]} — rows are every list_modules() entry (with resolved acl, source, an open flag, an error slot), columns are the sorted union of every caller name mentioned in any target's allow keys or deny list (via get_resolved_acl), not limited to modules this installation itself runs. Per-(target, caller) cell values (_acl_cell) follow the table in "What the matrix shows": open/all/methods/denied, each carrying mode (enforce/log). Interface-name shorthand entries (item 5) are tagged is_interface: True per method-list entry via _is_interface_name — a regex heuristic (^I[A-Z]\w*$) rather than importing pyobs-core, since pyobs's own naming convention (interfaces IPascalCase, methods snake_case) makes the two patterns non-overlapping; the template layer (not yet built) is responsible for actually badging it. A malformed acl: block (bad YAML, non-mapping allow, non-list deny, broken {include}) is caught per-target and recorded in that row's error field rather than aborting the whole scan — validated by test_broken_config_reported_as_error_not_crash. Tests: BuildAclMatrixTests in modules/tests.py. python manage.py test modules — 28/28 passing as of this entry.

  • Done — Work Plan item 6. views.acl_matrix + templates/modules/acl_matrix.html + path("acl/", ...) in modules/urls.py, plus a new "ACL Matrix" sidebar entry in templates/base.html (between Dashboard and the Modules section; Dashboard's own "active" check was extended to also exclude active_acl_matrix so the two links don't both highlight). The view calls services.build_acl_matrix() and, per-row, precomputes mode (from acl.get("mode", "enforce"), defaulting to "enforce" for open/error rows) and cell_list — an ordered list of {"caller": ..., **cell} dicts matching the callers column order — so the template can iterate rows/columns in lockstep without a custom dict.get-by-variable-key template filter. Rendering: open rows collapse to a single spanning "open" badge rather than repeating it per cell (per the design doc's call to surface these prominently); error rows likewise collapse to a spanning error message; a row's mode: log shows as a badge next to the module name (mode is block-wide, not per-cell, so this avoids repeating it in every cell); a row's shared-fragment source shows as a badge linking to shared_detail; caller column headers link to module_detail when the caller name matches a managed module. Hub mode is explicitly not handled yet (Work Plan item 7, still open) — acl_matrix view returns a "not available for remote hosts" message when a non-local host is active, since build_acl_matrix() only reads the local PYOBS_CONFIG_DIR and there's no proxy endpoint for it yet. Verified manually: python manage.py test modules (28/28 passing, no regressions) plus a manual dev-server pass — logged into a temp PYOBS_CONFIG_DIR (real dir untouched) with sample acl: blocks covering every cell kind (open, all, methods w/ interface shorthand, denied, mode: log, and a deliberately broken block) and confirmed via curl'd HTML that every badge/kind renders and that sidebar active-state highlighting is correct on both / and /acl/.

  • Done — Work Plan item 7. No new save path was needed — per the design doc, the module-local case "needs no new semantics," so this item is entirely about routing each row to the right existing editor rather than building a structured acl-only editor. views.acl_matrix now also computes source_counts (how many target rows resolve their acl: through the same shared fragment) and attaches it to each row as source_count. In the template: a row whose acl: is defined in its own file gets a pencil icon next to its name linking to {% url 'module_detail' row.name %}#tab-config (reusing detail.html's existing hash-based tab selection — no new view/endpoint); a row sourced from a shared fragment instead shows the existing fragment badge, now reading "<fragment> · N modules" and linking to shared_detail, per the design doc's requirement to surface blast radius ("this rule comes from X, included by N modules") before sending the admin there. Error rows (broken YAML) get the pencil too, since _acl_source_file can't determine provenance for content that didn't parse — same conservative "own file" fallback used elsewhere. The "detach from shared fragment into a module-local override" action from the Open Questions is still not built — per that section's own leaning, this first version only links to the shared editor and lets the admin decide by hand, it doesn't offer a one-click rewrite. Verified manually the same way as item 6: a scratch PYOBS_CONFIG_DIR (temporary local_settings.py override, reverted after — real config untouched) with two modules (telescope, dome) sharing one {include acl.shared.yaml} fragment and two with local blocks (cam1, a deliberately broken.yaml) plus one open module (filecache); confirmed via curl'd HTML that the shared pair get the "· 2 modules" badge linking to /shared/acl.shared/ with no pencil, and the other three get the pencil linking to their own #tab-config. python manage.py test modules — 28/28 passing, no regressions (no view/template tests exist yet in this repo; only services.py is unit-tested, per the existing pattern noted in items 1–2 above).

  • Superseded above — v0.8 finishes Work Plan item 7 properly. The v0.7 entry above only routed the local-block case to module_detail's raw-YAML Config tab, which technically matched the design doc's literal words but not its intent (or what was actually asked for): a graphical editor, not "go type YAML by hand." Added: services._dump_acl_block/_replace_local_acl_block (splice a fresh acl: block, serialized via ruamel.yaml's round-trip dumper, into a module's raw config text at the same line range the old block occupied — or append one if there wasn't one — leaving every other line, including unrelated {include ...} directives, untouched) and services.save_local_acl(name, acl) (refuses with ValueError if get_resolved_acl's source isn't None, i.e. the block actually lives in a shared fragment; otherwise writes via the splice, then re-resolves and rolls back to the original file content if the result doesn't match what was requested — the safety net for the splice's line-detection heuristic rather than trying to make that heuristic exhaustive). New endpoint POST /api/modules/<name>/acl/ (views.api_acl, path("api/modules/<str:name>/acl/", ...)) validates the request shape (at most one of allow/deny, mode in {enforce, log}) and returns the same "not available for remote hub hosts" 400 the matrix view already used, without making any remote call. Template: the per-row pencil for local, non-broken rows now opens a Bootstrap modal (#acl-edit-modal in acl_matrix.html) instead of linking away — mode radio (enforce/log), policy radio (allow-list/deny-list), dynamic add/remove rows per caller (methods list or "all"), a "Remove ACL (open access)" action, all POSTing structured JSON to the new endpoint and reloading on success. Each editable row's current acl is embedded via Django's json_script filter (row.acl_data_id, computed in the view) so the modal can prefill without a round-trip fetch. Error rows (broken YAML) still link to the raw Config tab, since there's no valid structure to prefill a form from — same reasoning as before, just no longer applied to the common case. Tests: SaveLocalAclTests in modules/tests.py (add-to-open, replace-existing-preserving-surrounding-keys, preserve-unrelated-{include}-lines, remove-entirely, refuse-shared-fragment) — python manage.py test modules, 33/33 passing. Verified manually via a scratch PYOBS_CONFIG_DIR (temporary local_settings.py override, reverted after — real config and the separately-running dev server on port 8000 untouched): exercised the API directly for add/edit/remove/shared-refusal/malformed-input/404/hub-mode-rejection, confirming each produced the expected file content or error and that failures left files byte-for-byte unchanged.

  • Done — Work Plan item 8. The matrix now aggregates every configured host on one page, regardless of which host is "active" in the session — unlike the rest of this app's hub-mode views (dashboard/config/logs), which show one host at a time via the session's active-host switcher, this page's whole point is fleet-wide visibility, so it always queries all of them. New local endpoint GET /api/acl-matrix/ (views.api_acl_matrix) returns this instance's own build_acl_matrix() unchanged — what a hub queries on each configured HUB_HOSTS entry via the existing proxy.call. New services.merge_acl_matrices(per_host) (pure function, list[(host_name, matrix)] -> matrix) tags each row with its host and recomputes every cell against the union of all hosts' callers — a row fetched from one host only had cells for the callers that host's own modules mention, so its cells are wrong (missing entries) once merged with another host's caller set; recomputing via _acl_cell(row["acl"], caller) (already a pure function, safe to call again outside the host that resolved that acl:) fixes this cheaply instead of re-deriving the whole matrix per host. views.acl_matrix calls the local matrix plus each HUB_HOSTS entry, catching per-host failures into an unreachable_hosts list (shown as a warning banner, not silently hidden — hiding an unreachable host's modules would defeat the point of a fleet-wide gap-finder). Row/caller links that aren't for localhost route through a small addition to the existing set_host view (now honors an optional same-site ?next= redirect, mirroring login_view's existing pattern) since module_detail/shared_detail only ever operate on the session's active host, not a URL argument — this is a real seam in the app's "one active host at a time" model that this page's aggregation requirement runs into; routing through set_host first is the minimal fix rather than threading a host parameter through every other view. A #tab-config deep-link (used for broken-YAML rows) doesn't survive that redirect hop for remote hosts (fragments aren't sent to the server, so they can't round-trip through a server-side redirect) — accepted as a minor degradation (remote + broken-acl lands on the module's Overview tab instead of Config, one extra click) rather than engineering around it. Editing: views.api_acl no longer consults the session's active host at all (doing so was a footgun here — an admin who'd switched their sidebar to a remote host would have had local edits silently misrouted) and instead trusts an explicit "host" field in the request body, present on every row's edit payload; a non-local host proxies the same request to that host's own (unmodified) /api/modules/<name>/acl/ endpoint via _proxy, exactly like every other per-module hub-mode action in this app. Shared-fragment blast-radius counts (source_count) are now keyed by (host, source), not just source, since two different hosts can coincidentally have same-named fragments that are unrelated to each other. Tests: MergeAclMatricesTests in modules/tests.py (host tagging, caller-union-spans-hosts, cell-recompute-against-global-union) — python manage.py test modules, 36/36 passing. Verified end-to-end with two genuinely separate, simultaneously-running Django dev server processes on different ports (one configured as a hub with a HUB_HOSTS entry + shared token pointing at the other, mirroring how two real installations would be configured) rather than mocking the HTTP layer: confirmed the merged matrix's rows, hosts, and per-cell kinds were all correct via response.context introspection, and that killing the spoke mid-session produced the "Could not reach" banner with the local rows still intact rather than a crash or a silently-incomplete matrix.

  • Groups (items 9–12) moved out to its own doc — DEV_ACL_GROUPS.md. Designed, fully implemented (storage layer, standalone management page, expand-on-save into both ACL editing surfaces), then reverted at explicit request. That doc has the complete design and implementation record; nothing about Groups is tracked here anymore.

  • Added — v0.10, a new "ACL" tab on module_detail, not a Work Plan item. Requested after using v0.9's matrix modal, as an additional (not replacement, see "Two editing surfaces" above) editing surface — keeping both was an explicit choice, not an oversight. Backend: views.module_detail now also passes other_modules (list_modules() minus the module itself; for a remote host, the same list via that host's /api/statuses/, proxied). views.api_acl gained a GET branch (the view previously only handled POST; the @require_POST decorator was removed and method dispatch is now manual) that follows the session's active host — unlike POST's explicit-host-field contract (built for the matrix's multi-host-per-page case), GET here matches every other module_detail-feeding endpoint (api_config, api_logs, ...), since module_detail only ever shows one host at a time. services.resolve_and_validate_acl(name) is a new small extraction of the try/except-and-shape-check logic build_acl_matrix already had inline per-target -- factored out so the new GET endpoint doesn't duplicate that error handling; build_acl_matrix was refactored to call it too, no behavior change (ResolveAndValidateAclTests in modules/tests.py covers it directly; all existing BuildAclMatrixTests still pass unchanged). Frontend (templates/modules/detail.html): a 4th tab, lazy-loaded on first show (same pattern as the Logs tab) via the new GET endpoint; renders one of three states -- a parse-error message linking back to the Config tab, a "this comes from a shared fragment, edit it there" message (link routes through set_host's ?next= for a remote host, exactly like the matrix does) for the {include} case, or the interactive editor for everything else (including open modules, to add a first acl: from scratch). The editor: mode/policy radios identical in spirit to the matrix's modal, but the caller list itself is different by design (per this conversation's explicit choice) -- every other known module renders as a checkbox row up front (check it to allow/deny, methods input appears only when relevant), plus a manually-added freeform row per caller that isn't a known module (mirroring the matrix modal's only mode). Saves POST the session's active host explicitly (ACTIVE_HOST JS constant from the already-global active_host context var) to the same endpoint the matrix modal uses. Verified via a scratch, fully separate settings module + dev server (not touching the real local_settings.py, since it was mid-edit in your editor during this work) covering: open module (add first rule), existing local rule (edit in place, including a non-module caller like scheduler), shared-fragment rule (correct message + link, no editable form shown), broken rule (error message + Config-tab link), and ACL removal (block cleanly stripped back to just the surrounding file content). python manage.py test modules -- 39/39 passing.

  • Added — v0.11, mobile-friendly matrix rendering, not a Work Plan item. Rendering changes: caller column headers now wrap their label in a writing-mode: vertical-rl; transform: rotate(180deg) span so a column only needs to be as wide as one row-height rather than the longest caller name -- far more columns fit before the table needs to scroll horizontally (the existing table-responsive wrapper already scrolls unconditionally, at every breakpoint, once the table does overflow -- confirmed still present and working after this change, nothing new needed there). Cells collapse from a text/badge per kind to a single colored icon (bi-check-circle-fill text-success for all, bi-circle-half text-warning for methods (with the actual method list moved into the title tooltip rather than shown inline), bi-dash-circle text-secondary for denied) -- same three cell.kind values as before, just a different rendering, no data-layer change for the kinds themselves. open/error rows are unchanged (still a single spanning badge/message, not per-cell icons, since "any caller" can't be enumerated as discrete icons the way a known caller list can). New views.acl_matrix field module_acl_url (same #tab-acl-vs-remote-redirect shape as the existing module_config_url) replaces module_url for the row's own module-name link, so clicking a module's name goes straight to its ACL tab instead of landing on Overview first -- the pencil icon's existing inline quick-edit modal is untouched (explicitly kept, not redundant with the new navigation links), this only changes where the name link goes.

    One real data-layer change, also folded into v0.11: services.build_acl_matrix's callers set now always includes every module list_modules() returns (callers: set[str] = set(targets), was empty), not just names actually referenced in some allow/deny -- "always show all modules in both headers," so "could A reach B" is answerable for any module pair, not only ones where B already happens to be referenced somewhere. merge_acl_matrices needed no change -- it already unions each host's own callers list, so once each host's own list includes its own modules, the fleet-wide union naturally includes every module from every host too. BuildAclMatrixTests's existing caller-union test updated for the wider expected set; new test_every_module_is_always_a_column_even_with_no_acl_at_all covers a module with no acl: at all still getting a real (correctly denied) cell against every other module, including itself, rather than being missing as a column. Verified against two scratch PYOBS_CONFIG_DIRs (temporary settings modules, not local_settings.py): one covering all three cell kinds plus an open row (confirmed via curl'd HTML that each icon/class/tooltip renders correctly and module names link to #tab-acl), another with 4 modules where only one had an explicit acl: (confirmed all 4 modules appear as columns, not just the one referenced caller). python manage.py test modules -- 108/108 passing, no regressions.

    Also folded into v0.11, requested afterward once the columns above were visibly narrower: keep the table's own overall width to a minimum too, not just its columns -- on a wide screen, Bootstrap's .table (width: 100%) was stretching the whole table to fill table-responsive's full container width regardless of how narrow the columns actually were, which visually reads as every column being squeezed over to one side with a large dead gap. This took three real attempts to get right, via a Playwright-driven bisection against a standalone reproduction (no Django involved) rather than guessing from the CSS alone, since the actual behavior surprised the obvious fixes:

    1. width: auto on the table: no effect, because per the CSS table-layout spec, an auto-width table's used width is max(containing-block width, sum-of-column-widths) -- it can only ever be at least the container's width, never less.
    2. width: fit-content: also no effect in this Chromium build, for a display: table element specifically -- confirmed by direct measurement, not assumed.
    3. display: inline-table (shrink-wraps like inline-block) did work in isolation, but adding width: 1% to .col-target to further "minimize" it (the same trick already used on the icon cells) actively broke it again, stretching the table straight back to full container width. Root cause, confirmed by bisecting the standalone repro cell-by-cell: a percentage width on any column requires a definite table width to resolve against, so its mere presence forces the whole table back into "fill the container" sizing -- undoing inline-table's shrink-wrap for the entire table, not just that one column. None of the columns needed a width hint in the first place; a rotated header and an icon-only cell are already about as minimal as content gets under plain auto layout. Final, verified-correct rule: #acl-matrix { display: inline-table; width: auto; }, with no width (percentage or otherwise) on any individual column. Confirmed via Playwright screenshot at a 1920px viewport: table width dropped from a stretched 1630px to its actual ~350px content width, sitting flush left with the extra space empty on the right, not squeezed over to one side.

    Checking this same page at a 390px mobile viewport (stress-tested with 11 modules, so the matrix genuinely needs to scroll) surfaced one more real bug, this time in templates/base.html, not this page: .main-content is a flex item (<div class="d-flex"> wraps the sidebar and it together) with no min-width set, and a flex item's default min-width is auto -- its own content's min-content size -- not 0. That means .main-content refused to shrink below whatever its widest child needed and grew past the viewport instead, defeating table-responsive's own internal horizontal scroll: confirmed live, document.body.scrollWidth was 523px on a 390px viewport, with table-responsive's clientWidth and scrollWidth reported as equal (no internal overflow at all -- the div had simply been stretched to fit, rather than clipping/scrolling its content). Fix: .main-content { min-width: 0; }, added unconditionally (not inside a breakpoint) since .main-content is a flex item at every screen width, even though this only actually mattered on mobile in practice, where the sidebar being an off-canvas overlay (not shrinking the available row width) leaves much less room than desktop. Verified fixed: body.scrollWidth back to 390px, table-responsive now correctly shows clientWidth: 356 vs scrollWidth: 489 (using its own internal scrollbar as designed), and the sticky Target column stays put (confirmed same x-position before/after a programmatic 200px scroll) while the caller columns scroll underneath it, via two more Playwright screenshots.

    Spot-checked Dashboard (/) and All Logs (/logs/) at the same 390px viewport for regressions from the base.html change: Logs is clean, but Dashboard has its own separate, pre-existing overflow (483px on a 390px viewport) -- its modules table (templates/modules/dashboard.html or wherever it lives) isn't wrapped in table-responsive at all, unlike this page. Not fixed here -- out of scope for an ACL-matrix mobile check, and deserves its own design pass (a Name/RAM/CPU/Uptime table has different mobile-friendly options than a rotated-header icon matrix does) rather than a reflexive wrap-in-table-responsive patch.

Motivation

pyobs-core 2.0 adds per-module access control (acl: blocks in each module's own YAML config — see pyobs-core's DEVELOPMENT.md, Access Control (ACLs)). That design deliberately keeps ACL storage and enforcement per-module: a module's reachability is legible from its own config file, and the runtime check (Module.execute()) never depends on fleet-wide state. The trade-off, raised from the pyobs-core side: once a fleet has a dozen-plus modules each with their own acl: block, "who can reach the telescope, and with what" is scattered across a dozen files with no single place to read it back.

This is a visibility/authoring problem, not an enforcement one, and pyobs-web-admin is the right place to solve it — it already owns exactly this kind of fleet-wide, config-editing surface (dashboard across all modules, hub mode across multiple hosts, per-module Config tab).

Current state (checked against this repo before implementation started; see Progress log above for what's changed since)

  • modules/services.py's get_config(name)/save_config(name, content) and get_shared_config/save_shared_config treat config files as opaque text — read and written as raw strings, no YAML parsing anywhere in this repo. pyproject.toml had no pyyaml (or any YAML library) dependency — now added, see Progress log.
  • *.shared.yaml fragments (services.list_shared_configs, matched via *.shared.yaml glob) and {include} references are a first-class editing concept (their own sidebar section, own editor) but {include} resolution itself doesn't happen server-side — the Config tab's "included shared configs are shown as clickable links" is a display affordance over the raw text, not an actual merge.
  • pyobs-core's real {include} resolution lives in pyobs.utils.config.pre_process_yaml (pyobs-core/pyobs/utils/config.py) — regex-driven, recursive, handles nested {include file key} and YAML anchors/aliases across included files. It depends only on os, re, yaml, io.StringIO, typing — nothing else from pyobs-core. That matters here because this repo's README explicitly advertises "No pyobs-core dependency — communicates with pyobs directly via subprocess," so reproducing this logic has to either vendor that one function or take a very narrow dependency, not pull in all of pyobs-core.
  • list_modules() enumerates module names from config filenames in PYOBS_CONFIG_DIR (*.yaml, excluding *.shared.yaml). This is the existing source of truth for "which modules does this installation manage," reused below.

Design

What the matrix shows

One page, one table: rows = target modules (from list_modules()), columns = callers, cells = what that caller may do on that target.

Callers are not the same set as list_modules(). An acl: block's allow/deny entries are just caller-name strings — they don't have to correspond to a module this installation runs or even manages (a caller could be pyobs-gui's or pyobs-web-client's own connecting identity, an ad hoc script's JID, or a module living on a different host under hub mode). The column set is the union of every caller name that appears in any module's resolved acl: block, harvested by scanning all modules — not assumed equal to the row set. A caller name that matches a known module gets linked to that module's own page; one that doesn't is still shown, just without a link.

Cell values, derived per (target, caller) pair from the target's resolved acl::

Target's acl: Cell
no acl: key open — every caller, including ones with no row/column presence elsewhere
allow: {caller: "*"} all methods
allow: {caller: [m1, m2]} m1, m2
allow: {...}, caller not listed denied
deny: [caller, ...], caller listed denied
deny: [...], caller not listed all methods
any of the above with mode: log same computed value, visually flagged (e.g. a badge) as not yet enforced

"Open" targets (no acl: at all) are worth surfacing prominently rather than just leaving blank — the matrix's main value is spotting modules that should have a policy and don't, not just displaying ones that already do.

Call-out: an allow entry may itself be an interface name (e.g. ICamera) rather than a method name — pyobs-core expands this at runtime into that interface's full method list (see below). The matrix does not perform this expansion; it shows the entry as-is, badged to distinguish it from a plain method name (e.g. ICamera (interface)). See "Interface-name shorthand" below for why, and for a possible way to close this gap later.

Resolving {include} correctly

The matrix has to read each module's effective config, not its literal file content — an acl: block that lives in a *.shared.yaml fragment and is pulled in via {include acl.shared.yaml} must show up for every module that includes it. Vendor pre_process_yaml (or a trimmed subset covering just {include}, if the anchor/alias handling turns out unnecessary for acl: blocks specifically) rather than reimplementing {include} parsing independently — two independent implementations of the same include syntax drifting apart is a worse outcome than one vendored copy with a comment noting its origin (pyobs-core/pyobs/utils/config.py) and a pyobs-core version it was last synced against, re-checked whenever pyobs-core's version bumps.

Add pyyaml as a real dependency of this repo — required either way (vendoring pre_process_yaml still needs it), and matches what pyobs-core already depends on.

Interface-name shorthand: static display vs. live XMPP discovery

Module.__init__ (pyobs-core/pyobs/modules/module.py) expands interface names in allow entries into that interface's full method list via _get_interfaces_and_methods(), which does isinstance(self, interface) against the module's actual runtime class — a class that can live in pyobs-core itself or in any device-driver package (pyobs-sbig, pyobs-fli, pyobs-alpaca, ...). Reproducing that expansion statically would mean importing the concrete class named in every module's class: key, i.e. potentially every device package present in the fleet, installed into pyobs-web-admin's own venv — a far bigger dependency footprint than the one vendored {include} function, and it reintroduces the coupling the "no pyobs-core dependency" design principle exists to avoid. First cut, therefore: show interface-name shorthand as a literal, badged cell value rather than expanding it (see call-out above) — the matrix's job is to surface configured policy, not simulate Module.execute()'s runtime resolution bit-for-bit.

Idea raised: a dedicated XMPP account for pyobs-web-admin. Comm.get_interfaces(client) (pyobs-core/pyobs/comm/comm.py, implemented in pyobs/comm/xmpp/xmppcomm.py over slixmpp) asks a running module, live, which interfaces it implements — this is XMPP disco-based, resolved by the module itself (which already has its own class and pyobs-core loaded) and returned as interface names over the wire, so the querying side never needs to import any device-driver class at all. Paired with a small vendored, pyobs-core-only static table of interface name → method names (built from pyobs.interfaces, which unlike device drivers is small, stable, and has no hardware coupling), this would let the matrix expand interface-name shorthand into real method lists without ever importing a device package — closing the gap the static approach above can't.

Decided: not pursuing this. get_interfaces isn't standard XEP-0030 disco — it rides on pyobs-core's own bespoke stanzas (pyobs:event, pyobs:state) and a custom rpc.py/serializer.py protocol for dataclasses and interface schemas. A "thin, vendored XMPP client" would mean reimplementing that custom wire protocol too, which is a much bigger and more drift-prone undertaking than the one pre_process_yaml function — doing this properly means using pyobs-core's actual XmppComm, i.e. taking the real dependency, not a narrow one. That also adds a live network dependency (XMPP server reachability, credentials, an async session) to what is otherwise a fast, file-based page, and only works for modules that happen to be running at query time. Weighed against what it buys — expanding a cosmetic shorthand into a method list — that's disproportionate, and it breaks the README's explicit "no pyobs-core dependency" claim for a non-essential feature. Staying with the narrow vendor approach and literal/badged shorthand display (see call-out and cell-rendering item above) for the foreseeable future; revisit only if some other feature creates independent justification for a full pyobs-core dependency.

Editing from the matrix

A cell edit has to land in the file the rule actually came from, which is not always the target module's own file:

  • If the target's acl: block is not behind an {include}, edit and save directly via the existing save_config path — this is the common case and needs no new semantics.
  • If the target's acl: block is pulled in from a shared fragment, editing it in place would silently change every other module that includes the same fragment. The matrix must show this ("this rule comes from acl.shared.yaml, included by 4 modules") and either open the shared fragment's own editor (existing shared_detail view) for the edit, or require an explicit "detach into this module's own config" action before allowing an inline edit — not silently write through to a file whose blast radius is bigger than the one row being edited.

"Edit and save directly" means a structured form, not a raw-YAML textarea. The first pass at this item (see Progress log) just routed each row to an existing raw-text editor — technically satisfying "no new semantics" for storage, but not what was actually wanted: a point-and-click editor for the acl: block itself (add/remove a caller, pick methods vs. "all", toggle enforce/log), the same way the rest of the matrix is already structured rather than raw YAML. That still ultimately calls save_config under the hood (per the bullet above — no new storage semantics), but the write has to be built from the form's structured state, not typed by hand, which means:

  • A text-splice, not a full-file YAML round-trip, to write just the acl: block back. A module's raw config can contain bare {include ...} lines that aren't valid standalone YAML on their own (see pyobs_config.pre_process_yaml), so a generic YAML parser can't load a whole config file directly — only the isolated acl: block (which, for the local-edit case, is never itself behind an {include}) gets touched; every other line is left byte-identical.
  • ruamel.yaml (round-trip dumper) to serialize the new acl: block, not plain pyyaml, so the written block reads like hand-written YAML (indented block sequences, minimal quoting) rather than pyyaml's default flow-ish output. This does not preserve comments that were inside the old acl: block being replaced — the form is the new source of truth for that block's content each save — it only preserves everything outside the block, via the splice above.
  • Verify-then-rollback, not an exhaustively correct splice. Locating the acl: block's line range is a heuristic (same one _acl_source_file already uses, plus a stricter "blank line ends the block" rule for splicing). Rather than trying to make that heuristic bulletproof against every hand-written layout, the save path re-resolves the module's acl: after writing and reverts to the original file content if it doesn't match what was requested.

Two editing surfaces, one backend, by explicit choice. After using the matrix's per-row modal (v0.9), a second, independent editing surface was requested: a full "ACL" tab on module_detail itself (list every other managed module as a click-to-allow row, not just manually-typed caller names), rather than replacing the matrix's modal with it. Both surfaces call the same services.save_local_acl/GET,POST /api/modules/<name>/acl/ endpoint and share nothing else client-side (the tab's JS is its own copy of the row-building logic, not a shared component) — deliberate duplication given the two pages have different host-context models (the matrix aggregates every host on one page; module_detail shows one host at a time, like the rest of its own tabs), so a shared implementation would have had to paper over that difference rather than let each page's editor match its page's own conventions.

Groups (a.k.a. profiles) used to be designed here — see DEV_ACL_GROUPS.md for the full design and implementation record, kept separately after being reverted.

Hub mode interaction

Hub mode already proxies dashboard/config/log actions to remote hosts transparently. The matrix should do the same — aggregate across every configured host, not just the local one — since ACL policy for a real multi-host fleet is exactly the kind of thing that's easy to get wrong on one host and forget on another. This falls out of reusing the existing hub-proxying mechanism rather than needing new cross-host plumbing, but is worth calling out explicitly as a requirement, not an incidental nice-to-have.

Open questions

  • Exact UI treatment for "open" targets and mode: log rules (color/badge choice) — a UI/visual-design decision, not an architectural one, deferred to implementation.
  • Whether to offer the "detach from shared fragment into this module's own config" action as a one-click automatic rewrite, or just point the admin at the shared fragment's editor and let them decide by hand. Leaning toward the latter for a first version — automatically rewriting a shared {include} into a module-local override is a bigger, riskier piece of config surgery than this feature needs to solve on day one.

Work Plan

  • Add pyyaml to pyproject.toml.
  • Vendor (or reimplement, scoped to just {include}) pre_process_yaml-equivalent resolution; unit-test against pyobs-core's own test fixtures for {include} if available, to catch drift early. → modules/pyobs_config.py, tests in modules/tests.py.
  • services.py: a function that, for a given module name, returns its resolved acl: block (dict | None) plus, if present, which file it actually came from (the module's own config, or a named shared fragment). → services.get_resolved_acl.
  • services.py: a fleet-wide scan building the (target × caller) matrix data structure described above, including the "callers not in list_modules()" case. → services.build_acl_matrix.
  • Cell rendering: show interface-name shorthand entries (e.g. ICamera) as a literal, badged value, not expanded into method names. → data side (_acl_cell/_is_interface_name tag each method entry with is_interface) plus the template badge (acl_matrix.html, "(interface)" suffix + distinct badge color) done as part of item 6 below.
  • New view + template + URL entry (modules/urls.py) for the matrix page, following the existing dashboard/module_detail pattern. → views.acl_matrix, templates/modules/acl_matrix.html, path("acl/", ...).
  • Editing: direct save for module-local acl: blocks; shared-fragment case routes to (or at minimum clearly links to) the existing shared_detail editor rather than writing through silently. → structured per-target edit modal in acl_matrix.html (mode/policy/caller rows) backed by services.save_local_acl + POST /api/modules/<name>/acl/ for local blocks; shared-fragment badge w/ blast-radius count linking to shared_detail for the shared case; broken/error rows still link to module_detail#tab-config's raw editor.
  • Hub mode: confirm the matrix aggregates across configured remote hosts using the existing proxying mechanism. → services.merge_acl_matrices + GET /api/acl-matrix/ + views.acl_matrix querying every HUB_HOSTS entry; editing proxies per-row via an explicit host field in views.api_acl.

Groups (items 9–12) moved to DEV_ACL_GROUPS.md's own Work Plan — implemented, then reverted; see that doc for the full item list and what was actually done.