Date: 2026-07-28
Accepted
The ACL matrix (ADR-0006) grants a role access to one concrete resource at a time — role_resource_grants(role_id, resource_id, all_ports). Testing feedback on the 0.15.0 cycle flagged a real gap: an admin wants to grant a role "every printer in the Homeoffice site", not click through each printer individually, and — just as importantly — not have to remember to also grant every future printer added to that site later. The existing all-ports flag already solves the equivalent problem within one resource (a grant widens automatically as new ports are added to that resource, per ADR-0006's own R-054); this asks for the same "widens automatically" property one level up, across resources of a type.
Two scope questions were resolved by direct confirmation before implementation, both toward the simpler option:
A new role_resource_type_grants(role_id, site_id, resource_type) table, structurally simple (no port columns, no join table) because of the two scoping decisions above. A row means "this role can reach every resource matching this site + type, on every port, now and in the future" — no all_ports column needed since it's always true, no port-link join table needed since there's nothing to link.
Expansion, not a parallel enforcement path. Every consumer of role_resource_grants — RuleBuilder (nftables generation), MyAccessResource (self-service portal "what can I reach"), RdpGrantService (browser-RDP gate) — gets a small addition that resolves matching resources and treats them exactly like an existing all-ports grant, rather than a second, separately-maintained access-control code path:
RuleBuilder: for each type-grant, find resources where site_id and type match, and append a synthetic (never-persisted) RoleResourceGrant.createNew(roleId, resourceId, allPorts=true) per match into the same grantsByRole map the rest of the method already renders. Zero changes to the actual rule-emission logic below that point.MyAccessResource: a second query resolves type-grant-matched resource IDs, unioned into the same effective map the concrete-grant query already builds (unconditional all-ports overwrite — safe, since all-ports is always the correct widest result regardless of whether a narrower grant already touched that resource).RdpGrantService: hasGrant first checks the existing concrete-grant path; if that's false, a second check (hasTypeGrant) looks for a type-grant whose site+type matches the resource. Mirrors the existing query's use of user_roles only (not auto_all/Everyone roles) — deliberately unchanged behavior, not a bug fix bundled into this feature.ConfigService export/import: a new TypeGrantSnapshot list, following the exact pattern already used for GrantSnapshot/GrantPortLink (delete-then-reinsert on import, in FK order after roles and sites).A separate small panel, not a matrix extension. The existing ACL matrix (ADR-0006) is fundamentally a grid keyed by (role, concrete resource) — a type-grant has no resourceId, so it isn't a matrix cell and doesn't fit the tri-state (∅/ⓐ/N) cell model. It renders as its own small list-plus-add-form panel under the matrix, scoped to whichever site tab is currently active (reusing the matrix's own site-selection state), with its own POST/DELETE REST resource (AclTypeGrantResource) rather than folding into AclMatrixResource's batch-apply endpoint. Unlike the matrix's deliberate no-auto-save behavior, a type-grant add/remove takes effect immediately — there's no per-cell "dirty" state to stage since each row is a single atomic create/delete, not a value being edited.
Baseline: A — new table, always all-ports, additive-only, expansion-not-parallel-path (the decision).
| Criterion (weight) | A: simple type-grant (baseline) | B: port-subset-capable type-grant | C: type-grant with per-resource exclusions | D: expand into concrete role_resource_grants rows at write time (materialized, not resolved live) |
|---|---|---|---|---|
| Enforcement-path simplicity (5) | 0 | −1 (port list vs. varying per-resource ports has no clean semantics) | −1 (a second conflict-resolution model alongside additive-only) | 0 |
| Implementation effort (4) | 0 | −1 (new port-picker UI + validation for a rule that isn't resource-scoped) | −1 (exclusion table, UI, and a real "which wins" resolution order) | −1 (needs a resource-added/removed hook to keep materialized rows in sync) |
| Correctness under resource churn (a printer added after the grant) (5) | +1 (resolved live, always current) | +1 | +1 | −1 (a newly-added resource is invisible until something re-runs the materialization — the exact problem this feature exists to solve) |
| Matches the actual request ("all printers, full stop") (4) | +1 | 0 (solves a narrower-port case nobody asked for) | 0 (solves an exclusion case nobody asked for) | +1 |
| Weighted total | 2 | −2 | −3 | −1 |
RuleBuilder/MyAccessResource/RdpGrantService — but it loses on the one criterion the whole feature exists for: a resource created after the type-grant needs an event hook (on ResourceService.create) to backfill a grant row, and a symmetric hook on delete to clean one up. Miss that hook once (a bulk import path, a future resource-creation route) and the type-grant silently stops working for resources created through it — resolving live against the current DB state, as A does, cannot go stale by construction.RuleBuilder.java, MyAccessResource.java, RdpGrantService.java, ConfigService.java — each already had exactly one place where concrete grants are resolved, extended rather than restructured.AclTypeGrantResource (/api/v1/acl/type-grants), and a new small UI panel in AclMatrixView.js, separate from the matrix's batch-apply/dirty-state flow.RdpGrantService.hasTypeGrant mirrors the existing hasGrant query's user_roles-only join, which does not account for auto_all (Everyone) role membership — an existing, unrelated gap in the RDP-gate path (MyAccessResource's own grant resolution already unions in auto_all roles; RdpGrantService's does not, seemingly always). Deliberately not fixed here to keep this ADR's diff scoped to the type-grant feature; flagged for a separate look.RoleResourceTypeGrant.java, AclTypeGrantResource.java, RuleBuilder.java, MyAccessResource.java, RdpGrantService.java — the implementation.