Skip to content

Surface object-group membership and reverse policy lookup in the UI - #82

Merged
ianmuchyri merged 2 commits into
edgefrom
atom-app-object-group-policy-ui
Aug 17, 2026
Merged

Surface object-group membership and reverse policy lookup in the UI#82
ianmuchyri merged 2 commits into
edgefrom
atom-app-object-group-policy-ui

Conversation

@ianmuchyri

Copy link
Copy Markdown
Contributor

ATOM-04 (many-to-many object-group membership) and ATOM-03 (directPolicies(objectId:) reverse lookup) both shipped backend-only. The admin panel was never updated, so object groups had no membership UI at all and there was no way to ask who an entity or resource is shared with. This is frontend-only (app/) and builds against the schema as it already exists on edge — no resolver, migration, or test changes under src/.

1. Object-group membership panel

Object groups now get a Members panel in the inspect sheet, added alongside the principal-group branch rather than replacing it (groupType === "object" vs "principal").

There is no dedicated "members of this object group" query and none was added — the panel reads the list filters ATOM-04 made work, entities(parentGroupId:) and resources(parentGroupId:), under an Entities/Resources tab pair. Each tab paginates the current members, gives a per-row remove, and has a tenant-scoped search-to-add picker (q is served by the backend, so the picker filters server-side).

StatusBadge is rendered only for entities: Resource has no status field in the schema, and its name is nullable, so resource rows fall back to the id.

2. Object groups from the entity/resource side

EntityInspectDetails and ResourceInspectDetails gained an Object groups section: removable chips for each group the object belongs to, plus an "add to group" picker.

  • Membership is seeded from the objectGroupIds the entity/resource list query already selects, so opening the sheet costs no extra round trip.
  • Group ids resolve to names through the existing useNameMap in lib/reconcile, which is the generalised form of the resolution pattern in policy-inspect-details.tsx.
  • The picker is sourced from objectGroups(...), not the generic groups(...), so principal groups can never show up there.
  • clearEntityObjectGroups / clearResourceObjectGroups are exposed as a separate, clearly-labelled destructive action behind an AlertDialog, not folded into single-remove. It lives on this side of the relationship because clearing is per-member, not per-group.
  • Platform-scoped (tenant-less) objects get an explanation instead of a picker — the server rejects grouping them.

Both directions share app/lib/object-groups/ (documents + a mutation hook), so the two views cannot drift apart. The mutations return the mutated Entity/Resource, so the response's objectGroupIds seeds the query cache directly rather than triggering a refetch, and the group-side member lists are invalidated at the same time.

Adds and removes are idempotent server-side (ON CONFLICT ... DO NOTHING for inserts, zero-row deletes on the way out), so there is deliberately no client-side "already a member" guard that could go stale.

3. Reverse policy lookup — "Access" tab

Entities and resources gained a read-only Access tab backed by directPolicies(objectId:, objectKind:, ...), alongside the existing Details / Audit Logs tabs.

Per the resolver's own doc comment, this lists only the direct policies whose permission block names the object — by id, by object group, or through a group hierarchy scope. Role-derived access and blocks that reach the object without naming it (platform, tenant, objectKind, objectType) are excluded by the server, so the tab is presented as "who is this shared with", not effective access. Each row states why the policy reaches this object (directly, via a named group, via a group hierarchy scope), resolves the subject id to a name for entity and group subjects, and shows effect plus granted actions.

objectKind is sent as the lowercase "entity" / "resource" that parse_object_kind accepts. The tab is read-only by design — creating a grant still goes through the Policies page's create form.

Checks

pnpm --dir app lint, pnpm --dir app test, and pnpm --dir app build all pass locally. Test count went from 58 to 97: the shared membership module and the direct-policy display helpers are unit-tested, and both new panels have component tests covering the mutation variables actually sent.

ATOM-04 (many-to-many object-group membership) and ATOM-03
(directPolicies(objectId:) reverse lookup) both shipped backend-only.
The admin panel never grew a way to reach either, so object groups had
no membership UI at all and there was no way to ask who an entity or
resource is shared with. This closes that gap against the existing
schema; no resolver changes.

Object groups get a Members panel in the inspect sheet, mirroring the
principal-group branch rather than replacing it. There is no "members
of this object group" query, so it reads the list filters that ATOM-04
made work — entities(parentGroupId:) and resources(parentGroupId:) —
under an Entities/Resources tab pair, each with a tenant-scoped
search-to-add picker and a per-row remove.

The same membership is editable from the other side: entity and
resource details now carry an Object groups section built from the
objectGroupIds the list query already selects, so opening the sheet
costs no extra round trip. Names resolve through the existing
useNameMap. Groups are picked from objectGroups(...) rather than
groups(...) so principal groups can never appear. clearEntityObjectGroups
/ clearResourceObjectGroups are exposed separately, behind a
confirmation, since they drop every membership at once.

Both directions share lib/object-groups: one set of documents and one
mutation hook, so the two views cannot drift. The mutations return the
mutated object, so its objectGroupIds seed the cache directly instead
of forcing a refetch. Adds and removes are idempotent server-side
(ON CONFLICT DO NOTHING, zero-row delete), so there is deliberately no
client-side "already a member" guard to go stale.

Entities and resources also gain a read-only Access tab over
directPolicies(objectId:, objectKind:). Per the resolver's own doc
comment this lists only policies whose permission block names the
object — role-derived access and platform/tenant/kind-wide grants are
excluded — so each row says why it reaches this object. Creating a
grant still belongs to the Policies page.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
@ianmuchyri
ianmuchyri merged commit b119f87 into edge Aug 17, 2026
@ianmuchyri
ianmuchyri deleted the atom-app-object-group-policy-ui branch August 17, 2026 05:28
drasko pushed a commit that referenced this pull request Aug 17, 2026
* Add attributesContains filter to entities and groups queries (#63)

* Add objectId reverse lookup to directPolicies query (#64)

* Make object group membership many-to-many (#65)

* Add external_id to entities, unique per tenant (#66)

* Expose scoping filters on authorizedObjectIds (#68)

* Fix blank externalId filter and no-op object-group removal events (#77)

* Keep a blank externalId filter distinct from an omitted one

normalize_external_id collapsed both "filter omitted" and "filter
explicitly blank/whitespace" to None. The SQL callers bind that as
`col IS NULL OR col = $n`, so a caller who passed a blank externalId
filter (a mistake, or an attempt to match nothing) got back the full
unfiltered authorized/listed set instead of zero rows.

Every Some(_) input now stays Some(_): blank, NUL-bearing, and
over-cap values normalize to a sentinel longer than
MAX_EXTERNAL_ID_LEN, which the length CHECK constraint guarantees no
stored row can equal. Only a genuinely omitted None still means "no
filter".

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Suppress object-group membership events on no-op removals

remove_entity_from_object_group, clear_entity_object_groups, and
their resource counterparts discarded the DELETE's affected-row
count and always published entity.object_group.remove /
entity.object_groups.clear (and the resource equivalents), even when
zero rows were deleted — e.g. removing a membership that never
existed, or clearing an already-empty set. Event consumers were told
a change happened when nothing changed.

delete_entity_object_groups_in_tx and
delete_resource_object_groups_in_tx now return the deleted row
count, and the four *_with_audit callers skip the event publish (but
still commit) when it's zero — the same shape already used by
add_entity_to_object_group_with_audit / add_resource_to_object_group_with_audit
for the additive side, and by remove_group_member_with_audit for
principal-group membership.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Surface object-group membership and reverse policy lookup in the UI (#82)

* Surface object-group membership and reverse policy lookup in the UI

ATOM-04 (many-to-many object-group membership) and ATOM-03
(directPolicies(objectId:) reverse lookup) both shipped backend-only.
The admin panel never grew a way to reach either, so object groups had
no membership UI at all and there was no way to ask who an entity or
resource is shared with. This closes that gap against the existing
schema; no resolver changes.

Object groups get a Members panel in the inspect sheet, mirroring the
principal-group branch rather than replacing it. There is no "members
of this object group" query, so it reads the list filters that ATOM-04
made work — entities(parentGroupId:) and resources(parentGroupId:) —
under an Entities/Resources tab pair, each with a tenant-scoped
search-to-add picker and a per-row remove.

The same membership is editable from the other side: entity and
resource details now carry an Object groups section built from the
objectGroupIds the list query already selects, so opening the sheet
costs no extra round trip. Names resolve through the existing
useNameMap. Groups are picked from objectGroups(...) rather than
groups(...) so principal groups can never appear. clearEntityObjectGroups
/ clearResourceObjectGroups are exposed separately, behind a
confirmation, since they drop every membership at once.

Both directions share lib/object-groups: one set of documents and one
mutation hook, so the two views cannot drift. The mutations return the
mutated object, so its objectGroupIds seed the cache directly instead
of forcing a refetch. Adds and removes are idempotent server-side
(ON CONFLICT DO NOTHING, zero-row delete), so there is deliberately no
client-side "already a member" guard to go stale.

Entities and resources also gain a read-only Access tab over
directPolicies(objectId:, objectKind:). Per the resolver's own doc
comment this lists only policies whose permission block names the
object — role-derived access and platform/tenant/kind-wide grants are
excluded — so each row says why it reaches this object. Creating a
grant still belongs to the Policies page.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* fix(ui): hide object group add picker without tenant scope

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Hide already-joined items from object-group add pickers (#84)

* Hide already-joined items from object-group add pickers

The four search-to-add pickers added with the object-group membership UI
offered every candidate in the tenant, including the ones already joined.
Adding one of those is a server-side no-op, so the picker showed an Add
button that changed nothing and still reported success — and nothing in
the row said the item was already a member.

Both directions now filter on `objectGroupIds`: the entity/resource side
already holds its own membership set, and the group-side candidates query
selects each candidate's set, so neither picker needs an extra round trip.
The candidate cache is invalidated alongside the members cache, since an
add or remove is exactly what makes those rows stale. When filtering
empties a non-empty result, the picker says every match is already joined
rather than claiming there is nothing to find.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Fix object group picker pagination for joined members

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Fix object group picker pagination for joined groups

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Fix pagination footer ignoring the active list filter (#85)

The CRUD list pages never sent the search box term to the backend. Every
list query fetched an unfiltered page and `DataTable` narrowed the rows in
the browser, so the footer printed the unfiltered `total` next to a handful
of visible rows — "350 rows... Page 1 of 18" above a single matching entity.
Paging was broken the same way: the search only ever matched rows that
happened to be on the page already on screen.

Forward the search term as `q` on the list queries whose resolver accepts it
(tenants, entities, groups, resources, roles) so the backend narrows the rows
and `total` together and the filtered set pages properly. Tell the table which
filters were resolved server-side; it skips re-filtering those and, for the
resources with no backend text search, counts the rows it filtered itself
rather than reporting a total that no longer describes them.

Also clamp the displayed page to the available range so a collapsed result
set cannot render "Page 7 of 1".

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant