Surface object-group membership and reverse policy lookup in the UI - #82
Merged
Conversation
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>
This was referenced Aug 14, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onedge— no resolver, migration, or test changes undersrc/.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:)andresources(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 (qis served by the backend, so the picker filters server-side).StatusBadgeis rendered only for entities:Resourcehas nostatusfield in the schema, and itsnameis nullable, so resource rows fall back to the id.2. Object groups from the entity/resource side
EntityInspectDetailsandResourceInspectDetailsgained an Object groups section: removable chips for each group the object belongs to, plus an "add to group" picker.objectGroupIdsthe entity/resource list query already selects, so opening the sheet costs no extra round trip.useNameMapinlib/reconcile, which is the generalised form of the resolution pattern inpolicy-inspect-details.tsx.objectGroups(...), not the genericgroups(...), so principal groups can never show up there.clearEntityObjectGroups/clearResourceObjectGroupsare exposed as a separate, clearly-labelled destructive action behind anAlertDialog, not folded into single-remove. It lives on this side of the relationship because clearing is per-member, not per-group.Both directions share
app/lib/object-groups/(documents + a mutation hook), so the two views cannot drift apart. The mutations return the mutatedEntity/Resource, so the response'sobjectGroupIdsseeds 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 NOTHINGfor 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.
objectKindis sent as the lowercase"entity"/"resource"thatparse_object_kindaccepts. 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, andpnpm --dir app buildall 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.