Scoped object visibility
Overview
Extend the existing device-group / user-group RBAC scope (issue #7, V1) to the four shared object types — actions, action-sets, definitions, and compliance policies — which today are visible org-wide to every operator regardless of scope. After this change a scope-restricted admin sees and manages only the objects assigned within their scope, on the same principle already enforced for devices and users: a scoped admin only has access to their own devices, their own users, and the objects that run on them.
As-built note (2026-06-25)
The implementation refined the index strategy for full freshness with no new cascade (see ADR 0024):
- The search index field
scope_group_idscarries the object's DIRECT device-/user-group target assignment ids — not effective groups, and not device/user targets resolved to groups. A direct group-target edge changes only on the object's own assignment events, which already reindex it, so the field can never go stale-high (the leak failure mode). No projection table, no container cascade, no membership cascade. - Transitivity (effective read) and device/user→group resolution are resolved live in the handler —
Getwalks containers (effective);Getand mutations resolve device/user targets through membership. Single-object lookups, always fresh. - Consequence: a scope-restricted Search under-shows objects that are only transitively in scope or assigned to an individual device/user — they stay readable via
Get. Fail-closed (sees less, never more); the dominant group-assignment case is exact. Full effective-search is a deferred refinement. - A related broader leak was discovered and closed in the same workstream: the
SearchRPC applied no device/user scope, so the device/user list pages (which useSearch) leaked the whole org to a scoped admin. Nowidx:devices/idx:userscarryscope_group_ids(the entity's group memberships) and Search confines on the caller'sListDevices/ListUsersscope. To keep it fresh — including for dynamic groups — without a ticker, the dynamic-group evaluator was reworked to be event-driven: it emits a*GroupMembersReevaluateddelta event (instead of writing membership directly), a projector applies it (the event is the source of truth → no audit/state drift, and a rebuild replays it), and the search listener reindexes the affected devices/users. See ADR 0024.
Motivation
Scope enforcement (#7 / ADR 0006) already confines a scoped admin to their device/user groups for ~61 permissions — devices, users, groups, dispatch, terminal, lists. But every action/set/definition/compliance-policy permission is TargetUnspecified (not scopable), so a device-group-scoped operator can List and Get the entire org catalog: every action's ShellParams script and FileParams contents, every LUKS operation, every definition, and every compliance policy (the org's complete security posture). That is information disclosure past the operator's scope — a V1 security gap, not a V2 nicety. This spec closes it. Promotes the "group-keyed first-class scope match" item from #7's V2 list to V1.
Definitions
- Caller scope — the union of device-group and user-group ids the caller's scoped grants confine them to. For the object scopes there is no per-object-permission scope tier; the confinement reuses the device-/user-group scope the caller already holds from their grants (an admin scoped to device group X for device permissions is also confined to X's objects). A caller holding an unscoped base grant for the object permission is unrestricted (sees/does everything, as today). Caller scope is read from the JWT-backed request context (
UserContext.ScopedGrants), never from a per-request DB lookup — see criterion 13. - Direct scope groups of object O — the groups derived from O's own assignments (
Assignment.source_id == O):DEVICE_GROUP/USER_GROUPtargets directly, plus the groups of anyDEVICE/USERtarget resolved through membership. - Effective scope groups of object O —
direct(O)plus, for read only, the direct scope groups of any container that includes O and is itself assigned: for an action, every set and definition that contains it; for a set, every definition that contains it. (An action that runs on group X's devices because a definition assigned to X contains it is visible to X.)
Read uses effective scope groups; write uses direct scope groups.
Acceptance criteria
- Given a scope-restricted caller and a
Searchon theACTIONS,ACTION_SETS,DEFINITIONS, orCOMPLIANCE_POLICIESscope, when it runs, then only objects whose effective scope groups intersect the caller's scope groups are returned — and the total count reflects the same filter (no out-of-scope count leak). - Given an unrestricted caller, when they
Search/Listany of the four scopes, then they see every object (no regression). - Given a scope-restricted caller and an object with no effective scope groups (unassigned), when they
Search/Getit, then it is not returned / returnsNotFound(fail-closed — unassigned objects are managed only by unrestricted admins). - Given a definition assigned to group X and a caller scoped to X, when they
Search/Getthe definition's member sets and those sets' actions, then they are visible even though only the definition is directly assigned (transitive read). - Given a scope-restricted caller and
GetAction/GetActionSet/GetDefinition/GetCompliancePolicyfor an object outside their effective scope, when it runs, then it returnsNotFoundto the client (neverPermissionDenied— no existence leak), and the server logs the real reason at WARN (e.g. "out-of-scope object access denied", with caller id, object id, and the caller's scope group ids) so the denial is observable to operators even though the client sees onlyNotFound. Consistent with the device handlers. - Given a scope-restricted caller and a mutating RPC (
Rename*,Update*,Delete*,Add*ToSet/Remove*,AddActionSetToDefinition/Remove*,Add/Remove/UpdateCompliancePolicyRule,Reorder*) on an object whose direct scope groups do not intersect their scope, when it runs, then it returnsPermissionDenied— including the case where the object is only transitively in scope (e.g. an action visible because its set is assigned to the caller is not editable by the caller). - Given a scope-restricted caller and a mutating RPC on an object directly in their scope, when it runs, then it succeeds.
- Given an object assigned to a
DEVICE(orUSER) that is a member of the caller's scope group, when the callerSearch/Gets it, then it is in scope (device/user → group membership is resolved). - Given the four scope indexes, when read, then each carries
assigned_group_ids(multi-value TAG) equal to the object's effective scope group ids; assigning/unassigning the object (or any container), and adding/removing the target device/user from a group, updates the TAG. - Given a scope-restricted caller, when the
Searchhandler builds the query for the four scopes, then it appends@assigned_group_ids:{<caller scope group ids>}; an unrestricted caller adds no such clause. - Given the index schema-version constant is bumped, when the indexer boots, then
Index.Rebuildrepopulatesassigned_group_idsover existing data. - Given the four object Search scopes, a self-discovering parity test fails the build if any of them lacks the scope filter wiring (matches-zero guard), mirroring
scope_enforcement_parity_test.go. - Given a request, when the handler resolves the caller's scope groups for the object filter, then it reads them only from the JWT-backed request context (
UserContext.ScopedGrants, populated by the auth interceptor from thesgrantsclaim) and issues no database query to determine caller scope. TheSearchslice is computed by intersecting the caller's JWT scope groups with each object's pre-projectedassigned_group_idsindex field — both sides are already in hand (JWT + index), so a scopedSearchadds zero round-trips over an unscoped one. - Given the web role-assignment flow (users → roles, user-groups → roles), when the operator selects role(s), then the scope picker is offered for any role whose permissions are scopable — driven by each permission's
PermissionInfo.target_kindfromListPermissions, not a hardcoded permission allowlist — and offers a device-group picker forDEVICE-kind roles and a user-group picker forUSER-kind roles. A role mixing kinds (or any non-scopable permission) offers no scope (unscoped grant), preserving the paired-or-neitherscope_kind/scope_idcontract. - Given the web scope picker, when the operator assigns a role with a chosen device-group/user-group scope, then
AssignRoleToUser/AssignRoleToUserGroupis called with the matchingscope_kind+scope_id; when no scope is chosen (or the role is non-scopable), both fields are absent.
Out of scope
:assigned-tier composition with a device-group-scoped base grant (#7's remaining V2 item — an edge combination, narrowing-only, not a leak).- New
:assignedtiers for object types (objects are scoped by group assignment, not per-user ownership). - Changing the device/user scope enforcement, which already ships (#7 V1).
- A "shared object is read-only / exclusive-ownership" write model — rejected in design: because an admin can only assign to groups they administer, the visible/manageable set is self-bounded and peer-group sharing is acceptable.
- The object list-page rendering itself — those pages already consume
Searchand need no change (the scoping is server-side). The web work in this spec is the role-assignment scope picker (criteria 14–15), not the list pages.
Technical design
Affected packages
server/internal/search/index.go— addassigned_group_idsTAG toidx:actions,idx:action_sets,idx:definitions,idx:compliance_policies; bump the schema-version constant.server/internal/search(indexer/projection) — populateassigned_group_ids(effective scope groups) per object; recompute onAssignment*, device/user group-membership, and set/definition-membership events.server/internal/api/search_handler.go— for the four scopes, when the caller is scope-restricted, append@assigned_group_ids:{…}.server/internal/auth— anObjectScopeListFilter(ctx)helper returning(scopeGroupIDs, restricted)(the union of the caller's device- and user-group scope ids), analogous toDeviceScopeListFilter. It readsUserContext.ScopedGrantsfrom context only — the grants already arrived in the JWTsgrantsclaim, so no DB query (criterion 13).server/internal/api/{action,action_set,definition,compliance_policy}_handler.go—Get*enforces effective-scope membership →NotFound; mutating handlers enforce direct-scope membership →PermissionDenied.server/internal/store— query exposing an object's direct and effective scope groups (for the write check and the projection).web/src/lib/components/device-group-scope-picker.svelte(+ a sibling user-group picker, or a generalisedrole-scope-picker.svelte) and the two role-assignment flowsweb/src/routes/(app)/users/[id]/+page.svelteandweb/src/routes/(app)/user-groups/[id]/+page.svelte— replace the hardcodedTTY_PERMISSIONSallowlist (showScopePickerderivation) with scopability derived fromListPermissions().target_kind, and add user-group scoping forUSER-kind roles (criteria 14–15). The picker is currently device-group-only and TTY-only; both limits go away.
Web design notes
- The server already surfaces
PermissionInfo.target_kindviaListPermissions(UNSPECIFIED = not scopable, DEVICE → device-group scope, USER → user-group scope) precisely so the web can gate the picker. The web caches apermissionKey → target_kindmap fromListPermissionsand computes a role's scopability as: every permission scopable and all of the same kind → offer that kind's group picker; otherwise no scope. RoleGrantScopeKind(UNSPECIFIED / DEVICE_GROUP / USER_GROUP) and the paired-or-neitherscope_kind/scope_idcontract already exist onAssignRoleToUser{,Group}— no proto or RPC change for the web work.
Proto changes
None. Scope is derived server-side from the caller's JWT grants; SearchRequest already carries the request and the caller's scope rides the JWT, not the body. No request field is added (a client cannot widen its own scope). The web work reuses the existing target_kind metadata and scope_kind/scope_id grant fields.
Database changes
- An object→scope-groups projection (or extension of the assignment projection) maintaining direct and effective scope group sets per object id, rebuilt from
Assignment*+ group-membership + set/definition-membership events. The indexer reads it to populateassigned_group_ids; the mutation handlers read the direct set. - No new event types — derived entirely from existing assignment/membership events. Migration adds the projection table + its trigger/backfill.
New dependencies
None.
Security considerations
- Authorization. Extends fail-closed scope confinement to the four object types. Default (unassigned / no effective groups) → invisible to scoped callers. Read uses effective groups; write uses direct groups so transitive visibility never grants mutation (editing a set never implies editing its actions).
- No existence leak, but observable. Out-of-scope
Get*returnsNotFound, notPermissionDenied— same contract as the device/user handlers — while the server logs the true reason at WARN (out-of-scope access, caller id, object id, scope ids). The operator can see denials; the client cannot infer existence (criterion 5). - No self-widening. Scope comes from the caller's JWT grants, never from the request; a client cannot request a wider
assigned_group_ids. Reading caller scope from the signed token (not a mutable per-request lookup) keeps the slice tamper-proof and avoids a confused-deputy DB read (criterion 13). - Count honesty. The scoped filter drives the search total so pagination never reveals the out-of-scope object count.
- Secrets. None handled. (The point of the feature is to stop scoped admins reading object payloads — scripts, file contents — that are outside their scope.)
- Audit. Read paths are not audited (unchanged); the mutating RPCs are already audit-logged.
Test requirements
Handler tests (real Postgres + real handler)
Get{Action,ActionSet,Definition,CompliancePolicy}: in-scope → returned; out-of-scope →NotFound; unassigned →NotFoundfor scoped, returned for unrestricted.Search(each of the four scopes): scoped caller → only effective-in-scope objects + honest total; unrestricted → all.- Transitive read: definition assigned to X; scoped-X caller sees its member sets and their actions via
Search/Get. - Mutating RPCs (the full set in criterion 6): directly-in-scope → success; out-of-scope →
PermissionDenied; transitive-only (action visible via an assigned set, not directly assigned) →PermissionDenied. - Device/user membership: object assigned to a device in X → in scope of scoped-X caller.
Integration tests (testcontainer Postgres + valkey-search)
End-to-end: assign objects across two device groups with two scoped admins; assert each admin's
Searchreturns only their set and neither sees the other's; assertassigned_group_idsis populated and updates when an assignment or a group membership changes.Rebuild: bump schema version, boot indexer, assert
assigned_group_idsbackfills.No-round-trip: a
Searchhandler test asserts the caller's scope is taken from the context grants (set up viaWithUser(..., ScopedGrants: …)) and that no store/DB call is made to resolve caller scope — exercised with a store seam that fails the test if queried for caller-scope resolution (criterion 13).
Self-discovering / parity
- A test that enumerates the four object Search scopes and fails if any lacks the scope-filter wiring (matches-zero guard).
- A self-discovering web/permissions test (or a server-fed assertion) that the scope picker's scopability is driven by
target_kindand not a hardcoded permission list — i.e. there is noTTY_PERMISSIONS-style allowlist left in the role-assignment flow (grep-guard or a derived-from-ListPermissionsunit assertion).
Web tests (Playwright, behavioral — web/tests/e2e)
- Selecting a scopable
DEVICE-kind role shows the device-group picker; choosing a group and assigning sendsAssignRoleToUserwithscopeKind=DEVICE_GROUP+ the chosenscopeId(asserted via captured RPC,recordRpc). - Selecting a scopable
USER-kind role shows the user-group picker and sendsscopeKind=USER_GROUP. - Selecting a non-scopable role (or a mixed-kind selection) shows no picker and sends absent
scope_kind/scope_id. - Same three on the user-groups → roles flow.
Rejection paths
| Scenario | Error code | Client-visible message | Logged context |
|---|---|---|---|
Scoped caller Get* an object outside effective scope | NotFound | "<object> not found" | caller id, object id, scope group ids |
Scoped caller Get* an unassigned object | NotFound | "<object> not found" | caller id, object id |
Scoped caller Search (out-of-scope objects) | — (no error) | absent from results; total excludes them | scope group ids applied |
| Scoped caller mutates an out-of-scope object | PermissionDenied | "permission denied" | caller id, object id, direct scope ids |
| Scoped caller mutates a transitive-only object (visible, not directly assigned) | PermissionDenied | "permission denied" | caller id, object id |
| Caller with only wrong-kind scope (e.g. user-group scope, device-only object) | NotFound (read) / PermissionDenied (write) | as above | caller id, object id |
Rollout and migration
- Migration adds the object→scope-groups projection + backfill; the search schema-version bump triggers an index rebuild on indexer boot (criterion 11).
- Backward-compatible: unrestricted admins are unaffected; only callers that already hold scoped grants gain the additional confinement. No feature flag — it's a security fix and should apply on upgrade.
- No web change required; the list pages already render whatever
Searchreturns.
References
- server#7 — Scoped Access (device-group RBAC) — V1 device/user scope; this promotes its object-visibility item to V1.
- ADR 0006 — scope enforcement, handler-level, uniform (
server/docs/adr/0006-scope-enforcement-handler-level-uniform.md). - Spec 13 — List-page sort & filter via valkey-search — the
Searchindex/handler this builds on.