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.
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.0added topyproject.toml(uv syncrun,uv.lockupdated).pre_process_yaml/include_parts/reload_anchors/replace_aliasesvendored verbatim intomodules/pyobs_config.py, with a header comment recording the exactpyobs-corecommit/version synced against. Unit tests inmodules/tests.py(IncludePartsTests,ReloadAnchorsTests,PreProcessYamlTests) portpyobs-core's own fixtures fromtests/utils/test_config.pyplus one ACL-specific case (test_acl_block_via_include). Uses plainunittest.TestCase, not Django'sTestCase— this app has noDATABASESconfigured (sessions are signed-cookie based), so DB-wrapped test cases aren't appropriate here. Run withpython 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 effectiveacl:block (viapyobs_config.pre_process_yaml+yaml.safe_load) and its provenance. Provenance detection (_acl_source_file, private helper) only recognizes the two patternspyobs-web-admin's own editor can produce — a bare top-level{include x.shared.yaml}whose target definesacl:itself, or anacl: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:GetResolvedAclTestsinmodules/tests.py, usingdjango.test.override_settingsto pointPYOBS_CONFIG_DIRat a temp dir (safe with plainunittest.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() -> dictreturns{"targets": [...], "callers": [...]}— rows are everylist_modules()entry (with resolvedacl,source, anopenflag, anerrorslot), columns are the sorted union of every caller name mentioned in any target'sallowkeys ordenylist (viaget_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 carryingmode(enforce/log). Interface-name shorthand entries (item 5) are taggedis_interface: Trueper method-list entry via_is_interface_name— a regex heuristic (^I[A-Z]\w*$) rather than importingpyobs-core, since pyobs's own naming convention (interfacesIPascalCase, methodssnake_case) makes the two patterns non-overlapping; the template layer (not yet built) is responsible for actually badging it. A malformedacl:block (bad YAML, non-mappingallow, non-listdeny, broken{include}) is caught per-target and recorded in that row'serrorfield rather than aborting the whole scan — validated bytest_broken_config_reported_as_error_not_crash. Tests:BuildAclMatrixTestsinmodules/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/", ...)inmodules/urls.py, plus a new "ACL Matrix" sidebar entry intemplates/base.html(between Dashboard and the Modules section; Dashboard's own "active" check was extended to also excludeactive_acl_matrixso the two links don't both highlight). The view callsservices.build_acl_matrix()and, per-row, precomputesmode(fromacl.get("mode", "enforce"), defaulting to"enforce"for open/error rows) andcell_list— an ordered list of{"caller": ..., **cell}dicts matching thecallerscolumn order — so the template can iterate rows/columns in lockstep without a customdict.get-by-variable-key template filter. Rendering:openrows collapse to a single spanning "open" badge rather than repeating it per cell (per the design doc's call to surface these prominently);errorrows likewise collapse to a spanning error message; a row'smode: logshows 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 toshared_detail; caller column headers link tomodule_detailwhen the caller name matches a managed module. Hub mode is explicitly not handled yet (Work Plan item 7, still open) —acl_matrixview returns a "not available for remote hosts" message when a non-local host is active, sincebuild_acl_matrix()only reads the localPYOBS_CONFIG_DIRand 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 tempPYOBS_CONFIG_DIR(real dir untouched) with sampleacl: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_matrixnow also computessource_counts(how many target rows resolve theiracl:through the same shared fragment) and attaches it to each row assource_count. In the template: a row whoseacl:is defined in its own file gets a pencil icon next to its name linking to{% url 'module_detail' row.name %}#tab-config(reusingdetail.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 toshared_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_filecan'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 scratchPYOBS_CONFIG_DIR(temporarylocal_settings.pyoverride, reverted after — real config untouched) with two modules (telescope,dome) sharing one{include acl.shared.yaml}fragment and two with local blocks (cam1, a deliberatelybroken.yaml) plus oneopenmodule (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; onlyservices.pyis 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 freshacl:block, serialized viaruamel.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) andservices.save_local_acl(name, acl)(refuses withValueErrorifget_resolved_acl's source isn'tNone, 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 endpointPOST /api/modules/<name>/acl/(views.api_acl,path("api/modules/<str:name>/acl/", ...)) validates the request shape (at most one ofallow/deny,modein{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-modalinacl_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 currentaclis embedded via Django'sjson_scriptfilter (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:SaveLocalAclTestsinmodules/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 scratchPYOBS_CONFIG_DIR(temporarylocal_settings.pyoverride, 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 ownbuild_acl_matrix()unchanged — what a hub queries on each configuredHUB_HOSTSentry via the existingproxy.call. Newservices.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 thatacl:) fixes this cheaply instead of re-deriving the whole matrix per host.views.acl_matrixcalls the local matrix plus eachHUB_HOSTSentry, catching per-host failures into anunreachable_hostslist (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 forlocalhostroute through a small addition to the existingset_hostview (now honors an optional same-site?next=redirect, mirroringlogin_view's existing pattern) sincemodule_detail/shared_detailonly 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 throughset_hostfirst is the minimal fix rather than threading a host parameter through every other view. A#tab-configdeep-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_aclno 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 justsource, since two different hosts can coincidentally have same-named fragments that are unrelated to each other. Tests:MergeAclMatricesTestsinmodules/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 aHUB_HOSTSentry + 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-cellkinds were all correct viaresponse.contextintrospection, 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_detailnow also passesother_modules(list_modules()minus the module itself; for a remote host, the same list via that host's/api/statuses/, proxied).views.api_aclgained aGETbranch (the view previously only handledPOST; the@require_POSTdecorator was removed and method dispatch is now manual) that follows the session's active host — unlikePOST's explicit-host-field contract (built for the matrix's multi-host-per-page case),GEThere matches every other module_detail-feeding endpoint (api_config,api_logs, ...), sincemodule_detailonly 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 logicbuild_acl_matrixalready had inline per-target -- factored out so the new GET endpoint doesn't duplicate that error handling;build_acl_matrixwas refactored to call it too, no behavior change (ResolveAndValidateAclTestsinmodules/tests.pycovers it directly; all existingBuildAclMatrixTestsstill pass unchanged). Frontend (templates/modules/detail.html): a 4th tab, lazy-loaded on first show (same pattern as the Logs tab) via the newGETendpoint; 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 throughset_host's?next=for a remote host, exactly like the matrix does) for the{include}case, or the interactive editor for everything else (includingopenmodules, to add a firstacl: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_HOSTJS constant from the already-globalactive_hostcontext var) to the same endpoint the matrix modal uses. Verified via a scratch, fully separate settings module + dev server (not touching the reallocal_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 likescheduler), 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 existingtable-responsivewrapper 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-successforall,bi-circle-half text-warningformethods(with the actual method list moved into thetitletooltip rather than shown inline),bi-dash-circle text-secondaryfordenied) -- same threecell.kindvalues as before, just a different rendering, no data-layer change for the kinds themselves.open/errorrows 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). Newviews.acl_matrixfieldmodule_acl_url(same#tab-acl-vs-remote-redirect shape as the existingmodule_config_url) replacesmodule_urlfor 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'scallersset now always includes every modulelist_modules()returns (callers: set[str] = set(targets), was empty), not just names actually referenced in someallow/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_matricesneeded no change -- it already unions each host's owncallerslist, 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; newtest_every_module_is_always_a_column_even_with_no_acl_at_allcovers a module with noacl:at all still getting a real (correctlydenied) cell against every other module, including itself, rather than being missing as a column. Verified against two scratchPYOBS_CONFIG_DIRs (temporary settings modules, notlocal_settings.py): one covering all three cell kinds plus anopenrow (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 explicitacl:(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 filltable-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:width: autoon the table: no effect, because per the CSS table-layout spec, an auto-width table's used width ismax(containing-block width, sum-of-column-widths)-- it can only ever be at least the container's width, never less.width: fit-content: also no effect in this Chromium build, for adisplay: tableelement specifically -- confirmed by direct measurement, not assumed.display: inline-table(shrink-wraps likeinline-block) did work in isolation, but addingwidth: 1%to.col-targetto 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 -- undoinginline-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 nowidth(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-contentis a flex item (<div class="d-flex">wraps the sidebar and it together) with nomin-widthset, and a flex item's defaultmin-widthisauto-- its own content's min-content size -- not0. That means.main-contentrefused to shrink below whatever its widest child needed and grew past the viewport instead, defeatingtable-responsive's own internal horizontal scroll: confirmed live,document.body.scrollWidthwas 523px on a 390px viewport, withtable-responsive'sclientWidthandscrollWidthreported 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-contentis 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.scrollWidthback to 390px,table-responsivenow correctly showsclientWidth: 356vsscrollWidth: 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 thebase.htmlchange: Logs is clean, but Dashboard has its own separate, pre-existing overflow (483px on a 390px viewport) -- its modules table (templates/modules/dashboard.htmlor wherever it lives) isn't wrapped intable-responsiveat 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.
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'sget_config(name)/save_config(name, content)andget_shared_config/save_shared_configtreat config files as opaque text — read and written as raw strings, no YAML parsing anywhere in this repo.pyproject.tomlhad nopyyaml(or any YAML library) dependency — now added, see Progress log.*.shared.yamlfragments (services.list_shared_configs, matched via*.shared.yamlglob) 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 inpyobs.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 onos,re,yaml,io.StringIO,typing— nothing else frompyobs-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 ofpyobs-core.list_modules()enumerates module names from config filenames inPYOBS_CONFIG_DIR(*.yaml, excluding*.shared.yaml). This is the existing source of truth for "which modules does this installation manage," reused below.
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
allowentry may itself be an interface name (e.g.ICamera) rather than a method name —pyobs-coreexpands 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.
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.
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.
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 existingsave_configpath — 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 fromacl.shared.yaml, included by 4 modules") and either open the shared fragment's own editor (existingshared_detailview) 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 (seepyobs_config.pre_process_yaml), so a generic YAML parser can't load a whole config file directly — only the isolatedacl: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 newacl:block, not plainpyyaml, so the written block reads like hand-written YAML (indented block sequences, minimal quoting) rather thanpyyaml's default flow-ish output. This does not preserve comments that were inside the oldacl: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_filealready 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'sacl: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 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.
- Exact UI treatment for "open" targets and
mode: logrules (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.
- Add
pyyamltopyproject.toml. - Vendor (or reimplement, scoped to just
{include})pre_process_yaml-equivalent resolution; unit-test againstpyobs-core's own test fixtures for{include}if available, to catch drift early. →modules/pyobs_config.py, tests inmodules/tests.py. -
services.py: a function that, for a given module name, returns its resolvedacl: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 inlist_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_nametag each method entry withis_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 existingshared_detaileditor rather than writing through silently. → structured per-target edit modal inacl_matrix.html(mode/policy/caller rows) backed byservices.save_local_acl+POST /api/modules/<name>/acl/for local blocks; shared-fragment badge w/ blast-radius count linking toshared_detailfor the shared case; broken/error rows still link tomodule_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_matrixquerying everyHUB_HOSTSentry; editing proxies per-row via an explicit host field inviews.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.