From 0dd2c15704253fd6ef1face82d3809e73e58dc36 Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Tue, 4 Aug 2026 12:24:38 +0000 Subject: [PATCH 01/14] [Feature] Collapse managed resources under business roles in the matrix (#370) --- app/ui/e2e/matrix.spec.js | 123 +++++++++ app/ui/src/components/MatrixView.jsx | 74 ++++-- .../src/components/MatrixView.mount.test.jsx | 52 ++++ .../src/components/matrix/MatrixGroupRow.jsx | 55 ++++ .../matrix/MatrixGroupRow.mount.test.jsx | 83 ++++++ .../src/components/matrix/MatrixToolbar.jsx | 27 ++ .../components/matrix/SortableMatrixBody.jsx | 9 + app/ui/src/hooks/useBusinessRoleFold.js | 185 +++++++++++++ app/ui/src/hooks/useBusinessRoleFold.test.jsx | 250 ++++++++++++++++++ app/ui/src/hooks/useMatrixRowOrder.js | 2 +- app/ui/src/hooks/useMatrixRowOrder.test.js | 2 +- changes/dor-issue-370.md | 5 + docs/architecture/matrix.md | 38 +++ 13 files changed, 882 insertions(+), 23 deletions(-) create mode 100644 app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx create mode 100644 app/ui/src/hooks/useBusinessRoleFold.js create mode 100644 app/ui/src/hooks/useBusinessRoleFold.test.jsx create mode 100644 changes/dor-issue-370.md diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index 570f34975..30714f6d5 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -112,6 +112,129 @@ test.describe('Matrix View', () => { }); }); +// ─── Folding a business role's resources away (#370) ────────────────────────── +// +// A business role row can hide the rows of the resources it grants, so the grid +// reduces to "business roles + resources no role covers". Fold state is pure +// view state and sticks per matrix. These run against the demo dataset; when it +// holds no business role that grants a visible resource there is no fold +// affordance at all (the zero case), and the tests skip rather than fail. +test.describe('Matrix — fold business-role resources', () => { + test.setTimeout(90000); + + const ALL_DATA_FILTER = { + rowType: 'principal', + orientation: 'rows-as-resources', + subject: { include: [], exclude: [] }, + resource: { include: [], exclude: [] }, + }; + const MATRIX_URL = '/#matrix?filter=' + encodeURIComponent(JSON.stringify(ALL_DATA_FILTER)); + + // Open the all-data matrix. Returns false when no grid renders (no data here). + async function openGrid(page) { + await page.goto(MATRIX_URL); + await page.waitForLoadState('networkidle'); + try { + await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); + } catch { + return false; + } + await page.waitForTimeout(1000); // let the virtualizer settle + return true; + } + + const foldAll = (page) => page.getByRole('button', { name: 'Fold roles', exact: true }); + const unfoldAll = (page) => page.getByRole('button', { name: 'Unfold roles', exact: true }); + + // Total height of the (virtualised) row list — it shrinks when rows fold away. + const rowsHeight = (page) => page.evaluate(() => { + const tbody = document.querySelector('table tbody'); + return tbody ? Math.round(tbody.getBoundingClientRect().height) : 0; + }); + + // Value shown next to a scope-statistics label ("Resources", "Assignments"). + const statValue = (page, label) => + page.locator('span').filter({ hasText: new RegExp(`^${label}$`) }).first() + .locator('xpath=preceding-sibling::span[1]').innerText(); + + async function openFoldableGrid(page) { + const rendered = await openGrid(page); + test.skip(!rendered, 'matrix grid did not render (no data) — cannot exercise the fold'); + const foldable = await foldAll(page).count(); + test.skip(foldable === 0, 'no business role grants a visible resource in this dataset'); + } + + test('"Fold roles" hides the resources roles grant, "Unfold roles" restores them', async ({ page }) => { + await openFoldableGrid(page); + + const before = await rowsHeight(page); + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + await expect.poll(() => rowsHeight(page)).toBeLessThan(before); + // The folded roles say how many rows they took with them. + await expect(page.getByText(/\d+ resources? folded/).first()).toBeVisible(); + + await unfoldAll(page).click(); + await expect.poll(() => rowsHeight(page)).toBe(before); + await expect(unfoldAll(page)).toHaveCount(0); + }); + + test('a per-role chevron folds only that role, and is labelled for screen readers', async ({ page }) => { + await openFoldableGrid(page); + + const chevron = page.getByRole('button', { name: 'Fold business role resources' }).first(); + await expect(chevron).toBeVisible(); + + const before = await rowsHeight(page); + await chevron.click(); + await expect.poll(() => rowsHeight(page)).toBeLessThan(before); + // The same control now offers the reverse action. + const unfoldOne = page.getByRole('button', { name: 'Unfold business role resources' }).first(); + await expect(unfoldOne).toBeVisible(); + await unfoldOne.click(); + await expect.poll(() => rowsHeight(page)).toBe(before); + }); + + test('folding changes no number in the scope-statistics panel', async ({ page }) => { + await openFoldableGrid(page); + + const before = { + resources: await statValue(page, 'Resources'), + assignments: await statValue(page, 'Assignments'), + }; + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + + expect(await statValue(page, 'Resources')).toBe(before.resources); + expect(await statValue(page, 'Assignments')).toBe(before.assignments); + }); + + test('fold state is restored when the same matrix is re-opened', async ({ page }) => { + await openFoldableGrid(page); + + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + const folded = await rowsHeight(page); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); + await expect(unfoldAll(page)).toBeVisible({ timeout: 20000 }); + await expect.poll(() => rowsHeight(page)).toBe(folded); + + // A different matrix slice keeps its own (expanded) state. + const other = { ...ALL_DATA_FILTER, rowType: 'identity' }; + await page.goto('/#matrix?filter=' + encodeURIComponent(JSON.stringify(other))); + await page.waitForLoadState('networkidle'); + await expect(unfoldAll(page)).toHaveCount(0); + + // Leave the browser profile clean for the next test. + await page.goto(MATRIX_URL); + await page.waitForLoadState('networkidle'); + if (await unfoldAll(page).count()) await unfoldAll(page).click(); + }); +}); + // ─── Regression: no double scrollbar behind the matrix grid ──────────────────── // // The bug: the grid's height was a fixed max-h-[calc(100vh-280px)] that guessed diff --git a/app/ui/src/components/MatrixView.jsx b/app/ui/src/components/MatrixView.jsx index cd5153ca2..81578f9d2 100644 --- a/app/ui/src/components/MatrixView.jsx +++ b/app/ui/src/components/MatrixView.jsx @@ -7,6 +7,7 @@ const setStateReducer = (s, a) => (typeof a === 'function' ? a(s) : a); import { useAuth } from '@ui/auth/AuthGate'; import { useMatrixRowOrder } from '@ui/hooks/useMatrixRowOrder'; import { useNestedGroupExpand, MAX_NEST_LEVEL } from '@ui/hooks/useNestedGroupExpand'; +import { useBusinessRoleFold } from '@ui/hooks/useBusinessRoleFold'; import MatrixToolbar from './matrix/MatrixToolbar'; import MatrixLegend from './matrix/MatrixLegend'; import MatrixFilterSummary from './matrix/MatrixFilterSummary'; @@ -62,6 +63,20 @@ export const AGG_SENTINEL = '@@AGG@@'; // Above this many assignments, an 'auto' fold-on-load matrix opens folded. const FOLD_AUTO_THRESHOLD = 5000; +// The AP-staircase bucket a resource row falls into: the leftmost access-package +// column that grants it, or accessPackages.length ("unmanaged") when none does. +// Owner rows only match AP columns whose role is Owner. +function leftmostApBucket(group, accessPackages, apGroupMap) { + const gidUpper = (group.realGroupId || group.id).toUpperCase(); + const isOwnerRow = !!group.realGroupId; + for (let i = 0; i < accessPackages.length; i++) { + const role = apGroupMap.get(`${gidUpper}|${accessPackages[i].id.toLowerCase()}`); + if (!role) continue; + if (isOwnerRow === role.toLowerCase().includes('owner')) return i; + } + return accessPackages.length; +} + // Short label for a manager-hierarchy node name ("A · B · C (Manager)" → "C"). function orgShort(name) { const noMgr = String(name || '').replace(/\s*\([^)]*\)\s*$/, '').trim(); @@ -491,39 +506,33 @@ export default function MatrixView({ if (managedFilter === 'unmanaged') return groups; if (accessPackages.length === 0) return groups; // no APs, keep member count sort - // Assign each group to the AP bucket of its leftmost AP column + // Assign each group to the AP bucket of its leftmost AP column. A business + // role's OWN row is promoted into its own bucket so it sits directly above + // the resources it grants — folding is only coherent when a parent row is + // adjacent to the children it hides. const groupApBucket = new Map(); + const roleRowIds = new Set(); for (const g of groups) { - let bucket = accessPackages.length; // unmanaged = after all APs - const gidUpper = (g.realGroupId || g.id).toUpperCase(); // use realGroupId for owner rows - const isOwnerRow = !!g.realGroupId; - for (let i = 0; i < accessPackages.length; i++) { - const mapKey = `${gidUpper}|${accessPackages[i].id.toLowerCase()}`; - if (apGroupMap.has(mapKey)) { - // Owner rows only match AP buckets where the role is Owner - const role = apGroupMap.get(mapKey); - const roleIsOwner = (role || '').toLowerCase().includes('owner'); - if (isOwnerRow ? roleIsOwner : !roleIsOwner) { - bucket = i; - break; - } - } - } - groupApBucket.set(g.id, bucket); + const selfBucket = g.realGroupId ? undefined : apIdToIndex.get(g.id.toLowerCase()); + if (selfBucket != null) roleRowIds.add(g.id); + groupApBucket.set(g.id, selfBucket ?? leftmostApBucket(g, accessPackages, apGroupMap)); } return [...groups].sort((a, b) => { const aBucket = groupApBucket.get(a.id); const bBucket = groupApBucket.get(b.id); if (aBucket !== bBucket) return aBucket - bBucket; - // Same bucket: sort by type priority (Direct > Eligible > Indirect) + // Same bucket: the business role row itself comes first, above its resources + const aRole = roleRowIds.has(a.id); + if (aRole !== roleRowIds.has(b.id)) return aRole ? -1 : 1; + // Then by type priority (Direct > Eligible > Indirect) const directCmp = (b.directCount || 0) - (a.directCount || 0); if (directCmp !== 0) return directCmp; const eligibleCmp = (b.eligibleCount || 0) - (a.eligibleCount || 0); if (eligibleCmp !== 0) return eligibleCmp; return b.memberCount - a.memberCount; }); - }, [groups, accessPackages, apGroupMap, managedFilter]); + }, [groups, accessPackages, apGroupMap, apIdToIndex, managedFilter]); // Apply custom drag-row order on top of the default AP staircase sort. All // subject/resource selection happens through the filter wizard, so there @@ -651,6 +660,17 @@ export default function MatrixView({ }); }, [displayGroups, managedFilter, accessPackages, apGroupMap, users, managedApMap, displayMemberships]); + // ─── Business-role fold ───────────────────────────────────────── + // Folding a business role hides the rows of the resources it grants, so the + // grid can be reduced to "business roles + resources no role covers". Applied + // last in the row pipeline, so it composes with the All/Governed/Non-governed/ + // Gaps toggles and with the injected nested sub-rows. + const { + visibleRows: foldedGroups, + foldableRoles, foldedRoles, roleChildCounts, + toggleRoleFold, foldAllRoles, unfoldAllRoles, canFoldRoles, hasFoldedRoles, + } = useBusinessRoleFold({ accessPackageGroups, rows: visibleGroups, storageKey }); + // Lazy-load SortableMatrixBody (contains @dnd-kit + @tanstack/react-virtual) const [SortableBody, setSortableBody] = useState(null); useEffect(() => { @@ -958,6 +978,10 @@ export default function MatrixView({ isFolded={collapsedGroups.size > 0} onFoldAllColumns={foldAllColumns} onUnfoldAllColumns={unfoldAllColumns} + canFoldRoles={canFoldRoles} + hasFoldedRoles={hasFoldedRoles} + onFoldAllRoles={foldAllRoles} + onUnfoldAllRoles={unfoldAllRoles} /> {filterIsApplied && } @@ -984,7 +1008,7 @@ export default function MatrixView({ {SortableBody ? ( ) : ( {columnHeaders} - {visibleGroups.map(group => ( + {foldedGroups.map(group => ( ))} diff --git a/app/ui/src/components/MatrixView.mount.test.jsx b/app/ui/src/components/MatrixView.mount.test.jsx index d97d8cd6e..e5e3151bb 100644 --- a/app/ui/src/components/MatrixView.mount.test.jsx +++ b/app/ui/src/components/MatrixView.mount.test.jsx @@ -36,6 +36,28 @@ function makeData() { ]; } +// A matrix that contains a business role (br-1) granting one of the resource +// rows (res-1) — the shape the business-role fold operates on. res-2 is granted +// by no role, so it must survive every fold. +function makeRoleData() { + return [ + ...makeData(), + { memberId: 'u1', memberDisplayName: 'Alice Eng', department: 'Engineering', memberType: 'User', resourceId: 'br-1', resourceDisplayName: 'HR Manager Role', resourceType: 'BusinessRole', membershipType: 'Direct' }, + ]; +} +const roleProps = { + data: makeRoleData(), + accessPackageGroups: [ + { accessPackageId: 'br-1', accessPackageName: 'HR Manager Role', resourceId: 'res-1', roleName: 'Member', totalAssignments: 1 }, + ], + managedByPackages: [ + { resourceId: 'res-1', memberId: 'u1', accessPackageIds: ['br-1'] }, + ], +}; + +const rowLabels = () => + screen.queryAllByTestId('row-label').filter(el => el.isConnected).map(el => el.textContent); + const baseFilter = { rowType: 'user', subject: { include: [], exclude: [] }, @@ -227,6 +249,36 @@ describe('MatrixView (mounted)', () => { ); }); + it('folds a business role\'s resources away and back from the toolbar', async () => { + renderView(roleProps); + const user = userEvent.setup(); + await expectRowVisible('HR Manager Role'); + await expectRowVisible('Finance App'); + + await user.click(await screen.findByText('Fold roles')); + // Only the role row and the resource no role grants remain. + await waitFor(() => expect(rowLabels()).not.toContain('Finance App')); + expect(rowLabels()).toContain('HR Manager Role'); + expect(rowLabels()).toContain('HR Portal'); + + await user.click(await screen.findByText('Unfold roles')); + await expectRowVisible('Finance App'); + }); + + it('promotes the business-role row directly above the resources it grants', async () => { + renderView(roleProps); + await expectRowVisible('HR Manager Role'); + const labels = rowLabels(); + expect(labels.indexOf('HR Manager Role')).toBe(labels.indexOf('Finance App') - 1); + }); + + it('offers no fold controls in a matrix without business-role rows', async () => { + renderView(); + await expectRowVisible('Finance App'); + expect(screen.queryByText('Fold roles')).not.toBeInTheDocument(); + expect(screen.queryByText('Unfold roles')).not.toBeInTheDocument(); + }); + it('clears expanded nesting when the matrix filter changes (#674)', async () => { // Nested data is scoped to the resource filter, so a filter change must drop // any expansion (else stale nested rows from the old scope would linger). diff --git a/app/ui/src/components/matrix/MatrixGroupRow.jsx b/app/ui/src/components/matrix/MatrixGroupRow.jsx index 23dbe0f7f..3dfe7bbca 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.jsx @@ -14,6 +14,50 @@ function getRoleBadge(roleName) { return BADGE_DIRECT; } +// Fold affordance state for this row, or null when the row is not a foldable +// business role (only roles that are present in the grid AND grant at least one +// visible resource get one — see useBusinessRoleFold). +function roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }) { + const roleKey = String(group.realGroupId || group.id || '').toUpperCase(); + if (group.isNestedRow || !foldableRoles?.has(roleKey)) return null; + return { + roleKey, + folded: !!foldedRoles?.has(roleKey), + count: roleChildCounts?.get(roleKey) || 0, + }; +} + +// Chevron that folds a business role's resources away (and back). Rendered +// unconditionally so the row body stays branch-free; renders nothing for rows +// that aren't foldable business roles. +function RoleFoldToggle({ fold, onToggle }) { + if (!fold) return null; + const label = fold.folded ? 'Unfold business role resources' : 'Fold business role resources'; + return ( + + ); +} + +// "N resources folded" chip on a collapsed business role row. The row's own +// cells are untouched — folding hides rows, it never rolls access up. +function RoleFoldChip({ fold }) { + if (!fold?.folded || fold.count === 0) return null; + return ( + + {fold.count} resource{fold.count === 1 ? '' : 's'} folded + + ); +} + export default function MatrixGroupRow({ group, users, @@ -33,6 +77,11 @@ export default function MatrixGroupRow({ expandedGroups, onToggleExpand, loadingNested, + // Business-role fold props + foldableRoles, + foldedRoles, + roleChildCounts, + onToggleRoleFold, // Optional DnD props (provided by SortableRow wrapper) sortableRef, sortableStyle, @@ -48,6 +97,10 @@ export default function MatrixGroupRow({ const isExpanded = expandedGroups?.has(realGidForExpand); const isLoadingNested = loadingNested?.has(realGidForExpand); + // Business-role fold (never clashes with the nested-expand chevron above: a + // business role is not a principal, so it is never in groupsWithNested). + const roleFold = roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }); + const nestedBg = group.isNestedRow ? 'bg-gray-50/60 dark:bg-gray-700/40' : 'bg-white dark:bg-gray-800'; return ( @@ -71,6 +124,7 @@ export default function MatrixGroupRow({ title={group.displayName} >
+ {canExpand && (
+ diff --git a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx new file mode 100644 index 000000000..3600425b9 --- /dev/null +++ b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { createElement as h } from 'react'; +import MatrixGroupRow from './MatrixGroupRow'; +import { renderWithProviders, screen, userEvent } from '@ui/test-utils/renderWithProviders'; + +const users = [{ id: 'u1', displayName: 'Alice' }]; + +function renderRow(group, props = {}) { + const onToggleRoleFold = props.onToggleRoleFold || vi.fn(); + const result = renderWithProviders( + h('table', null, h('tbody', null, + h(MatrixGroupRow, { + group, + users, + totalUsers: users.length, + memberships: new Map(), + managedApMap: new Map(), + apIdToIndex: new Map(), + accessPackages: [], + apGroupMap: new Map(), + managedFilter: 'all', + foldableRoles: new Set(['BR1']), + foldedRoles: new Set(), + roleChildCounts: new Map([['BR1', 2]]), + ...props, + onToggleRoleFold, + }))), + ); + return { ...result, onToggleRoleFold }; +} + +const foldButton = () => screen.queryByRole('button', { name: /fold business role resources/i }); + +describe('MatrixGroupRow — business-role fold affordance', () => { + it('renders a labelled fold chevron on a foldable business-role row', () => { + renderRow({ id: 'BR1', displayName: 'HR Manager BR', groupType: 'BusinessRole', memberCount: 3 }); + expect(screen.getByRole('button', { name: 'Fold business role resources' })).toBeInTheDocument(); + // Nothing is folded yet, so no chip. + expect(screen.queryByText(/resources folded/i)).not.toBeInTheDocument(); + }); + + it('shows the folded-count chip and the unfold label when the role is folded', () => { + renderRow( + { id: 'BR1', displayName: 'HR Manager BR', groupType: 'BusinessRole', memberCount: 3 }, + { foldedRoles: new Set(['BR1']) }, + ); + expect(screen.getByRole('button', { name: 'Unfold business role resources' })).toBeInTheDocument(); + expect(screen.getByText('2 resources folded')).toBeInTheDocument(); + }); + + it('singularises the chip for a single folded resource', () => { + renderRow( + { id: 'BR1', displayName: 'HR Manager BR', memberCount: 1 }, + { foldedRoles: new Set(['BR1']), roleChildCounts: new Map([['BR1', 1]]) }, + ); + expect(screen.getByText('1 resource folded')).toBeInTheDocument(); + }); + + it('reports the role id to onToggleRoleFold when clicked', async () => { + const { onToggleRoleFold } = renderRow({ id: 'br1', displayName: 'HR Manager BR', memberCount: 1 }); + await userEvent.setup().click(screen.getByRole('button', { name: /fold business role resources/i })); + expect(onToggleRoleFold).toHaveBeenCalledWith('BR1'); + }); + + it('renders no fold chevron on a resource row that is not a foldable role', () => { + renderRow({ id: 'G1', displayName: 'Finance Group', memberCount: 2 }); + expect(foldButton()).toBeNull(); + }); + + it('renders no fold chevron on a nested sub-row', () => { + renderRow({ id: 'X__nested__BR1', realGroupId: 'BR1', displayName: 'HR Manager BR', isNestedRow: true, nestLevel: 1 }); + expect(foldButton()).toBeNull(); + }); + + it('renders nothing extra when no fold props are supplied at all', () => { + renderRow( + { id: 'BR1', displayName: 'HR Manager BR', memberCount: 1 }, + { foldableRoles: undefined, foldedRoles: undefined, roleChildCounts: undefined }, + ); + expect(foldButton()).toBeNull(); + }); +}); diff --git a/app/ui/src/components/matrix/MatrixToolbar.jsx b/app/ui/src/components/matrix/MatrixToolbar.jsx index fb6164564..979605a97 100644 --- a/app/ui/src/components/matrix/MatrixToolbar.jsx +++ b/app/ui/src/components/matrix/MatrixToolbar.jsx @@ -24,6 +24,10 @@ export default function MatrixToolbar({ isFolded = false, onFoldAllColumns, onUnfoldAllColumns, + canFoldRoles = false, + hasFoldedRoles = false, + onFoldAllRoles, + onUnfoldAllRoles, hideGaps = false, }) { const [copied, setCopied] = useState(false); @@ -141,6 +145,29 @@ export default function MatrixToolbar({ )} + {/* Fold the resources a business role grants into the role's own row */} + {canFoldRoles && ( + <> +
+ + {hasFoldedRoles && ( + + )} + + )} +
); } diff --git a/app/ui/src/components/matrix/SortableMatrixBody.jsx b/app/ui/src/components/matrix/SortableMatrixBody.jsx index 55e357451..8c34d273a 100644 --- a/app/ui/src/components/matrix/SortableMatrixBody.jsx +++ b/app/ui/src/components/matrix/SortableMatrixBody.jsx @@ -60,6 +60,11 @@ export default function SortableMatrixBody({ expandedGroups, onToggleExpand, loadingNested, + // Business-role fold props + foldableRoles, + foldedRoles, + roleChildCounts, + onToggleRoleFold, }) { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) @@ -109,6 +114,10 @@ export default function SortableMatrixBody({ expandedGroups, onToggleExpand, loadingNested, + foldableRoles, + foldedRoles, + roleChildCounts, + onToggleRoleFold, }; // Render a single row — nested rows are plain (not sortable), others are sortable diff --git a/app/ui/src/hooks/useBusinessRoleFold.js b/app/ui/src/hooks/useBusinessRoleFold.js new file mode 100644 index 000000000..ee86c1dd3 --- /dev/null +++ b/app/ui/src/hooks/useBusinessRoleFold.js @@ -0,0 +1,185 @@ +import { useState, useCallback, useMemo } from 'react'; + +// Folding a business role hides the rows of the resources that role grants (its +// `Contains` children), leaving only the role row itself. This is pure view +// state — the same tier as the column fold and the nested-group expand: it +// changes what is *rendered*, never what is fetched, counted or exported. +// +// The parent → child mapping is already modelled server-side +// (ResourceRelationships / relationshipType='Contains') and arrives in the +// matrix as the `accessPackageGroups` rows the SOLL columns are built from, so +// nothing is derived client-side that the data model doesn't already state. + +// Bump when the stored shape changes; older entries are discarded on read. +export const ROLE_FOLD_VERSION = 1; + +function foldStorageKey(matrixKey) { + return `fgraph-rolefold-${matrixKey || 'all'}`; +} + +// Read the folded-role ids saved for one matrix, discarding anything written by +// an older ROLE_FOLD_VERSION. Returns an empty Set when nothing is stored — +// business roles arrive expanded by default. +function readStoredFolds(matrixKey) { + try { + const raw = localStorage.getItem(foldStorageKey(matrixKey)); + if (raw) { + const saved = JSON.parse(raw); + if (saved.version === ROLE_FOLD_VERSION && Array.isArray(saved.folded)) return new Set(saved.folded); + localStorage.removeItem(foldStorageKey(matrixKey)); + } + } catch {} + return new Set(); +} + +function writeStoredFolds(matrixKey, folded) { + try { + if (folded.size === 0) localStorage.removeItem(foldStorageKey(matrixKey)); + else localStorage.setItem(foldStorageKey(matrixKey), JSON.stringify({ + version: ROLE_FOLD_VERSION, + folded: [...folded], + })); + } catch {} +} + +// Resource id a row stands for, normalised for case-insensitive matching against +// the access-package rows (owner/nested rows carry the real id separately). +export function rowResourceKey(row) { + return String(row.realGroupId || row.id || '').toUpperCase(); +} + +// Business role → the resources it contains, from the access-package rows. +export function buildRoleChildMap(accessPackageGroups) { + const map = new Map(); + for (const row of accessPackageGroups || []) { + const roleId = String(row.accessPackageId || row.businessRoleId || '').toUpperCase(); + const childId = String(row.resourceId || row.groupId || '').toUpperCase(); + if (!roleId || !childId || roleId === childId) continue; + if (!map.has(roleId)) map.set(roleId, new Set()); + map.get(roleId).add(childId); + } + return map; +} + +// The resources the grid shows as top-level rows (nested sub-rows are not rows +// of their own — they come and go with the parent they hang under). +function topLevelRowIds(rows) { + const ids = new Set(); + for (const row of rows || []) { + if (!row.isNestedRow) ids.add(rowResourceKey(row)); + } + return ids; +} + +// Record one role as a parent of each of its resources that has a row, and +// return how many rows that role would fold away. +function linkRoleChildren(roleId, children, rowIds, rolesByChild) { + let n = 0; + for (const childId of children) { + if (childId === roleId || !rowIds.has(childId)) continue; + n++; + if (!rolesByChild.has(childId)) rolesByChild.set(childId, new Set()); + rolesByChild.get(childId).add(roleId); + } + return n; +} + +// Which roles in the grid can be folded, how many rows each one folds away, and +// — per contained resource — the roles that are actually present as rows. +// D4: a role with no row of its own gets no fold affordance and hides nothing, +// so a resource never vanishes without a visible parent to unfold it from. +export function analyseRoleRows(rows, childrenByRole) { + const rowIds = topLevelRowIds(rows); + const rolesByChild = new Map(); + const childCounts = new Map(); + for (const roleId of rowIds) { + const children = childrenByRole.get(roleId); + if (!children) continue; + const n = linkRoleChildren(roleId, children, rowIds, rolesByChild); + if (n > 0) childCounts.set(roleId, n); + } + return { foldableRoles: new Set(childCounts.keys()), rolesByChild, childCounts }; +} + +// A contained resource is hidden only when EVERY business role that grants it and +// is present in the grid is folded (D3) — an expanded role always shows its +// resources. A hidden row takes its own expanded nested sub-rows with it: those +// follow it in the list at a deeper nest level. +export function hideFoldedRows(rows, rolesByChild, folded) { + if (!folded || folded.size === 0) return rows; + const out = []; + let dropDepth = null; + for (const row of rows) { + const depth = row.nestLevel || 0; + if (dropDepth !== null) { + if (depth > dropDepth) continue; // sub-row of a row we just dropped + dropDepth = null; + } + const parents = row.isNestedRow ? null : rolesByChild.get(rowResourceKey(row)); + if (parents && parents.size > 0 && [...parents].every(id => folded.has(id))) { + dropDepth = depth; + continue; + } + out.push(row); + } + return out; +} + +/** + * Business-role fold state for the per-subject matrix. + * + * @param {object} args + * @param {Array} args.accessPackageGroups - (role, resource) rows from /api/access-package-groups + * @param {Array} args.rows - the rows the grid would render unfolded + * @param {string} args.storageKey - stable string form of the matrix filter + */ +export function useBusinessRoleFold({ accessPackageGroups, rows, storageKey }) { + const [foldedRoles, setFoldedRoles] = useState(() => readStoredFolds(storageKey)); + + // Reload the saved folds when the matrix changes, during render rather than in + // an effect (same pattern as useMatrixRowOrder) — fold state is per matrix, so + // switching filters must never carry the previous matrix's folds across. + const [seenKey, setSeenKey] = useState(storageKey); + if (storageKey !== seenKey) { + setSeenKey(storageKey); + setFoldedRoles(readStoredFolds(storageKey)); + } + + const childrenByRole = useMemo(() => buildRoleChildMap(accessPackageGroups), [accessPackageGroups]); + const { foldableRoles, rolesByChild, childCounts } = useMemo( + () => analyseRoleRows(rows, childrenByRole), [rows, childrenByRole]); + + const applyFolds = useCallback((next) => { + setFoldedRoles(next); + writeStoredFolds(storageKey, next); + }, [storageKey]); + + const toggleRoleFold = useCallback((roleId) => { + const id = String(roleId || '').toUpperCase(); + const next = new Set(foldedRoles); + if (next.has(id)) next.delete(id); + else next.add(id); + applyFolds(next); + }, [foldedRoles, applyFolds]); + + const foldAllRoles = useCallback(() => applyFolds(new Set(foldableRoles)), [foldableRoles, applyFolds]); + const unfoldAllRoles = useCallback(() => applyFolds(new Set()), [applyFolds]); + + const visibleRows = useMemo( + () => hideFoldedRows(rows, rolesByChild, foldedRoles), [rows, rolesByChild, foldedRoles]); + + const hasFoldedRoles = useMemo( + () => [...foldableRoles].some(id => foldedRoles.has(id)), [foldableRoles, foldedRoles]); + + return { + visibleRows, + foldableRoles, + foldedRoles, + roleChildCounts: childCounts, + toggleRoleFold, + foldAllRoles, + unfoldAllRoles, + canFoldRoles: foldableRoles.size > 0, + hasFoldedRoles, + }; +} diff --git a/app/ui/src/hooks/useBusinessRoleFold.test.jsx b/app/ui/src/hooks/useBusinessRoleFold.test.jsx new file mode 100644 index 000000000..33d1e95de --- /dev/null +++ b/app/ui/src/hooks/useBusinessRoleFold.test.jsx @@ -0,0 +1,250 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@ui/test-utils/renderWithProviders'; +import { + useBusinessRoleFold, buildRoleChildMap, analyseRoleRows, hideFoldedRows, + rowResourceKey, ROLE_FOLD_VERSION, +} from './useBusinessRoleFold'; + +const storeKey = (k) => `fgraph-rolefold-${k || 'all'}`; + +// jsdom in this project runs without an origin, so window.localStorage is +// absent. Back it with a simple in-memory Map for these tests. +function makeLocalStorage() { + const store = new Map(); + return { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }; +} + +// Fixture from the spec: BR1 contains {G1, G2}, BR2 contains {G2, G3}, G4 in no +// role. Both business roles have a row of their own in the grid. +const AP_GROUPS = [ + { accessPackageId: 'BR1', resourceId: 'G1' }, + { accessPackageId: 'BR1', resourceId: 'G2' }, + { accessPackageId: 'BR2', resourceId: 'G2' }, + { accessPackageId: 'BR2', groupId: 'G3' }, +]; + +const ROWS = [ + { id: 'BR1', displayName: 'Business Role 1' }, + { id: 'G1', displayName: 'Group 1' }, + { id: 'G2', displayName: 'Group 2' }, + { id: 'BR2', displayName: 'Business Role 2' }, + { id: 'G3', displayName: 'Group 3' }, + { id: 'G4', displayName: 'Group 4' }, +]; + +const ids = (rows) => rows.map((r) => r.id); + +function render(props = {}) { + return renderHook(({ p }) => useBusinessRoleFold(p), { + initialProps: { + p: { + accessPackageGroups: AP_GROUPS, + rows: ROWS, + storageKey: 'matrix-a', + ...props, + }, + }, + }); +} + +describe('buildRoleChildMap', () => { + it('maps each business role to the resources it contains', () => { + const map = buildRoleChildMap(AP_GROUPS); + expect([...map.get('BR1')]).toEqual(['G1', 'G2']); + expect([...map.get('BR2')]).toEqual(['G2', 'G3']); + }); + + it('matches ids case-insensitively and accepts the businessRoleId alias', () => { + const map = buildRoleChildMap([{ businessRoleId: 'br1', groupId: 'g1' }]); + expect(map.get('BR1').has('G1')).toBe(true); + }); + + it('ignores rows without a resource, and a role that contains itself', () => { + const map = buildRoleChildMap([ + { accessPackageId: 'BR1', resourceId: null, groupId: null }, + { accessPackageId: 'BR2', resourceId: 'BR2' }, + { accessPackageId: '', resourceId: 'G9' }, + ]); + expect(map.size).toBe(0); + }); + + it('tolerates a missing list', () => { + expect(buildRoleChildMap(undefined).size).toBe(0); + }); +}); + +describe('analyseRoleRows', () => { + const childrenByRole = buildRoleChildMap(AP_GROUPS); + + it('marks roles present in the grid as foldable and counts their child rows', () => { + const { foldableRoles, childCounts, rolesByChild } = analyseRoleRows(ROWS, childrenByRole); + expect([...foldableRoles].sort()).toEqual(['BR1', 'BR2']); + expect(childCounts.get('BR1')).toBe(2); + expect(childCounts.get('BR2')).toBe(2); + // G2 is granted by both roles + expect([...rolesByChild.get('G2')].sort()).toEqual(['BR1', 'BR2']); + expect(rolesByChild.has('G4')).toBe(false); + }); + + it('gives no fold affordance to a role that has no row in the grid (D4)', () => { + const rows = ROWS.filter((r) => r.id !== 'BR1'); + const { foldableRoles, rolesByChild } = analyseRoleRows(rows, childrenByRole); + expect(foldableRoles.has('BR1')).toBe(false); + // G1 is only granted by the absent BR1 → nothing can hide it + expect(rolesByChild.has('G1')).toBe(false); + }); + + it('ignores nested sub-rows when deciding what is present', () => { + const rows = [ + { id: 'BR1' }, + { id: 'BR1__nested__G1', realGroupId: 'G1', isNestedRow: true, nestLevel: 1 }, + ]; + const { foldableRoles } = analyseRoleRows(rows, childrenByRole); + expect(foldableRoles.size).toBe(0); + }); + + it('does not count a role whose resources are all outside the grid', () => { + const { foldableRoles } = analyseRoleRows([{ id: 'BR1' }, { id: 'G4' }], childrenByRole); + expect(foldableRoles.size).toBe(0); + }); +}); + +describe('hideFoldedRows', () => { + const childrenByRole = buildRoleChildMap(AP_GROUPS); + const { rolesByChild } = analyseRoleRows(ROWS, childrenByRole); + + it('returns the rows untouched when nothing is folded', () => { + expect(hideFoldedRows(ROWS, rolesByChild, new Set())).toBe(ROWS); + }); + + it('keeps a shared resource visible while one of its roles is expanded (D3)', () => { + expect(ids(hideFoldedRows(ROWS, rolesByChild, new Set(['BR1'])))) + .toEqual(['BR1', 'G2', 'BR2', 'G3', 'G4']); + }); + + it('hides a shared resource once every role granting it is folded (D3)', () => { + expect(ids(hideFoldedRows(ROWS, rolesByChild, new Set(['BR1', 'BR2'])))) + .toEqual(['BR1', 'BR2', 'G4']); + }); + + it('takes a hidden row\'s expanded nested sub-rows with it', () => { + const rows = [ + { id: 'BR1' }, + { id: 'G1' }, + { id: 'G1__nested__X', isNestedRow: true, nestLevel: 1 }, + { id: 'G1__nested__X__nested__Y', isNestedRow: true, nestLevel: 2 }, + { id: 'G2' }, + { id: 'G4' }, + ]; + const analysis = analyseRoleRows(rows, childrenByRole); + expect(ids(hideFoldedRows(rows, analysis.rolesByChild, new Set(['BR1'])))) + .toEqual(['BR1', 'G4']); + }); + + it('keeps nested sub-rows of a row that stays visible', () => { + const rows = [ + { id: 'BR1' }, + { id: 'G1' }, + { id: 'G4' }, + { id: 'G4__nested__X', isNestedRow: true, nestLevel: 1 }, + ]; + const analysis = analyseRoleRows(rows, childrenByRole); + expect(ids(hideFoldedRows(rows, analysis.rolesByChild, new Set(['BR1'])))) + .toEqual(['BR1', 'G4', 'G4__nested__X']); + }); +}); + +describe('rowResourceKey', () => { + it('prefers the real resource id of a synthetic row', () => { + expect(rowResourceKey({ id: 'g1__owner', realGroupId: 'g1' })).toBe('G1'); + expect(rowResourceKey({ id: 'g2' })).toBe('G2'); + }); +}); + +describe('useBusinessRoleFold', () => { + beforeEach(() => vi.stubGlobal('localStorage', makeLocalStorage())); + afterEach(() => vi.unstubAllGlobals()); + + it('starts expanded and exposes the foldable roles', () => { + const { result } = render(); + expect(result.current.canFoldRoles).toBe(true); + expect(result.current.hasFoldedRoles).toBe(false); + expect(ids(result.current.visibleRows)).toEqual(ids(ROWS)); + expect(result.current.roleChildCounts.get('BR1')).toBe(2); + }); + + it('offers no fold at all for a matrix without business-role rows', () => { + const { result } = render({ accessPackageGroups: [] }); + expect(result.current.canFoldRoles).toBe(false); + expect(result.current.foldableRoles.size).toBe(0); + }); + + it('folds and unfolds a single role', () => { + const { result } = render(); + act(() => result.current.toggleRoleFold('br1')); + expect(result.current.hasFoldedRoles).toBe(true); + expect(ids(result.current.visibleRows)).toEqual(['BR1', 'G2', 'BR2', 'G3', 'G4']); + act(() => result.current.toggleRoleFold('BR1')); + expect(ids(result.current.visibleRows)).toEqual(ids(ROWS)); + }); + + it('folds every role at once, leaving roles plus ungranted resources', () => { + const { result } = render(); + act(() => result.current.foldAllRoles()); + expect(ids(result.current.visibleRows)).toEqual(['BR1', 'BR2', 'G4']); + act(() => result.current.unfoldAllRoles()); + expect(ids(result.current.visibleRows)).toEqual(ids(ROWS)); + }); + + it('persists folds per matrix and restores them on a later mount', () => { + const first = render(); + act(() => first.result.current.foldAllRoles()); + const saved = JSON.parse(localStorage.getItem(storeKey('matrix-a'))); + expect(saved.version).toBe(ROLE_FOLD_VERSION); + expect(saved.folded.sort()).toEqual(['BR1', 'BR2']); + + const second = render(); + expect(ids(second.result.current.visibleRows)).toEqual(['BR1', 'BR2', 'G4']); + }); + + it('clears the stored entry when everything is unfolded again', () => { + const { result } = render(); + act(() => result.current.foldAllRoles()); + act(() => result.current.unfoldAllRoles()); + expect(localStorage.getItem(storeKey('matrix-a'))).toBeNull(); + }); + + it('keeps fold state independent per matrix filter', () => { + localStorage.setItem(storeKey('matrix-a'), JSON.stringify({ version: ROLE_FOLD_VERSION, folded: ['BR1'] })); + const { result, rerender } = render(); + expect(ids(result.current.visibleRows)).toEqual(['BR1', 'G2', 'BR2', 'G3', 'G4']); + rerender({ p: { accessPackageGroups: AP_GROUPS, rows: ROWS, storageKey: 'matrix-b' } }); + expect(ids(result.current.visibleRows)).toEqual(ids(ROWS)); + }); + + it('discards a stored fold set written by an older version', () => { + localStorage.setItem(storeKey('matrix-a'), JSON.stringify({ version: ROLE_FOLD_VERSION - 1, folded: ['BR1'] })); + const { result } = render(); + expect(result.current.hasFoldedRoles).toBe(false); + expect(localStorage.getItem(storeKey('matrix-a'))).toBeNull(); + }); + + it('survives unreadable and unwritable storage', () => { + localStorage.setItem(storeKey('matrix-a'), '{not json'); + const { result } = render(); + expect(result.current.hasFoldedRoles).toBe(false); + vi.stubGlobal('localStorage', { + getItem: () => { throw new Error('blocked'); }, + setItem: () => { throw new Error('blocked'); }, + removeItem: () => { throw new Error('blocked'); }, + }); + act(() => result.current.foldAllRoles()); + expect(ids(result.current.visibleRows)).toEqual(['BR1', 'BR2', 'G4']); + }); +}); diff --git a/app/ui/src/hooks/useMatrixRowOrder.js b/app/ui/src/hooks/useMatrixRowOrder.js index b0bb28935..b8f2d8a13 100644 --- a/app/ui/src/hooks/useMatrixRowOrder.js +++ b/app/ui/src/hooks/useMatrixRowOrder.js @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; // Bump this when the default sort logic changes (e.g., staircase sort introduced). // Stored orders from an older version are discarded so the new default takes effect. -const ROW_ORDER_VERSION = 6; +const ROW_ORDER_VERSION = 7; function getStorageKey(department) { return `fgraph-roworder-${department || 'all'}`; diff --git a/app/ui/src/hooks/useMatrixRowOrder.test.js b/app/ui/src/hooks/useMatrixRowOrder.test.js index 3f98d69f5..ee7e4a763 100644 --- a/app/ui/src/hooks/useMatrixRowOrder.test.js +++ b/app/ui/src/hooks/useMatrixRowOrder.test.js @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { renderHook, act } from '@ui/test-utils/renderWithProviders'; import { useMatrixRowOrder } from './useMatrixRowOrder'; -const VERSION = 6; // must match ROW_ORDER_VERSION in the hook +const VERSION = 7; // must match ROW_ORDER_VERSION in the hook const key = (d) => `fgraph-roworder-${d || 'all'}`; // jsdom in this project runs without an origin, so window.localStorage is diff --git a/changes/dor-issue-370.md b/changes/dor-issue-370.md new file mode 100644 index 000000000..819cd4c50 --- /dev/null +++ b/changes/dor-issue-370.md @@ -0,0 +1,5 @@ +- Matrix: a business role row can now be folded to hide the resources it grants, leaving just the role plus an "N resources folded" chip — click the chevron again to bring them back. +- Matrix: new "Fold roles" / "Unfold roles" toolbar buttons fold every business role at once, reducing the grid to business roles plus the resources no role grants. +- Business roles arrive expanded, and your fold choices are remembered per matrix so they are still there when you come back to the same slice. +- A resource granted by more than one business role stays visible until every one of those roles is folded. +- Matrix rows: a business role now sits directly above the resources it grants in the staircase order (any saved custom row order is reset once as a result). diff --git a/docs/architecture/matrix.md b/docs/architecture/matrix.md index a641eff51..36aa30b3a 100644 --- a/docs/architecture/matrix.md +++ b/docs/architecture/matrix.md @@ -144,6 +144,44 @@ count of Direct assignments. `▾`/`↳` explode an aggregate back into its memb (direct + indirect, or direct only). This is all **client-side** on the flat per-subject payload — it changes what is *rendered*, not what is *fetched*. +### Business-role fold (rows) + +The column fold above collapses *columns*; the **business-role fold** collapses +*rows*. A business-role row (`resourceType='BusinessRole'`) carries a chevron +that hides the rows of the resources that role grants — its `Contains` children +— leaving the role row with an "*N* resources folded" chip. A **Fold roles / +Unfold roles** toolbar pair does it for every role at once, which reduces the +grid to exactly "business roles + resources no role grants" — the role-mining +view without the duplication between a role and its contents. + +The parent → child mapping is not derived client-side: it is the same +`ResourceRelationships` / `relationshipType='Contains'` data that +`GET /api/access-package-groups` already delivers for the SOLL columns +(`accessPackageGroups`). Folding is pure view state, the same tier as the column +fold and the nested-group expand — it changes what is *rendered*, never what is +fetched, counted or exported. It lives in +[`hooks/useBusinessRoleFold.js`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/ui/src/hooks/useBusinessRoleFold.js) +and is applied last in the row pipeline, so it composes with the +All / Governed / Non-governed / Gaps toggles and with injected nested sub-rows. + +Rules worth knowing: + +- **Default expanded.** Fold choices persist per matrix filter in versioned + localStorage (`fgraph-rolefold-`), the mechanism the custom row order + uses — so two different matrix slices keep independent fold state. +- **A resource granted by several roles** stays visible until *every* role + granting it that is present in the grid is folded. +- **A role with no row of its own** (nobody visible holds it) gets no fold + affordance and hides nothing — a resource never disappears without a visible + parent to unfold it from. +- **The folded role's own cells are untouched.** Folding hides rows; it never + rolls a child's assignments up into the parent row. +- **Ownership rows are not folded** — they hang off a group by `HasOwnership`, + not off a role by `Contains`. +- The AP staircase promotes a **business role's own row to the top of its + bucket**, directly above the resources it grants, so a parent is always + adjacent to the children it folds away. + ### Size gate Folding does not shrink the fetch, so a flat per-subject matrix has a hard size From fb4ac348e7e0d51f0c4213041cf19792276c40ed Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Tue, 4 Aug 2026 12:46:22 +0000 Subject: [PATCH 02/14] fix: address e2e/CI failures (attempt 1, #370) --- app/ui/e2e/matrix.spec.js | 35 ++++++++++++------- .../components/matrix/MatrixScopePanel.jsx | 5 +-- .../matrix/MatrixScopePanel.mount.test.jsx | 9 +++++ changes/dor-issue-370.md | 1 + 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index 30714f6d5..b561662f9 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -128,12 +128,22 @@ test.describe('Matrix — fold business-role resources', () => { subject: { include: [], exclude: [] }, resource: { include: [], exclude: [] }, }; - const MATRIX_URL = '/#matrix?filter=' + encodeURIComponent(JSON.stringify(ALL_DATA_FILTER)); + const matrixUrl = (filter) => '/#matrix?filter=' + encodeURIComponent(JSON.stringify(filter)); + + // Load a matrix slice in a fresh document. The app reads `?filter=` once, at + // mount — the matrix hash is a share/bookmark entry point, not a live route — + // so a hash-only change would leave the previous slice on screen. Going via + // about:blank forces the real navigation (localStorage survives it, which is + // what the fold-persistence assertions rely on). + async function gotoSlice(page, filter) { + await page.goto('about:blank'); + await page.goto(matrixUrl(filter)); + await page.waitForLoadState('networkidle'); + } // Open the all-data matrix. Returns false when no grid renders (no data here). async function openGrid(page) { - await page.goto(MATRIX_URL); - await page.waitForLoadState('networkidle'); + await gotoSlice(page, ALL_DATA_FILTER); try { await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); } catch { @@ -152,10 +162,14 @@ test.describe('Matrix — fold business-role resources', () => { return tbody ? Math.round(tbody.getBoundingClientRect().height) : 0; }); - // Value shown next to a scope-statistics label ("Resources", "Assignments"). - const statValue = (page, label) => - page.locator('span').filter({ hasText: new RegExp(`^${label}$`) }).first() - .locator('xpath=preceding-sibling::span[1]').innerText(); + // Value shown in a scope-statistics tile ("Resources", "Assignments"). Each + // tile is a group named after its metric; the number is its first span. Waits + // out the em-dash placeholder the tile shows until scope-stats has loaded. + async function statValue(page, label) { + const value = page.getByRole('group', { name: label, exact: true }).locator('span').first(); + await expect(value).not.toHaveText('—', { timeout: 30000 }); + return value.innerText(); + } async function openFoldableGrid(page) { const rendered = await openGrid(page); @@ -223,14 +237,11 @@ test.describe('Matrix — fold business-role resources', () => { await expect.poll(() => rowsHeight(page)).toBe(folded); // A different matrix slice keeps its own (expanded) state. - const other = { ...ALL_DATA_FILTER, rowType: 'identity' }; - await page.goto('/#matrix?filter=' + encodeURIComponent(JSON.stringify(other))); - await page.waitForLoadState('networkidle'); + await gotoSlice(page, { ...ALL_DATA_FILTER, rowType: 'identity' }); await expect(unfoldAll(page)).toHaveCount(0); // Leave the browser profile clean for the next test. - await page.goto(MATRIX_URL); - await page.waitForLoadState('networkidle'); + await gotoSlice(page, ALL_DATA_FILTER); if (await unfoldAll(page).count()) await unfoldAll(page).click(); }); }); diff --git a/app/ui/src/components/matrix/MatrixScopePanel.jsx b/app/ui/src/components/matrix/MatrixScopePanel.jsx index bdbba4971..2b039d757 100644 --- a/app/ui/src/components/matrix/MatrixScopePanel.jsx +++ b/app/ui/src/components/matrix/MatrixScopePanel.jsx @@ -29,10 +29,11 @@ const UNGOV_TEXT = 'text-amber-700 dark:text-amber-400'; function pct(n) { return `${Math.round((n + Number.EPSILON) * 10) / 10}%`; } function num(n) { return (n ?? 0).toLocaleString(); } -// One headline number. +// One headline number. The tile is a named group so the number is announced +// with the metric it belongs to ("Resources, 39") instead of as a bare figure. function Stat({ label, value, sub }) { return ( -
+
{value} {label} {sub && {sub}} diff --git a/app/ui/src/components/matrix/MatrixScopePanel.mount.test.jsx b/app/ui/src/components/matrix/MatrixScopePanel.mount.test.jsx index d6036ea17..f92426f1a 100644 --- a/app/ui/src/components/matrix/MatrixScopePanel.mount.test.jsx +++ b/app/ui/src/components/matrix/MatrixScopePanel.mount.test.jsx @@ -52,6 +52,15 @@ describe('MatrixScopePanel (mounted)', () => { expect(screen.getByText('900 governed')).toBeInTheDocument(); }); + it('names each stat tile so its number is announced with its metric', async () => { + renderWithProviders(h(MatrixScopePanel, { filter }), { auth: { authFetch: routes() } }); + await screen.findByText('120'); + + expect(screen.getByRole('group', { name: 'Principals' })).toHaveTextContent('120'); + expect(screen.getByRole('group', { name: 'Resources' })).toHaveTextContent('30'); + expect(screen.getByRole('group', { name: 'Assignments' })).toHaveTextContent('1,500'); + }); + it('expands to fetch the trends timeseries and department breakdown', async () => { const authFetch = routes(); renderWithProviders(h(MatrixScopePanel, { filter }), { auth: { authFetch } }); diff --git a/changes/dor-issue-370.md b/changes/dor-issue-370.md index 819cd4c50..ac41aac85 100644 --- a/changes/dor-issue-370.md +++ b/changes/dor-issue-370.md @@ -3,3 +3,4 @@ - Business roles arrive expanded, and your fold choices are remembered per matrix so they are still there when you come back to the same slice. - A resource granted by more than one business role stays visible until every one of those roles is folded. - Matrix rows: a business role now sits directly above the resources it grants in the staircase order (any saved custom row order is reset once as a result). +- Matrix scope statistics: each headline number is now announced together with the metric it belongs to (e.g. "Resources, 39") by screen readers. From 63e7676fdf432e4d978ce5bcb0a4cc93c7069c5e Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Tue, 4 Aug 2026 14:28:56 +0000 Subject: [PATCH 03/14] fix: address requestor feedback (#370) --- .github/scripts/dor_build_flow.sh | 6 +- .../governedIntentGap.contract.test.js | 43 +++++++ .../061_business_role_covers_itself.sql | 59 ++++++++++ .../061_business_role_covers_itself.test.js | 46 ++++++++ app/ui/e2e/matrix.spec.js | 39 +++++++ app/ui/src/components/MatrixView.jsx | 108 ++++++++++++------ .../src/components/MatrixView.mount.test.jsx | 62 +++++++++- app/ui/src/components/matrix/MatrixCell.jsx | 76 +++++++----- .../matrix/MatrixCell.mount.test.jsx | 73 ++++++++++++ .../src/components/matrix/MatrixGroupRow.jsx | 79 ++++++++----- .../matrix/MatrixGroupRow.mount.test.jsx | 64 +++++++++++ app/ui/src/components/matrix/MatrixLegend.jsx | 6 + .../components/matrix/MatrixLegend.test.js | 5 + .../components/matrix/SortableMatrixBody.jsx | 2 + app/ui/src/components/matrix/cellMarkers.js | 8 ++ app/ui/src/hooks/useBusinessRoleFold.js | 66 ++++++++++- app/ui/src/hooks/useBusinessRoleFold.test.jsx | 81 ++++++++++++- changes/dor-issue-370.md | 3 + docs/architecture/matrix.md | 46 ++++++-- 19 files changed, 768 insertions(+), 104 deletions(-) create mode 100644 app/api/src/db/migrations/061_business_role_covers_itself.sql create mode 100644 app/api/src/db/migrations/061_business_role_covers_itself.test.js create mode 100644 app/ui/src/components/matrix/MatrixCell.mount.test.jsx create mode 100644 app/ui/src/components/matrix/cellMarkers.js diff --git a/.github/scripts/dor_build_flow.sh b/.github/scripts/dor_build_flow.sh index 1e354a2ef..931233ed3 100644 --- a/.github/scripts/dor_build_flow.sh +++ b/.github/scripts/dor_build_flow.sh @@ -28,6 +28,10 @@ grep -qxF '.dor/' .git/info/exclude 2>/dev/null || echo '.dor/' >> .git/info/exc grep -qxF 'dor-tls.override.yml' .git/info/exclude 2>/dev/null || echo 'dor-tls.override.yml' >> .git/info/exclude # Consume the trigger label now so dor-resume.yml can re-apply it to re-dispatch a paused build. gh issue edit "$ISSUE" --repo "$REPO" --remove-label ready-to-build >/dev/null 2>&1 || true +# Flip the board to Building the moment the build starts (i.e. right after the Product Board approved +# the gate) — not only after the PR is created ~15-20 min later, which would leave it wrongly reading +# "Awaiting approval" for the whole implement phase. +GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" building 2>/dev/null || true # Resume-aware: if a branch with real work already exists (a previous run paused on a usage limit), # continue from it instead of re-implementing from scratch — that is the expensive part we must not @@ -63,7 +67,7 @@ if [ -z "$pr" ]; then | grep -oE '[0-9]+$') || bail "could not open the PR" fi echo "$pr $ISSUE" > "$HOME/.dor-reservation" # — dor-reset/feedback route off this -GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" building 2>/dev/null || true +# (board was already moved to Building at the start of the run) # 3-5. Verify: deploy+seed → e2e on live env → CI green. Fix + retry up to MAX_ATTEMPTS (else Exceptions). verify_loop "$pr" diff --git a/app/api/contract-tests/governedIntentGap.contract.test.js b/app/api/contract-tests/governedIntentGap.contract.test.js index 6bc23cd9c..ea1dced8f 100644 --- a/app/api/contract-tests/governedIntentGap.contract.test.js +++ b/app/api/contract-tests/governedIntentGap.contract.test.js @@ -91,3 +91,46 @@ describe('matrix matview — managedByAccessPackage from governance coverage', ( expect(rows).toHaveLength(0); }); }); + +// Migration 061: holding a business role is governed access, so the role's own +// row must be covered by the role itself — otherwise the matrix paints the role +// row ungoverned and the scope statistics count it as an ungoverned assignment. +describe('business-role coverage — a role covers its own membership row (061)', () => { + async function coverage(principalId) { + await pool.query(`REFRESH MATERIALIZED VIEW "vw_UserPermissionAssignmentViaBusinessRole"`); + const r = await pool.query( + `SELECT "resourceId", "businessRoleId" + FROM "vw_UserPermissionAssignmentViaBusinessRole" + WHERE "userId" = $1 + ORDER BY "resourceId"`, + [principalId], + ); + return r.rows; + } + + it('reports both the role itself and the resources it Contains', async () => { + await assign({ resourceId: BR, principalId: U_OK, governed: true }); + const rows = await coverage(U_OK); + expect(rows).toEqual(expect.arrayContaining([ + { resourceId: BR, businessRoleId: BR }, + { resourceId: G, businessRoleId: BR }, + ])); + expect(rows).toHaveLength(2); + }); + + it('leaves a subject who does not hold the role uncovered', async () => { + await assign({ resourceId: G, principalId: U_UN, governed: false }); + expect(await coverage(U_UN)).toEqual([]); + }); + + it('drops the self row again when the role assignment is soft-deleted', async () => { + await assign({ resourceId: BR, principalId: U_GAP, governed: true }); + expect(await coverage(U_GAP)).toHaveLength(2); + await pool.query( + `UPDATE "ResourceAssignments" SET "deletedAt" = now() + WHERE "resourceId" = $1 AND "principalId" = $2`, + [BR, U_GAP], + ); + expect(await coverage(U_GAP)).toEqual([]); + }); +}); diff --git a/app/api/src/db/migrations/061_business_role_covers_itself.sql b/app/api/src/db/migrations/061_business_role_covers_itself.sql new file mode 100644 index 000000000..26cb9a1d1 --- /dev/null +++ b/app/api/src/db/migrations/061_business_role_covers_itself.sql @@ -0,0 +1,59 @@ +-- 061: a business role also covers its own membership row +-- +-- "vw_UserPermissionAssignmentViaBusinessRole" answers one question: which +-- business role(s) account for this (subject, resource) cell? Migration 049 +-- built it purely from the `Contains` relationships, so it listed the resources +-- a role grants but never the role itself — even though holding a business role +-- IS governed access (a Direct assignment carrying governed=true, see 049). +-- +-- The business role is a resource row in the matrix like any other, so that +-- omission surfaced everywhere the view is the governed signal: +-- * the role's own row rendered ungoverned — no business-role colour on its +-- cells, and it disappeared from the Governed view entirely; +-- * the scope statistics counted every business-role membership as an +-- ungoverned assignment, understating the governed percentage; +-- * a role that grants no resources at all never appeared in the roll-ups. +-- +-- Fixed at the source: the view gains a self arm, so every consumer (matrix +-- colouring, the Governed filter, scope statistics, the roll-up builders) +-- agrees without any of them special-casing the role row. + +DROP MATERIALIZED VIEW IF EXISTS "vw_UserPermissionAssignmentViaBusinessRole" CASCADE; +CREATE MATERIALIZED VIEW "vw_UserPermissionAssignmentViaBusinessRole" AS +-- Arm 1 (unchanged): the resources a governance resource Contains. +SELECT + bru."principalId" AS "userId", + rr."childResourceId" AS "groupId", + rr."childResourceId" AS "resourceId", + rr."parentResourceId" AS "businessRoleId" +FROM "ResourceRelationships" rr +JOIN "Resources" gov + ON gov.id = rr."parentResourceId" AND gov."governanceResource" +JOIN "ResourceAssignments" bru + ON bru."resourceId" = rr."parentResourceId" + AND bru."principalId" IS NOT NULL + AND bru."deletedAt" IS NULL +WHERE rr."relationshipType" = 'Contains' +UNION +-- Arm 2 (new): the governance resource covers its own membership cell. +SELECT + bru."principalId" AS "userId", + gov.id AS "groupId", + gov.id AS "resourceId", + gov.id AS "businessRoleId" +FROM "Resources" gov +JOIN "ResourceAssignments" bru + ON bru."resourceId" = gov.id + AND bru."principalId" IS NOT NULL + AND bru."deletedAt" IS NULL +WHERE gov."governanceResource" +WITH NO DATA; + +CREATE UNIQUE INDEX "ix_vw_UPABR_pk" + ON "vw_UserPermissionAssignmentViaBusinessRole" ("userId", "groupId", "businessRoleId"); +CREATE INDEX "ix_vw_UPABR_userId" + ON "vw_UserPermissionAssignmentViaBusinessRole" ("userId"); +CREATE INDEX "ix_vw_UPABR_groupId" + ON "vw_UserPermissionAssignmentViaBusinessRole" ("groupId"); + +REFRESH MATERIALIZED VIEW "vw_UserPermissionAssignmentViaBusinessRole"; diff --git a/app/api/src/db/migrations/061_business_role_covers_itself.test.js b/app/api/src/db/migrations/061_business_role_covers_itself.test.js new file mode 100644 index 000000000..1ccec6929 --- /dev/null +++ b/app/api/src/db/migrations/061_business_role_covers_itself.test.js @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sql = readFileSync(join(__dirname, '061_business_role_covers_itself.sql'), 'utf8'); + +// Text-level guard, mirroring 054's/058's. The behavioural proof (a real +// Postgres returning the self row) lives in +// app/api/contract-tests/governedIntentGap.contract.test.js. +describe('migration 061 — a business role covers its own membership row', () => { + it('rebuilds the business-role coverage matview', () => { + expect(sql).toMatch( + /DROP MATERIALIZED VIEW IF EXISTS "vw_UserPermissionAssignmentViaBusinessRole" CASCADE/, + ); + expect(sql).toMatch( + /CREATE MATERIALIZED VIEW "vw_UserPermissionAssignmentViaBusinessRole"/, + ); + }); + + it('keeps the Contains arm and adds a self arm', () => { + expect(sql).toContain(`WHERE rr."relationshipType" = 'Contains'`); + // The self arm reports the governance resource as its own role AND resource. + expect(sql).toMatch(/gov\.id\s+AS "resourceId",\s*\n\s*gov\.id\s+AS "businessRoleId"/); + expect(sql.match(/^UNION$/m)).not.toBeNull(); + }); + + it('counts only effective assignments — soft-deleted ones stay out of both arms', () => { + const guards = sql.match(/bru\."deletedAt" IS NULL/g); + expect(guards).toHaveLength(2); + }); + + it('restricts the self arm to governance resources', () => { + expect(sql).toContain(`WHERE gov."governanceResource"`); + }); + + it('recreates the indexes the matview is queried through and populates it', () => { + for (const ix of ['ix_vw_UPABR_pk', 'ix_vw_UPABR_userId', 'ix_vw_UPABR_groupId']) { + expect(sql).toContain(`"${ix}"`); + } + expect(sql).toMatch( + /REFRESH MATERIALIZED VIEW "vw_UserPermissionAssignmentViaBusinessRole"/, + ); + }); +}); diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index b561662f9..66c8a4e03 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -244,6 +244,45 @@ test.describe('Matrix — fold business-role resources', () => { await gotoSlice(page, ALL_DATA_FILTER); if (await unfoldAll(page).count()) await unfoldAll(page).click(); }); + + // The resources a role grants hang under it with the same indent + elbow an + // expanded nested group uses, so "what is in this role" reads off the grid. + const childElbows = (page) => page.locator('tbody tr td span', { hasText: /^└$/ }).count(); + + test('a role\'s resources hang under it as child rows, and go with it when it folds', async ({ page }) => { + await openFoldableGrid(page); + + const before = await childElbows(page); + test.skip(before === 0, 'no role and one of its resources are on screen together here'); + + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + await expect.poll(() => childElbows(page)).toBeLessThan(before); + + await unfoldAll(page).click(); + await expect.poll(() => childElbows(page)).toBe(before); + }); + + test('a folded role counts the access it hides but does not grant', async ({ page }) => { + await openFoldableGrid(page); + + // Nothing is folded yet, so the marker cannot be on screen. + const marker = page.locator('tbody span[title*="does not grant"]'); + await expect(marker).toHaveCount(0); + + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + + if (await marker.count()) { + // Every marker states a count of ungoverned assignments it stands for. + await expect(marker.first()).toHaveText(/^[1-9]\d*$/); + await expect(marker.first()).toHaveAttribute( + 'title', /assignments? on the folded resources that this business role does not grant/, + ); + } + await unfoldAll(page).click(); + await expect(marker).toHaveCount(0); + }); }); // ─── Regression: no double scrollbar behind the matrix grid ──────────────────── diff --git a/app/ui/src/components/MatrixView.jsx b/app/ui/src/components/MatrixView.jsx index 81578f9d2..24825ec94 100644 --- a/app/ui/src/components/MatrixView.jsx +++ b/app/ui/src/components/MatrixView.jsx @@ -110,6 +110,50 @@ function makeAccountCol(parent, acc, sortKeys) { }; } +// Which business role grants which resource row, from the (role, resource) +// payload — the SOLL side of the grid. Two things earn a role a column: it +// grants a resource that is on screen, or its OWN row is (holding the role IS a +// governed Direct assignment on it, which is not a `Contains` relationship and +// so can never arrive as a pair — without this the role's column was blank on +// its own row). Returns the roles keyed by id plus a "RESOURCE|role" → roleName +// mapping. +function buildApMapping(accessPackageGroups, visibleGroupIds) { + const apMap = new Map(); + const mapping = new Map(); + for (const row of accessPackageGroups) { + const gid = (row.resourceId || row.groupId)?.toUpperCase(); + const selfGid = row.accessPackageId?.toUpperCase(); + const selfVisible = !!selfGid && visibleGroupIds.has(selfGid); + const childVisible = !!gid && visibleGroupIds.has(gid); + if (!selfVisible && !childVisible) continue; + if (!apMap.has(row.accessPackageId)) { + apMap.set(row.accessPackageId, { + id: row.accessPackageId, + displayName: row.accessPackageName, + catalogName: row.catalogName, + totalAssignments: row.totalAssignments || 0, + categoryName: row.categoryName || null, + categoryColor: row.categoryColor || null, + }); + } + const apKey = row.accessPackageId.toLowerCase(); + if (selfVisible) mapping.set(`${selfGid}|${apKey}`, 'Member'); + if (childVisible) mapping.set(`${gid}|${apKey}`, row.roleName || 'Member'); + } + return { apMap, mapping }; +} + +// Column order: by category name, then by total assignments descending within +// each category; uncategorized roles go last. +function compareAccessPackages(a, b) { + const aCat = a.categoryName; + const bCat = b.categoryName; + if (aCat && !bCat) return -1; + if (!aCat && bCat) return 1; + if (aCat && bCat && aCat !== bCat) return aCat.localeCompare(bCat); + return b.totalAssignments - a.totalAssignments || a.displayName.localeCompare(b.displayName); +} + export default function MatrixView({ data, accessPackageGroups = [], managedByPackages = [], filter, @@ -439,24 +483,7 @@ export default function MatrixView({ } const visibleGroupIds = new Set(groups.map(g => (g.realGroupId || g.id).toUpperCase())); const visibleUserIds = new Set(users.map(u => u.id.toLowerCase())); - const apMap = new Map(); - const mapping = new Map(); // "groupId|apId" -> roleName - - for (const row of accessPackageGroups) { - const gid = (row.resourceId || row.groupId)?.toUpperCase(); - if (!gid || !visibleGroupIds.has(gid)) continue; - if (!apMap.has(row.accessPackageId)) { - apMap.set(row.accessPackageId, { - id: row.accessPackageId, - displayName: row.accessPackageName, - catalogName: row.catalogName, - totalAssignments: row.totalAssignments || 0, - categoryName: row.categoryName || null, - categoryColor: row.categoryColor || null, - }); - } - mapping.set(`${gid}|${row.accessPackageId.toLowerCase()}`, row.roleName || 'Member'); - } + const { apMap, mapping } = buildApMapping(accessPackageGroups, visibleGroupIds); // Filter to APs that have at least one visible user assignment const apIdsWithAssignments = new Set(); @@ -474,20 +501,7 @@ export default function MatrixView({ } } - // Sort access packages: by category name first, then by total assignments - // descending within each category. Uncategorized APs go at the end. - const accessPackages = [...apMap.values()].sort((a, b) => { - const aCat = a.categoryName; - const bCat = b.categoryName; - // Uncategorized after all categorized - if (aCat && !bCat) return -1; - if (!aCat && bCat) return 1; - // Both categorized: sort by category name - if (aCat && bCat && aCat !== bCat) return aCat.localeCompare(bCat); - // Same category (or both uncategorized): sort by total assignments descending - return b.totalAssignments - a.totalAssignments || a.displayName.localeCompare(b.displayName); - }); - return { accessPackages, apGroupMap: mapping }; + return { accessPackages: [...apMap.values()].sort(compareAccessPackages), apGroupMap: mapping }; }, [accessPackageGroups, groups, users, managedApMap]); // AP ID (lowercase) -> sorted index (for consistent color lookup) @@ -666,7 +680,7 @@ export default function MatrixView({ // last in the row pipeline, so it composes with the All/Governed/Non-governed/ // Gaps toggles and with the injected nested sub-rows. const { - visibleRows: foldedGroups, + visibleRows: foldedGroups, foldedChildRows, foldableRoles, foldedRoles, roleChildCounts, toggleRoleFold, foldAllRoles, unfoldAllRoles, canFoldRoles, hasFoldedRoles, } = useBusinessRoleFold({ accessPackageGroups, rows: visibleGroups, storageKey }); @@ -825,6 +839,32 @@ export default function MatrixView({ return counts; }, [colMemberships, userToAgg, collapsedGroups]); + // Per (folded role, subject column): how many of the rows that role folded + // away carry access the role itself does NOT grant. Folding a role otherwise + // hides exactly what a role-mining review is looking for — the grants a role + // does not account for — so the folded row keeps a count of them. Coverage + // comes from managedApMap (the server's business-role → cell mapping), never + // from a client-side guess at what a role ought to grant. + const roleExtraCounts = useMemo(() => { + if (foldedChildRows.size === 0) return null; + const counts = new Map(); + for (const [roleId, hiddenRows] of foldedChildRows) { + const roleIdLower = roleId.toLowerCase(); + for (const row of hiddenRows) { + const realGid = (row.realGroupId || row.id).toLowerCase(); + for (const u of users) { + const types = colMemberships.get(`${row.id}|${u.id}`); + if (!types || types.size === 0) continue; + if (managedApMap.get(`${realGid}|${u.id.toLowerCase()}`)?.includes(roleIdLower)) continue; + // A folded subject column carries the tally of everyone behind it. + const key = `${roleId}|${userToAgg.get(u.id) || u.id}`; + counts.set(key, (counts.get(key) || 0) + 1); + } + } + } + return counts; + }, [foldedChildRows, users, colMemberships, managedApMap, userToAgg]); + // Fold every top-level (first sort attribute) group into one aggregate column; // unfold clears all collapses. There's something to fold only when the first // attribute has more than one distinct value. @@ -1030,6 +1070,7 @@ export default function MatrixView({ foldableRoles={foldableRoles} foldedRoles={foldedRoles} roleChildCounts={roleChildCounts} + roleExtraCounts={roleExtraCounts} onToggleRoleFold={toggleRoleFold} /> ) : ( @@ -1059,6 +1100,7 @@ export default function MatrixView({ foldableRoles={foldableRoles} foldedRoles={foldedRoles} roleChildCounts={roleChildCounts} + roleExtraCounts={roleExtraCounts} onToggleRoleFold={toggleRoleFold} /> ))} diff --git a/app/ui/src/components/MatrixView.mount.test.jsx b/app/ui/src/components/MatrixView.mount.test.jsx index e5e3151bb..78537b80f 100644 --- a/app/ui/src/components/MatrixView.mount.test.jsx +++ b/app/ui/src/components/MatrixView.mount.test.jsx @@ -13,16 +13,23 @@ import { // element MatrixView builds (exercising MatrixColumnHeaders) plus one labelled // row per visible resource, so the orchestrator's column/sort/grouping wiring // runs end to end. +// `body.props` keeps the last props the grid body received, so tests can assert +// on the derived structures MatrixView hands it (SOLL mapping, row marking, +// folded-role tallies) without reimplementing the cell rendering here. +const body = vi.hoisted(() => ({ props: null })); vi.mock('./matrix/SortableMatrixBody', () => ({ - default: ({ columnHeaders, orderedGroups = [] }) => - h('table', null, + default: (props) => { + body.props = props; + const { columnHeaders, orderedGroups = [] } = props; + return h('table', null, columnHeaders, h('tbody', null, orderedGroups.map(g => h('tr', { key: g.id }, h('td', null, h('span', { 'data-testid': 'row-label' }, g.displayName))), ), ), - ), + ); + }, })); // A small but realistic matrix dataset: two resources, three subjects across two @@ -50,8 +57,11 @@ const roleProps = { accessPackageGroups: [ { accessPackageId: 'br-1', accessPackageName: 'HR Manager Role', resourceId: 'res-1', roleName: 'Member', totalAssignments: 1 }, ], + // Server-side business-role coverage: the role covers the resource it + // Contains AND its own membership row (migration 061). managedByPackages: [ { resourceId: 'res-1', memberId: 'u1', accessPackageIds: ['br-1'] }, + { resourceId: 'br-1', memberId: 'u1', accessPackageIds: ['br-1'] }, ], }; @@ -272,6 +282,52 @@ describe('MatrixView (mounted)', () => { expect(labels.indexOf('HR Manager Role')).toBe(labels.indexOf('Finance App') - 1); }); + it('grants a business role its own SOLL cell, so its column is not blank on its own row', async () => { + renderView(roleProps); + await expectRowVisible('HR Manager Role'); + // Holding the role IS the assignment; the diagonal cell renders it as a + // Member (D) grant in the role's own column. + expect(body.props.apGroupMap.get('BR-1|br-1')).toBe('Member'); + expect(body.props.apGroupMap.get('RES-1|br-1')).toBe('Member'); + }); + + it('keeps a business role\'s column when only its own row is on screen', async () => { + renderView({ + ...roleProps, + // The role grants a resource that is outside this matrix slice. + accessPackageGroups: [ + { accessPackageId: 'br-1', accessPackageName: 'HR Manager Role', resourceId: 'res-99', roleName: 'Member', totalAssignments: 1 }, + ], + managedByPackages: [{ resourceId: 'br-1', memberId: 'u1', accessPackageIds: ['br-1'] }], + }); + await expectRowVisible('HR Manager Role'); + expect(body.props.accessPackages.map(ap => ap.id)).toEqual(['br-1']); + expect(body.props.apGroupMap.get('BR-1|br-1')).toBe('Member'); + }); + + it('shows the resources a role grants as that role\'s children', async () => { + renderView(roleProps); + await expectRowVisible('Finance App'); + const rows = new Map(body.props.orderedGroups.map(g => [g.displayName, g])); + expect(rows.get('Finance App').roleParentId).toBe('BR-1'); + // A resource no role grants stays a plain top-level row. + expect(rows.get('HR Portal').roleParentId).toBeUndefined(); + }); + + it('tallies the access a folded role hides but does not grant', async () => { + renderView(roleProps); + const user = userEvent.setup(); + await expectRowVisible('Finance App'); + expect(body.props.roleExtraCounts).toBeNull(); + + await user.click(await screen.findByText('Fold roles')); + await waitFor(() => expect(body.props.roleExtraCounts).not.toBeNull()); + // Alice holds Finance App through the role — covered, so not counted. + expect(body.props.roleExtraCounts.get('BR-1|u1')).toBeUndefined(); + // Bob's Indirect membership on the same resource is not covered by it. + expect(body.props.roleExtraCounts.get('BR-1|u2')).toBe(1); + }); + it('offers no fold controls in a matrix without business-role rows', async () => { renderView(); await expectRowVisible('Finance App'); diff --git a/app/ui/src/components/matrix/MatrixCell.jsx b/app/ui/src/components/matrix/MatrixCell.jsx index e4cfaed4a..120f1f52c 100644 --- a/app/ui/src/components/matrix/MatrixCell.jsx +++ b/app/ui/src/components/matrix/MatrixCell.jsx @@ -1,41 +1,59 @@ import { memo } from 'react'; import { TYPE_COLORS } from '@ui/utils/colors'; +import { extraAccessTitle } from './cellMarkers'; -function MatrixCell({ cellKey, membershipTypes, managed, apColor, apCount, apNames, provisioningGap, gapExpected, onExplainInherited }) { - const hasMembership = membershipTypes && membershipTypes.size > 0; - - // Background: AP color for managed cells only; unmanaged cells stay white - let bgColor; - if (hasMembership && managed) { - bgColor = apColor || '#dbeafe'; - } +// Access that a folded business role hides but does NOT grant — the subject +// holds it on one of the folded resources through some other route. Shown as a +// count on the folded role's own cell so folding can never quietly swallow the +// very thing a role-mining review is hunting for. Exported so the aggregate +// (folded-column) cell can render the same marker. +export function ExtraAccessBadge({ count }) { + if (!count) return null; + return ( + + {count} + + ); +} - // Tooltip - let title; - if (hasMembership) { +// Everything the cell says on hover: how the access is held, which business +// roles govern it, and any marker it carries. Pulled out of the component so +// the wording lives in one readable place. +function cellTitle({ membershipTypes, managed, apNames, provisioningGap, gapExpected, extraAccessCount }) { + const parts = []; + const managedBy = apNames?.length ? `Managed by: ${apNames.join(', ')}` : null; + if (membershipTypes?.size) { const types = [...membershipTypes].join(', '); - if (apNames && apNames.length > 0) { - title = `${types}\nManaged by: ${apNames.join(', ')}`; - } else if (managed) { - title = `${types} (managed by business role)`; - } else { - title = types; - } + parts.push(managedBy ? `${types}\n${managedBy}` : (managed ? `${types} (managed by business role)` : types)); if (provisioningGap) { - const expectedLabel = gapExpected ? ` (expects ${gapExpected})` : ''; - title += `\n\u26a0 Provisioning gap: user lacks the membership type specified by the business role${expectedLabel}`; + const expected = gapExpected ? ` (expects ${gapExpected})` : ''; + parts.push(`\u26a0 Provisioning gap: user lacks the membership type specified by the business role${expected}`); } } else if (provisioningGap) { - // AP manages this cell but user has no membership at all - const expectedLabel = gapExpected ? ` ${gapExpected}` : ''; - title = `\u26a0 Provisioning gap: business role expects${expectedLabel} membership but user has none`; - if (apNames && apNames.length > 0) { - title += `\nManaged by: ${apNames.join(', ')}`; - } - bgColor = apColor || '#dbeafe'; + // A business role manages this cell but the subject has no membership at all. + const expected = gapExpected ? ` ${gapExpected}` : ''; + parts.push(`\u26a0 Provisioning gap: business role expects${expected} membership but user has none`); + if (managedBy) parts.push(managedBy); } + if (extraAccessCount > 0) parts.push(extraAccessTitle(extraAccessCount)); + return parts.length ? parts.join('\n') : undefined; +} + +function MatrixCell({ cellKey, membershipTypes, managed, apColor, apCount, apNames, provisioningGap, gapExpected, extraAccessCount = 0, onExplainInherited }) { + const hasMembership = membershipTypes && membershipTypes.size > 0; + + // Background: the business role's colour on a governed cell — and on a + // provisioning gap, which is governance without the membership. An ungoverned + // cell stays white. + const bgColor = (hasMembership ? managed : provisioningGap) ? (apColor || '#dbeafe') : undefined; + + const title = cellTitle({ membershipTypes, managed, apNames, provisioningGap, gapExpected, extraAccessCount }); - const needsRelative = apCount > 1 || provisioningGap; + const needsRelative = apCount > 1 || provisioningGap || extraAccessCount > 0; return (
); } @@ -101,6 +120,7 @@ export default memo(MatrixCell, (prev, next) => { prev.apNames === next.apNames && prev.provisioningGap === next.provisioningGap && prev.gapExpected === next.gapExpected && + prev.extraAccessCount === next.extraAccessCount && prev.onExplainInherited === next.onExplainInherited ); }); diff --git a/app/ui/src/components/matrix/MatrixCell.mount.test.jsx b/app/ui/src/components/matrix/MatrixCell.mount.test.jsx new file mode 100644 index 000000000..4fe689297 --- /dev/null +++ b/app/ui/src/components/matrix/MatrixCell.mount.test.jsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { createElement as h } from 'react'; +import MatrixCell from './MatrixCell'; +import { renderWithProviders, screen, userEvent } from '@ui/test-utils/renderWithProviders'; + +function renderCell(props = {}) { + const { container } = renderWithProviders( + h('table', null, h('tbody', null, h('tr', null, + h(MatrixCell, { cellKey: 'g1|u1', ...props })))), + ); + return container.querySelector('td'); +} + +const types = (...t) => new Set(t); + +describe('MatrixCell', () => { + it('renders one badge per membership type and names them in the tooltip', () => { + const td = renderCell({ membershipTypes: types('Direct', 'Eligible') }); + expect(screen.getByText('D')).toBeInTheDocument(); + expect(screen.getByText('E')).toBeInTheDocument(); + expect(td).toHaveAttribute('title', 'Direct, Eligible'); + }); + + it('paints a governed cell in its business role\'s colour and names the role', () => { + const td = renderCell({ + membershipTypes: types('Direct'), managed: true, + apColor: '#fde68a', apCount: 1, apNames: ['HR Manager Role'], + }); + expect(td).toHaveStyle({ backgroundColor: '#fde68a' }); + expect(td.getAttribute('title')).toContain('Managed by: HR Manager Role'); + }); + + it('marks a provisioning gap when a role expects a membership the subject lacks', () => { + const td = renderCell({ provisioningGap: true, gapExpected: 'Direct', apColor: '#fde68a' }); + expect(screen.getByText('!')).toBeInTheDocument(); + expect(td.getAttribute('title')).toContain('Provisioning gap'); + }); + + it('counts the roles covering a cell when there is more than one', () => { + renderCell({ membershipTypes: types('Direct'), managed: true, apCount: 2, apNames: ['A', 'B'] }); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('explains inherited access on click', async () => { + const onExplainInherited = vi.fn(); + renderCell({ membershipTypes: types('Indirect'), onExplainInherited }); + await userEvent.setup().click(screen.getByText('I')); + expect(onExplainInherited).toHaveBeenCalledWith('g1|u1'); + }); + + // Feedback on #370: a folded business role must not swallow the access it + // does not itself hand out. + describe('access a folded business role does not grant', () => { + it('shows the count and explains it, even on an otherwise empty cell', () => { + const td = renderCell({ extraAccessCount: 4 }); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(td.getAttribute('title')).toContain('4 assignments on the folded resources'); + expect(td).toHaveStyle({ position: 'relative' }); + }); + + it('appends the explanation to the cell\'s own tooltip', () => { + const td = renderCell({ membershipTypes: types('Direct'), extraAccessCount: 1 }); + expect(td.getAttribute('title')).toContain('Direct'); + expect(td.getAttribute('title')).toContain('1 assignment on the folded resources'); + }); + + it('renders no marker when there is nothing extra', () => { + const td = renderCell({ membershipTypes: types('Direct') }); + expect(td.querySelector('.bg-rose-600')).toBeNull(); + }); + }); +}); diff --git a/app/ui/src/components/matrix/MatrixGroupRow.jsx b/app/ui/src/components/matrix/MatrixGroupRow.jsx index 3dfe7bbca..bc0b22021 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.jsx @@ -1,4 +1,4 @@ -import MatrixCell from './MatrixCell'; +import MatrixCell, { ExtraAccessBadge } from './MatrixCell'; import { getAccessPackageColor } from '@ui/utils/colors'; import { useIsDark } from '@ui/contexts/ThemeContext'; @@ -27,26 +27,42 @@ function roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }) { }; } -// Chevron that folds a business role's resources away (and back). Rendered -// unconditionally so the row body stays branch-free; renders nothing for rows -// that aren't foldable business roles. -function RoleFoldToggle({ fold, onToggle }) { - if (!fold) return null; - const label = fold.folded ? 'Unfold business role resources' : 'Fold business role resources'; +// The one expand/collapse affordance of the grid — used both by the nested-group +// expand and by the business-role fold, so a row that opens into sub-rows always +// looks and behaves the same wherever those sub-rows come from. +function RowExpandToggle({ expanded, loading, onClick, label }) { return ( ); } +// Folds a business role's resources away (and back). Renders nothing for rows +// that aren't foldable business roles. +function RoleFoldToggle({ fold, onToggle }) { + if (!fold) return null; + return ( + onToggle?.(fold.roleKey)} + label={fold.folded ? 'Unfold business role resources' : 'Fold business role resources'} + /> + ); +} + // "N resources folded" chip on a collapsed business role row. The row's own // cells are untouched — folding hides rows, it never rolls access up. function RoleFoldChip({ fold }) { @@ -81,6 +97,7 @@ export default function MatrixGroupRow({ foldableRoles, foldedRoles, roleChildCounts, + roleExtraCounts, onToggleRoleFold, // Optional DnD props (provided by SortableRow wrapper) sortableRef, @@ -101,6 +118,15 @@ export default function MatrixGroupRow({ // business role is not a principal, so it is never in groupsWithNested). const roleFold = roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }); + // How many hidden assignments this folded role does NOT grant, per column. + const extraAccessFor = (userId) => + (roleFold?.folded && roleExtraCounts?.get(`${roleFold.roleKey}|${userId}`)) || 0; + + // A resource shown beneath the business role that grants it is drawn as that + // role's child — same indent + elbow as an expanded nested group. + const isRoleChild = !!group.roleParentId; + const indentLevel = (group.nestLevel || 0) + (isRoleChild ? 1 : 0); + const nestedBg = group.isNestedRow ? 'bg-gray-50/60 dark:bg-gray-700/40' : 'bg-white dark:bg-gray-800'; return ( @@ -123,25 +149,17 @@ export default function MatrixGroupRow({ style={{ left: '24px', minWidth: '275px', maxWidth: '275px', zIndex: 10 }} title={group.displayName} > -
+
{canExpand && ( - + onToggleExpand?.(realGidForExpand)} + label={isExpanded ? 'Collapse nested groups' : 'Expand nested groups'} + /> )} - {group.isNestedRow && ( + {(group.isNestedRow || isRoleChild) && ( {'\u2514'} )}
+ style={{ width: '24px', minWidth: '24px', position: extra > 0 ? 'relative' : undefined }}> {n > 0 ? {n} : ·} + ); } @@ -221,6 +241,7 @@ export default function MatrixGroupRow({ apNames={apNames} provisioningGap={provisioningGap} gapExpected={gapExpected} + extraAccessCount={extraAccessFor(user.id)} onExplainInherited={onExplainInherited} /> ); diff --git a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx index 3600425b9..5e8998939 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx @@ -80,4 +80,68 @@ describe('MatrixGroupRow — business-role fold affordance', () => { ); expect(foldButton()).toBeNull(); }); + + it('uses the same triangle affordance as the nested-group expand', () => { + renderRow({ id: 'BR1', displayName: 'HR Manager BR', memberCount: 3 }); + expect(screen.getByRole('button', { name: /fold business role resources/i })).toHaveTextContent('▼'); + renderRow( + { id: 'BR2', displayName: 'Other BR', memberCount: 3 }, + { foldableRoles: new Set(['BR2']), foldedRoles: new Set(['BR2']), roleChildCounts: new Map([['BR2', 1]]) }, + ); + expect(screen.getByRole('button', { name: /unfold business role resources/i })).toHaveTextContent('▶'); + }); +}); + +describe('MatrixGroupRow — a resource shown under the role that grants it', () => { + it('indents the row and marks it with the nested elbow', () => { + const { container } = renderRow({ + id: 'G1', displayName: 'Finance Group', memberCount: 2, roleParentId: 'BR1', + }); + expect(screen.getByText('└')).toBeInTheDocument(); + expect(container.querySelector('td [style*="padding-left: 16px"]')).not.toBeNull(); + }); + + it('indents a sub-row of such a resource one level deeper', () => { + const { container } = renderRow({ + id: 'G1__nested__X', realGroupId: 'X', displayName: 'Nested', isNestedRow: true, + nestLevel: 1, roleParentId: 'BR1', + }); + expect(container.querySelector('td [style*="padding-left: 32px"]')).not.toBeNull(); + }); + + it('leaves a resource no role grants flush left', () => { + const { container } = renderRow({ id: 'G4', displayName: 'Unmanaged Group', memberCount: 1 }); + expect(screen.queryByText('└')).toBeNull(); + expect(container.querySelector('td [style*="padding-left: 0px"]')).not.toBeNull(); + }); +}); + +describe('MatrixGroupRow — access a folded role does not grant', () => { + const folded = { + foldedRoles: new Set(['BR1']), + roleExtraCounts: new Map([['BR1|u1', 3]]), + }; + const role = { id: 'BR1', displayName: 'HR Manager BR', memberCount: 9 }; + // The cell and its corner badge share the explanation, so hovering anywhere + // in the cell shows it; the badge is the innermost of the two. + const extraBadge = () => screen.queryAllByTitle(/does not grant/).at(-1) ?? null; + + it('counts it on the folded role row', () => { + renderRow(role, folded); + expect(extraBadge()).toHaveTextContent('3'); + }); + + it('shows nothing while the role is expanded — the rows speak for themselves', () => { + renderRow(role, { roleExtraCounts: folded.roleExtraCounts }); + expect(extraBadge()).toBeNull(); + }); + + it('tallies it on a folded subject column too', () => { + renderRow(role, { + ...folded, + users: [{ id: 'agg-1', isAggregateCol: true, displayName: 'Engineering' }], + roleExtraCounts: new Map([['BR1|agg-1', 5]]), + }); + expect(extraBadge()).toHaveTextContent('5'); + }); }); diff --git a/app/ui/src/components/matrix/MatrixLegend.jsx b/app/ui/src/components/matrix/MatrixLegend.jsx index de1de009d..12043e884 100644 --- a/app/ui/src/components/matrix/MatrixLegend.jsx +++ b/app/ui/src/components/matrix/MatrixLegend.jsx @@ -94,6 +94,12 @@ export default function MatrixLegend() { Provisioning gap — a business role expects this membership but the user doesn't have it.
+
+ + + On a folded business role — more is handed out below than the role hands out: the number counts the folded resources this subject holds outside the role. + +
)} diff --git a/app/ui/src/components/matrix/MatrixLegend.test.js b/app/ui/src/components/matrix/MatrixLegend.test.js index 0b17eba20..6152ccd3f 100644 --- a/app/ui/src/components/matrix/MatrixLegend.test.js +++ b/app/ui/src/components/matrix/MatrixLegend.test.js @@ -29,4 +29,9 @@ describe('MatrixLegend', () => { expect(html).toContain('governed'); expect(html).toContain('Provisioning gap'); }); + + it('explains the count a folded business role shows for access it does not grant', () => { + expect(html).toContain('folded business role'); + expect(html).toContain('outside the role'); + }); }); diff --git a/app/ui/src/components/matrix/SortableMatrixBody.jsx b/app/ui/src/components/matrix/SortableMatrixBody.jsx index 8c34d273a..161c8e80e 100644 --- a/app/ui/src/components/matrix/SortableMatrixBody.jsx +++ b/app/ui/src/components/matrix/SortableMatrixBody.jsx @@ -64,6 +64,7 @@ export default function SortableMatrixBody({ foldableRoles, foldedRoles, roleChildCounts, + roleExtraCounts, onToggleRoleFold, }) { const sensors = useSensors( @@ -117,6 +118,7 @@ export default function SortableMatrixBody({ foldableRoles, foldedRoles, roleChildCounts, + roleExtraCounts, onToggleRoleFold, }; diff --git a/app/ui/src/components/matrix/cellMarkers.js b/app/ui/src/components/matrix/cellMarkers.js new file mode 100644 index 000000000..b54d5dee4 --- /dev/null +++ b/app/ui/src/components/matrix/cellMarkers.js @@ -0,0 +1,8 @@ +// Wording of the matrix cell markers, kept out of the cell components so both +// the cell and the aggregate (folded-column) cell explain a marker identically. + +// A folded business role hides rows; this is the access on those rows that the +// role itself does NOT grant — the subject holds it through some other route. +export function extraAccessTitle(count) { + return `⚠ ${count} assignment${count === 1 ? '' : 's'} on the folded resources that this business role does not grant`; +} diff --git a/app/ui/src/hooks/useBusinessRoleFold.js b/app/ui/src/hooks/useBusinessRoleFold.js index ee86c1dd3..bb7d2e704 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.js +++ b/app/ui/src/hooks/useBusinessRoleFold.js @@ -125,6 +125,65 @@ export function hideFoldedRows(rows, rolesByChild, folded) { return out; } +// The rows each folded role took away, keyed by role id. A row counts under a +// role when that role is folded and grants it; a resource granted by two folded +// roles is listed under both, since either one can bring it back. Used to +// summarise, on the folded role's own row, the access hiding underneath it. +export function collectFoldedChildRows(rows, rolesByChild, folded) { + const byRole = new Map(); + if (!folded || folded.size === 0) return byRole; + for (const row of rows) { + if (row.isNestedRow) continue; + const parents = rolesByChild.get(rowResourceKey(row)); + // Same rule hideFoldedRows applies: the row is gone only once every role + // granting it is folded — so each of those roles can bring it back. + if (!parents?.size || ![...parents].every(id => folded.has(id))) continue; + for (const id of parents) { + if (!byRole.has(id)) byRole.set(id, []); + byRole.get(id).push(row); + } + } + return byRole; +} + +// Mark the resources that sit directly beneath the business role granting them +// (the AP staircase puts them there) so the grid can render them as that role's +// children — the same indent + elbow an expanded nested group already uses. A +// resource that is not adjacent to one of its roles stays a plain top-level row +// rather than being indented under an unrelated one. +export function markRoleChildren(rows, foldableRoles, rolesByChild) { + if (!foldableRoles || foldableRoles.size === 0) return rows; + const out = []; + let roleId = null; // the role block we are currently inside + let underChild = false; // the last top-level row was one of its resources + let marked = false; + for (const row of rows) { + if (row.isNestedRow) { + // Sub-rows follow the row they were expanded from, so they inherit its + // place in the role block. + out.push(underChild ? { ...row, roleParentId: roleId } : row); + continue; + } + const key = rowResourceKey(row); + if (foldableRoles.has(key)) { + roleId = key; + underChild = false; + out.push(row); + continue; + } + if (roleId && rolesByChild.get(key)?.has(roleId)) { + underChild = true; + marked = true; + out.push({ ...row, roleParentId: roleId }); + continue; + } + roleId = null; + underChild = false; + out.push(row); + } + return marked ? out : rows; +} + /** * Business-role fold state for the per-subject matrix. * @@ -166,13 +225,18 @@ export function useBusinessRoleFold({ accessPackageGroups, rows, storageKey }) { const unfoldAllRoles = useCallback(() => applyFolds(new Set()), [applyFolds]); const visibleRows = useMemo( - () => hideFoldedRows(rows, rolesByChild, foldedRoles), [rows, rolesByChild, foldedRoles]); + () => markRoleChildren(hideFoldedRows(rows, rolesByChild, foldedRoles), foldableRoles, rolesByChild), + [rows, rolesByChild, foldedRoles, foldableRoles]); + + const foldedChildRows = useMemo( + () => collectFoldedChildRows(rows, rolesByChild, foldedRoles), [rows, rolesByChild, foldedRoles]); const hasFoldedRoles = useMemo( () => [...foldableRoles].some(id => foldedRoles.has(id)), [foldableRoles, foldedRoles]); return { visibleRows, + foldedChildRows, foldableRoles, foldedRoles, roleChildCounts: childCounts, diff --git a/app/ui/src/hooks/useBusinessRoleFold.test.jsx b/app/ui/src/hooks/useBusinessRoleFold.test.jsx index 33d1e95de..4cdcedf6b 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.test.jsx +++ b/app/ui/src/hooks/useBusinessRoleFold.test.jsx @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { renderHook, act } from '@ui/test-utils/renderWithProviders'; import { useBusinessRoleFold, buildRoleChildMap, analyseRoleRows, hideFoldedRows, - rowResourceKey, ROLE_FOLD_VERSION, + collectFoldedChildRows, markRoleChildren, rowResourceKey, ROLE_FOLD_VERSION, } from './useBusinessRoleFold'; const storeKey = (k) => `fgraph-rolefold-${k || 'all'}`; @@ -160,6 +160,69 @@ describe('hideFoldedRows', () => { }); }); +describe('collectFoldedChildRows', () => { + const childrenByRole = buildRoleChildMap(AP_GROUPS); + const { rolesByChild } = analyseRoleRows(ROWS, childrenByRole); + + it('is empty when nothing is folded', () => { + expect(collectFoldedChildRows(ROWS, rolesByChild, new Set()).size).toBe(0); + }); + + it('lists the rows a folded role took away', () => { + const byRole = collectFoldedChildRows(ROWS, rolesByChild, new Set(['BR1', 'BR2'])); + expect(ids(byRole.get('BR1'))).toEqual(['G1', 'G2']); + expect(ids(byRole.get('BR2'))).toEqual(['G2', 'G3']); + }); + + it('omits a shared resource that is still visible because one role is expanded', () => { + const byRole = collectFoldedChildRows(ROWS, rolesByChild, new Set(['BR1'])); + // G2 is also granted by the expanded BR2, so it never left the grid. + expect(ids(byRole.get('BR1'))).toEqual(['G1']); + expect(byRole.has('BR2')).toBe(false); + }); + + it('ignores nested sub-rows — they follow the row they hang under', () => { + const rows = [...ROWS, { id: 'G1__nested__X', realGroupId: 'G1', isNestedRow: true, nestLevel: 1 }]; + const byRole = collectFoldedChildRows(rows, rolesByChild, new Set(['BR1', 'BR2'])); + expect(ids(byRole.get('BR1'))).toEqual(['G1', 'G2']); + }); +}); + +describe('markRoleChildren', () => { + const childrenByRole = buildRoleChildMap(AP_GROUPS); + const { foldableRoles, rolesByChild } = analyseRoleRows(ROWS, childrenByRole); + const parents = (rows) => rows.map((r) => r.roleParentId || null); + + it('marks the resources sitting directly under the role that grants them', () => { + const marked = markRoleChildren(ROWS, foldableRoles, rolesByChild); + expect(ids(marked)).toEqual(ids(ROWS)); + expect(parents(marked)).toEqual([null, 'BR1', 'BR1', null, 'BR2', null]); + }); + + it('does not indent a resource that is not adjacent to one of its roles', () => { + // G1 has drifted below G4, away from BR1's block. + const rows = [{ id: 'BR1' }, { id: 'G4' }, { id: 'G1' }]; + expect(parents(markRoleChildren(rows, foldableRoles, rolesByChild))).toEqual([null, null, null]); + }); + + it('carries the marking onto a child row\'s own nested sub-rows', () => { + const rows = [ + { id: 'BR1' }, + { id: 'G1' }, + { id: 'G1__nested__X', isNestedRow: true, nestLevel: 1 }, + { id: 'G4' }, + { id: 'G4__nested__Y', isNestedRow: true, nestLevel: 1 }, + ]; + expect(parents(markRoleChildren(rows, foldableRoles, rolesByChild))) + .toEqual([null, 'BR1', 'BR1', null, null]); + }); + + it('returns the rows untouched when nothing can be marked', () => { + expect(markRoleChildren(ROWS, new Set(), rolesByChild)).toBe(ROWS); + expect(markRoleChildren([{ id: 'G4' }], foldableRoles, rolesByChild)).toHaveLength(1); + }); +}); + describe('rowResourceKey', () => { it('prefers the real resource id of a synthetic row', () => { expect(rowResourceKey({ id: 'g1__owner', realGroupId: 'g1' })).toBe('G1'); @@ -179,6 +242,22 @@ describe('useBusinessRoleFold', () => { expect(result.current.roleChildCounts.get('BR1')).toBe(2); }); + it('renders the resources of an expanded role as its children', () => { + const { result } = render(); + const byId = new Map(result.current.visibleRows.map((r) => [r.id, r])); + expect(byId.get('G1').roleParentId).toBe('BR1'); + expect(byId.get('G3').roleParentId).toBe('BR2'); + expect(byId.get('G4').roleParentId).toBeUndefined(); + }); + + it('reports the rows each folded role took away', () => { + const { result } = render(); + expect(result.current.foldedChildRows.size).toBe(0); + act(() => result.current.foldAllRoles()); + expect(ids(result.current.foldedChildRows.get('BR1'))).toEqual(['G1', 'G2']); + expect(ids(result.current.foldedChildRows.get('BR2'))).toEqual(['G2', 'G3']); + }); + it('offers no fold at all for a matrix without business-role rows', () => { const { result } = render({ accessPackageGroups: [] }); expect(result.current.canFoldRoles).toBe(false); diff --git a/changes/dor-issue-370.md b/changes/dor-issue-370.md index ac41aac85..932cf2051 100644 --- a/changes/dor-issue-370.md +++ b/changes/dor-issue-370.md @@ -3,4 +3,7 @@ - Business roles arrive expanded, and your fold choices are remembered per matrix so they are still there when you come back to the same slice. - A resource granted by more than one business role stays visible until every one of those roles is folded. - Matrix rows: a business role now sits directly above the resources it grants in the staircase order (any saved custom row order is reset once as a result). +- Matrix: the resources a business role grants are now shown as its children — indented under the role with the same triangle and elbow that expanding a group already used. +- Matrix: a business role's own row now shows the "D" badge in its own column and its cells are coloured as governed, like any other access a business role hands out. Business-role memberships are also counted as governed in the scope statistics instead of as ungoverned assignments. +- Matrix: a folded business role now shows, per subject, a red count of the folded resources that subject holds outside the role — so folding never hides that more is handed out than the role hands out. The "How to read this matrix" legend explains the marker. - Matrix scope statistics: each headline number is now announced together with the metric it belongs to (e.g. "Resources, 39") by screen readers. diff --git a/docs/architecture/matrix.md b/docs/architecture/matrix.md index 36aa30b3a..16e5e0562 100644 --- a/docs/architecture/matrix.md +++ b/docs/architecture/matrix.md @@ -11,7 +11,7 @@ outcome: You can read any cell in the matrix and say exactly how that access is Brand new? Start at [The words you need first](../start/glossary.md). > **Status:** current as of May 2026. -> Companion to [`013_matrix_matviews_and_indexes.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/013_matrix_matviews_and_indexes.sql), [`024_matrix_view_all_assignment_types.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/024_matrix_view_all_assignment_types.sql), [`046_owner_as_resource.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/046_owner_as_resource.sql), [`049_governed_intent_rows.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/049_governed_intent_rows.sql). +> Companion to [`013_matrix_matviews_and_indexes.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/013_matrix_matviews_and_indexes.sql), [`024_matrix_view_all_assignment_types.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/024_matrix_view_all_assignment_types.sql), [`046_owner_as_resource.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/046_owner_as_resource.sql), [`049_governed_intent_rows.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/049_governed_intent_rows.sql), [`061_business_role_covers_itself.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/061_business_role_covers_itself.sql). ## The grid @@ -147,12 +147,14 @@ per-subject payload — it changes what is *rendered*, not what is *fetched*. ### Business-role fold (rows) The column fold above collapses *columns*; the **business-role fold** collapses -*rows*. A business-role row (`resourceType='BusinessRole'`) carries a chevron -that hides the rows of the resources that role grants — its `Contains` children -— leaving the role row with an "*N* resources folded" chip. A **Fold roles / -Unfold roles** toolbar pair does it for every role at once, which reduces the -grid to exactly "business roles + resources no role grants" — the role-mining -view without the duplication between a role and its contents. +*rows*. A business-role row (`resourceType='BusinessRole'`) carries the grid's +ordinary expand triangle (`▼`/`▶` — the same control, and the same indent + `└` +elbow on the rows below it, as the nested-group expand): collapsing it hides the +rows of the resources that role grants — its `Contains` children — leaving the +role row with an "*N* resources folded" chip. A **Fold roles / Unfold roles** +toolbar pair does it for every role at once, which reduces the grid to exactly +"business roles + resources no role grants" — the role-mining view without the +duplication between a role and its contents. The parent → child mapping is not derived client-side: it is the same `ResourceRelationships` / `relationshipType='Contains'` data that @@ -180,7 +182,35 @@ Rules worth knowing: not off a role by `Contains`. - The AP staircase promotes a **business role's own row to the top of its bucket**, directly above the resources it grants, so a parent is always - adjacent to the children it folds away. + adjacent to the children it folds away — and those resources are drawn as its + children (indented, with the elbow). A resource that is *not* adjacent to one + of its roles stays a plain top-level row rather than being indented under an + unrelated one. +- **A folded role says how much it is hiding that it does not grant.** Per + subject column, the folded row carries a red count of the folded resources + that subject holds *outside* this role. Folding is a summary, never a + cover-up: the access a role does not account for — exactly what role mining is + looking for — stays on screen. Coverage comes from the server's business-role + mapping (`managedByPackages`), not from a client-side guess at what a role + ought to grant. + +### A business role's own row + +A business role is a resource row like any other, and holding it is a `Direct` +assignment carrying `governed=true`. Two consequences the grid makes visible: + +- **Its own SOLL column is filled in.** The role grants itself, so the diagonal + cell (role row × its own column) renders the **D** badge in the role's colour. + That grant is not a `Contains` relationship, so it can never arrive as a + (role, resource) pair from `GET /api/access-package-groups`; `MatrixView` + fills the diagonal when it builds the SOLL mapping. +- **Its cells are coloured governed.** + [`061_business_role_covers_itself.sql`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/api/src/db/migrations/061_business_role_covers_itself.sql) + adds a self arm to `vw_UserPermissionAssignmentViaBusinessRole`, so a role + covers its own membership row as well as the resources it Contains. Before + that, the role row painted ungoverned, dropped out of the **Governed** view, + and every business-role membership counted as an *ungoverned* assignment in + the scope statistics. ### Size gate From 5a93e7887a2af8b3e032eac99bc97540c1e13abf Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Tue, 4 Aug 2026 16:52:42 +0000 Subject: [PATCH 04/14] fix: address requestor feedback (#370) --- .github/scripts/dor_build_flow.sh | 10 +- .github/scripts/dor_build_lib.sh | 16 ++- .github/scripts/dor_feedback_flow.sh | 11 +- .github/workflows/dor-acceptance.yml | 32 ++++- app/ui/e2e/matrix.spec.js | 86 ++++++++++++ app/ui/src/components/MatrixView.jsx | 60 +++------ .../src/components/MatrixView.mount.test.jsx | 41 ++++++ app/ui/src/components/matrix/CellBadges.jsx | 98 ++++++++++++++ app/ui/src/components/matrix/MatrixCell.jsx | 117 ++++++++--------- .../matrix/MatrixCell.mount.test.jsx | 35 +++++ .../src/components/matrix/MatrixGroupRow.jsx | 76 +++++++++-- .../matrix/MatrixGroupRow.mount.test.jsx | 116 ++++++++++++++++ app/ui/src/components/matrix/MatrixLegend.jsx | 20 ++- .../components/matrix/MatrixLegend.test.js | 12 ++ .../components/matrix/SortableMatrixBody.jsx | 4 +- app/ui/src/components/matrix/cellMarkers.js | 19 ++- .../components/matrix/coverageDeviation.js | 118 +++++++++++++++++ .../matrix/coverageDeviation.test.js | 124 ++++++++++++++++++ app/ui/src/hooks/useBusinessRoleFold.js | 103 ++++++++++----- app/ui/src/hooks/useBusinessRoleFold.test.jsx | 56 +++++++- changes/dor-issue-370.md | 5 + docs/architecture/demo-dataset.md | 49 ++++++- docs/architecture/matrix.md | 67 ++++++++-- test/demo-dataset/Generate-DemoDataset.ps1 | 4 +- test/demo-dataset/Verify-DemoDataset.ps1 | 64 ++++++++- test/demo-dataset/parts/DemoRoleDrift.ps1 | 116 ++++++++++++++++ test/demo-dataset/parts/DemoState.ps1 | 13 +- 27 files changed, 1278 insertions(+), 194 deletions(-) create mode 100644 app/ui/src/components/matrix/CellBadges.jsx create mode 100644 app/ui/src/components/matrix/coverageDeviation.js create mode 100644 app/ui/src/components/matrix/coverageDeviation.test.js create mode 100644 test/demo-dataset/parts/DemoRoleDrift.ps1 diff --git a/.github/scripts/dor_build_flow.sh b/.github/scripts/dor_build_flow.sh index 931233ed3..0c69bf1c1 100644 --- a/.github/scripts/dor_build_flow.sh +++ b/.github/scripts/dor_build_flow.sh @@ -67,7 +67,8 @@ if [ -z "$pr" ]; then | grep -oE '[0-9]+$') || bail "could not open the PR" fi echo "$pr $ISSUE" > "$HOME/.dor-reservation" # — dor-reset/feedback route off this -# (board was already moved to Building at the start of the run) +# (board was already moved to Building at the start of the run) — now post the PR + follow link. +comment_issue "$(printf '🔨 Building (PR #%s) — I'\''ll comment when it'\''s ready to test.%s' "$pr" "${RUN_URL:+ · 👀 [follow progress]($RUN_URL)}")" # 3-5. Verify: deploy+seed → e2e on live env → CI green. Fix + retry up to MAX_ATTEMPTS (else Exceptions). verify_loop "$pr" @@ -82,7 +83,8 @@ GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" build-done 2> || echo "::warning::board move failed (BOT token likely expired on a long build) — label is set; the fresh-token step reconciles the column" summary=$(jq -r '.result // empty' /tmp/impl.json 2>/dev/null | head -c 1200) -[ -n "$summary" ] || summary="Implemented the approved spec; unit tests, the feature e2e on the live env, and the full PR CI are all green." -comment_issue "$(printf '%s — ✅ this feature has been **built and verified**, and is ready for your functional testing.\n\n🔗 **Test it here:** %s (Fortigi-tenant sign-in via authentik)\n📦 **PR:** #%s (CI green)\n\n**What was built & tested:**\n%s\n\nThe demo dataset + context plugins are loaded so the feature has data to exercise. Please try it and **comment anything that is not yet 100%% right** — I monitor this thread and will adjust the build incrementally. When you are fully happy, **reply `approved`** and it moves to the Product Board for the final merge.' "$(issue_mentions)" "$URL" "$pr" "$summary")" -gh pr comment "$pr" --repo "$REPO" --body "🤖 Built + verified on **${HOST}**. e2e on the live env + full CI green. Functional testing: ${URL}. Awaiting requestor acceptance on #${ISSUE}." >/dev/null 2>&1 || true +[ "${#summary}" -lt 25 ] && summary="$(changed_summary origin/main..HEAD)" # terse output → describe from the diff +[ -n "$summary" ] || summary="Implemented the approved spec; unit tests and the feature e2e on the live env pass." +comment_issue "$(printf '%s — ✅ built and ready to test.\n\n🔗 **Test:** %s · 📦 **PR:** #%s\n\n%s\n\nReply with anything that'\''s off, or **`approved`** to send it to merge.' "$(issue_mentions)" "$URL" "$pr" "$summary")" +gh pr comment "$pr" --repo "$REPO" --body "🤖 Built + verified on **${HOST}** (e2e + CI green) → ${URL}" >/dev/null 2>&1 || true echo "::notice::#${ISSUE} built + verified → Awaiting functional acceptance (PR #${pr}, ${URL})" diff --git a/.github/scripts/dor_build_lib.sh b/.github/scripts/dor_build_lib.sh index e0c292823..2ed19c0ca 100644 --- a/.github/scripts/dor_build_lib.sh +++ b/.github/scripts/dor_build_lib.sh @@ -25,6 +25,9 @@ FIX_TURNS="${FIX_TURNS:-60}" # a fix is scoped; cap it so a c MAINTAINERS="@WimvandenHeijkant @TaekeK @robb536" PLUGINS="entra-group-category-tree resource-type-tree resource-cluster scope-hierarchy risky-consent" FLOW_NOUN="${FLOW_NOUN:-build}" +# Link to THIS workflow run so a comment can say "follow progress → here". Empty off-Actions. +RUN_URL="" +[ -n "${GITHUB_RUN_ID:-}" ] && RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-$REPO}/actions/runs/${GITHUB_RUN_ID}" # Terse working output (the agent runs headless — nobody reads its narration), but keep the DELIVERABLES # professional. Appended to every prompt. TERSE=$'\n\nWork directly and without narration: make the edits and run the commands, do not explain your steps or write prose summaries. Keep the actual deliverables — commit messages, the PR description, code comments, and changelog entries — normal, clear and professional.' @@ -36,7 +39,14 @@ git -C "$WORK" config user.name "IdentityAtlas DoR agent" >/dev/null 2>&1 || tr issue_mentions() { # requestor (author) + commenters, deduped, bots excluded, @-prefixed gh issue view "$ISSUE" --repo "$REPO" --json author,comments \ - --jq '([.author.login]+[.comments[].author.login]) | map(select(. and (endswith("[bot]")|not))) | unique | map("@"+.) | join(" ")' + --jq '([.author.login]+[.comments[].author.login]) | map(select(. and (endswith("[bot]")|not) and (.!="github-actions"))) | unique | map("@"+.) | join(" ")' +} + +# A short, deterministic description of what a commit changed (files touched), for report comments — +# more useful than the AI'\''s terse final message. $1 = git range (e.g. origin/main..HEAD). +changed_summary() { + local stat; stat="$(git -C "$WORK" diff --stat "$1" 2>/dev/null | tail -8)" + [ -n "$stat" ] && printf 'Files changed:\n```\n%s\n```' "$stat" } comment_issue() { gh issue comment "$ISSUE" --repo "$REPO" --body "$1" >/dev/null 2>&1 || true; } @@ -77,7 +87,7 @@ bail() { git -C "$WORK" restore --source=HEAD --staged --worktree -- .github 2>/dev/null || true GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" exception 2>/dev/null || true gh issue edit "$ISSUE" --repo "$REPO" --add-label needs-triage >/dev/null 2>&1 || true - comment_issue "$(printf '⚠️ %s — the automated %s for this feature hit a problem and was moved to **Exceptions** for triage.\n\n**What broke:** %s\n\nBranch `%s` on **%s**. A maintainer needs to look.' "$MAINTAINERS" "$FLOW_NOUN" "$reason" "$BRANCH" "$HOST")" + comment_issue "$(printf '⚠️ %s — %s hit a problem → **Exceptions** (needs triage).\n**What broke:** %s (`%s` on %s)' "$MAINTAINERS" "$FLOW_NOUN" "$reason" "$BRANCH" "$HOST")" exit 1 } @@ -94,7 +104,7 @@ pause_and_exit() { git -C "$WORK" push --force-with-lease origin "$BRANCH" 2>/dev/null || true GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" paused 2>/dev/null || true gh issue edit "$ISSUE" --repo "$REPO" --add-label dor-paused --remove-label ready-to-build >/dev/null 2>&1 || true - comment_issue "$(printf '⏸️ **Paused** — the %s hit a Claude usage limit (%s). Work so far is saved on `%s`, so nothing is lost. It will **auto-resume** when capacity returns (usage limits reset periodically) — no action needed. A maintainer can also re-run it by re-applying `ready-to-build`.' "$FLOW_NOUN" "$reason" "$BRANCH")" + comment_issue "$(printf '⏸️ **Paused** — hit a Claude usage limit. Work is saved on `%s`; will **auto-resume** when capacity returns (no action needed).' "$BRANCH")" exit 0 } diff --git a/.github/scripts/dor_feedback_flow.sh b/.github/scripts/dor_feedback_flow.sh index 44b77923a..5b57814ff 100644 --- a/.github/scripts/dor_feedback_flow.sh +++ b/.github/scripts/dor_feedback_flow.sh @@ -26,7 +26,7 @@ git checkout -B "$BRANCH" "origin/$BRANCH" || bail "could not check out $BRANCH pr=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number // empty') [ -n "$pr" ] || { comment_issue "🤖 The PR for this build is no longer open, so there's nothing to adjust. Re-open it or file a new request."; exit 0; } -comment_issue "$(printf '🤖 @%s — on it. Adjusting the build to address your feedback, then I'\''ll re-deploy to %s and report back.' "$FEEDBACK_AUTHOR" "$URL")" +comment_issue "$(printf '🤖 On it — adjusting for your feedback, then re-deploying to %s.%s' "$URL" "${RUN_URL:+ · 👀 [follow progress]($RUN_URL)}")" # The AI is now working — reflect that on the board (not "Awaiting functional acceptance", which reads # as "ready for you to test"). Restored to functional acceptance when the adjustment is deployed. GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" building 2>/dev/null || true @@ -42,8 +42,9 @@ esac git restore --source=origin/main --staged --worktree -- .github 2>/dev/null || true git add -A if git diff --cached --quiet; then + touch "${RUNNER_TEMP:-/tmp}/dor-done" # success (nothing to change) → the reconcile step keeps it at functional acceptance GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" build-done 2>/dev/null || true # nothing to rebuild → back to functional acceptance - comment_issue "$(printf '🤖 @%s — I looked at that but couldn'\''t find a concrete code change to make from it. Could you point me at the specific behaviour to change? (Or reply `approved` if it'\''s actually fine as-is.)' "$FEEDBACK_AUTHOR")" + comment_issue "$(printf '🤖 @%s — couldn'\''t find a concrete change to make from that. Which specific behaviour should change? (Or reply **`approved`** if it'\''s fine.)' "$FEEDBACK_AUTHOR")" exit 0 fi git commit -q -m "fix: address requestor feedback (#${ISSUE})" || bail "git commit failed" @@ -53,8 +54,10 @@ git push --force-with-lease origin "$BRANCH" || bail "could not push the adjustm verify_loop "$pr" # 3. Adjustment deployed + verified → back to Awaiting functional acceptance, and report back. +touch "${RUNNER_TEMP:-/tmp}/dor-done" # success → the workflow's fresh-token reconcile step asserts build-done (survives >1h cycles) GH_TOKEN="$BOARD_TOKEN" bash "$SCRIPTS/dor_set_status.sh" "$ISSUE" build-done 2>/dev/null || true summary=$(jq -r '.result // empty' /tmp/adjust.json 2>/dev/null | head -c 1000) -[ -n "$summary" ] || summary="Applied your feedback; the feature e2e on the live env and the full PR CI are green again." -comment_issue "$(printf '%s — ✅ updated per your feedback and re-deployed.\n\n🔗 **Re-test here:** %s\n📦 **PR:** #%s (CI green)\n\n**What changed:**\n%s\n\nTake another look — comment anything still off, or reply `approved` when you'\''re happy and it moves to the Product Board for merge.' "$(issue_mentions)" "$URL" "$pr" "$summary")" +[ "${#summary}" -lt 25 ] && summary="$(changed_summary origin/main..HEAD)" # terse output → describe from the diff +[ -n "$summary" ] || summary="Applied your feedback; the feature e2e on the live env is green again." +comment_issue "$(printf '%s — ✅ updated and re-deployed.\n\n🔗 **Re-test:** %s · 📦 **PR:** #%s\n\n%s\n\nAnything still off? Comment. Happy? Reply **`approved`**.' "$(issue_mentions)" "$URL" "$pr" "$summary")" echo "::notice::#${ISSUE} adjusted per feedback → still Awaiting functional acceptance (PR #${pr})" diff --git a/.github/workflows/dor-acceptance.yml b/.github/workflows/dor-acceptance.yml index 5461a9a33..f1e9a8eef 100644 --- a/.github/workflows/dor-acceptance.yml +++ b/.github/workflows/dor-acceptance.yml @@ -101,7 +101,7 @@ jobs: GH_TOKEN="$BOARD_TOKEN" bash .github/scripts/dor_set_status.sh "$ISSUE" awaiting-merge || true # Drop build-done so the feedback loop stops firing; the board is now canonical. gh issue edit "$ISSUE" --repo "$REPO" --remove-label build-done >/dev/null 2>&1 || true - gh issue comment "$ISSUE" --repo "$REPO" --body "$(printf '✅ @%s accepted this feature. @WimvandenHeijkant @TaekeK @robb536 (Product Board) — it'\''s ready for the **final merge review** of PR #%s. Merging it releases the build sidekick and closes this issue.' "$ACTOR" "${pr:-?}")" >/dev/null 2>&1 || true + gh issue comment "$ISSUE" --repo "$REPO" --body "$(printf '✅ @%s approved. @WimvandenHeijkant @TaekeK @robb536 — PR #%s is ready for **final merge**.' "$ACTOR" "${pr:-?}")" >/dev/null 2>&1 || true echo "::notice::#${ISSUE} accepted by ${ACTOR} → Awaiting merge (PR #${pr:-?})" # Loop E — feedback. Only the sidekick that holds this feature's live env acts; the others no-op. @@ -192,6 +192,36 @@ jobs: run: | set -uo pipefail [ -f "$RUNNER_TEMP/dor-bailed" ] && { echo "flow already handled the failure"; exit 0; } + [ -f "$RUNNER_TEMP/dor-paused" ] && { echo "flow paused on a usage limit — not an error"; exit 0; } GH_TOKEN="$BOARD_TOKEN" bash .github/scripts/dor_set_status.sh "$ISSUE" exception 2>/dev/null || true gh issue comment "$ISSUE" --repo "$REPO" \ --body "⚠️ @WimvandenHeijkant @TaekeK @robb536 — the automated adjustment for this feature failed unexpectedly on ${HOST}. Moved to **Exceptions** — please check the workflow run." >/dev/null 2>&1 || true + + # A feedback adjust can run >1h, so the flow's terminal board move can fail on the expired + # BOARD_TOKEN (leaving the board stuck at Building — exactly what happened on #370). Mint a FRESH + # token and reconcile the column from the outcome marker. Mirrors the build agent (#925). + - name: Mint a fresh BOT token to reconcile the board + if: always() && steps.sk.outputs.holder == 'true' + id: bot2 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + owner: Fortigi + permission-organization-projects: write + permission-issues: read + - name: Reconcile the board column with the fresh token + if: always() && steps.sk.outputs.holder == 'true' + env: + BOARD_TOKEN: ${{ steps.bot2.outputs.token }} + ISSUE: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: | + set -uo pipefail + if [ -f "$RUNNER_TEMP/dor-done" ]; then tok=build-done # adjust finished → functional acceptance + elif [ -f "$RUNNER_TEMP/dor-paused" ]; then tok=paused + elif [ -f "$RUNNER_TEMP/dor-bailed" ]; then tok=exception + else echo "no terminal marker — leaving the board as-is"; exit 0; fi + GH_TOKEN="$BOARD_TOKEN" bash .github/scripts/dor_set_status.sh "$ISSUE" "$tok" \ + && echo "::notice::reconciled board → $tok (fresh token)" \ + || echo "::warning::board reconcile failed for $tok" diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index 66c8a4e03..7f779c4ab 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -283,6 +283,92 @@ test.describe('Matrix — fold business-role resources', () => { await unfoldAll(page).click(); await expect(marker).toHaveCount(0); }); + + // Requestor feedback on #370: the grid showed only over-granting. The demo + // dataset's BR-Service-Desk carries both directions (see DemoRoleDrift.ps1). + test('a folded role counts what it assigns that the subject does not have', async ({ page }) => { + await openFoldableGrid(page); + + const marker = page.locator('tbody span[title*="does not have"]'); + await expect(marker).toHaveCount(0); + + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + + if (await marker.count()) { + await expect(marker.first()).toHaveText(/^[1-9]\d*$/); + await expect(marker.first()).toHaveAttribute( + 'title', /assignments? on the folded resources that this business role assigns but this subject does not have/, + ); + } + await unfoldAll(page).click(); + await expect(marker).toHaveCount(0); + }); + + test('marks a standing membership where the role only grants eligibility', async ({ page }) => { + await openFoldableGrid(page); + + // Unfolded, the deviation sits on the resource's own cell. + const overGrant = page.locator('tbody span[title*="More than the business role assigns"]'); + if (await overGrant.count()) { + await expect(overGrant.first()).toHaveText('+'); + await expect(overGrant.first()).toHaveAttribute('title', /just-in-time/); + } + }); + + // Rows keep whatever position they are moved to, so a resource can end up far + // from the role that grants it. The row must still say which role that is. + test('a resource still names its business role after being moved away from it', async ({ page }) => { + await openFoldableGrid(page); + + // Every resource a role grants answers the question from its row tooltip, + // wherever it sits. + const named = page.locator('tbody td[title*="Granted by business role:"]'); + await expect.poll(() => named.count()).toBeGreaterThan(0); + + // Persist a row order the way the drag handle does — clicking the "#" + // header sorts by member count, which writes the current ids to storage. + await page.locator('th[title="Sort by member count (descending)"]').click(); + + // Then move one role-granted resource to the very top, above every role row + // — the "user dragged it away from its role" case. + const pairs = await (await page.request.get('/api/access-package-groups')).json(); + const moved = await page.evaluate((rows) => { + const key = Object.keys(localStorage).find(k => k.startsWith('fgraph-roworder-')); + if (!key) return null; + const saved = JSON.parse(localStorage.getItem(key)); + const order = saved.order.map(id => String(id).toUpperCase()); + // A pair whose role AND resource both have a row — only then is there a + // role in the grid to name. + const pair = rows.find(r => r.resourceId + && order.includes(String(r.accessPackageId).toUpperCase()) + && order.includes(String(r.resourceId).toUpperCase())); + if (!pair) return null; + const child = saved.order.find(id => String(id).toUpperCase() === String(pair.resourceId).toUpperCase()); + localStorage.setItem(key, JSON.stringify({ + ...saved, + order: [child, ...saved.order.filter(id => id !== child)], + })); + return { role: pair.accessPackageName }; + }, pairs); + test.skip(!moved, 'no business role and one of its resources share this grid'); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); + + // The moved row now names its role on the row itself. (A resource granted by + // several roles lists them all in the chip's tooltip, hence the substring.) + const chip = page.locator(`tbody button[title^="Granted by business role:"][title*="${moved.role}"]`).first(); + await expect(chip).toBeVisible({ timeout: 20000 }); + + // Leave the browser profile clean for the next test. + await page.evaluate(() => { + for (const k of Object.keys(localStorage)) { + if (k.startsWith('fgraph-roworder-')) localStorage.removeItem(k); + } + }); + }); }); // ─── Regression: no double scrollbar behind the matrix grid ──────────────────── diff --git a/app/ui/src/components/MatrixView.jsx b/app/ui/src/components/MatrixView.jsx index 24825ec94..755ec7f4c 100644 --- a/app/ui/src/components/MatrixView.jsx +++ b/app/ui/src/components/MatrixView.jsx @@ -15,6 +15,7 @@ import MatrixScopePanel from './matrix/MatrixScopePanel'; import MatrixColumnHeaders from './matrix/MatrixColumnHeaders'; import { makeUserComparator, buildSortKeys } from './matrix/sortUsers'; import MatrixGroupRow from './matrix/MatrixGroupRow'; +import { buildRoleDeviationCounts, cellDeviation, NO_ROLE_DEVIATIONS } from './matrix/coverageDeviation'; // Inline arrayMove so MatrixView doesn't depend on @dnd-kit function arrayMove(arr, from, to) { @@ -654,22 +655,15 @@ export default function MatrixView({ }); if (groupAps.length === 0) return false; + // A row is a gap row when some subject is short of what a role covering + // this cell assigns — the same comparison the cell markers use, so the + // Gaps view and the amber "!" can never disagree. const groupApIdSetLower = new Set(groupAps.map(ap => ap.id.toLowerCase())); return users.some(user => { const cellKeyLower = `${realGid.toLowerCase()}|${user.id.toLowerCase()}`; - const userApIds = (managedApMap?.get(cellKeyLower) || []).filter(id => groupApIdSetLower.has(id)); - if (userApIds.length === 0) return false; - - const cellKey = `${group.id}|${user.id}`; - const cellTypes = displayMemberships.get(cellKey); - return userApIds.some(apId => { - const apObj = groupAps.find(a => a.id.toLowerCase() === apId); - const role = apObj ? (apGroupMap?.get(`${lookupGid}|${apObj.id.toLowerCase()}`) || 'Member') : 'Member'; - const lower = role.toLowerCase(); - if (lower.includes('owner')) return !cellTypes?.has('Owner'); - if (lower.includes('eligible')) return !cellTypes?.has('Eligible'); - return !cellTypes?.has('Direct'); - }); + const apIds = (managedApMap?.get(cellKeyLower) || []).filter(id => groupApIdSetLower.has(id)); + const types = displayMemberships.get(`${group.id}|${user.id}`); + return cellDeviation({ types, apIds, apGroupMap, resourceKey: lookupGid }).missing.length > 0; }); }); }, [displayGroups, managedFilter, accessPackages, apGroupMap, users, managedApMap, displayMemberships]); @@ -839,31 +833,15 @@ export default function MatrixView({ return counts; }, [colMemberships, userToAgg, collapsedGroups]); - // Per (folded role, subject column): how many of the rows that role folded - // away carry access the role itself does NOT grant. Folding a role otherwise - // hides exactly what a role-mining review is looking for — the grants a role - // does not account for — so the folded row keeps a count of them. Coverage - // comes from managedApMap (the server's business-role → cell mapping), never - // from a client-side guess at what a role ought to grant. - const roleExtraCounts = useMemo(() => { - if (foldedChildRows.size === 0) return null; - const counts = new Map(); - for (const [roleId, hiddenRows] of foldedChildRows) { - const roleIdLower = roleId.toLowerCase(); - for (const row of hiddenRows) { - const realGid = (row.realGroupId || row.id).toLowerCase(); - for (const u of users) { - const types = colMemberships.get(`${row.id}|${u.id}`); - if (!types || types.size === 0) continue; - if (managedApMap.get(`${realGid}|${u.id.toLowerCase()}`)?.includes(roleIdLower)) continue; - // A folded subject column carries the tally of everyone behind it. - const key = `${roleId}|${userToAgg.get(u.id) || u.id}`; - counts.set(key, (counts.get(key) || 0) + 1); - } - } - } - return counts; - }, [foldedChildRows, users, colMemberships, managedApMap, userToAgg]); + // Per (folded role, subject column): how the rows a role folded away deviate + // from what the role assigns — more than it grants (red) and fewer (amber). + // Folding a role otherwise hides exactly what a role-mining review is looking + // for, in both directions, so the folded row keeps both counts. Coverage comes + // from managedApMap (the server's business-role → cell mapping), never from a + // client-side guess at what a role ought to grant. + const roleDeviations = useMemo(() => buildRoleDeviationCounts({ + foldedChildRows, users, memberships: colMemberships, managedApMap, apGroupMap, userToAgg, + }) || NO_ROLE_DEVIATIONS, [foldedChildRows, users, colMemberships, managedApMap, apGroupMap, userToAgg]); // Fold every top-level (first sort attribute) group into one aggregate column; // unfold clears all collapses. There's something to fold only when the first @@ -1070,7 +1048,8 @@ export default function MatrixView({ foldableRoles={foldableRoles} foldedRoles={foldedRoles} roleChildCounts={roleChildCounts} - roleExtraCounts={roleExtraCounts} + roleExtraCounts={roleDeviations.extra} + roleMissingCounts={roleDeviations.missing} onToggleRoleFold={toggleRoleFold} /> ) : ( @@ -1100,7 +1079,8 @@ export default function MatrixView({ foldableRoles={foldableRoles} foldedRoles={foldedRoles} roleChildCounts={roleChildCounts} - roleExtraCounts={roleExtraCounts} + roleExtraCounts={roleDeviations.extra} + roleMissingCounts={roleDeviations.missing} onToggleRoleFold={toggleRoleFold} /> ))} diff --git a/app/ui/src/components/MatrixView.mount.test.jsx b/app/ui/src/components/MatrixView.mount.test.jsx index 78537b80f..fce284d28 100644 --- a/app/ui/src/components/MatrixView.mount.test.jsx +++ b/app/ui/src/components/MatrixView.mount.test.jsx @@ -65,6 +65,22 @@ const roleProps = { ], }; +// The same matrix plus Carol, who holds the role but not the resource it grants +// — the "fewer than the role assigns" side. Its own filter, so the fold state a +// previous test persisted (per filter) can't carry into it. +const driftProps = { + ...roleProps, + data: [ + ...makeRoleData(), + { memberId: 'u3', memberDisplayName: 'Carol Sales', department: 'Sales', memberType: 'User', resourceId: 'br-1', resourceDisplayName: 'HR Manager Role', resourceType: 'BusinessRole', membershipType: 'Direct' }, + ], + managedByPackages: [ + ...roleProps.managedByPackages, + { resourceId: 'res-1', memberId: 'u3', accessPackageIds: ['br-1'] }, + { resourceId: 'br-1', memberId: 'u3', accessPackageIds: ['br-1'] }, + ], +}; + const rowLabels = () => screen.queryAllByTestId('row-label').filter(el => el.isConnected).map(el => el.textContent); @@ -328,6 +344,31 @@ describe('MatrixView (mounted)', () => { expect(body.props.roleExtraCounts.get('BR-1|u2')).toBe(1); }); + // Requestor feedback on #370: folding must summarise both directions of drift + // — Carol holds the role but not the resource it grants (fewer), while Bob + // holds that resource without the role (more). + it('tallies what a folded role assigns that the subject does not have', async () => { + renderView({ ...driftProps, filter: { ...baseFilter, sortAttributes: [{ attribute: 'jobTitle', dir: 'asc' }] } }); + const user = userEvent.setup(); + await expectRowVisible('Finance App'); + expect(body.props.roleMissingCounts).toBeNull(); + + await user.click(await screen.findByText('Fold roles')); + await waitFor(() => expect(body.props.roleMissingCounts).not.toBeNull()); + expect(body.props.roleMissingCounts.get('BR-1|u3')).toBe(1); + expect(body.props.roleMissingCounts.get('BR-1|u1')).toBeUndefined(); + // ...and the opposite drift is still counted in the same folded row. + expect(body.props.roleExtraCounts.get('BR-1|u2')).toBe(1); + }); + + it('keeps only the rows a subject is short on in the Gaps view', async () => { + renderView({ ...driftProps, managedFilter: 'gaps' }); + // Carol holds the role but not the resource it grants — the only gap here. + await expectRowVisible('Finance App'); + expect(rowLabels()).not.toContain('HR Portal'); // no role covers it + expect(rowLabels()).not.toContain('HR Manager Role'); // everyone holding it has it + }); + it('offers no fold controls in a matrix without business-role rows', async () => { renderView(); await expectRowVisible('Finance App'); diff --git a/app/ui/src/components/matrix/CellBadges.jsx b/app/ui/src/components/matrix/CellBadges.jsx new file mode 100644 index 000000000..24ef055f9 --- /dev/null +++ b/app/ui/src/components/matrix/CellBadges.jsx @@ -0,0 +1,98 @@ +import { extraAccessTitle, missingAccessTitle, overGrantTitle } from './cellMarkers'; + +// The corner markers a matrix cell can carry. Colour is the whole language: +// +// amber, on the left — FEWER permissions than the business role assigns +// red, on the right — MORE permissions than the business role assigns +// +// A cell can carry both at once (a business role that grants several resources +// can be short in one and over in another for the same subject), so the two +// never share a corner. Kept out of MatrixCell so the aggregate (folded-column) +// cell in MatrixGroupRow renders exactly the same markers. + +// Access a folded business role hides but does NOT grant — shown as a count on +// the folded role's own cell so folding can never quietly swallow the very +// thing a role-mining review is hunting for. +export function ExtraAccessBadge({ count }) { + if (!count) return null; + return ( + + {count} + + ); +} + +// The mirror: memberships a folded business role assigns that the subject does +// not have. Same idea, opposite direction — under-provisioning stays visible +// through the fold too. +export function MissingAccessBadge({ count }) { + if (!count) return null; + return ( + + {count} + + ); +} + +// A business role expects a membership this subject does not have. +function GapMarker() { + return ( + + ! + + ); +} + +// The subject holds a standing membership where the role only grants +// just-in-time eligibility. Shares the bottom-right "more than the role +// assigns" corner with the folded-role count, which only ever appears on a +// folded role's own row — so the two never draw over each other. +function OverGrantMarker({ expected }) { + return ( + + + + + ); +} + +// How many business roles cover this cell, when more than one does. +function ApCountBadge({ count }) { + if (!(count > 1)) return null; + return ( + + {count} + + ); +} + +export default function CellBadges({ + provisioningGap, overGrant, apCount, extraAccessCount, missingAccessCount, +}) { + return ( + <> + {provisioningGap && } + {overGrant && !extraAccessCount && } + + + + + ); +} diff --git a/app/ui/src/components/matrix/MatrixCell.jsx b/app/ui/src/components/matrix/MatrixCell.jsx index 120f1f52c..11d78d33b 100644 --- a/app/ui/src/components/matrix/MatrixCell.jsx +++ b/app/ui/src/components/matrix/MatrixCell.jsx @@ -1,49 +1,55 @@ import { memo } from 'react'; import { TYPE_COLORS } from '@ui/utils/colors'; -import { extraAccessTitle } from './cellMarkers'; - -// Access that a folded business role hides but does NOT grant — the subject -// holds it on one of the folded resources through some other route. Shown as a -// count on the folded role's own cell so folding can never quietly swallow the -// very thing a role-mining review is hunting for. Exported so the aggregate -// (folded-column) cell can render the same marker. -export function ExtraAccessBadge({ count }) { - if (!count) return null; - return ( - - {count} - - ); -} +import CellBadges from './CellBadges'; +import { extraAccessTitle, missingAccessTitle, overGrantTitle } from './cellMarkers'; // Everything the cell says on hover: how the access is held, which business // roles govern it, and any marker it carries. Pulled out of the component so // the wording lives in one readable place. -function cellTitle({ membershipTypes, managed, apNames, provisioningGap, gapExpected, extraAccessCount }) { +function cellTitle({ membershipTypes, managed, apNames, provisioningGap, gapExpected, overGrant, extraAccessCount, missingAccessCount }) { const parts = []; const managedBy = apNames?.length ? `Managed by: ${apNames.join(', ')}` : null; if (membershipTypes?.size) { const types = [...membershipTypes].join(', '); parts.push(managedBy ? `${types}\n${managedBy}` : (managed ? `${types} (managed by business role)` : types)); - if (provisioningGap) { - const expected = gapExpected ? ` (expects ${gapExpected})` : ''; - parts.push(`\u26a0 Provisioning gap: user lacks the membership type specified by the business role${expected}`); - } } else if (provisioningGap) { // A business role manages this cell but the subject has no membership at all. const expected = gapExpected ? ` ${gapExpected}` : ''; - parts.push(`\u26a0 Provisioning gap: business role expects${expected} membership but user has none`); + parts.push(`⚠ Provisioning gap: business role expects${expected} membership but user has none`); if (managedBy) parts.push(managedBy); } + if (overGrant) parts.push(overGrantTitle(overGrant)); if (extraAccessCount > 0) parts.push(extraAccessTitle(extraAccessCount)); + if (missingAccessCount > 0) parts.push(missingAccessTitle(missingAccessCount)); return parts.length ? parts.join('\n') : undefined; } -function MatrixCell({ cellKey, membershipTypes, managed, apColor, apCount, apNames, provisioningGap, gapExpected, extraAccessCount = 0, onExplainInherited }) { +function MembershipBadges({ membershipTypes, cellKey, onExplainInherited }) { + return [...membershipTypes].map(type => { + const ind = TYPE_COLORS[type]; + if (!ind) return ?; + const clickable = type === 'Indirect' && !!onExplainInherited; + return ( + { e.stopPropagation(); onExplainInherited(cellKey); } : undefined} + title={clickable ? 'Show how this inherited access was derived' : undefined} + className={`inline-block rounded-sm text-center font-bold ${membershipTypes.size === 1 ? 'w-4 h-4 text-[9px] leading-4' : 'w-[9px] h-[14px] text-[7px] leading-[14px]'} ${clickable ? 'cursor-pointer ring-1 ring-white/50 hover:ring-2 hover:ring-white' : ''}`} + style={{ backgroundColor: ind.bg, color: ind.text }} + > + {ind.letter} + + ); + }); +} + +function MatrixCell({ + cellKey, membershipTypes, managed, apColor, apCount, apNames, + provisioningGap, gapExpected, overGrant = null, + extraAccessCount = 0, missingAccessCount = 0, onExplainInherited, +}) { const hasMembership = membershipTypes && membershipTypes.size > 0; // Background: the business role's colour on a governed cell — and on a @@ -51,9 +57,13 @@ function MatrixCell({ cellKey, membershipTypes, managed, apColor, apCount, apNam // cell stays white. const bgColor = (hasMembership ? managed : provisioningGap) ? (apColor || '#dbeafe') : undefined; - const title = cellTitle({ membershipTypes, managed, apNames, provisioningGap, gapExpected, extraAccessCount }); + const title = cellTitle({ + membershipTypes, managed, apNames, provisioningGap, gapExpected, + overGrant, extraAccessCount, missingAccessCount, + }); - const needsRelative = apCount > 1 || provisioningGap || extraAccessCount > 0; + const needsRelative = apCount > 1 || provisioningGap || !!overGrant + || extraAccessCount > 0 || missingAccessCount > 0; return (
); } @@ -120,7 +105,9 @@ export default memo(MatrixCell, (prev, next) => { prev.apNames === next.apNames && prev.provisioningGap === next.provisioningGap && prev.gapExpected === next.gapExpected && + prev.overGrant === next.overGrant && prev.extraAccessCount === next.extraAccessCount && + prev.missingAccessCount === next.missingAccessCount && prev.onExplainInherited === next.onExplainInherited ); }); diff --git a/app/ui/src/components/matrix/MatrixCell.mount.test.jsx b/app/ui/src/components/matrix/MatrixCell.mount.test.jsx index 4fe689297..ac8bdea16 100644 --- a/app/ui/src/components/matrix/MatrixCell.mount.test.jsx +++ b/app/ui/src/components/matrix/MatrixCell.mount.test.jsx @@ -70,4 +70,39 @@ describe('MatrixCell', () => { expect(td.querySelector('.bg-rose-600')).toBeNull(); }); }); + + // Requestor feedback on #370: over-granting was visible, under-granting was + // not — and one subject can be short on one resource of a role and over on + // another, so both must be able to show at once. + describe('fewer than the business role assigns', () => { + it('counts it on a folded role\'s cell and explains it', () => { + const td = renderCell({ missingAccessCount: 2 }); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(td.getAttribute('title')).toContain('2 assignments on the folded resources that this business role assigns'); + expect(td).toHaveStyle({ position: 'relative' }); + }); + + it('shows the fewer and the more count side by side on one cell', () => { + const td = renderCell({ missingAccessCount: 1, extraAccessCount: 3 }); + expect(td.querySelector('.bg-amber-500')).toHaveTextContent('1'); + expect(td.querySelector('.bg-rose-600')).toHaveTextContent('3'); + expect(td.getAttribute('title')).toContain('does not grant'); + expect(td.getAttribute('title')).toContain('but this subject does not have'); + }); + }); + + describe('more than the business role assigns, on one cell', () => { + it('marks a standing membership where the role grants eligibility', () => { + const td = renderCell({ membershipTypes: types('Direct'), managed: true, overGrant: 'Eligible' }); + expect(screen.getByText('+')).toBeInTheDocument(); + expect(td.getAttribute('title')).toContain('More than the business role assigns'); + expect(td.getAttribute('title')).toContain('Eligible'); + }); + + it('yields the corner to the folded-role count so the two never overlap', () => { + const td = renderCell({ membershipTypes: types('Direct'), overGrant: 'Eligible', extraAccessCount: 2 }); + expect(screen.queryByText('+')).toBeNull(); + expect(td.querySelector('.bg-rose-600')).toHaveTextContent('2'); + }); + }); }); diff --git a/app/ui/src/components/matrix/MatrixGroupRow.jsx b/app/ui/src/components/matrix/MatrixGroupRow.jsx index bc0b22021..688dc58d2 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.jsx @@ -1,4 +1,6 @@ -import MatrixCell, { ExtraAccessBadge } from './MatrixCell'; +import MatrixCell from './MatrixCell'; +import { ExtraAccessBadge, MissingAccessBadge } from './CellBadges'; +import { cellDeviation, NO_DEVIATION } from './coverageDeviation'; import { getAccessPackageColor } from '@ui/utils/colors'; import { useIsDark } from '@ui/contexts/ThemeContext'; @@ -63,6 +65,28 @@ function RoleFoldToggle({ fold, onToggle }) { ); } +// Which business role a resource row belongs to, said on the row itself rather +// than by where the row sits. Rows can be dragged anywhere and stay there, so a +// resource can end up far from the role that grants it (or above it) — the +// indent + elbow alone would then leave its role a guess. The chip is rendered +// only when the row is NOT drawn directly under that role, so the common, +// undisturbed case stays uncluttered. +function RoleOwnerChip({ owners, onOpenDetail }) { + if (!owners?.length) return null; + const [first, ...rest] = owners; + const label = rest.length ? `${first.name} +${rest.length}` : first.name; + return ( + + ); +} + // "N resources folded" chip on a collapsed business role row. The row's own // cells are untouched — folding hides rows, it never rolls access up. function RoleFoldChip({ fold }) { @@ -74,6 +98,20 @@ function RoleFoldChip({ fold }) { ); } +// The name cell's tooltip. A resource row states the business role(s) granting +// it whatever position it has been moved to, so the answer never depends on the +// row still sitting under its role. +function rowTitle(group) { + return group.roleGrantedBy + ? `${group.displayName}\nGranted by business role: ${group.roleGrantedBy}` + : group.displayName; +} + +// Sticky columns paint their own background, so it has to match the row's. +function stickyBg(group) { + return group.isNestedRow ? 'bg-gray-50/60 dark:bg-gray-700/40' : 'bg-white dark:bg-gray-800'; +} + export default function MatrixGroupRow({ group, users, @@ -98,6 +136,7 @@ export default function MatrixGroupRow({ foldedRoles, roleChildCounts, roleExtraCounts, + roleMissingCounts, onToggleRoleFold, // Optional DnD props (provided by SortableRow wrapper) sortableRef, @@ -118,16 +157,20 @@ export default function MatrixGroupRow({ // business role is not a principal, so it is never in groupsWithNested). const roleFold = roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }); - // How many hidden assignments this folded role does NOT grant, per column. + // What the rows this folded role hides say per column: how much access it does + // NOT grant (more than the role assigns) and how much it assigns that the + // subject does not have (fewer). Both can be non-zero for the same subject. const extraAccessFor = (userId) => (roleFold?.folded && roleExtraCounts?.get(`${roleFold.roleKey}|${userId}`)) || 0; + const missingAccessFor = (userId) => + (roleFold?.folded && roleMissingCounts?.get(`${roleFold.roleKey}|${userId}`)) || 0; // A resource shown beneath the business role that grants it is drawn as that // role's child — same indent + elbow as an expanded nested group. const isRoleChild = !!group.roleParentId; const indentLevel = (group.nestLevel || 0) + (isRoleChild ? 1 : 0); - const nestedBg = group.isNestedRow ? 'bg-gray-50/60 dark:bg-gray-700/40' : 'bg-white dark:bg-gray-800'; + const nestedBg = stickyBg(group); return ( @@ -147,7 +190,7 @@ export default function MatrixGroupRow({ @@ -186,14 +230,16 @@ export default function MatrixGroupRow({ if (user.isAggregateCol) { const n = aggDirectCounts?.get(`${group.id} ${user.id}`) || 0; const extra = extraAccessFor(user.id); + const short = missingAccessFor(user.id); return ( ); } @@ -222,13 +268,13 @@ export default function MatrixGroupRow({ }); } - // Provisioning gap: the cell is governance-managed (an access package the - // subject holds Contains this resource — server-computed managedByAccessPackage) - // but the subject has no actual membership. The SOLL coverage is derived in - // the data; the gap is just "managed and empty". - const hasActual = cellTypes && cellTypes.size > 0; - const provisioningGap = managed && !hasActual; - const gapExpected = provisioningGap ? 'Direct' : null; + // How this cell deviates from what the business roles covering it assign: + // `missing` = fewer than they assign (the provisioning gap), `excess` = + // more (a standing membership where the role only grants eligibility). + // Both sides read off server-computed coverage — see coverageDeviation.js. + const deviation = managed + ? cellDeviation({ types: cellTypes, apIds: relevantApIds, apGroupMap, resourceKey: realGid.toUpperCase() }) + : NO_DEVIATION; return ( 0} + gapExpected={deviation.missing[0] || null} + overGrant={deviation.excess[0] || null} extraAccessCount={extraAccessFor(user.id)} + missingAccessCount={missingAccessFor(user.id)} onExplainInherited={onExplainInherited} /> ); diff --git a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx index 5e8998939..1480b8339 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx @@ -116,6 +116,85 @@ describe('MatrixGroupRow — a resource shown under the role that grants it', () }); }); +describe('MatrixGroupRow — which business role a resource belongs to', () => { + const owners = [{ id: 'BR1', name: 'HR Manager BR' }]; + + it('names the granting role on a row that was moved away from it', async () => { + const onOpenDetail = vi.fn(); + renderRow( + { id: 'G1', displayName: 'Finance Group', memberCount: 2, roleOwners: owners, roleGrantedBy: 'HR Manager BR' }, + { onOpenDetail }, + ); + const chip = screen.getByRole('button', { name: 'HR Manager BR' }); + expect(chip).toHaveAttribute('title', 'Granted by business role: HR Manager BR'); + // The chip is a way into the role itself. + await userEvent.setup().click(chip); + expect(onOpenDetail).toHaveBeenCalledWith('resource', 'BR1', 'HR Manager BR'); + }); + + it('summarises several granting roles and lists them all in the tooltip', () => { + renderRow({ + id: 'G2', displayName: 'Shared Group', memberCount: 2, + roleOwners: [...owners, { id: 'BR2', name: 'Finance BR' }], + }); + const chip = screen.getByRole('button', { name: 'HR Manager BR +1' }); + expect(chip).toHaveAttribute('title', 'Granted by business role: HR Manager BR, Finance BR'); + }); + + it('answers the question from the row tooltip whatever the row\'s position', () => { + const { container } = renderRow({ + id: 'G1', displayName: 'Finance Group', memberCount: 2, roleParentId: 'BR1', roleGrantedBy: 'HR Manager BR', + }); + expect(container.querySelector('td[title*="Granted by business role: HR Manager BR"]')).not.toBeNull(); + // Sitting directly under its role, the row says it by position — no chip. + expect(screen.queryByRole('button', { name: /HR Manager BR/ })).toBeNull(); + }); + + it('leaves a resource no business role grants unlabelled', () => { + renderRow({ id: 'G4', displayName: 'Unmanaged Group', memberCount: 1 }); + expect(screen.queryByRole('button', { name: /BR/ })).toBeNull(); + }); +}); + +describe('MatrixGroupRow — how a cell deviates from what its role assigns', () => { + const cellRow = { id: 'G1', displayName: 'Finance Group', memberCount: 1 }; + const covered = { + managedApMap: new Map([['g1|u1', ['br1']]]), + apIdToIndex: new Map([['br1', 0]]), + accessPackages: [{ id: 'br1', displayName: 'HR Manager BR' }], + }; + + it('marks the cell when the role assigns a membership the subject lacks', () => { + renderRow(cellRow, { ...covered, apGroupMap: new Map([['G1|br1', 'Member']]) }); + expect(screen.getByText('!')).toBeInTheDocument(); + }); + + it('marks the cell when the subject holds permanently what the role grants just-in-time', () => { + renderRow(cellRow, { + ...covered, + apGroupMap: new Map([['G1|br1', 'Eligible Member']]), + memberships: new Map([['G1|u1', new Set(['Direct'])]]), + }); + expect(screen.getByText('+')).toBeInTheDocument(); + expect(screen.queryByText('!')).toBeNull(); + }); + + it('marks nothing when the subject holds exactly what the role assigns', () => { + renderRow(cellRow, { + ...covered, + apGroupMap: new Map([['G1|br1', 'Member']]), + memberships: new Map([['G1|u1', new Set(['Direct'])]]), + }); + expect(screen.queryByText('!')).toBeNull(); + expect(screen.queryByText('+')).toBeNull(); + }); + + it('marks nothing on a cell no business role covers', () => { + renderRow(cellRow, { apGroupMap: new Map([['G1|br1', 'Member']]) }); + expect(screen.queryByText('!')).toBeNull(); + }); +}); + describe('MatrixGroupRow — access a folded role does not grant', () => { const folded = { foldedRoles: new Set(['BR1']), @@ -145,3 +224,40 @@ describe('MatrixGroupRow — access a folded role does not grant', () => { expect(extraBadge()).toHaveTextContent('5'); }); }); + +describe('MatrixGroupRow — access a folded role assigns but the subject lacks', () => { + const role = { id: 'BR1', displayName: 'HR Manager BR', memberCount: 9 }; + const missingBadge = () => screen.queryAllByTitle(/does not have/).at(-1) ?? null; + const extraBadge = () => screen.queryAllByTitle(/does not grant/).at(-1) ?? null; + + it('counts it on the folded role row', () => { + renderRow(role, { foldedRoles: new Set(['BR1']), roleMissingCounts: new Map([['BR1|u1', 2]]) }); + expect(missingBadge()).toHaveTextContent('2'); + }); + + it('shows both directions of drift on the same subject at once', () => { + renderRow(role, { + foldedRoles: new Set(['BR1']), + roleMissingCounts: new Map([['BR1|u1', 1]]), + roleExtraCounts: new Map([['BR1|u1', 4]]), + }); + expect(missingBadge()).toHaveTextContent('1'); + expect(extraBadge()).toHaveTextContent('4'); + }); + + it('shows nothing while the role is expanded — the rows speak for themselves', () => { + renderRow(role, { roleMissingCounts: new Map([['BR1|u1', 2]]) }); + expect(missingBadge()).toBeNull(); + }); + + it('tallies it on a folded subject column too', () => { + renderRow(role, { + foldedRoles: new Set(['BR1']), + users: [{ id: 'agg-1', isAggregateCol: true, displayName: 'Engineering' }], + roleMissingCounts: new Map([['BR1|agg-1', 3]]), + roleExtraCounts: new Map([['BR1|agg-1', 2]]), + }); + expect(missingBadge()).toHaveTextContent('3'); + expect(extraBadge()).toHaveTextContent('2'); + }); +}); diff --git a/app/ui/src/components/matrix/MatrixLegend.jsx b/app/ui/src/components/matrix/MatrixLegend.jsx index 12043e884..d61f11a21 100644 --- a/app/ui/src/components/matrix/MatrixLegend.jsx +++ b/app/ui/src/components/matrix/MatrixLegend.jsx @@ -94,10 +94,28 @@ export default function MatrixLegend() { Provisioning gap — a business role expects this membership but the user doesn't have it. +
+ + + More than the role assigns — the business role grants just-in-time (Eligible) access here, but the subject holds a standing membership. + +
- On a folded business role — more is handed out below than the role hands out: the number counts the folded resources this subject holds outside the role. + On a folded business role, bottom-right — more is handed out below than the role hands out: the number counts the folded resources this subject holds outside the role. + +
+
+ + + On a folded business role, bottom-left — fewer: the number counts the folded resources the role assigns this subject but they do not have. A subject can carry both counts at once. + +
+
+ + + The business role that grants this resource, named on the row itself when the row does not sit directly under that role (after you drag it elsewhere, for example).
diff --git a/app/ui/src/components/matrix/MatrixLegend.test.js b/app/ui/src/components/matrix/MatrixLegend.test.js index 6152ccd3f..75eb27948 100644 --- a/app/ui/src/components/matrix/MatrixLegend.test.js +++ b/app/ui/src/components/matrix/MatrixLegend.test.js @@ -34,4 +34,16 @@ describe('MatrixLegend', () => { expect(html).toContain('folded business role'); expect(html).toContain('outside the role'); }); + + // Feedback on #370: over-granting was explained, under-granting was not, and + // the two can occur together on one subject. + it('explains fewer permissions than the role assigns, in both views', () => { + expect(html).toContain('More than the role assigns'); + expect(html).toContain('the role assigns this subject but they do not have'); + expect(html).toContain('both counts at once'); + }); + + it('explains the chip naming the business role a moved row belongs to', () => { + expect(html).toContain('named on the row itself'); + }); }); diff --git a/app/ui/src/components/matrix/SortableMatrixBody.jsx b/app/ui/src/components/matrix/SortableMatrixBody.jsx index 161c8e80e..53d86163d 100644 --- a/app/ui/src/components/matrix/SortableMatrixBody.jsx +++ b/app/ui/src/components/matrix/SortableMatrixBody.jsx @@ -65,6 +65,7 @@ export default function SortableMatrixBody({ foldedRoles, roleChildCounts, roleExtraCounts, + roleMissingCounts, onToggleRoleFold, }) { const sensors = useSensors( @@ -118,7 +119,8 @@ export default function SortableMatrixBody({ foldableRoles, foldedRoles, roleChildCounts, - roleExtraCounts, + roleExtraCounts, + roleMissingCounts, onToggleRoleFold, }; diff --git a/app/ui/src/components/matrix/cellMarkers.js b/app/ui/src/components/matrix/cellMarkers.js index b54d5dee4..ad23980e2 100644 --- a/app/ui/src/components/matrix/cellMarkers.js +++ b/app/ui/src/components/matrix/cellMarkers.js @@ -1,8 +1,23 @@ // Wording of the matrix cell markers, kept out of the cell components so both // the cell and the aggregate (folded-column) cell explain a marker identically. +const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`; + // A folded business role hides rows; this is the access on those rows that the -// role itself does NOT grant — the subject holds it through some other route. +// role itself does NOT grant — the subject holds it through some other route, +// or holds permanently what the role only makes them eligible for. export function extraAccessTitle(count) { - return `⚠ ${count} assignment${count === 1 ? '' : 's'} on the folded resources that this business role does not grant`; + return `⚠ ${plural(count, 'assignment')} on the folded resources that this business role does not grant`; +} + +// The mirror image: rows the folded role DOES grant this subject, where the +// subject does not have the membership the role assigns. +export function missingAccessTitle(count) { + return `⚠ ${plural(count, 'assignment')} on the folded resources that this business role assigns but this subject does not have`; +} + +// One cell where the subject holds permanently what the role only makes them +// eligible for. +export function overGrantTitle(expected) { + return `⚠ More than the business role assigns: it grants ${expected || 'Eligible'} (just-in-time) access, but the subject holds a standing membership`; } diff --git a/app/ui/src/components/matrix/coverageDeviation.js b/app/ui/src/components/matrix/coverageDeviation.js new file mode 100644 index 000000000..540c81a97 --- /dev/null +++ b/app/ui/src/components/matrix/coverageDeviation.js @@ -0,0 +1,118 @@ +// How a subject's ACTUAL access (IST) on one cell deviates from what the +// business roles covering that cell prescribe (SOLL). Two directions, and one +// business role can carry both at once across the resources it grants: +// +// missing — FEWER permissions than the role assigns (the provisioning gap) +// excess — MORE permissions than the role assigns +// +// Both sides come from data the server already states: the `Contains` edge and +// its `roleName` (delivered as the SOLL mapping behind `apGroupMap`) say what a +// role assigns, the coverage matview (`managedByPackages`) says which cells a +// role covers for which subject. Nothing here guesses what a role "ought" to +// grant. See docs/architecture/matrix.md → "Fewer and more than the role +// assigns". + +// Standing access — the subject holds the resource right now, either directly +// or through a nested resource. `Eligible` is weaker: it only permits +// activation, so holding a resource permanently where the role only makes the +// subject eligible is MORE than the role assigns. +const STANDING_TYPES = ['Direct', 'Indirect']; + +export const NO_DEVIATION = { missing: [], excess: [] }; + +// Returned when nothing is folded, so callers can keep passing an explicit +// "no tallies" value rather than two undefineds. +export const NO_ROLE_DEVIATIONS = { extra: null, missing: null }; + +// What one business role prescribes for a resource: an "Eligible …" role name +// on the Contains edge means just-in-time, anything else means standing access. +export function expectedTypeFor(roleName) { + return String(roleName || '').toLowerCase().includes('eligible') ? 'Eligible' : 'Direct'; +} + +/** + * Compare one cell's actual membership against the roles that cover it. + * + * @param {object} args + * @param {Set} args.types - membership types the subject actually has + * @param {string[]} args.apIds - ids (lowercase) of the roles covering this cell + * @param {Map} args.apGroupMap - "RESOURCEID|apid" → roleName (the SOLL mapping) + * @param {string} args.resourceKey - the resource id, uppercased + * @returns {{missing: string[], excess: string[]}} + */ +export function cellDeviation({ types, apIds, apGroupMap, resourceKey }) { + let wantsStanding = false; + let wantsEligible = false; + for (const apId of apIds || []) { + if (expectedTypeFor(apGroupMap?.get(`${resourceKey}|${apId}`)) === 'Eligible') wantsEligible = true; + else wantsStanding = true; + } + if (!wantsStanding && !wantsEligible) return NO_DEVIATION; + + // FEWER: the role assigns this membership and the subject simply does not + // have it. Deliberately "has nothing at all" rather than "has exactly the + // prescribed type" — holding a role eligibly rather than actively is a + // legitimate way to hold what it assigns, not an under-grant. + if (!types?.size) return { missing: [wantsStanding ? 'Direct' : 'Eligible'], excess: [] }; + + // MORE: a standing membership where the role only makes the subject eligible + // — the access stands whether or not it is ever activated. + const standing = STANDING_TYPES.some(t => types.has(t)); + if (wantsEligible && !wantsStanding && standing) return { missing: [], excess: ['Eligible'] }; + + return NO_DEVIATION; +} + +function bump(map, key) { + map.set(key, (map.get(key) || 0) + 1); +} + +// One (folded role, subject, folded row) triple: does it read as more, as less, +// or as exactly what the role assigns? +function tallyFoldedCell({ counts, key, types, covered, roleIdLower, apGroupMap, resourceKey }) { + if (!covered) { + // The subject holds a resource this role folded away without this role + // handing it out — access the role does not account for. + if (types?.size) bump(counts.extra, key); + return; + } + const dev = cellDeviation({ types, apIds: [roleIdLower], apGroupMap, resourceKey }); + if (dev.missing.length) bump(counts.missing, key); + if (dev.excess.length) bump(counts.extra, key); +} + +/** + * Per (folded role, subject column): how many of the rows the role folded away + * deviate from what the role assigns — `extra` for more, `missing` for fewer. + * Folding is a summary, never a cover-up: both counts stay on the folded row so + * neither direction of drift can hide underneath it. + * + * @returns {{extra: Map, missing: Map}|null} null when nothing is folded + */ +export function buildRoleDeviationCounts({ + foldedChildRows, users, memberships, managedApMap, apGroupMap, userToAgg, +}) { + if (!foldedChildRows || foldedChildRows.size === 0) return null; + const counts = { extra: new Map(), missing: new Map() }; + + for (const [roleId, hiddenRows] of foldedChildRows) { + const roleIdLower = roleId.toLowerCase(); + for (const row of hiddenRows) { + const resourceKey = String(row.realGroupId || row.id).toUpperCase(); + const coverageKey = resourceKey.toLowerCase(); + for (const u of users) { + tallyFoldedCell({ + counts, + // A folded subject column carries the tally of everyone behind it. + key: `${roleId}|${userToAgg?.get(u.id) || u.id}`, + types: memberships?.get(`${row.id}|${u.id}`), + covered: !!managedApMap?.get(`${coverageKey}|${u.id.toLowerCase()}`)?.includes(roleIdLower), + roleIdLower, + apGroupMap, + resourceKey, + }); + } + } + } + return counts; +} diff --git a/app/ui/src/components/matrix/coverageDeviation.test.js b/app/ui/src/components/matrix/coverageDeviation.test.js new file mode 100644 index 000000000..71775d328 --- /dev/null +++ b/app/ui/src/components/matrix/coverageDeviation.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { + expectedTypeFor, cellDeviation, buildRoleDeviationCounts, NO_DEVIATION, +} from './coverageDeviation'; + +// SOLL mapping as MatrixView builds it: "RESOURCEID|roleid" → role name on the +// Contains edge. BR1 grants G1 as a member and G2 just-in-time. +const AP_GROUP_MAP = new Map([ + ['G1|br1', 'Member'], + ['G2|br1', 'Eligible Member'], +]); + +const types = (...t) => new Set(t); +const deviate = (props) => cellDeviation({ apGroupMap: AP_GROUP_MAP, ...props }); + +describe('expectedTypeFor', () => { + it('reads an eligible role name as just-in-time and everything else as standing', () => { + expect(expectedTypeFor('Eligible Member')).toBe('Eligible'); + expect(expectedTypeFor('Member')).toBe('Direct'); + expect(expectedTypeFor(null)).toBe('Direct'); + }); +}); + +describe('cellDeviation', () => { + it('reports nothing for a cell no business role covers', () => { + expect(deviate({ types: types('Direct'), apIds: [], resourceKey: 'G1' })).toBe(NO_DEVIATION); + }); + + it('reports FEWER when the role assigns a membership the subject does not have', () => { + expect(deviate({ types: undefined, apIds: ['br1'], resourceKey: 'G1' })) + .toEqual({ missing: ['Direct'], excess: [] }); + }); + + it('names the just-in-time membership when that is what is missing', () => { + expect(deviate({ types: types(), apIds: ['br1'], resourceKey: 'G2' })) + .toEqual({ missing: ['Eligible'], excess: [] }); + }); + + it('reports MORE when the subject holds permanently what the role grants just-in-time', () => { + expect(deviate({ types: types('Direct'), apIds: ['br1'], resourceKey: 'G2' })) + .toEqual({ missing: [], excess: ['Eligible'] }); + // Inherited standing access counts the same — it stands without activation. + expect(deviate({ types: types('Indirect'), apIds: ['br1'], resourceKey: 'G2' }).excess) + .toEqual(['Eligible']); + }); + + it('accepts eligible access where the role grants it', () => { + expect(deviate({ types: types('Eligible'), apIds: ['br1'], resourceKey: 'G2' })).toBe(NO_DEVIATION); + }); + + it('accepts eligible access where the role assigns a standing membership', () => { + // Holding a role eligibly rather than actively is a legitimate way to hold + // what it assigns — not an under-grant. + expect(deviate({ types: types('Eligible'), apIds: ['br1'], resourceKey: 'G1' })).toBe(NO_DEVIATION); + }); + + it('does not call a standing membership excessive when any role assigns one', () => { + const map = new Map([['G2|br1', 'Eligible Member'], ['G2|br2', 'Member']]); + expect(cellDeviation({ types: types('Direct'), apIds: ['br1', 'br2'], apGroupMap: map, resourceKey: 'G2' })) + .toBe(NO_DEVIATION); + }); + + it('treats an unmapped role as assigning a standing membership', () => { + expect(deviate({ types: types(), apIds: ['unknown'], resourceKey: 'G9' }).missing).toEqual(['Direct']); + }); +}); + +describe('buildRoleDeviationCounts', () => { + // BR1 folded away G1 (member) and G2 (eligible). Three subjects: + // u1 — exactly what the role assigns + // u2 — holds the role, missing G1, permanent on G2 → fewer AND more + // u3 — does not hold the role but holds G1 anyway → more + const foldedChildRows = new Map([['BR1', [{ id: 'G1' }, { id: 'G2' }]]]); + const users = [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]; + const memberships = new Map([ + ['G1|u1', types('Direct')], ['G2|u1', types('Eligible')], + ['G2|u2', types('Direct')], + ['G1|u3', types('Direct')], + ]); + const managedApMap = new Map([ + ['g1|u1', ['br1']], ['g2|u1', ['br1']], + ['g1|u2', ['br1']], ['g2|u2', ['br1']], + ]); + const build = (extra = {}) => buildRoleDeviationCounts({ + foldedChildRows, users, memberships, managedApMap, apGroupMap: AP_GROUP_MAP, ...extra, + }); + + it('is null while nothing is folded', () => { + expect(buildRoleDeviationCounts({ foldedChildRows: new Map(), users })).toBeNull(); + expect(buildRoleDeviationCounts({})).toBeNull(); + }); + + it('leaves a subject who holds exactly what the role assigns uncounted', () => { + const { extra, missing } = build(); + expect(extra.get('BR1|u1')).toBeUndefined(); + expect(missing.get('BR1|u1')).toBeUndefined(); + }); + + it('counts fewer and more for the same subject at the same time', () => { + const { extra, missing } = build(); + expect(missing.get('BR1|u2')).toBe(1); // G1 assigned, not held + expect(extra.get('BR1|u2')).toBe(1); // G2 held permanently, granted just-in-time + }); + + it('counts access the role does not cover for this subject at all', () => { + const { extra, missing } = build(); + expect(extra.get('BR1|u3')).toBe(1); + expect(missing.get('BR1|u3')).toBeUndefined(); + }); + + it('tallies onto the aggregate column when subjects are folded together', () => { + const { extra, missing } = build({ userToAgg: new Map([['u2', 'agg-1'], ['u3', 'agg-1']]) }); + expect(extra.get('BR1|agg-1')).toBe(2); + expect(missing.get('BR1|agg-1')).toBe(1); + }); + + it('reads a synthetic row through its real resource id', () => { + const { missing } = buildRoleDeviationCounts({ + foldedChildRows: new Map([['BR1', [{ id: 'G1__owner', realGroupId: 'G1' }]]]), + users: [{ id: 'u2' }], memberships: new Map(), managedApMap, apGroupMap: AP_GROUP_MAP, + }); + expect(missing.get('BR1|u2')).toBe(1); + }); +}); diff --git a/app/ui/src/hooks/useBusinessRoleFold.js b/app/ui/src/hooks/useBusinessRoleFold.js index bb7d2e704..5b2c156b4 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.js +++ b/app/ui/src/hooks/useBusinessRoleFold.js @@ -84,6 +84,16 @@ function linkRoleChildren(roleId, children, rowIds, rolesByChild) { return n; } +// Display name of every row, keyed by resource id — so a resource can name the +// business role that grants it without a second lookup table. +function rowNames(rows) { + const names = new Map(); + for (const row of rows || []) { + if (!row.isNestedRow) names.set(rowResourceKey(row), row.displayName || row.id); + } + return names; +} + // Which roles in the grid can be folded, how many rows each one folds away, and // — per contained resource — the roles that are actually present as rows. // D4: a role with no row of its own gets no fold affordance and hides nothing, @@ -98,7 +108,12 @@ export function analyseRoleRows(rows, childrenByRole) { const n = linkRoleChildren(roleId, children, rowIds, rolesByChild); if (n > 0) childCounts.set(roleId, n); } - return { foldableRoles: new Set(childCounts.keys()), rolesByChild, childCounts }; + return { + foldableRoles: new Set(childCounts.keys()), + rolesByChild, + childCounts, + roleNames: rowNames(rows), + }; } // A contained resource is hidden only when EVERY business role that grants it and @@ -146,44 +161,72 @@ export function collectFoldedChildRows(rows, rolesByChild, folded) { return byRole; } +// The roles granting a resource that its own row does not already sit under, +// as [{id, name}] — what the row has to say for itself once position stops +// answering the question. Rows are draggable and keep their new position, so a +// resource can be moved away from (or above) the role that grants it; the +// answer therefore has to live on the row, not in the layout. +function detachedOwners(key, rolesByChild, roleNames, parentId) { + const owners = []; + for (const id of rolesByChild.get(key) || []) { + if (id === parentId) continue; + owners.push({ id, name: roleNames?.get(id) || id }); + } + return owners; +} + +// Mark up one resource row: the role it is drawn beneath (adjacent, so it gets +// the indent + elbow) and the granting roles it is NOT beneath (named on the +// row itself). `roleGrantedBy` lists every granting role for the row tooltip, +// so the question is answerable from any position. +function markResourceRow(row, key, rolesByChild, roleNames, parentId) { + const roles = rolesByChild.get(key); + if (!roles?.size) return row; + const owners = detachedOwners(key, rolesByChild, roleNames, parentId); + const marked = { ...row, roleGrantedBy: [...roles].map(id => roleNames?.get(id) || id).join(', ') }; + if (parentId) marked.roleParentId = parentId; + if (owners.length) marked.roleOwners = owners; + return marked; +} + // Mark the resources that sit directly beneath the business role granting them // (the AP staircase puts them there) so the grid can render them as that role's // children — the same indent + elbow an expanded nested group already uses. A // resource that is not adjacent to one of its roles stays a plain top-level row -// rather than being indented under an unrelated one. -export function markRoleChildren(rows, foldableRoles, rolesByChild) { +// rather than being indented under an unrelated one, and carries the name of +// the role(s) that grant it instead. +export function markRoleChildren(rows, foldableRoles, rolesByChild, roleNames) { if (!foldableRoles || foldableRoles.size === 0) return rows; const out = []; - let roleId = null; // the role block we are currently inside - let underChild = false; // the last top-level row was one of its resources + // Where the walk currently stands: the role block we are inside, and the role + // the last top-level row was drawn under. + const pos = { roleId: null, childOf: null }; let marked = false; for (const row of rows) { - if (row.isNestedRow) { - // Sub-rows follow the row they were expanded from, so they inherit its - // place in the role block. - out.push(underChild ? { ...row, roleParentId: roleId } : row); - continue; - } - const key = rowResourceKey(row); - if (foldableRoles.has(key)) { - roleId = key; - underChild = false; - out.push(row); - continue; - } - if (roleId && rolesByChild.get(key)?.has(roleId)) { - underChild = true; - marked = true; - out.push({ ...row, roleParentId: roleId }); - continue; - } - roleId = null; - underChild = false; - out.push(row); + const next = markOneRow(row, { foldableRoles, rolesByChild, roleNames }, pos); + if (next !== row) marked = true; + out.push(next); } return marked ? out : rows; } +// One row of that walk: advances `pos` and returns the row as it should render. +function markOneRow(row, { foldableRoles, rolesByChild, roleNames }, pos) { + // Sub-rows follow the row they were expanded from, so they inherit its place + // in the role block. + if (row.isNestedRow) return pos.childOf ? { ...row, roleParentId: pos.childOf } : row; + + const key = rowResourceKey(row); + if (foldableRoles.has(key)) { + pos.roleId = key; + pos.childOf = null; + return row; + } + pos.childOf = pos.roleId && rolesByChild.get(key)?.has(pos.roleId) ? pos.roleId : null; + if (!pos.childOf) pos.roleId = null; + return markResourceRow(row, key, rolesByChild, roleNames, pos.childOf); +} + /** * Business-role fold state for the per-subject matrix. * @@ -205,7 +248,7 @@ export function useBusinessRoleFold({ accessPackageGroups, rows, storageKey }) { } const childrenByRole = useMemo(() => buildRoleChildMap(accessPackageGroups), [accessPackageGroups]); - const { foldableRoles, rolesByChild, childCounts } = useMemo( + const { foldableRoles, rolesByChild, childCounts, roleNames } = useMemo( () => analyseRoleRows(rows, childrenByRole), [rows, childrenByRole]); const applyFolds = useCallback((next) => { @@ -225,8 +268,8 @@ export function useBusinessRoleFold({ accessPackageGroups, rows, storageKey }) { const unfoldAllRoles = useCallback(() => applyFolds(new Set()), [applyFolds]); const visibleRows = useMemo( - () => markRoleChildren(hideFoldedRows(rows, rolesByChild, foldedRoles), foldableRoles, rolesByChild), - [rows, rolesByChild, foldedRoles, foldableRoles]); + () => markRoleChildren(hideFoldedRows(rows, rolesByChild, foldedRoles), foldableRoles, rolesByChild, roleNames), + [rows, rolesByChild, foldedRoles, foldableRoles, roleNames]); const foldedChildRows = useMemo( () => collectFoldedChildRows(rows, rolesByChild, foldedRoles), [rows, rolesByChild, foldedRoles]); diff --git a/app/ui/src/hooks/useBusinessRoleFold.test.jsx b/app/ui/src/hooks/useBusinessRoleFold.test.jsx index 4cdcedf6b..bdbf83798 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.test.jsx +++ b/app/ui/src/hooks/useBusinessRoleFold.test.jsx @@ -190,11 +190,13 @@ describe('collectFoldedChildRows', () => { describe('markRoleChildren', () => { const childrenByRole = buildRoleChildMap(AP_GROUPS); - const { foldableRoles, rolesByChild } = analyseRoleRows(ROWS, childrenByRole); + const { foldableRoles, rolesByChild, roleNames } = analyseRoleRows(ROWS, childrenByRole); const parents = (rows) => rows.map((r) => r.roleParentId || null); + const mark = (rows, roles = foldableRoles) => markRoleChildren(rows, roles, rolesByChild, roleNames); + const owners = (rows) => rows.map((r) => (r.roleOwners || []).map((o) => o.name)); it('marks the resources sitting directly under the role that grants them', () => { - const marked = markRoleChildren(ROWS, foldableRoles, rolesByChild); + const marked = mark(ROWS); expect(ids(marked)).toEqual(ids(ROWS)); expect(parents(marked)).toEqual([null, 'BR1', 'BR1', null, 'BR2', null]); }); @@ -202,7 +204,7 @@ describe('markRoleChildren', () => { it('does not indent a resource that is not adjacent to one of its roles', () => { // G1 has drifted below G4, away from BR1's block. const rows = [{ id: 'BR1' }, { id: 'G4' }, { id: 'G1' }]; - expect(parents(markRoleChildren(rows, foldableRoles, rolesByChild))).toEqual([null, null, null]); + expect(parents(mark(rows))).toEqual([null, null, null]); }); it('carries the marking onto a child row\'s own nested sub-rows', () => { @@ -213,13 +215,44 @@ describe('markRoleChildren', () => { { id: 'G4' }, { id: 'G4__nested__Y', isNestedRow: true, nestLevel: 1 }, ]; - expect(parents(markRoleChildren(rows, foldableRoles, rolesByChild))) - .toEqual([null, 'BR1', 'BR1', null, null]); + expect(parents(mark(rows))).toEqual([null, 'BR1', 'BR1', null, null]); }); it('returns the rows untouched when nothing can be marked', () => { - expect(markRoleChildren(ROWS, new Set(), rolesByChild)).toBe(ROWS); - expect(markRoleChildren([{ id: 'G4' }], foldableRoles, rolesByChild)).toHaveLength(1); + expect(markRoleChildren(ROWS, new Set(), rolesByChild, roleNames)).toBe(ROWS); + expect(mark([{ id: 'G4' }])).toHaveLength(1); + }); + + // Rows keep whatever position the user drags them to, so "which role does + // this group belong to?" must be answerable from the row itself. + it('names the granting role on a resource that was moved away from it', () => { + const rows = [{ id: 'BR1', displayName: 'Business Role 1' }, { id: 'G4' }, { id: 'G1' }]; + const marked = mark(rows); + expect(owners(marked)).toEqual([[], [], ['Business Role 1']]); + expect(marked[2].roleGrantedBy).toBe('Business Role 1'); + }); + + it('does not repeat the role a resource already sits under', () => { + const marked = mark(ROWS); + const byId = new Map(marked.map((r) => [r.id, r])); + expect(byId.get('G1').roleOwners).toBeUndefined(); + // G2 sits under BR1 but is granted by BR2 as well — that one still needs saying. + expect(byId.get('G2').roleOwners.map((o) => o.name)).toEqual(['Business Role 2']); + expect(byId.get('G2').roleGrantedBy).toBe('Business Role 1, Business Role 2'); + }); + + it('falls back to the role id when the role row has no display name', () => { + const rows = [{ id: 'BR1' }, { id: 'G4' }, { id: 'G1' }]; + const nameless = analyseRoleRows(rows, childrenByRole); + const marked = markRoleChildren(rows, nameless.foldableRoles, nameless.rolesByChild, nameless.roleNames); + expect(marked[2].roleOwners).toEqual([{ id: 'BR1', name: 'BR1' }]); + }); + + it('says nothing about a resource no role in the grid grants', () => { + const marked = mark(ROWS); + const g4 = marked.find((r) => r.id === 'G4'); + expect(g4.roleOwners).toBeUndefined(); + expect(g4.roleGrantedBy).toBeUndefined(); }); }); @@ -250,6 +283,15 @@ describe('useBusinessRoleFold', () => { expect(byId.get('G4').roleParentId).toBeUndefined(); }); + it('names the granting role on a resource the user dragged away from it', () => { + // G1 moved to the bottom of the grid, out of BR1's block. + const rows = ROWS.filter((r) => r.id !== 'G1').concat(ROWS.find((r) => r.id === 'G1')); + const { result } = render({ rows }); + const g1 = result.current.visibleRows.find((r) => r.id === 'G1'); + expect(g1.roleParentId).toBeUndefined(); + expect(g1.roleOwners).toEqual([{ id: 'BR1', name: 'Business Role 1' }]); + }); + it('reports the rows each folded role took away', () => { const { result } = render(); expect(result.current.foldedChildRows.size).toBe(0); diff --git a/changes/dor-issue-370.md b/changes/dor-issue-370.md index 932cf2051..00148e1be 100644 --- a/changes/dor-issue-370.md +++ b/changes/dor-issue-370.md @@ -7,3 +7,8 @@ - Matrix: a business role's own row now shows the "D" badge in its own column and its cells are coloured as governed, like any other access a business role hands out. Business-role memberships are also counted as governed in the scope statistics instead of as ungoverned assignments. - Matrix: a folded business role now shows, per subject, a red count of the folded resources that subject holds outside the role — so folding never hides that more is handed out than the role hands out. The "How to read this matrix" legend explains the marker. - Matrix scope statistics: each headline number is now announced together with the metric it belongs to (e.g. "Resources, 39") by screen readers. +- Matrix: a folded business role now also shows, per subject, an amber count of the resources the role assigns that the subject does not have — so folding shows both when someone has more than the role hands out and when they have less. +- Matrix: a cell is now marked when someone holds a permanent membership on a resource their business role only makes them eligible for — more access than the role assigns. +- Matrix: a subject who is short on one resource of a business role and over on another now shows both deviations at the same time, on the same business role. +- Matrix: a resource row now names the business role it belongs to — in its tooltip wherever it sits, and as a clickable chip once the row has been moved away from that role, so a dragged row is never orphaned from its role. +- Demo data: added a service desk business role whose holders show the full picture — one person short of what the role assigns, one both short on one group and over-provisioned on another, one holding a group of the role without holding the role, and two holding exactly what the role assigns. diff --git a/docs/architecture/demo-dataset.md b/docs/architecture/demo-dataset.md index 67f83bbe3..5a0da2894 100644 --- a/docs/architecture/demo-dataset.md +++ b/docs/architecture/demo-dataset.md @@ -18,6 +18,7 @@ It also backs the **public demo environment** and hides the **Capture-the-Flag** | `DemoEntraBase.ps1` | Entra groups / directory roles / app roles / group ownership | | `DemoGovernance.ps1` | IGA catalogs, business roles, policies, certifications | | `DemoSalesScenario.ps1` | The Sales role-mining scenario (flags 1–7) | +| `DemoRoleDrift.ps1` | Role holders with fewer / more access than their business role assigns | | `DemoConsent.ps1` | OAuth consent + shadow IT (flags 11–12) | | `DemoSap.ps1` | The SAP ERP system (flag 8) | | `DemoAzure.ps1` | The AzureRM system (flag 10) | @@ -153,6 +154,7 @@ AU-Netherlands | BR-Finance-Systems | BusinessRole | IGA | Financial systems access | | BR-Admin-Privileged | BusinessRole | IGA | Privileged admin access | | BR-Sales | BusinessRole | IGA | Sales role — Contains SG-Sales + SG-CRM-Users | +| BR-Service-Desk | BusinessRole | IGA | Service desk role — Contains the two service desk groups as a membership and SG-Servicedesk-Admin as *eligibility only*. The role-drift scenario hangs off it | | SG-Engineering | GroupOwnership | EntraID | Owners of the Engineering group (a GroupOwnership resource is named after the group it owns) | | SG-Finance | GroupOwnership | EntraID | Owners of the Finance group | | SG-Admin-Tier0 | GroupOwnership | EntraID | Owners of the Tier-0 admin group | @@ -160,6 +162,9 @@ AU-Netherlands | SG-CRM-Users | Group | EntraID | CRM access — granted **by** BR-Sales (flag 4) | | SG-Sales-SharePoint | Group | EntraID | Ad-hoc grant held directly by 5 of 6 Sales — the role candidate (flag 6) | | SG-Finance-Reports | Group | EntraID | Sensitive cross-department finance access — the over-privileged trap (flag 7) | +| SG-Servicedesk-Tools | Group | EntraID | Granted **by** BR-Service-Desk as a membership | +| SG-Servicedesk-KB | Group | EntraID | Granted **by** BR-Service-Desk as a membership — the one two holders never got (fewer than the role assigns) | +| SG-Servicedesk-Admin | Group | EntraID | Granted **by** BR-Service-Desk as *eligibility*; one holder has it as a standing membership (more than the role assigns) | | FileSync Pro | Application | EntraID | Third-party app, unverified publisher — the risky one | | Files.ReadWrite.All | DelegatedPermission | EntraID | The risky consent scope (flags 11–12) | | Contoso Timesheets | Application | EntraID | Approved app, verified publisher — the control | @@ -194,6 +199,11 @@ AU-Netherlands | E0029, E0021, E0022, E0024 | User.Read | Direct | Consent to the clean control app — E0029 is flag 12's trap | | 10 SAP accounts | SAP roles | Direct | Skewed Finance 4 / Sales 3 / Ops 2 / Eng 1 (flag 8) | | E0029 + SVC-001 → eastus; E0030 → eastus storage; E0020, E0010, E0029 → westeurope | AzureRoleAssignment | Direct | Flag 10. E0029 spans both regions on purpose | +| E0014, E0029, E0030, E0034 | BR-Service-Desk | Direct (`governed=true`) | The role-drift cast — see [Fewer and more than the role assigns](#fewer-and-more-than-the-role-assigns) | +| E0014, E0029 | SG-Servicedesk-Tools, SG-Servicedesk-KB (Indirect) + SG-Servicedesk-Admin (Eligible) | Indirect / Eligible | Exactly what the role assigns — the control | +| E0034 (Tom Bakker) | SG-Servicedesk-Tools only | Indirect | **Fewer** than the role assigns: never provisioned into the KB or the admin eligibility | +| E0030 (Wendy Xu) | SG-Servicedesk-Tools (Indirect), SG-Servicedesk-Admin (**Direct**) | Indirect / Direct | Both directions at once: no KB (**fewer**) and a standing membership where the role only grants eligibility (**more**) | +| E0024 (Lars Muller) | SG-Servicedesk-Tools | Direct | Holds one of the role's resources without holding the role — access the role does not account for | ### Resource Relationships @@ -209,6 +219,9 @@ AU-Netherlands | BR-Admin-Privileged | SG-PAM-Users | Contains | | | BR-Sales | SG-Sales | Contains | Business role grants the Sales group | | BR-Sales | SG-CRM-Users | Contains | Business role grants CRM — this edge is flag 4's answer | +| BR-Service-Desk | SG-Servicedesk-Tools | Contains (`roleName='Member'`) | A standing membership | +| BR-Service-Desk | SG-Servicedesk-KB | Contains (`roleName='Member'`) | A standing membership | +| BR-Service-Desk | SG-Servicedesk-Admin | Contains (`roleName='Eligible Member'`) | Just-in-time only — `roleName` is what makes a standing membership on it read as *more than the role assigns* | | FileSync Pro | Files.ReadWrite.All | DelegatesScope | App → the scope consented to it | | Contoso Timesheets | User.Read | DelegatesScope | The control app | | Fortigi Demo Tenant → rg-prod-eastus / rg-prod-westeurope → their storage accounts | Contains | Contains | The Azure scope tree (4 edges) | @@ -221,7 +234,7 @@ AU-Netherlands | Entity | Data | |---|---| -| Catalog: "Employee Access" | Contains BR-Employee-Base, BR-Engineering-Tools, BR-Finance-Systems, BR-Sales | +| Catalog: "Employee Access" | Contains BR-Employee-Base, BR-Engineering-Tools, BR-Finance-Systems, BR-Sales, BR-Service-Desk | | Catalog: "Privileged Access" | Contains BR-Admin-Privileged | | Policy: "Auto-assign all employees" | On BR-Employee-Base, scope: all, auto-approve | | Policy: "Manager approval" | On BR-Engineering-Tools, requires manager approval | @@ -297,6 +310,28 @@ Holding `BR-Sales` does **not** by itself give anyone `SG-CRM-Users`. The matrix So role-derived access is emitted as explicit `Indirect` assignments **and** a `Contains` edge. The assignment is the access; the edge is the *why*. Drop either and flag 4 has no answer. See `docs/architecture/matrix.md`. +### Fewer and more than the role assigns + +Matching access is the easy case. `DemoRoleDrift.ps1` supplies the two that a +role-mining review actually hunts for, on one business role — +**BR-Service-Desk**, which grants `SG-Servicedesk-Tools` and +`SG-Servicedesk-KB` as memberships and `SG-Servicedesk-Admin` as *eligibility +only* (`roleName='Eligible Member'` on the `Contains` edge): + +| Person | What they have | What the matrix shows | +|---|---|---| +| Ursula Visser (E0014), Victor Wang (E0029) | All three, exactly as assigned | Nothing — the control, so the deviations don't read as the norm | +| Tom Bakker (E0034) | Tools only | **Fewer**: two of the three resources the role assigns him are missing | +| Wendy Xu (E0030) | Tools, plus Admin as a *standing* membership; no KB | **Fewer and more at once** — the case the folded role row has to summarise in both directions | +| Lars Muller (E0024) | Tools, without holding the role | Access the role does not account for | + +Under-provisioning is modelled by *leaving an assignment out*: a `Contains` +child with no effective assignment is what the grid reads as fewer. Over- +provisioning needs the `roleName` on the edge — without it every child reads as +"standing membership expected" and holding one permanently is exactly right. +See [`matrix.md`](matrix.md) → "Fewer and more than the role assigns" for how +each is rendered. + --- ## Dataset Format @@ -317,9 +352,9 @@ The dataset is a single JSON file that maps directly to the Ingest API endpoints "entityCounts": { "systems": 5, "principals": 45, - "resources": 39, - "resourceAssignments": 143, - "resourceRelationships": 20, + "resources": 43, + "resourceAssignments": 157, + "resourceRelationships": 23, "identities": 27, "identityMembers": 38, "contexts": 9, @@ -357,9 +392,9 @@ Counted with `deletedAt IS NULL` on `Principals`, `Resources` and `ResourceAssig |---|---|---| | Systems | 5 | Entra ID + HR + IGA + SAP ERP + AzureRM | | Principals | 45 | 26 employees + 1 disabled + 1 contractor + 1 `ServicePrincipal` + 1 `AIAgent` + 1 `SharedMailbox` + 1 IGA account + 10 SAP accounts + 3 app service principals | -| Resources | 39 | Entra 10 + group-ownership 3 + business roles 5 + Sales 4 + consent 4 + SAP 4 + Azure 9 | -| ResourceAssignments | 143 | `Direct` + `Indirect` (role-derived) + 1 `Eligible`; the `governed=true` ones are the business-role memberships | -| ResourceRelationships | 20 | 14 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope | +| Resources | 43 | Entra 10 + group-ownership 3 + business roles 6 + Sales 4 + role drift 3 + consent 4 + SAP 4 + Azure 9 | +| ResourceAssignments | 157 | `Direct` + `Indirect` (role-derived) + `Eligible`; the `governed=true` ones are the business-role memberships | +| ResourceRelationships | 23 | 17 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope | | Identities | 27 | 26 employees + 1 disabled | | IdentityMembers | 38 | 27 Entra + 1 IGA + 10 SAP | | Contexts | 9 | 1 root + 5 departments + 2 teams + 1 admin unit — all `variant='synced'` | diff --git a/docs/architecture/matrix.md b/docs/architecture/matrix.md index 16e5e0562..ababa3500 100644 --- a/docs/architecture/matrix.md +++ b/docs/architecture/matrix.md @@ -185,14 +185,65 @@ Rules worth knowing: adjacent to the children it folds away — and those resources are drawn as its children (indented, with the elbow). A resource that is *not* adjacent to one of its roles stays a plain top-level row rather than being indented under an - unrelated one. -- **A folded role says how much it is hiding that it does not grant.** Per - subject column, the folded row carries a red count of the folded resources - that subject holds *outside* this role. Folding is a summary, never a - cover-up: the access a role does not account for — exactly what role mining is - looking for — stays on screen. Coverage comes from the server's business-role - mapping (`managedByPackages`), not from a client-side guess at what a role - ought to grant. + unrelated one — it **names its role on the row instead** (see below). +- **A folded role says how much it is hiding that it does not grant, and how + much it grants that isn't there.** Per subject column, the folded row carries + a red count bottom-right (folded resources the subject holds outside this + role) and an amber count bottom-left (folded resources the role assigns that + the subject does not have). Folding is a summary, never a cover-up — in + either direction. Coverage comes from the server's business-role mapping + (`managedByPackages`), not from a client-side guess at what a role ought to + grant. + +### Which business role does this row belong to? + +Rows are draggable and **keep the position they are dropped in**, so a resource +can easily end up far from — or above — the business role that grants it. The +indent + elbow then stops being an answer, so the row carries one of its own: + +- Every resource row a business role grants states its role(s) in the row + tooltip (`Granted by business role: …`), whatever position it sits in. +- A row that is *not* drawn directly under one of its granting roles also shows + that role's name as a chip next to the resource name; clicking it opens the + role. Where a row *is* drawn under its role, the chip is omitted (the layout + already says it) — except for any *other* role that also grants it, which is + still named. + +Both come from `markRoleChildren` in +[`useBusinessRoleFold.js`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/ui/src/hooks/useBusinessRoleFold.js), +off the same `Contains` data the fold uses. + +### Fewer and more than the role assigns + +A business role states what a subject should have (SOLL); the assignments state +what they do have (IST). The grid marks both directions of drift, and a subject +can carry both at once — short on one resource of a role, over on another: + +| Deviation | Where it shows | Marker | +|---|---|---| +| **Fewer** — the role assigns a membership the subject does not have | On the resource's own cell | Amber `!` top-left (the provisioning gap), tooltip naming the expected type | +| **Fewer**, while the role is folded | On the folded role's cell | Amber count bottom-left | +| **More** — the role grants *eligibility* (`roleName` contains "Eligible") but the subject holds a standing membership | On the resource's own cell | Red `+` bottom-right | +| **More**, while the role is folded | On the folded role's cell | Red count bottom-right (also counts folded resources held with no coverage from this role at all) | + +The comparison lives in +[`matrix/coverageDeviation.js`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/ui/src/components/matrix/coverageDeviation.js) +and reads only what the server already states: the `Contains` edge's `roleName` +(what the role assigns, delivered with `GET /api/access-package-groups`) and the +coverage matview (which cells a role covers for which subject). Two deliberate +asymmetries: + +- **Fewer means "does not have it at all".** Holding a resource eligibly rather + than actively is a legitimate way to hold what a role assigns, so it is not + reported as an under-grant — otherwise every PIM-eligible role holder would + light up. +- **More means standing access where only eligibility was granted.** `Direct` + and `Indirect` are both standing access — the difference between them is + *how* the subject holds it, not *how much*, so an inherited membership is + neither more nor less than a direct one. + +`docs/architecture/demo-dataset.md` → "Fewer and more than the role assigns" +describes the demo data that exercises every row of the table above. ### A business role's own row diff --git a/test/demo-dataset/Generate-DemoDataset.ps1 b/test/demo-dataset/Generate-DemoDataset.ps1 index 71675742e..061e26662 100644 --- a/test/demo-dataset/Generate-DemoDataset.ps1 +++ b/test/demo-dataset/Generate-DemoDataset.ps1 @@ -16,6 +16,7 @@ DemoEntraBase.ps1 — Entra groups / directory roles / app roles / ownership DemoGovernance.ps1 — IGA catalogs, business roles, policies, certifications DemoSalesScenario.ps1 — the Sales role-mining scenario (flags 1-7) + DemoRoleDrift.ps1 — holders with fewer / more access than their role assigns DemoConsent.ps1 — OAuth consent + shadow IT (flags 11-12) DemoSap.ps1 — the SAP ERP system (flag 8) DemoAzure.ps1 — the AzureRM system (flag 10) @@ -39,7 +40,7 @@ if (-not $OutputPath) { $OutputPath = Join-Path $PSScriptRoot 'demo-company.json $partsDir = Join-Path $PSScriptRoot 'parts' foreach ($part in @( 'DemoState.ps1', 'DemoOrg.ps1', 'DemoEntraBase.ps1', 'DemoGovernance.ps1', - 'DemoSalesScenario.ps1', 'DemoConsent.ps1', 'DemoSap.ps1', 'DemoAzure.ps1' + 'DemoSalesScenario.ps1', 'DemoRoleDrift.ps1', 'DemoConsent.ps1', 'DemoSap.ps1', 'DemoAzure.ps1' )) { . (Join-Path $partsDir $part) } @@ -52,6 +53,7 @@ Add-DemoOrg $state Add-DemoEntraBase $state Add-DemoGovernance $state Add-DemoSalesScenario $state +Add-DemoRoleDrift $state Add-DemoConsent $state Add-DemoSap $state Add-DemoAzure $state diff --git a/test/demo-dataset/Verify-DemoDataset.ps1 b/test/demo-dataset/Verify-DemoDataset.ps1 index eb67f29e8..0d5c12d2e 100644 --- a/test/demo-dataset/Verify-DemoDataset.ps1 +++ b/test/demo-dataset/Verify-DemoDataset.ps1 @@ -87,9 +87,9 @@ Write-Host "`n--- Row Counts ---" -ForegroundColor Yellow $counts = @{ 'Systems' = @{ Min = 5; Max = 5 } # EntraID + HR + IGA + SAP + AzureRM (#705) 'Principals' = @{ Min = 45; Max = 45 } # 26 employees + 5 edge cases + IGA acct + 10 SAP + 3 app SPs - 'Resources' = @{ Min = 39; Max = 39 } # Entra 10 + ownership 3 + business roles 5 + Sales 4 + consent 4 + SAP 4 + Azure 9 - 'ResourceAssignments' = @{ Min = 143; Max = 143 } - 'ResourceRelationships' = @{ Min = 20; Max = 20 } # 14 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope + 'Resources' = @{ Min = 43; Max = 43 } # Entra 10 + ownership 3 + business roles 6 + Sales 4 + role drift 3 + consent 4 + SAP 4 + Azure 9 + 'ResourceAssignments' = @{ Min = 157; Max = 157 } + 'ResourceRelationships' = @{ Min = 23; Max = 23 } # 17 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope 'Identities' = @{ Min = 27; Max = 27 } # 26 employees + the leaver 'IdentityMembers' = @{ Min = 38; Max = 38 } # 27 Entra + 1 IGA + 10 SAP 'GovernanceCatalogs' = @{ Min = 2; Max = 2 } @@ -384,6 +384,64 @@ WHERE r."resourceType" = 'DelegatedPermission' AND r."deletedAt" IS NULL ) '@ +# ─── Role drift: fewer / more access than the business role assigns ─────── +# The matrix shows both directions of drift against a business role, so the +# dataset has to contain both. These guard the scenario the grid renders (see +# parts/DemoRoleDrift.ps1 and docs/architecture/matrix.md). + +Write-Host "`n--- Role drift ---" -ForegroundColor Yellow + +# BR-Service-Desk grants three resources, one of them just-in-time only. +Assert-Count 'Drift-RoleGrantsThreeResources' -Min 3 -Max 3 -Query @' +SELECT COUNT(*) FROM "ResourceRelationships" rr +JOIN "Resources" parent ON parent."id" = rr."parentResourceId" +WHERE parent."displayName" = 'BR-Service-Desk' AND rr."relationshipType" = 'Contains' +'@ + +Assert-Count 'Drift-AdminIsEligibleOnly' -Min 1 -Max 1 -Query @' +SELECT COUNT(*) FROM "ResourceRelationships" rr +JOIN "Resources" parent ON parent."id" = rr."parentResourceId" +JOIN "Resources" child ON child."id" = rr."childResourceId" +WHERE parent."displayName" = 'BR-Service-Desk' AND child."displayName" = 'SG-Servicedesk-Admin' + AND lower(rr."roleName") LIKE '%eligible%' +'@ + +# FEWER — Tom Bakker holds the role but only one of the three resources. +Assert-Count 'Drift-HolderShortOfWhatRoleAssigns' -Min 1 -Max 1 -Label '1 of 3 resources held' -Query @' +SELECT COUNT(*) FROM "ResourceAssignments" ra +JOIN "Resources" r ON r."id" = ra."resourceId" +JOIN "Principals" p ON p."id" = ra."principalId" +WHERE p."displayName" = 'Tom Bakker' AND ra."deletedAt" IS NULL + AND r."displayName" IN ('SG-Servicedesk-Tools', 'SG-Servicedesk-KB', 'SG-Servicedesk-Admin') +'@ + +# BOTH AT ONCE — Wendy Xu is missing the KB the role assigns... +Assert-Count 'Drift-BothDirections-MissingKb' -Min 0 -Max 0 -Label '0 KB memberships' -Query @' +SELECT COUNT(*) FROM "ResourceAssignments" ra +JOIN "Resources" r ON r."id" = ra."resourceId" +JOIN "Principals" p ON p."id" = ra."principalId" +WHERE p."displayName" = 'Wendy Xu' AND r."displayName" = 'SG-Servicedesk-KB' AND ra."deletedAt" IS NULL +'@ + +# ...while holding permanently what the role only makes her eligible for. +Assert-Count 'Drift-BothDirections-StandingOnEligible' -Min 1 -Max 1 -Query @' +SELECT COUNT(*) FROM "ResourceAssignments" ra +JOIN "Resources" r ON r."id" = ra."resourceId" +JOIN "Principals" p ON p."id" = ra."principalId" +WHERE p."displayName" = 'Wendy Xu' AND r."displayName" = 'SG-Servicedesk-Admin' + AND ra."assignmentType" = 'Direct' AND ra."deletedAt" IS NULL +'@ + +# Both role holders who match their role exactly must stay clean, or the +# deviations above read as the norm rather than as findings. +Assert-Count 'Drift-CleanHoldersMatchTheRole' -Min 6 -Max 6 -Label '2 holders × 3 resources' -Query @' +SELECT COUNT(*) FROM "ResourceAssignments" ra +JOIN "Resources" r ON r."id" = ra."resourceId" +JOIN "Principals" p ON p."id" = ra."principalId" +WHERE p."displayName" IN ('Ursula Visser', 'Victor Wang') AND ra."deletedAt" IS NULL + AND r."displayName" IN ('SG-Servicedesk-Tools', 'SG-Servicedesk-KB', 'SG-Servicedesk-Admin') +'@ + # ─── API Verification ───────────────────────────────────────────── Write-Host "`n--- API Verification ---" -ForegroundColor Yellow diff --git a/test/demo-dataset/parts/DemoRoleDrift.ps1 b/test/demo-dataset/parts/DemoRoleDrift.ps1 new file mode 100644 index 000000000..00ae2412c --- /dev/null +++ b/test/demo-dataset/parts/DemoRoleDrift.ps1 @@ -0,0 +1,116 @@ +<# +.SYNOPSIS + Fortigi Demo Corp — role drift: people who hold FEWER and MORE permissions + than their business role assigns. + +.DESCRIPTION + The rest of the demo shows access that matches its business role, plus + ad-hoc access no role covers. What it did not show is the other direction: + a person whose actual access falls SHORT of what their role assigns, and a + person who is short on one resource of a role while over on another. + + The cast — BR-Service-Desk grants three resources to the Operations crew: + + * SG-Servicedesk-Tools (Member) — standing membership + * SG-Servicedesk-KB (Member) — standing membership + * SG-Servicedesk-Admin (Eligible Member) — just-in-time elevation only + + Ursula Visser (E0014) — holds all three exactly as assigned. The control. + Victor Wang (E0029) — same. Two clean holders keep the deviations from + looking like the norm. + Tom Bakker (E0034) — holds the role but was only ever provisioned into + Tools: FEWER than the role assigns, on two of its + three resources. + Wendy Xu (E0030) — the both-directions case: never provisioned into + the KB (FEWER), and holds Admin as a permanent + membership where the role only makes her eligible + (MORE). One person, one role, both deviations. + Lars Muller (E0024) — holds Tools directly without holding the role at + all: access the role does not account for. + + WHY THE Indirect ROWS ARE EXPLICIT: same reason as DemoSalesScenario.ps1 — + the matrix matview reads declared rows, so access a role confers has to be + emitted as a real assignment. Leaving one out is exactly what makes it read + as "fewer than the role assigns" rather than as access. +#> + +Set-StrictMode -Version Latest + +function Add-DemoRoleDrift { + param([Parameter(Mandatory)]$State) + + $sysEntra = $State.SystemIds['entra'] + $sysIga = $State.SystemIds['iga'] + + $drift = [ordered]@{ + BR = New-DemoGuid 'res-br-service-desk' + Tools = New-DemoGuid 'res-sg-servicedesk-tools' + KB = New-DemoGuid 'res-sg-servicedesk-kb' + Admin = New-DemoGuid 'res-sg-servicedesk-admin' + } + $State['Drift'] = $drift + + foreach ($g in @( + @{ Id = $drift.Tools; Name = 'SG-Servicedesk-Tools' + Desc = 'Service desk tooling — ticketing, remote assistance and asset lookup.' } + @{ Id = $drift.KB; Name = 'SG-Servicedesk-KB' + Desc = 'Service desk knowledge base — runbooks and internal procedures.' } + @{ Id = $drift.Admin; Name = 'SG-Servicedesk-Admin' + Desc = 'Service desk administration — queue configuration and mailbox delegation. Elevate only when needed.' } + )) { + $null = Add-DemoResource $State -Id $g.Id -DisplayName $g.Name -ResourceType 'Group' ` + -SystemId $sysEntra -Description $g.Desc + } + + $null = Add-DemoResource $State -Id $drift.BR -DisplayName 'BR-Service-Desk' -ResourceType 'BusinessRole' ` + -SystemId $sysIga -CatalogId (New-DemoGuid 'cat-employee-access') ` + -Description 'Service desk business role — grants the service desk tooling and knowledge base, and eligibility for service desk administration.' + + # What the role assigns. The Admin group is eligibility only, which is what + # makes a standing membership on it MORE than the role assigns. + foreach ($grant in @( + @{ Child = $drift.Tools; Role = 'Member' } + @{ Child = $drift.KB; Role = 'Member' } + @{ Child = $drift.Admin; Role = 'Eligible Member' } + )) { + Add-DemoRelationship $State -ParentResourceId $drift.BR -ChildResourceId $grant.Child ` + -RelationshipType 'Contains' -RoleName $grant.Role + } + + Add-DemoRoleDriftGrants $State +} + +function Add-DemoRoleDriftGrants { + param([Parameter(Mandatory)]$State) + + $drift = $State.Drift + + # Per holder: which of the role's resources they actually ended up with. + # A resource left out of a holder's list is the whole point of this part — + # it is what the grid must show as "fewer than the role assigns". + $holders = @( + @{ Emp = 'E0014'; Tools = 'Indirect'; KB = 'Indirect'; Admin = 'Eligible' } + @{ Emp = 'E0029'; Tools = 'Indirect'; KB = 'Indirect'; Admin = 'Eligible' } + @{ Emp = 'E0034'; Tools = 'Indirect'; KB = $null; Admin = $null } + # Standing membership where the role only grants eligibility, and no KB + # at all — both deviations on one person. + @{ Emp = 'E0030'; Tools = 'Indirect'; KB = $null; Admin = 'Direct' } + ) + + foreach ($h in $holders) { + $p = Get-DemoPrincipalId $h.Emp + Add-DemoAssignment $State -ResourceId $drift.BR -PrincipalId $p -AssignmentType 'Direct' -Governed + foreach ($grant in @( + @{ Res = $drift.Tools; Type = $h.Tools } + @{ Res = $drift.KB; Type = $h.KB } + @{ Res = $drift.Admin; Type = $h.Admin } + )) { + if (-not $grant.Type) { continue } + Add-DemoAssignment $State -ResourceId $grant.Res -PrincipalId $p -AssignmentType $grant.Type + } + } + + # Held without the role behind it — the access a folded role cannot account + # for, from the other side. + Add-DemoAssignment $State -ResourceId $drift.Tools -PrincipalId (Get-DemoPrincipalId 'E0024') -AssignmentType 'Direct' +} diff --git a/test/demo-dataset/parts/DemoState.ps1 b/test/demo-dataset/parts/DemoState.ps1 index 9621abb8f..76adf65d2 100644 --- a/test/demo-dataset/parts/DemoState.ps1 +++ b/test/demo-dataset/parts/DemoState.ps1 @@ -189,6 +189,10 @@ function Add-DemoAssignment { $State.Assignments.Add($rec) } +# RoleName is the SOLL side of a Contains edge: what the business role assigns +# on that resource. A name containing 'Eligible' means just-in-time access, any +# other name (or none) means a standing membership — that is how the matrix +# tells "more than the role assigns" from "exactly what it assigns". function Add-DemoRelationship { param( [Parameter(Mandatory)]$State, @@ -197,13 +201,16 @@ function Add-DemoRelationship { [Parameter(Mandatory)] [ValidateSet('Contains', 'GrantsAccessTo', 'DelegatesScope', 'HasAppRole', 'HasOwnership', 'HasAppOwnership', 'HasApplicationPermission')] - [string]$RelationshipType + [string]$RelationshipType, + [string]$RoleName ) - $State.Relationships.Add(@{ + $rec = @{ parentResourceId = $ParentResourceId childResourceId = $ChildResourceId relationshipType = $RelationshipType - }) + } + if ($RoleName) { $rec['roleName'] = $RoleName } + $State.Relationships.Add($rec) } function Add-DemoIdentity { From 91ea4d98091920db30e3a856ed7c03d87fc23266 Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Tue, 4 Aug 2026 18:24:32 +0000 Subject: [PATCH 05/14] fix: address requestor feedback (#370) --- app/ui/e2e/matrix.spec.js | 49 ++++++++ app/ui/src/components/MatrixView.jsx | 6 +- .../src/components/matrix/MatrixGroupRow.jsx | 32 +++-- .../matrix/MatrixGroupRow.mount.test.jsx | 23 +++- app/ui/src/components/matrix/MatrixLegend.jsx | 6 + .../components/matrix/MatrixLegend.test.js | 7 ++ .../components/matrix/SortableMatrixBody.jsx | 4 +- app/ui/src/hooks/useBusinessRoleFold.js | 106 ++++++++++++---- app/ui/src/hooks/useBusinessRoleFold.test.jsx | 81 +++++++++++- changes/dor-issue-370.md | 3 + docs/architecture/demo-dataset.md | 72 +++++++++-- docs/architecture/matrix.md | 30 ++++- test/demo-dataset/Generate-DemoDataset.ps1 | 7 +- test/demo-dataset/Verify-DemoDataset.ps1 | 72 ++++++++++- test/demo-dataset/parts/DemoRoleDrift.ps1 | 5 + test/demo-dataset/parts/DemoSharedGrants.ps1 | 116 ++++++++++++++++++ test/unit/DemoDataset.Tests.ps1 | 64 ++++++++++ 17 files changed, 624 insertions(+), 59 deletions(-) create mode 100644 test/demo-dataset/parts/DemoSharedGrants.ps1 diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index 7f779c4ab..58c86e5db 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -316,6 +316,55 @@ test.describe('Matrix — fold business-role resources', () => { } }); + // Requestor feedback on #370: what happens to a group / app role that two + // business roles grant. The demo dataset's BR-Service-Desk and + // BR-IT-Operations share exactly that (see DemoSharedGrants.ps1). + test('a resource two roles grant survives the first fold, and the role says so', async ({ page }) => { + await openFoldableGrid(page); + + // Find a (resource, role) pair where the resource is granted by two roles + // and both roles have a row — the scenario under test. + const pairs = await (await page.request.get('/api/access-package-groups')).json(); + const byResource = new Map(); + for (const r of pairs) { + if (!r.resourceId) continue; + const key = String(r.resourceId).toUpperCase(); + if (!byResource.has(key)) byResource.set(key, []); + byResource.get(key).push(r); + } + const shared = [...byResource.values()].find(rows => rows.length > 1); + test.skip(!shared, 'no resource in this dataset is granted by more than one business role'); + + // Fold every role but the last one that grants the shared resource: the row + // must still be on screen, because that role is still expanded. + const keepOpen = shared[shared.length - 1].accessPackageName; + const sharedRow = page.locator('tbody td', { hasText: shared[0].resourceName }).first(); + const wasOnScreen = await sharedRow.count() > 0; + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + const role = page.locator('tbody tr', { hasText: keepOpen }) + .filter({ has: page.getByRole('button', { name: 'Unfold business role resources' }) }).first(); + test.skip(await role.count() === 0, `the ${keepOpen} row is not rendered in this grid`); + await role.getByRole('button', { name: 'Unfold business role resources' }).click(); + await expect(page.getByRole('button', { name: 'Fold business role resources' }).first()).toBeVisible(); + + // The shared resource is back even though the other role granting it is + // still folded — one expanded role is enough to keep it on screen. + if (wasOnScreen) await expect.poll(() => sharedRow.count()).toBeGreaterThan(0); + + // Some other role folded a row it shares with the one just re-opened, so it + // reports what it really hid rather than everything it grants. + const partial = page.getByText(/\d+ of \d+ resources folded/); + if (await partial.count()) { + await expect(partial.first()).toHaveAttribute('title', /stay on screen/); + await expect(partial.first()).toHaveAttribute('title', /also granted by/); + } + + // Leave the browser profile clean for the next test. + await unfoldAll(page).click(); + await expect(unfoldAll(page)).toHaveCount(0); + }); + // Rows keep whatever position they are moved to, so a resource can end up far // from the role that grants it. The row must still say which role that is. test('a resource still names its business role after being moved away from it', async ({ page }) => { diff --git a/app/ui/src/components/MatrixView.jsx b/app/ui/src/components/MatrixView.jsx index 755ec7f4c..c2c359d6c 100644 --- a/app/ui/src/components/MatrixView.jsx +++ b/app/ui/src/components/MatrixView.jsx @@ -675,7 +675,7 @@ export default function MatrixView({ // Gaps toggles and with the injected nested sub-rows. const { visibleRows: foldedGroups, foldedChildRows, - foldableRoles, foldedRoles, roleChildCounts, + foldableRoles, foldedRoles, roleFoldInfo, toggleRoleFold, foldAllRoles, unfoldAllRoles, canFoldRoles, hasFoldedRoles, } = useBusinessRoleFold({ accessPackageGroups, rows: visibleGroups, storageKey }); @@ -1047,7 +1047,7 @@ export default function MatrixView({ loadingNested={loadingNested} foldableRoles={foldableRoles} foldedRoles={foldedRoles} - roleChildCounts={roleChildCounts} + roleFoldInfo={roleFoldInfo} roleExtraCounts={roleDeviations.extra} roleMissingCounts={roleDeviations.missing} onToggleRoleFold={toggleRoleFold} @@ -1078,7 +1078,7 @@ export default function MatrixView({ loadingNested={loadingNested} foldableRoles={foldableRoles} foldedRoles={foldedRoles} - roleChildCounts={roleChildCounts} + roleFoldInfo={roleFoldInfo} roleExtraCounts={roleDeviations.extra} roleMissingCounts={roleDeviations.missing} onToggleRoleFold={toggleRoleFold} diff --git a/app/ui/src/components/matrix/MatrixGroupRow.jsx b/app/ui/src/components/matrix/MatrixGroupRow.jsx index 688dc58d2..d8f27570f 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.jsx @@ -19,13 +19,16 @@ function getRoleBadge(roleName) { // Fold affordance state for this row, or null when the row is not a foldable // business role (only roles that are present in the grid AND grant at least one // visible resource get one — see useBusinessRoleFold). -function roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }) { +function roleFoldState({ group, foldableRoles, foldedRoles, roleFoldInfo }) { const roleKey = String(group.realGroupId || group.id || '').toUpperCase(); if (group.isNestedRow || !foldableRoles?.has(roleKey)) return null; + const info = roleFoldInfo?.get(roleKey); return { roleKey, folded: !!foldedRoles?.has(roleKey), - count: roleChildCounts?.get(roleKey) || 0, + total: info?.total || 0, + hidden: info?.hidden || 0, + shownBy: info?.shownBy || [], }; } @@ -89,11 +92,26 @@ function RoleOwnerChip({ owners, onOpenDetail }) { // "N resources folded" chip on a collapsed business role row. The row's own // cells are untouched — folding hides rows, it never rolls access up. +// +// A resource granted by more than one business role stays on screen until every +// one of those roles is folded, so this role's fold can take away fewer rows +// than it grants. The chip then reads "N of M" and names the role still showing +// the rest, rather than claiming rows it did not take. function RoleFoldChip({ fold }) { - if (!fold?.folded || fold.count === 0) return null; + if (!fold?.folded || fold.total === 0) return null; + const { hidden, total, shownBy } = fold; + const label = hidden === total + ? `${total} resource${total === 1 ? '' : 's'} folded` + : `${hidden} of ${total} resources folded`; + const title = shownBy.length + ? `${total - hidden} of the ${total} resources this role grants stay on screen — they are also granted by ${shownBy.join(', ')}, which is still unfolded.` + : undefined; return ( - - {fold.count} resource{fold.count === 1 ? '' : 's'} folded + + {label} ); } @@ -134,7 +152,7 @@ export default function MatrixGroupRow({ // Business-role fold props foldableRoles, foldedRoles, - roleChildCounts, + roleFoldInfo, roleExtraCounts, roleMissingCounts, onToggleRoleFold, @@ -155,7 +173,7 @@ export default function MatrixGroupRow({ // Business-role fold (never clashes with the nested-expand chevron above: a // business role is not a principal, so it is never in groupsWithNested). - const roleFold = roleFoldState({ group, foldableRoles, foldedRoles, roleChildCounts }); + const roleFold = roleFoldState({ group, foldableRoles, foldedRoles, roleFoldInfo }); // What the rows this folded role hides say per column: how much access it does // NOT grant (more than the role assigns) and how much it assigns that the diff --git a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx index 1480b8339..b1178e4a8 100644 --- a/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx +++ b/app/ui/src/components/matrix/MatrixGroupRow.mount.test.jsx @@ -22,7 +22,7 @@ function renderRow(group, props = {}) { managedFilter: 'all', foldableRoles: new Set(['BR1']), foldedRoles: new Set(), - roleChildCounts: new Map([['BR1', 2]]), + roleFoldInfo: new Map([['BR1', { total: 2, hidden: 2, shownBy: [] }]]), ...props, onToggleRoleFold, }))), @@ -52,11 +52,26 @@ describe('MatrixGroupRow — business-role fold affordance', () => { it('singularises the chip for a single folded resource', () => { renderRow( { id: 'BR1', displayName: 'HR Manager BR', memberCount: 1 }, - { foldedRoles: new Set(['BR1']), roleChildCounts: new Map([['BR1', 1]]) }, + { foldedRoles: new Set(['BR1']), roleFoldInfo: new Map([['BR1', { total: 1, hidden: 1, shownBy: [] }]]) }, ); expect(screen.getByText('1 resource folded')).toBeInTheDocument(); }); + // Feedback on #370: a resource granted by two business roles only disappears + // once both are folded, so this fold can take away fewer rows than it grants. + it('counts only what the fold really hid when another role still shows the rest', () => { + renderRow( + { id: 'BR1', displayName: 'HR Manager BR', memberCount: 3 }, + { + foldedRoles: new Set(['BR1']), + roleFoldInfo: new Map([['BR1', { total: 3, hidden: 1, shownBy: ['Finance BR'] }]]), + }, + ); + const chip = screen.getByText('1 of 3 resources folded'); + expect(chip).toHaveAttribute('title', expect.stringContaining('Finance BR')); + expect(chip).toHaveAttribute('title', expect.stringContaining('2 of the 3 resources')); + }); + it('reports the role id to onToggleRoleFold when clicked', async () => { const { onToggleRoleFold } = renderRow({ id: 'br1', displayName: 'HR Manager BR', memberCount: 1 }); await userEvent.setup().click(screen.getByRole('button', { name: /fold business role resources/i })); @@ -76,7 +91,7 @@ describe('MatrixGroupRow — business-role fold affordance', () => { it('renders nothing extra when no fold props are supplied at all', () => { renderRow( { id: 'BR1', displayName: 'HR Manager BR', memberCount: 1 }, - { foldableRoles: undefined, foldedRoles: undefined, roleChildCounts: undefined }, + { foldableRoles: undefined, foldedRoles: undefined, roleFoldInfo: undefined }, ); expect(foldButton()).toBeNull(); }); @@ -86,7 +101,7 @@ describe('MatrixGroupRow — business-role fold affordance', () => { expect(screen.getByRole('button', { name: /fold business role resources/i })).toHaveTextContent('▼'); renderRow( { id: 'BR2', displayName: 'Other BR', memberCount: 3 }, - { foldableRoles: new Set(['BR2']), foldedRoles: new Set(['BR2']), roleChildCounts: new Map([['BR2', 1]]) }, + { foldableRoles: new Set(['BR2']), foldedRoles: new Set(['BR2']), roleFoldInfo: new Map([['BR2', { total: 1, hidden: 1, shownBy: [] }]]) }, ); expect(screen.getByRole('button', { name: /unfold business role resources/i })).toHaveTextContent('▶'); }); diff --git a/app/ui/src/components/matrix/MatrixLegend.jsx b/app/ui/src/components/matrix/MatrixLegend.jsx index d61f11a21..9fd4ef661 100644 --- a/app/ui/src/components/matrix/MatrixLegend.jsx +++ b/app/ui/src/components/matrix/MatrixLegend.jsx @@ -118,6 +118,12 @@ export default function MatrixLegend() { The business role that grants this resource, named on the row itself when the row does not sit directly under that role (after you drag it elsewhere, for example). +
+ + + A resource granted by more than one business role names them all, and stays on screen until every one of those roles is folded — a folded role then reports “N of M resources folded” and says which role is still showing the rest. + +
)} diff --git a/app/ui/src/components/matrix/MatrixLegend.test.js b/app/ui/src/components/matrix/MatrixLegend.test.js index 75eb27948..1d502a72b 100644 --- a/app/ui/src/components/matrix/MatrixLegend.test.js +++ b/app/ui/src/components/matrix/MatrixLegend.test.js @@ -46,4 +46,11 @@ describe('MatrixLegend', () => { it('explains the chip naming the business role a moved row belongs to', () => { expect(html).toContain('named on the row itself'); }); + + // Feedback on #370: how a resource granted by several business roles behaves. + it('explains a resource granted by more than one business role', () => { + expect(html).toContain('more than one business role'); + expect(html).toContain('until every one of those roles is folded'); + expect(html).toContain('N of M resources folded'); + }); }); diff --git a/app/ui/src/components/matrix/SortableMatrixBody.jsx b/app/ui/src/components/matrix/SortableMatrixBody.jsx index 53d86163d..a7fed17b0 100644 --- a/app/ui/src/components/matrix/SortableMatrixBody.jsx +++ b/app/ui/src/components/matrix/SortableMatrixBody.jsx @@ -63,7 +63,7 @@ export default function SortableMatrixBody({ // Business-role fold props foldableRoles, foldedRoles, - roleChildCounts, + roleFoldInfo, roleExtraCounts, roleMissingCounts, onToggleRoleFold, @@ -118,7 +118,7 @@ export default function SortableMatrixBody({ loadingNested, foldableRoles, foldedRoles, - roleChildCounts, + roleFoldInfo, roleExtraCounts, roleMissingCounts, onToggleRoleFold, diff --git a/app/ui/src/hooks/useBusinessRoleFold.js b/app/ui/src/hooks/useBusinessRoleFold.js index 5b2c156b4..9a104e869 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.js +++ b/app/ui/src/hooks/useBusinessRoleFold.js @@ -140,20 +140,33 @@ export function hideFoldedRows(rows, rolesByChild, folded) { return out; } -// The rows each folded role took away, keyed by role id. A row counts under a -// role when that role is folded and grants it; a resource granted by two folded -// roles is listed under both, since either one can bring it back. Used to +// The top-level rows a fold has any say over: every row granted by at least one +// FOLDED role, with the roles granting it and whether every one of them is +// folded — the same rule hideFoldedRows applies, so a row that is still on +// screen because another role that grants it is expanded comes back as +// `hidden: false`. Both consumers below walk the grid through here, so what a +// folded role reports can never disagree with what it actually took away. +function* foldedRowParents(rows, rolesByChild, folded) { + for (const row of rows) { + if (row.isNestedRow) continue; + const parents = rolesByChild.get(rowResourceKey(row)); + if (!parents?.size) continue; + const roles = [...parents]; + if (!roles.some(id => folded.has(id))) continue; + yield { row, roles, hidden: roles.every(id => folded.has(id)) }; + } +} + +// The rows each folded role took away, keyed by role id. A resource granted by +// two folded roles is listed under both, since either one can bring it back; +// one still shown by an expanded role is listed under neither. Used to // summarise, on the folded role's own row, the access hiding underneath it. export function collectFoldedChildRows(rows, rolesByChild, folded) { const byRole = new Map(); if (!folded || folded.size === 0) return byRole; - for (const row of rows) { - if (row.isNestedRow) continue; - const parents = rolesByChild.get(rowResourceKey(row)); - // Same rule hideFoldedRows applies: the row is gone only once every role - // granting it is folded — so each of those roles can bring it back. - if (!parents?.size || ![...parents].every(id => folded.has(id))) continue; - for (const id of parents) { + for (const { row, roles, hidden } of foldedRowParents(rows, rolesByChild, folded)) { + if (!hidden) continue; + for (const id of roles) { if (!byRole.has(id)) byRole.set(id, []); byRole.get(id).push(row); } @@ -161,28 +174,67 @@ export function collectFoldedChildRows(rows, rolesByChild, folded) { return byRole; } +// Turn each role's "still shown by" id set into display names, once the walk is +// over. +function nameSharingRoles(info, roleNames) { + for (const entry of info.values()) { + entry.shownBy = [...entry.shownBy].map(id => roleNames?.get(id) || id); + } + return info; +} + +/** + * Per foldable role: how many of the resources it grants have a row (`total`), + * how many of those its fold actually took away (`hidden`), and which still- + * expanded roles are keeping the rest on screen (`shownBy`). + * + * A resource granted by several business roles only disappears once every one + * of them is folded, so `hidden` can be lower than `total` — the role row says + * so rather than claiming rows it did not take. + * + * @returns {Map} + */ +export function summariseFolds(rows, rolesByChild, childCounts, folded, roleNames) { + const foldedSet = folded || new Set(); + const info = new Map(); + for (const [roleId, total] of childCounts) info.set(roleId, { total, hidden: 0, shownBy: new Set() }); + for (const { roles, hidden } of foldedRowParents(rows, rolesByChild, foldedSet)) { + const stillShowing = roles.filter(id => !foldedSet.has(id)); + for (const id of roles) { + const entry = foldedSet.has(id) ? info.get(id) : null; + if (!entry) continue; + if (hidden) entry.hidden++; + else for (const other of stillShowing) entry.shownBy.add(other); + } + } + return nameSharingRoles(info, roleNames); +} + // The roles granting a resource that its own row does not already sit under, // as [{id, name}] — what the row has to say for itself once position stops // answering the question. Rows are draggable and keep their new position, so a // resource can be moved away from (or above) the role that grants it; the // answer therefore has to live on the row, not in the layout. -function detachedOwners(key, rolesByChild, roleNames, parentId) { +function detachedOwners(key, rolesByChild, roleNames, parentId, folded) { const owners = []; for (const id of rolesByChild.get(key) || []) { if (id === parentId) continue; owners.push({ id, name: roleNames?.get(id) || id }); } - return owners; + // The chip labels itself with the first name, so lead with a role that is + // still expanded: when one role granting the resource is folded and another + // is not, the expanded one is why the row is still on screen. + return owners.sort((a, b) => (folded?.has(a.id) ? 1 : 0) - (folded?.has(b.id) ? 1 : 0)); } // Mark up one resource row: the role it is drawn beneath (adjacent, so it gets // the indent + elbow) and the granting roles it is NOT beneath (named on the // row itself). `roleGrantedBy` lists every granting role for the row tooltip, // so the question is answerable from any position. -function markResourceRow(row, key, rolesByChild, roleNames, parentId) { +function markResourceRow(row, key, { rolesByChild, roleNames, folded }, parentId) { const roles = rolesByChild.get(key); if (!roles?.size) return row; - const owners = detachedOwners(key, rolesByChild, roleNames, parentId); + const owners = detachedOwners(key, rolesByChild, roleNames, parentId, folded); const marked = { ...row, roleGrantedBy: [...roles].map(id => roleNames?.get(id) || id).join(', ') }; if (parentId) marked.roleParentId = parentId; if (owners.length) marked.roleOwners = owners; @@ -195,7 +247,7 @@ function markResourceRow(row, key, rolesByChild, roleNames, parentId) { // resource that is not adjacent to one of its roles stays a plain top-level row // rather than being indented under an unrelated one, and carries the name of // the role(s) that grant it instead. -export function markRoleChildren(rows, foldableRoles, rolesByChild, roleNames) { +export function markRoleChildren(rows, foldableRoles, rolesByChild, roleNames, folded) { if (!foldableRoles || foldableRoles.size === 0) return rows; const out = []; // Where the walk currently stands: the role block we are inside, and the role @@ -203,7 +255,7 @@ export function markRoleChildren(rows, foldableRoles, rolesByChild, roleNames) { const pos = { roleId: null, childOf: null }; let marked = false; for (const row of rows) { - const next = markOneRow(row, { foldableRoles, rolesByChild, roleNames }, pos); + const next = markOneRow(row, { foldableRoles, rolesByChild, roleNames, folded }, pos); if (next !== row) marked = true; out.push(next); } @@ -211,20 +263,25 @@ export function markRoleChildren(rows, foldableRoles, rolesByChild, roleNames) { } // One row of that walk: advances `pos` and returns the row as it should render. -function markOneRow(row, { foldableRoles, rolesByChild, roleNames }, pos) { +function markOneRow(row, opts, pos) { + const { foldableRoles, rolesByChild, folded } = opts; // Sub-rows follow the row they were expanded from, so they inherit its place // in the role block. if (row.isNestedRow) return pos.childOf ? { ...row, roleParentId: pos.childOf } : row; const key = rowResourceKey(row); if (foldableRoles.has(key)) { - pos.roleId = key; + // A FOLDED role adopts nothing. Its own resources are gone, so a resource + // still drawn below it is only there because another role grants it — + // indenting it under a collapsed role would claim the opposite. It becomes + // a plain row that names its roles instead. + pos.roleId = folded?.has(key) ? null : key; pos.childOf = null; return row; } pos.childOf = pos.roleId && rolesByChild.get(key)?.has(pos.roleId) ? pos.roleId : null; if (!pos.childOf) pos.roleId = null; - return markResourceRow(row, key, rolesByChild, roleNames, pos.childOf); + return markResourceRow(row, key, opts, pos.childOf); } /** @@ -268,12 +325,19 @@ export function useBusinessRoleFold({ accessPackageGroups, rows, storageKey }) { const unfoldAllRoles = useCallback(() => applyFolds(new Set()), [applyFolds]); const visibleRows = useMemo( - () => markRoleChildren(hideFoldedRows(rows, rolesByChild, foldedRoles), foldableRoles, rolesByChild, roleNames), + () => markRoleChildren( + hideFoldedRows(rows, rolesByChild, foldedRoles), foldableRoles, rolesByChild, roleNames, foldedRoles), [rows, rolesByChild, foldedRoles, foldableRoles, roleNames]); const foldedChildRows = useMemo( () => collectFoldedChildRows(rows, rolesByChild, foldedRoles), [rows, rolesByChild, foldedRoles]); + // What each role's fold really took away — `hidden` trails `total` when a + // resource it grants is still shown by another role that is not folded. + const roleFoldInfo = useMemo( + () => summariseFolds(rows, rolesByChild, childCounts, foldedRoles, roleNames), + [rows, rolesByChild, childCounts, foldedRoles, roleNames]); + const hasFoldedRoles = useMemo( () => [...foldableRoles].some(id => foldedRoles.has(id)), [foldableRoles, foldedRoles]); @@ -282,7 +346,7 @@ export function useBusinessRoleFold({ accessPackageGroups, rows, storageKey }) { foldedChildRows, foldableRoles, foldedRoles, - roleChildCounts: childCounts, + roleFoldInfo, toggleRoleFold, foldAllRoles, unfoldAllRoles, diff --git a/app/ui/src/hooks/useBusinessRoleFold.test.jsx b/app/ui/src/hooks/useBusinessRoleFold.test.jsx index bdbf83798..8ce37af40 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.test.jsx +++ b/app/ui/src/hooks/useBusinessRoleFold.test.jsx @@ -3,7 +3,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { renderHook, act } from '@ui/test-utils/renderWithProviders'; import { useBusinessRoleFold, buildRoleChildMap, analyseRoleRows, hideFoldedRows, - collectFoldedChildRows, markRoleChildren, rowResourceKey, ROLE_FOLD_VERSION, + collectFoldedChildRows, summariseFolds, markRoleChildren, rowResourceKey, + ROLE_FOLD_VERSION, } from './useBusinessRoleFold'; const storeKey = (k) => `fgraph-rolefold-${k || 'all'}`; @@ -188,6 +189,46 @@ describe('collectFoldedChildRows', () => { }); }); +// A resource in two roles is the case the fold has to resolve: it survives the +// first fold, so the role that folded must not claim to have hidden it. +describe('summariseFolds', () => { + const childrenByRole = buildRoleChildMap(AP_GROUPS); + const { rolesByChild, childCounts, roleNames } = analyseRoleRows(ROWS, childrenByRole); + const summarise = (folded) => summariseFolds(ROWS, rolesByChild, childCounts, folded, roleNames); + + it('reports every foldable role as hiding nothing while all are expanded', () => { + const info = summarise(new Set()); + expect(info.get('BR1')).toEqual({ total: 2, hidden: 0, shownBy: [] }); + expect(info.get('BR2')).toEqual({ total: 2, hidden: 0, shownBy: [] }); + }); + + it('counts only the rows the fold really took away, and names who shows the rest', () => { + const info = summarise(new Set(['BR1'])); + // G1 went; G2 stayed because BR2 grants it and is still expanded. + expect(info.get('BR1')).toEqual({ total: 2, hidden: 1, shownBy: ['Business Role 2'] }); + // BR2 is not folded, so it hides nothing and owes no explanation. + expect(info.get('BR2')).toEqual({ total: 2, hidden: 0, shownBy: [] }); + }); + + it('counts a shared row under both roles once every one of them is folded', () => { + const info = summarise(new Set(['BR1', 'BR2'])); + expect(info.get('BR1')).toEqual({ total: 2, hidden: 2, shownBy: [] }); + expect(info.get('BR2')).toEqual({ total: 2, hidden: 2, shownBy: [] }); + }); + + it('falls back to the role id when the role keeping a row on screen has no name', () => { + const rows = [{ id: 'BR1' }, { id: 'BR2' }, { id: 'G2' }]; + const analysis = analyseRoleRows(rows, childrenByRole); + const info = summariseFolds(rows, analysis.rolesByChild, analysis.childCounts, + new Set(['BR1']), new Map()); + expect(info.get('BR1').shownBy).toEqual(['BR2']); + }); + + it('tolerates a missing fold set', () => { + expect(summariseFolds(ROWS, rolesByChild, childCounts, null, roleNames).get('BR1').hidden).toBe(0); + }); +}); + describe('markRoleChildren', () => { const childrenByRole = buildRoleChildMap(AP_GROUPS); const { foldableRoles, rolesByChild, roleNames } = analyseRoleRows(ROWS, childrenByRole); @@ -254,6 +295,20 @@ describe('markRoleChildren', () => { expect(g4.roleOwners).toBeUndefined(); expect(g4.roleGrantedBy).toBeUndefined(); }); + + // A folded role hides its resources, so anything still drawn below it is only + // there because another role grants it — it must not be indented under the + // collapsed one. + it('draws no children under a folded role', () => { + const rows = hideFoldedRows(ROWS, rolesByChild, new Set(['BR1'])); + const marked = markRoleChildren(rows, foldableRoles, rolesByChild, roleNames, new Set(['BR1'])); + const g2 = marked.find((r) => r.id === 'G2'); + expect(g2.roleParentId).toBeUndefined(); + // ...and it names both roles instead, the expanded one first, since that is + // the role keeping it on screen. + expect(g2.roleOwners.map((o) => o.name)).toEqual(['Business Role 2', 'Business Role 1']); + expect(marked.find((r) => r.id === 'G3').roleParentId).toBe('BR2'); + }); }); describe('rowResourceKey', () => { @@ -272,7 +327,7 @@ describe('useBusinessRoleFold', () => { expect(result.current.canFoldRoles).toBe(true); expect(result.current.hasFoldedRoles).toBe(false); expect(ids(result.current.visibleRows)).toEqual(ids(ROWS)); - expect(result.current.roleChildCounts.get('BR1')).toBe(2); + expect(result.current.roleFoldInfo.get('BR1')).toEqual({ total: 2, hidden: 0, shownBy: [] }); }); it('renders the resources of an expanded role as its children', () => { @@ -315,6 +370,28 @@ describe('useBusinessRoleFold', () => { expect(ids(result.current.visibleRows)).toEqual(ids(ROWS)); }); + // Feedback on #370 — the whole shared-resource story, end to end: G2 is + // granted by BR1 and BR2. + it('keeps a resource two roles grant until both fold, and says so on the role row', () => { + const { result } = render(); + act(() => result.current.toggleRoleFold('BR1')); + + // Still on screen, no longer drawn as BR1's child, and naming both roles. + const g2 = result.current.visibleRows.find((r) => r.id === 'G2'); + expect(g2.roleParentId).toBeUndefined(); + expect(g2.roleGrantedBy).toBe('Business Role 1, Business Role 2'); + // BR1 folded one of the two resources it grants, and BR2 is showing the other. + expect(result.current.roleFoldInfo.get('BR1')).toEqual({ + total: 2, hidden: 1, shownBy: ['Business Role 2'], + }); + // It is not counted as hidden by either role, so no deviation tally claims it. + expect(ids(result.current.foldedChildRows.get('BR1'))).toEqual(['G1']); + + act(() => result.current.toggleRoleFold('BR2')); + expect(ids(result.current.visibleRows)).toEqual(['BR1', 'BR2', 'G4']); + expect(result.current.roleFoldInfo.get('BR1')).toEqual({ total: 2, hidden: 2, shownBy: [] }); + }); + it('folds every role at once, leaving roles plus ungranted resources', () => { const { result } = render(); act(() => result.current.foldAllRoles()); diff --git a/changes/dor-issue-370.md b/changes/dor-issue-370.md index 00148e1be..b9ff7990a 100644 --- a/changes/dor-issue-370.md +++ b/changes/dor-issue-370.md @@ -11,4 +11,7 @@ - Matrix: a cell is now marked when someone holds a permanent membership on a resource their business role only makes them eligible for — more access than the role assigns. - Matrix: a subject who is short on one resource of a business role and over on another now shows both deviations at the same time, on the same business role. - Matrix: a resource row now names the business role it belongs to — in its tooltip wherever it sits, and as a clickable chip once the row has been moved away from that role, so a dragged row is never orphaned from its role. +- Matrix: a resource that more than one business role grants is now handled end to end — it stays on screen until every one of those roles is folded, stops being drawn under a role once that role is collapsed, and names the role that is still showing it. +- Matrix: a folded business role now reports what it actually hid ("1 of 3 resources folded") when another role it shares resources with is still open, and its tooltip names that role — so the count can never overstate what went away. +- Demo data: added an IT operations business role that shares a group and an application role with the service desk role, so the "same resource in two roles" case is visible in the demo. - Demo data: added a service desk business role whose holders show the full picture — one person short of what the role assigns, one both short on one group and over-provisioned on another, one holding a group of the role without holding the role, and two holding exactly what the role assigns. diff --git a/docs/architecture/demo-dataset.md b/docs/architecture/demo-dataset.md index 5a0da2894..c0db02cd6 100644 --- a/docs/architecture/demo-dataset.md +++ b/docs/architecture/demo-dataset.md @@ -19,6 +19,7 @@ It also backs the **public demo environment** and hides the **Capture-the-Flag** | `DemoGovernance.ps1` | IGA catalogs, business roles, policies, certifications | | `DemoSalesScenario.ps1` | The Sales role-mining scenario (flags 1–7) | | `DemoRoleDrift.ps1` | Role holders with fewer / more access than their business role assigns | +| `DemoSharedGrants.ps1` | The overlap between two business roles — one group and one app role granted by both | | `DemoConsent.ps1` | OAuth consent + shadow IT (flags 11–12) | | `DemoSap.ps1` | The SAP ERP system (flag 8) | | `DemoAzure.ps1` | The AzureRM system (flag 10) | @@ -154,7 +155,7 @@ AU-Netherlands | BR-Finance-Systems | BusinessRole | IGA | Financial systems access | | BR-Admin-Privileged | BusinessRole | IGA | Privileged admin access | | BR-Sales | BusinessRole | IGA | Sales role — Contains SG-Sales + SG-CRM-Users | -| BR-Service-Desk | BusinessRole | IGA | Service desk role — Contains the two service desk groups as a membership and SG-Servicedesk-Admin as *eligibility only*. The role-drift scenario hangs off it | +| BR-Service-Desk | BusinessRole | IGA | Service desk role — Contains the two service desk groups as a membership, SG-Servicedesk-Admin as *eligibility only*, and the Ticketing-Agent app role it shares with BR-IT-Operations. The role-drift scenario hangs off it | | SG-Engineering | GroupOwnership | EntraID | Owners of the Engineering group (a GroupOwnership resource is named after the group it owns) | | SG-Finance | GroupOwnership | EntraID | Owners of the Finance group | | SG-Admin-Tier0 | GroupOwnership | EntraID | Owners of the Tier-0 admin group | @@ -165,6 +166,9 @@ AU-Netherlands | SG-Servicedesk-Tools | Group | EntraID | Granted **by** BR-Service-Desk as a membership | | SG-Servicedesk-KB | Group | EntraID | Granted **by** BR-Service-Desk as a membership — the one two holders never got (fewer than the role assigns) | | SG-Servicedesk-Admin | Group | EntraID | Granted **by** BR-Service-Desk as *eligibility*; one holder has it as a standing membership (more than the role assigns) | +| BR-IT-Operations | BusinessRole | IGA | IT operations role — overlaps BR-Service-Desk on two resources. The shared-grant scenario hangs off it | +| Ticketing-Agent | AppRole | EntraID | Granted **by** BR-Service-Desk *and* BR-IT-Operations — the app role in two business roles | +| SG-Monitoring-Tools | Group | EntraID | Granted **by** BR-IT-Operations alone, so folding that role still takes a row away | | FileSync Pro | Application | EntraID | Third-party app, unverified publisher — the risky one | | Files.ReadWrite.All | DelegatedPermission | EntraID | The risky consent scope (flags 11–12) | | Contoso Timesheets | Application | EntraID | Approved app, verified publisher — the control | @@ -204,6 +208,10 @@ AU-Netherlands | E0034 (Tom Bakker) | SG-Servicedesk-Tools only | Indirect | **Fewer** than the role assigns: never provisioned into the KB or the admin eligibility | | E0030 (Wendy Xu) | SG-Servicedesk-Tools (Indirect), SG-Servicedesk-Admin (**Direct**) | Indirect / Direct | Both directions at once: no KB (**fewer**) and a standing membership where the role only grants eligibility (**more**) | | E0024 (Lars Muller) | SG-Servicedesk-Tools | Direct | Holds one of the role's resources without holding the role — access the role does not account for | +| E0010, E0029, E0030 | BR-IT-Operations | Direct (`governed=true`) | The shared-grant cast — E0010 holds this role only, E0029/E0030 hold both roles. See [One resource, two business roles](#one-resource-two-business-roles) | +| E0010, E0029, E0030 | SG-Monitoring-Tools | Indirect | What only BR-IT-Operations grants | +| E0010, E0014, E0029, E0030 | Ticketing-Agent | Indirect | The shared app role. E0034 is left out — one more thing his role assigns that he never got | +| E0010 | SG-Servicedesk-Tools | Indirect | The shared group. E0029/E0030 already hold it through BR-Service-Desk — a second role covering a membership adds coverage, **not** a second assignment | ### Resource Relationships @@ -222,6 +230,10 @@ AU-Netherlands | BR-Service-Desk | SG-Servicedesk-Tools | Contains (`roleName='Member'`) | A standing membership | | BR-Service-Desk | SG-Servicedesk-KB | Contains (`roleName='Member'`) | A standing membership | | BR-Service-Desk | SG-Servicedesk-Admin | Contains (`roleName='Eligible Member'`) | Just-in-time only — `roleName` is what makes a standing membership on it read as *more than the role assigns* | +| BR-Service-Desk | Ticketing-Agent | Contains (`roleName='Member'`) | The app role it shares with BR-IT-Operations | +| BR-IT-Operations | Ticketing-Agent | Contains (`roleName='Member'`) | The second grant of the same app role | +| BR-IT-Operations | SG-Servicedesk-Tools | Contains (`roleName='Member'`) | The second grant of the same group | +| BR-IT-Operations | SG-Monitoring-Tools | Contains (`roleName='Member'`) | Granted by this role alone | | FileSync Pro | Files.ReadWrite.All | DelegatesScope | App → the scope consented to it | | Contoso Timesheets | User.Read | DelegatesScope | The control app | | Fortigi Demo Tenant → rg-prod-eastus / rg-prod-westeurope → their storage accounts | Contains | Contains | The Azure scope tree (4 edges) | @@ -234,7 +246,7 @@ AU-Netherlands | Entity | Data | |---|---| -| Catalog: "Employee Access" | Contains BR-Employee-Base, BR-Engineering-Tools, BR-Finance-Systems, BR-Sales, BR-Service-Desk | +| Catalog: "Employee Access" | Contains BR-Employee-Base, BR-Engineering-Tools, BR-Finance-Systems, BR-Sales, BR-Service-Desk, BR-IT-Operations | | Catalog: "Privileged Access" | Contains BR-Admin-Privileged | | Policy: "Auto-assign all employees" | On BR-Employee-Base, scope: all, auto-approve | | Policy: "Manager approval" | On BR-Engineering-Tools, requires manager approval | @@ -316,12 +328,14 @@ Matching access is the easy case. `DemoRoleDrift.ps1` supplies the two that a role-mining review actually hunts for, on one business role — **BR-Service-Desk**, which grants `SG-Servicedesk-Tools` and `SG-Servicedesk-KB` as memberships and `SG-Servicedesk-Admin` as *eligibility -only* (`roleName='Eligible Member'` on the `Contains` edge): +only* (`roleName='Eligible Member'` on the `Contains` edge). The fourth thing it +grants — the `Ticketing-Agent` app role — belongs to the +[shared-grant scenario](#one-resource-two-business-roles) below: | Person | What they have | What the matrix shows | |---|---|---| | Ursula Visser (E0014), Victor Wang (E0029) | All three, exactly as assigned | Nothing — the control, so the deviations don't read as the norm | -| Tom Bakker (E0034) | Tools only | **Fewer**: two of the three resources the role assigns him are missing | +| Tom Bakker (E0034) | Tools only | **Fewer**: two of the three groups the role assigns him are missing — as is the Ticketing-Agent app role it shares with BR-IT-Operations | | Wendy Xu (E0030) | Tools, plus Admin as a *standing* membership; no KB | **Fewer and more at once** — the case the folded role row has to summarise in both directions | | Lars Muller (E0024) | Tools, without holding the role | Access the role does not account for | @@ -332,6 +346,36 @@ provisioning needs the `roleName` on the edge — without it every child reads a See [`matrix.md`](matrix.md) → "Fewer and more than the role assigns" for how each is rendered. +### One resource, two business roles + +Real catalogues overlap — the same group or application role is handed out by +more than one business role. `DemoSharedGrants.ps1` builds that overlap between +**BR-Service-Desk** and a second role, **BR-IT-Operations**: + +| Resource | Granted by | Why it is there | +|---|---|---| +| `SG-Servicedesk-Tools` (Group) | BR-Service-Desk **+** BR-IT-Operations | The group in two roles | +| `Ticketing-Agent` (AppRole) | BR-Service-Desk **+** BR-IT-Operations | The application role in two roles | +| `SG-Monitoring-Tools` (Group) | BR-IT-Operations only | So folding one of the two roles still takes a row away | + +The holders make the overlap non-trivial: Victor Wang (E0029) and Wendy Xu +(E0030) hold **both** roles, while Fatih Gunay (E0010) holds BR-IT-Operations +only — so the shared rows cannot be attributed to the service desk alone. + +Two properties of the data are the point, and +`Verify-DemoDataset.ps1` guards both: + +- **A membership covered by two roles is one assignment.** Victor's + `SG-Servicedesk-Tools` row is emitted once; the second role adds a second + `Contains` edge, i.e. coverage, not a second grant. That is why + `DemoSharedGrants.ps1` skips the memberships `DemoRoleDrift.ps1` already + emitted. +- **Each role also grants something exclusively**, so "fold one role" and "fold + both" are visibly different states. + +See [`matrix.md`](matrix.md) → "One resource, several business roles" for what +the grid does with it. + --- ## Dataset Format @@ -352,9 +396,9 @@ The dataset is a single JSON file that maps directly to the Ingest API endpoints "entityCounts": { "systems": 5, "principals": 45, - "resources": 43, - "resourceAssignments": 157, - "resourceRelationships": 23, + "resources": 46, + "resourceAssignments": 168, + "resourceRelationships": 27, "identities": 27, "identityMembers": 38, "contexts": 9, @@ -392,9 +436,9 @@ Counted with `deletedAt IS NULL` on `Principals`, `Resources` and `ResourceAssig |---|---|---| | Systems | 5 | Entra ID + HR + IGA + SAP ERP + AzureRM | | Principals | 45 | 26 employees + 1 disabled + 1 contractor + 1 `ServicePrincipal` + 1 `AIAgent` + 1 `SharedMailbox` + 1 IGA account + 10 SAP accounts + 3 app service principals | -| Resources | 43 | Entra 10 + group-ownership 3 + business roles 6 + Sales 4 + role drift 3 + consent 4 + SAP 4 + Azure 9 | -| ResourceAssignments | 157 | `Direct` + `Indirect` (role-derived) + `Eligible`; the `governed=true` ones are the business-role memberships | -| ResourceRelationships | 23 | 17 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope | +| Resources | 46 | Entra 10 + group-ownership 3 + business roles 7 + Sales 4 + role drift 3 + shared grants 2 + consent 4 + SAP 4 + Azure 9 | +| ResourceAssignments | 168 | `Direct` + `Indirect` (role-derived) + `Eligible`; the `governed=true` ones are the business-role memberships | +| ResourceRelationships | 27 | 21 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope | | Identities | 27 | 26 employees + 1 disabled | | IdentityMembers | 38 | 27 Entra + 1 IGA + 10 SAP | | Contexts | 9 | 1 root + 5 departments + 2 teams + 1 admin unit — all `variant='synced'` | @@ -448,13 +492,17 @@ These are **exact**, because the generator is deterministic. If one fails after | 5 principals consented to `Files.ReadWrite.All` (flag 11) | True | | 2 principals are risky-consenters with never-expiring passwords, and the "any consent" trap is wider at 3 (flag 12) | True | | Every `DelegatedPermission`'s `clientSpId` resolves to a Principal | True | +| BR-Service-Desk grants 4 resources, one of them `Eligible Member` only | True | +| `SG-Servicedesk-Tools` (Group) and `Ticketing-Agent` (AppRole) each have 2 `Contains` parents | True | +| A membership covered by two roles is stored once, not twice | True | +| One BR-IT-Operations holder (E0010) does not hold BR-Service-Desk | True | ### UI Verification (Playwright) | Check | How | |---|---| -| Resources page shows 34 resources (excluding BusinessRoles) | Count rows, filter by non-BusinessRole | -| Business Roles page shows 5 business roles | Count rows | +| Resources page shows 39 resources (excluding BusinessRoles) | Count rows, filter by non-BusinessRole | +| Business Roles page shows 7 business roles | Count rows | | Users page shows 45 principals | Count visible or total indicator | | Matrix shows data (not "0 users x 0 resources") | Assert text not present | | Click CTO user → detail page opens | Navigate, check heading | diff --git a/docs/architecture/matrix.md b/docs/architecture/matrix.md index ababa3500..15840439c 100644 --- a/docs/architecture/matrix.md +++ b/docs/architecture/matrix.md @@ -172,7 +172,8 @@ Rules worth knowing: localStorage (`fgraph-rolefold-`), the mechanism the custom row order uses — so two different matrix slices keep independent fold state. - **A resource granted by several roles** stays visible until *every* role - granting it that is present in the grid is folded. + granting it that is present in the grid is folded — see + [One resource, several business roles](#one-resource-several-business-roles). - **A role with no row of its own** (nobody visible holds it) gets no fold affordance and hides nothing — a resource never disappears without a visible parent to unfold it from. @@ -213,6 +214,33 @@ Both come from `markRoleChildren` in [`useBusinessRoleFold.js`](https://github.com/Fortigi/IdentityAtlas/blob/main/app/ui/src/hooks/useBusinessRoleFold.js), off the same `Contains` data the fold uses. +### One resource, several business roles + +Catalogues overlap: the same group or application role is routinely handed out +by more than one business role. That is one row with two (or more) `Contains` +parents — **never** a duplicated row or a duplicated assignment. The membership +is stored once; what the second role adds is *coverage*, and the grid resolves +the overlap in five places: + +| Where | What it does | +|---|---| +| **SOLL columns** | The row shows a badge under **every** role that grants it, so the overlap is readable straight off the grid | +| **Cell colour** | The cell carries a count bubble — "covered by *n* business roles" — and takes the colour of the first role in `managedByPackages` for that cell | +| **Row position** | The AP staircase files the row under its **leftmost** granting role; the others are named on the row (chip + tooltip), so no role's claim is lost | +| **Folding** | The row survives until *every* granting role is folded. Fold one, and it stays — drawn as a plain top-level row rather than as a child of the collapsed role, naming the role that is still showing it first | +| **The fold chip** | Reads "*N* of *M* resources folded" when this fold took fewer rows than the role grants, and its tooltip names the still-expanded role holding the rest | + +The last two are the point: a folded role never claims to have hidden a row +that is still on screen, and unfolding *either* role brings a shared row back. +The deviation tallies follow the same rule — a shared row that is still visible +is counted by neither folded role, because its own cell is right there saying +it. `summariseFolds` and `collectFoldedChildRows` walk the grid through one +shared helper so the count and the rows can never drift apart. + +`docs/architecture/demo-dataset.md` → "One resource, two business roles" +describes the demo data (`BR-Service-Desk` and `BR-IT-Operations` sharing a +group and an app role) that exercises every row of this table. + ### Fewer and more than the role assigns A business role states what a subject should have (SOLL); the assignments state diff --git a/test/demo-dataset/Generate-DemoDataset.ps1 b/test/demo-dataset/Generate-DemoDataset.ps1 index 061e26662..1a034237d 100644 --- a/test/demo-dataset/Generate-DemoDataset.ps1 +++ b/test/demo-dataset/Generate-DemoDataset.ps1 @@ -17,6 +17,7 @@ DemoGovernance.ps1 — IGA catalogs, business roles, policies, certifications DemoSalesScenario.ps1 — the Sales role-mining scenario (flags 1-7) DemoRoleDrift.ps1 — holders with fewer / more access than their role assigns + DemoSharedGrants.ps1 — one group / app role granted by two business roles DemoConsent.ps1 — OAuth consent + shadow IT (flags 11-12) DemoSap.ps1 — the SAP ERP system (flag 8) DemoAzure.ps1 — the AzureRM system (flag 10) @@ -40,7 +41,8 @@ if (-not $OutputPath) { $OutputPath = Join-Path $PSScriptRoot 'demo-company.json $partsDir = Join-Path $PSScriptRoot 'parts' foreach ($part in @( 'DemoState.ps1', 'DemoOrg.ps1', 'DemoEntraBase.ps1', 'DemoGovernance.ps1', - 'DemoSalesScenario.ps1', 'DemoRoleDrift.ps1', 'DemoConsent.ps1', 'DemoSap.ps1', 'DemoAzure.ps1' + 'DemoSalesScenario.ps1', 'DemoRoleDrift.ps1', 'DemoSharedGrants.ps1', + 'DemoConsent.ps1', 'DemoSap.ps1', 'DemoAzure.ps1' )) { . (Join-Path $partsDir $part) } @@ -54,6 +56,9 @@ Add-DemoEntraBase $state Add-DemoGovernance $state Add-DemoSalesScenario $state Add-DemoRoleDrift $state +# Shared grants runs after the drift part: it reuses the service desk resources +# that part creates, and skips memberships it already emitted. +Add-DemoSharedGrants $state Add-DemoConsent $state Add-DemoSap $state Add-DemoAzure $state diff --git a/test/demo-dataset/Verify-DemoDataset.ps1 b/test/demo-dataset/Verify-DemoDataset.ps1 index 0d5c12d2e..a3a420a24 100644 --- a/test/demo-dataset/Verify-DemoDataset.ps1 +++ b/test/demo-dataset/Verify-DemoDataset.ps1 @@ -87,9 +87,9 @@ Write-Host "`n--- Row Counts ---" -ForegroundColor Yellow $counts = @{ 'Systems' = @{ Min = 5; Max = 5 } # EntraID + HR + IGA + SAP + AzureRM (#705) 'Principals' = @{ Min = 45; Max = 45 } # 26 employees + 5 edge cases + IGA acct + 10 SAP + 3 app SPs - 'Resources' = @{ Min = 43; Max = 43 } # Entra 10 + ownership 3 + business roles 6 + Sales 4 + role drift 3 + consent 4 + SAP 4 + Azure 9 - 'ResourceAssignments' = @{ Min = 157; Max = 157 } - 'ResourceRelationships' = @{ Min = 23; Max = 23 } # 17 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope + 'Resources' = @{ Min = 46; Max = 46 } # Entra 10 + ownership 3 + business roles 7 + Sales 4 + role drift 3 + shared grants 2 + consent 4 + SAP 4 + Azure 9 + 'ResourceAssignments' = @{ Min = 168; Max = 168 } + 'ResourceRelationships' = @{ Min = 27; Max = 27 } # 21 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope 'Identities' = @{ Min = 27; Max = 27 } # 26 employees + the leaver 'IdentityMembers' = @{ Min = 38; Max = 38 } # 27 Entra + 1 IGA + 10 SAP 'GovernanceCatalogs' = @{ Min = 2; Max = 2 } @@ -176,7 +176,7 @@ SELECT COUNT(*) FROM "Principals" WHERE "accountEnabled" = false AND "deletedAt" '@ # Resource types -Assert-Count 'BusinessRole-Count' -Min 5 -Max 5 -Query @' +Assert-Count 'BusinessRole-Count' -Min 7 -Max 7 -Query @' SELECT COUNT(*) FROM "Resources" WHERE "resourceType" = 'BusinessRole' AND "deletedAt" IS NULL '@ @@ -391,8 +391,9 @@ WHERE r."resourceType" = 'DelegatedPermission' AND r."deletedAt" IS NULL Write-Host "`n--- Role drift ---" -ForegroundColor Yellow -# BR-Service-Desk grants three resources, one of them just-in-time only. -Assert-Count 'Drift-RoleGrantsThreeResources' -Min 3 -Max 3 -Query @' +# BR-Service-Desk grants four resources: three groups, one of them just-in-time +# only, plus the app role it shares with BR-IT-Operations (DemoSharedGrants.ps1). +Assert-Count 'Drift-RoleGrantsFourResources' -Min 4 -Max 4 -Query @' SELECT COUNT(*) FROM "ResourceRelationships" rr JOIN "Resources" parent ON parent."id" = rr."parentResourceId" WHERE parent."displayName" = 'BR-Service-Desk' AND rr."relationshipType" = 'Contains' @@ -442,6 +443,65 @@ WHERE p."displayName" IN ('Ursula Visser', 'Victor Wang') AND ra."deletedAt" IS AND r."displayName" IN ('SG-Servicedesk-Tools', 'SG-Servicedesk-KB', 'SG-Servicedesk-Admin') '@ +# ─── Shared grants: one resource, two business roles ───────────────────── +# The matrix has to resolve a resource that more than one business role grants +# (requestor feedback on #370), so the dataset has to contain one of each kind — +# a group and an application role. See parts/DemoSharedGrants.ps1. + +Write-Host "`n--- Shared grants ---" -ForegroundColor Yellow + +# A GROUP granted by two roles. +Assert-Count 'Shared-GroupGrantedByTwoRoles' -Min 2 -Max 2 -Label '2 granting roles' -Query @' +SELECT COUNT(*) FROM "ResourceRelationships" rr +JOIN "Resources" child ON child."id" = rr."childResourceId" +WHERE child."displayName" = 'SG-Servicedesk-Tools' AND rr."relationshipType" = 'Contains' +'@ + +# ...and an APPLICATION ROLE granted by two roles — the case the requestor asked +# about by name. The resourceType matters: it is what makes this the app-role +# variant rather than a second group. +Assert-Count 'Shared-AppRoleGrantedByTwoRoles' -Min 2 -Max 2 -Label '2 granting roles' -Query @' +SELECT COUNT(*) FROM "ResourceRelationships" rr +JOIN "Resources" child ON child."id" = rr."childResourceId" +WHERE child."displayName" = 'Ticketing-Agent' AND child."resourceType" = 'AppRole' + AND rr."relationshipType" = 'Contains' +'@ + +# Each of the two roles must also grant something no other role does, or folding +# one of them would take no row away and the scenario would show nothing. +Assert-Count 'Shared-EachRoleAlsoGrantsSomethingAlone' -Min 2 -Max 2 -Label '2 exclusive resources' -Query @' +SELECT COUNT(*) FROM "ResourceRelationships" rr +JOIN "Resources" child ON child."id" = rr."childResourceId" +WHERE child."displayName" IN ('SG-Monitoring-Tools', 'SG-Servicedesk-KB') + AND rr."relationshipType" = 'Contains' +'@ + +# One holder of the new role must NOT hold the role it overlaps with, or the +# shared rows could still be read as belonging to the service desk alone. +Assert-Count 'Shared-HolderOfOneRoleOnly' -Min 1 -Max 1 -Query @' +SELECT COUNT(*) FROM "ResourceAssignments" ra +JOIN "Resources" r ON r."id" = ra."resourceId" +JOIN "Principals" p ON p."id" = ra."principalId" +WHERE p."displayName" = 'Fatih Gunay' AND r."displayName" = 'BR-IT-Operations' + AND ra."deletedAt" IS NULL + AND NOT EXISTS ( + SELECT 1 FROM "ResourceAssignments" ra2 + JOIN "Resources" r2 ON r2."id" = ra2."resourceId" + WHERE ra2."principalId" = p."id" AND r2."displayName" = 'BR-Service-Desk' + AND ra2."deletedAt" IS NULL + ) +'@ + +# A membership covered by two roles is ONE assignment, not two — the overlap is +# in the coverage. A duplicate here would double-count every shared cell. +Assert-Count 'Shared-MembershipIsNotDuplicated' -Min 1 -Max 1 -Query @' +SELECT COUNT(*) FROM "ResourceAssignments" ra +JOIN "Resources" r ON r."id" = ra."resourceId" +JOIN "Principals" p ON p."id" = ra."principalId" +WHERE p."displayName" = 'Victor Wang' AND r."displayName" = 'SG-Servicedesk-Tools' + AND ra."deletedAt" IS NULL +'@ + # ─── API Verification ───────────────────────────────────────────── Write-Host "`n--- API Verification ---" -ForegroundColor Yellow diff --git a/test/demo-dataset/parts/DemoRoleDrift.ps1 b/test/demo-dataset/parts/DemoRoleDrift.ps1 index 00ae2412c..7ba57c78d 100644 --- a/test/demo-dataset/parts/DemoRoleDrift.ps1 +++ b/test/demo-dataset/parts/DemoRoleDrift.ps1 @@ -28,6 +28,11 @@ Lars Muller (E0024) — holds Tools directly without holding the role at all: access the role does not account for. + A fourth resource — the Ticketing-Agent app role — is added to this role by + DemoSharedGrants.ps1, which also gives it to BR-IT-Operations. It lives + there because it belongs to that scenario (one resource, two roles), not + because it is a fourth drift case. Tom is left out of it as well. + WHY THE Indirect ROWS ARE EXPLICIT: same reason as DemoSalesScenario.ps1 — the matrix matview reads declared rows, so access a role confers has to be emitted as a real assignment. Leaving one out is exactly what makes it read diff --git a/test/demo-dataset/parts/DemoSharedGrants.ps1 b/test/demo-dataset/parts/DemoSharedGrants.ps1 new file mode 100644 index 000000000..03069e081 --- /dev/null +++ b/test/demo-dataset/parts/DemoSharedGrants.ps1 @@ -0,0 +1,116 @@ +<# +.SYNOPSIS + Fortigi Demo Corp — one resource, two business roles: the overlap between + BR-Service-Desk and BR-IT-Operations. + +.DESCRIPTION + Real IGA catalogues overlap: the same group or application role is handed + out by more than one business role. Until this part, every resource in the + demo belonged to exactly one role, so nothing on screen showed what the + matrix does when two roles grant the same row (requestor feedback on #370). + + The overlap — BR-IT-Operations grants three resources, two of which + BR-Service-Desk grants as well: + + * SG-Servicedesk-Tools (Group) — also granted by BR-Service-Desk + * Ticketing-Agent (AppRole) — also granted by BR-Service-Desk + * SG-Monitoring-Tools (Group) — granted by BR-IT-Operations alone + + The third one matters: folding one of the two roles must still take a row + away, or "does folding do anything here?" has no visible answer. + + The cast: + Victor Wang (E0029), Wendy Xu (E0030) — the two SysAdmins, who hold BOTH + roles. Their membership of the shared resources is ONE assignment + covered by TWO roles — which is the whole point: the overlap is in the + coverage, not in the access. + Fatih Gunay (E0010) — Team Lead Platform, holds BR-IT-Operations only. He + is why the shared rows cannot be attributed to the service desk alone. + + Tom Bakker (E0034) deliberately does NOT get the ticketing role: he is the + under-provisioned service desk holder from DemoRoleDrift.ps1, and the shared + app role is one more thing his role assigns that he never received. + + WHY THE Indirect ROWS ARE EXPLICIT: same as DemoSalesScenario.ps1 — the + matrix matview reads declared rows, so access a role confers is emitted as a + real assignment. Memberships the drift part already emitted are NOT repeated + here; a second role covering them adds coverage, not a second assignment. +#> + +Set-StrictMode -Version Latest + +# Holders of BR-IT-Operations. The two SysAdmins hold the service desk role too; +# the platform lead holds this one only. +$script:ItOpsHolders = @('E0010', 'E0029', 'E0030') + +# Who ends up with the shared ticketing app role: the IT operations holders plus +# the service desk holders — except Tom Bakker, whose gap is the point. +$script:TicketingHolders = @('E0010', 'E0014', 'E0029', 'E0030') + +# SG-Servicedesk-Tools memberships DemoRoleDrift.ps1 already emitted. Re-emitting +# them would duplicate the assignment rather than add the second role's coverage. +$script:AlreadyHoldsTools = @('E0014', 'E0029', 'E0030', 'E0034') + +function Add-DemoSharedGrants { + param([Parameter(Mandatory)]$State) + + $sysEntra = $State.SystemIds['entra'] + $sysIga = $State.SystemIds['iga'] + + # Derived, not read from state: every demo GUID is a pure function of its + # seed, so referencing the drift part's resources needs no ordering contract. + $shared = [ordered]@{ + BR = New-DemoGuid 'res-br-it-operations' + Ticketing = New-DemoGuid 'res-app-ticketing-agent' + Monitoring = New-DemoGuid 'res-sg-monitoring-tools' + Tools = New-DemoGuid 'res-sg-servicedesk-tools' + ServicesBR = New-DemoGuid 'res-br-service-desk' + } + $State['Shared'] = $shared + + $null = Add-DemoResource $State -Id $shared.Ticketing -DisplayName 'Ticketing-Agent' -ResourceType 'AppRole' ` + -SystemId $sysEntra ` + -Description 'Ticketing platform agent role — work, assign and close tickets on behalf of the service desk.' + + $null = Add-DemoResource $State -Id $shared.Monitoring -DisplayName 'SG-Monitoring-Tools' -ResourceType 'Group' ` + -SystemId $sysEntra ` + -Description 'Infrastructure monitoring — dashboards, alert routing and on-call schedules.' + + $null = Add-DemoResource $State -Id $shared.BR -DisplayName 'BR-IT-Operations' -ResourceType 'BusinessRole' ` + -SystemId $sysIga -CatalogId (New-DemoGuid 'cat-employee-access') ` + -Description 'IT operations business role — grants the monitoring tools, and shares the service desk tooling and ticketing role with BR-Service-Desk.' + + # The overlap itself. Two roles, one Contains edge each, on the same child. + foreach ($grant in @( + @{ Parent = $shared.BR; Child = $shared.Tools } + @{ Parent = $shared.BR; Child = $shared.Ticketing } + @{ Parent = $shared.BR; Child = $shared.Monitoring } + @{ Parent = $shared.ServicesBR; Child = $shared.Ticketing } + )) { + Add-DemoRelationship $State -ParentResourceId $grant.Parent -ChildResourceId $grant.Child ` + -RelationshipType 'Contains' -RoleName 'Member' + } + + Add-DemoSharedGrantAssignments $State +} + +function Add-DemoSharedGrantAssignments { + param([Parameter(Mandatory)]$State) + + $shared = $State.Shared + + foreach ($e in $script:ItOpsHolders) { + $p = Get-DemoPrincipalId $e + Add-DemoAssignment $State -ResourceId $shared.BR -PrincipalId $p -AssignmentType 'Direct' -Governed + Add-DemoAssignment $State -ResourceId $shared.Monitoring -PrincipalId $p -AssignmentType 'Indirect' + # Only for a holder who does not already have the membership through the + # service desk role — one membership, covered by both roles. + if ($script:AlreadyHoldsTools -notcontains $e) { + Add-DemoAssignment $State -ResourceId $shared.Tools -PrincipalId $p -AssignmentType 'Indirect' + } + } + + foreach ($e in $script:TicketingHolders) { + Add-DemoAssignment $State -ResourceId $shared.Ticketing -PrincipalId (Get-DemoPrincipalId $e) -AssignmentType 'Indirect' + } +} diff --git a/test/unit/DemoDataset.Tests.ps1 b/test/unit/DemoDataset.Tests.ps1 index 62595d001..f8a166d00 100644 --- a/test/unit/DemoDataset.Tests.ps1 +++ b/test/unit/DemoDataset.Tests.ps1 @@ -390,6 +390,70 @@ Describe 'Demo dataset — Capture-the-Flag scenarios (#705)' { } } +Describe 'Demo dataset — one resource, two business roles (#370)' { + + # Catalogues overlap: the same group or app role is granted by more than one + # business role. The matrix has to resolve that (a shared row survives until + # every granting role is folded), so the dataset has to contain it — one + # group and one application role, both granted twice. See + # parts/DemoSharedGrants.ps1 and docs/architecture/matrix.md. + BeforeAll { + $script:sharedResByName = @{} + foreach ($r in $script:data.resources) { $script:sharedResByName[$r.displayName] = $r } + $script:sharedPrincByName = @{} + foreach ($p in $script:data.principals) { $script:sharedPrincByName[$p.displayName] = $p } + + # childResourceId -> the parents that Contain it. A resource with two + # entries here is one granted by two business roles. + $script:containsByChild = @{} + foreach ($rel in $script:data.resourceRelationships) { + if ($rel.relationshipType -ne 'Contains') { continue } + if (-not $script:containsByChild.ContainsKey($rel.childResourceId)) { + $script:containsByChild[$rel.childResourceId] = @() + } + $script:containsByChild[$rel.childResourceId] += $rel.parentResourceId + } + } + + It 'grants one GROUP from two different business roles' { + $tools = $script:sharedResByName['SG-Servicedesk-Tools'] + $tools.resourceType | Should -Be 'Group' + @($script:containsByChild[$tools.id]).Count | Should -Be 2 + } + + It 'grants one APPLICATION ROLE from two different business roles' { + $ticketing = $script:sharedResByName['Ticketing-Agent'] + $ticketing.resourceType | Should -Be 'AppRole' + @($script:containsByChild[$ticketing.id]).Count | Should -Be 2 + } + + It 'gives each of the two roles a resource of its own, so folding one still hides a row' { + foreach ($name in @('SG-Monitoring-Tools', 'SG-Servicedesk-KB')) { + @($script:containsByChild[$script:sharedResByName[$name].id]).Count | Should -Be 1 + } + } + + It 'stores a membership covered by two roles once — the overlap is coverage, not access' { + $tools = $script:sharedResByName['SG-Servicedesk-Tools'].id + foreach ($who in @('Victor Wang', 'Wendy Xu')) { + $pid = $script:sharedPrincByName[$who].id + @($script:data.resourceAssignments | + Where-Object { $_.resourceId -eq $tools -and $_.principalId -eq $pid }).Count | Should -Be 1 + } + } + + It 'gives one holder only the new role, so the shared rows are not the service desk alone' { + $only = $script:sharedPrincByName['Fatih Gunay'].id + $held = @($script:data.resourceAssignments | Where-Object { $_.principalId -eq $only } | + ForEach-Object { $_.resourceId }) + $held | Should -Contain $script:sharedResByName['BR-IT-Operations'].id + $held | Should -Not -Contain $script:sharedResByName['BR-Service-Desk'].id + # ...and he holds the resources that role shares with the service desk. + $held | Should -Contain $script:sharedResByName['SG-Servicedesk-Tools'].id + $held | Should -Contain $script:sharedResByName['Ticketing-Agent'].id + } +} + Describe 'Demo dataset — vendor-neutral IGA (#705)' { It 'names the governance system generically, not after a vendor' { From 32909bc2b5cda9f0b5ec0f9e7a5e2786c0c9d778 Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Tue, 4 Aug 2026 19:14:31 +0000 Subject: [PATCH 06/14] fix: address e2e/CI failures (attempt 1, #370) --- app/ui/diag.tmp.mjs | 38 +++++ app/ui/e2e/matrix.spec.js | 71 +++++---- app/ui/shot.tmp.mjs | 10 ++ app/ui/src/components/MatrixView.jsx | 42 +----- .../components/MatrixView.scrollbar.test.js | 17 ++- app/ui/src/components/RollupMatrixView.jsx | 22 +-- app/ui/src/components/RotatedMatrixView.jsx | 28 +--- app/ui/src/hooks/useViewportFitHeight.js | 86 +++++++++++ .../src/hooks/useViewportFitHeight.test.jsx | 141 ++++++++++++++++++ 9 files changed, 341 insertions(+), 114 deletions(-) create mode 100644 app/ui/diag.tmp.mjs create mode 100644 app/ui/shot.tmp.mjs create mode 100644 app/ui/src/hooks/useViewportFitHeight.js create mode 100644 app/ui/src/hooks/useViewportFitHeight.test.jsx diff --git a/app/ui/diag.tmp.mjs b/app/ui/diag.tmp.mjs new file mode 100644 index 000000000..18cb7b2f1 --- /dev/null +++ b/app/ui/diag.tmp.mjs @@ -0,0 +1,38 @@ +import { chromium } from '@playwright/test'; +const b = await chromium.launch(); +const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); +const filter = { rowType:'principal', orientation:'rows-as-resources', subject:{include:[],exclude:[]}, resource:{include:[],exclude:[]} }; +await p.goto('http://localhost:5173/#matrix?filter=' + encodeURIComponent(JSON.stringify(filter))); +await p.waitForLoadState('networkidle'); +await p.locator('table').first().waitFor({ timeout: 40000 }); +await p.waitForTimeout(2000); +const out = await p.evaluate(() => { + const de = document.documentElement; + const grid = [...document.querySelectorAll('div')].find(el => { + const s = getComputedStyle(el); + return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 2; + }); + const r = grid.getBoundingClientRect(); + const footer = document.querySelector('footer'); + const chain = []; + let el = grid; + while (el && el !== de) { + const s = getComputedStyle(el); + const b = el.getBoundingClientRect(); + chain.push({ tag: el.tagName, cls: (el.className||'').toString().slice(0,80), top: Math.round(b.top+scrollY), h: Math.round(b.height), pt: s.paddingTop, pb: s.paddingBottom, mt: s.marginTop, mb: s.marginBottom, gap: s.gap, disp: s.display, ovY: s.overflowY }); + el = el.parentElement; + } + return { + pageOverflow: de.scrollHeight - de.clientHeight, + clientH: de.clientHeight, scrollH: de.scrollHeight, bodyScrollH: document.body.scrollHeight, + gridTop: Math.round(r.top + scrollY), gridH: Math.round(r.height), gridMaxH: grid.style.maxHeight, + gridBottom: Math.round(r.bottom + scrollY), + footerH: footer ? Math.round(footer.getBoundingClientRect().height) : null, + footerTop: footer ? Math.round(footer.getBoundingClientRect().top+scrollY) : null, + footerBottom: footer ? Math.round(footer.getBoundingClientRect().bottom+scrollY) : null, + footerMB: footer ? getComputedStyle(footer).marginBottom : null, + chain, + }; +}); +console.log(JSON.stringify(out, null, 2)); +await b.close(); diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index 58c86e5db..6ac69e2a8 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -426,18 +426,24 @@ test.describe('Matrix — fold business-role resources', () => { // the chrome height. The real chrome (auth banner + scope-statistics + "How to // read") is taller, so the grid was too tall and the PAGE got a second // scrollbar next to the grid's own (measured ~310px page overflow). The fix -// measures the remaining viewport and caps the grid to fit, so only the grid -// scrolls. This test renders a tall grid (all data) and asserts the page itself -// does not overflow. +// measures the space really left below the grid and caps it to fit — and when +// less than a usable grid is left, drops the cap so the page is the single +// scroller instead. Either way exactly one of the two scrolls. test.describe('Matrix — no double scrollbar', () => { test.setTimeout(90000); - test('the page does not scroll when the grid does (only one scrollbar)', async ({ page }) => { - // A viewport tall enough that the fix has room (it floors the grid at 240px), - // but where a full-data grid is far taller than the space left for it — so - // the OLD fixed cap would push the page past the viewport. - await page.setViewportSize({ width: 1280, height: 800 }); + // Measures which of the two scrolls: the grid (internally) or the page. + const readScrollState = (page) => page.evaluate(() => { + const de = document.documentElement; + const gridScrolls = [...document.querySelectorAll('div')].some((el) => { + const s = getComputedStyle(el); + return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 2; + }); + // A few px of slack for sub-pixel rounding. + return { pageScrolls: de.scrollHeight - de.clientHeight > 4, gridScrolls }; + }); + async function openFullMatrix(page) { // Apply an all-data filter directly via the hash (resources as rows → many // rows → a grid taller than the viewport). Bypasses the wizard. const filter = { @@ -452,32 +458,41 @@ test.describe('Matrix — no double scrollbar', () => { // Need the grid to actually render. If it doesn't (no demo data in this // environment), the scrollbar path can't be exercised — skip rather than // fail on an unrelated data condition. - const table = page.locator('table').first(); try { - await expect(table).toBeVisible({ timeout: 40000 }); + await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); } catch { - test.skip(true, 'matrix grid did not render (no data) — cannot exercise the scrollbar path'); - return; + return false; } await page.waitForTimeout(1500); // let the height-measuring effect settle + return true; + } - const m = await page.evaluate(() => { - const de = document.documentElement; - // The grid is the lone vertical-overflow scroll container. - const gridScrolls = [...document.querySelectorAll('div')].some((el) => { - const s = getComputedStyle(el); - return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 2; - }); - return { pageOverflow: de.scrollHeight - de.clientHeight, gridScrolls }; - }); + test('the grid and the page never scroll at the same time', async ({ page }) => { + // Short viewport + the "How to read this matrix" panel open: the chrome eats + // most of the window, which is exactly the case the old fixed cap got wrong. + await page.setViewportSize({ width: 1280, height: 800 }); + test.skip(!await openFullMatrix(page), 'matrix grid did not render (no data)'); - if (!m.gridScrolls) { - test.skip(true, 'grid is not taller than the viewport in this dataset — nothing to assert'); - return; - } + const m = await readScrollState(page); + expect(m.gridScrolls && m.pageScrolls, 'only one of grid/page may scroll').toBe(false); + // Something must scroll — a full-data grid cannot fit an 800px window. This + // keeps the assertion above from passing vacuously. + expect(m.gridScrolls || m.pageScrolls, 'the full-data matrix must scroll somewhere').toBe(true); + }); - // The grid scrolls internally; the page must NOT (a few px of slack for - // sub-pixel rounding). On the old fixed-cap code this overflowed by ~200px. - expect(m.pageOverflow, 'page should not scroll when only the grid does').toBeLessThanOrEqual(4); + test('with room for the grid, only the grid scrolls', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + test.skip(!await openFullMatrix(page), 'matrix grid did not render (no data)'); + + // Collapse the legend to free the ~270px that keeps the grid from getting a + // usable height. The measuring hook re-measures and caps the grid. + const legend = page.getByRole('button', { name: /How to read this matrix/i }).first(); + await expect(legend).toBeVisible({ timeout: 20000 }); + if (await legend.getAttribute('aria-expanded') === 'true') await legend.click(); + await page.waitForTimeout(1000); + + const m = await readScrollState(page); + expect(m.gridScrolls, 'the grid should scroll internally').toBe(true); + expect(m.pageScrolls, 'the page should not scroll when the grid does').toBe(false); }); }); diff --git a/app/ui/shot.tmp.mjs b/app/ui/shot.tmp.mjs new file mode 100644 index 000000000..258eaca1a --- /dev/null +++ b/app/ui/shot.tmp.mjs @@ -0,0 +1,10 @@ +import { chromium } from '@playwright/test'; +const b = await chromium.launch(); +const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); +const filter = { rowType:'principal', orientation:'rows-as-resources', subject:{include:[],exclude:[]}, resource:{include:[],exclude:[]} }; +await p.goto('http://localhost:5173/#matrix?filter=' + encodeURIComponent(JSON.stringify(filter))); +await p.waitForLoadState('networkidle'); +await p.locator('table').first().waitFor({ timeout: 40000 }); +await p.waitForTimeout(2000); +await p.screenshot({ path: '/tmp/matrix-800.png', fullPage: true }); +await b.close(); diff --git a/app/ui/src/components/MatrixView.jsx b/app/ui/src/components/MatrixView.jsx index c2c359d6c..4db532870 100644 --- a/app/ui/src/components/MatrixView.jsx +++ b/app/ui/src/components/MatrixView.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useReducer, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useMemo, useState, useReducer, useCallback, useEffect, useRef } from 'react'; // useState-equivalent backed by useReducer (supports value + functional // updates): dispatch isn't flagged by react-hooks/set-state-in-effect, so the @@ -8,6 +8,7 @@ import { useAuth } from '@ui/auth/AuthGate'; import { useMatrixRowOrder } from '@ui/hooks/useMatrixRowOrder'; import { useNestedGroupExpand, MAX_NEST_LEVEL } from '@ui/hooks/useNestedGroupExpand'; import { useBusinessRoleFold } from '@ui/hooks/useBusinessRoleFold'; +import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'; import MatrixToolbar from './matrix/MatrixToolbar'; import MatrixLegend from './matrix/MatrixLegend'; import MatrixFilterSummary from './matrix/MatrixFilterSummary'; @@ -930,44 +931,9 @@ export default function MatrixView({ const filterIsApplied = filter !== null && filter !== undefined; // Cap the grid's height to the remaining viewport so ONLY the grid scrolls, - // never the page too. A fixed viewport-minus-fixed-pixels max-height guesses - // the chrome height; the real chrome (auth banner + scope stats + "How to - // read") is taller, so the grid sat too low and the page got a second - // scrollbar. Measure the grid's real document-top instead and re-measure on - // any layout change (header content loads late, panels toggle). + // never the page too. const rootRef = useRef(null); - const [gridMaxH, setGridMaxH] = useState(null); - useLayoutEffect(() => { - const measure = () => { - const el = scrollRef.current; - if (!el) return; - // Reserve room for the app footer (below
) + main's bottom padding. - const footer = document.querySelector('footer'); - const below = (footer ? footer.getBoundingClientRect().height : 0) + 28; - // clientHeight = real layout height; document-relative top (rect.top is - // viewport-relative, so a scrolled page would read too small and cap the - // grid too tall — a self-sustaining overflow). scrollY corrects that. - const vh = document.documentElement.clientHeight; - const gridTop = el.getBoundingClientRect().top + window.scrollY; - // Fit the grid into the remaining viewport so ONLY the grid scrolls. Use - // the available space directly (so the page never gets a second - // scrollbar); a fixed 240px floor on a short viewport with tall chrome - // (e.g. gridTop ~530 on an 800px viewport leaves ~206px) overflowed the - // page by ~30px. A small 160px floor keeps the grid usable without - // re-introducing the overflow in any realistic viewport. - const avail = vh - gridTop - below; - setGridMaxH(Math.max(160, avail)); - }; - measure(); - const raf = requestAnimationFrame(measure); - window.addEventListener('resize', measure); - let ro; - if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(measure); // body: anything above the grid shifts it down - ro.observe(document.body); - } - return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', measure); if (ro) ro.disconnect(); }; - }, [filterIsApplied, users.length]); + const gridMaxH = useViewportFitHeight(scrollRef, [filterIsApplied, users.length]); return (
diff --git a/app/ui/src/components/MatrixView.scrollbar.test.js b/app/ui/src/components/MatrixView.scrollbar.test.js index 5f485c8ca..c750101be 100644 --- a/app/ui/src/components/MatrixView.scrollbar.test.js +++ b/app/ui/src/components/MatrixView.scrollbar.test.js @@ -8,14 +8,17 @@ import { describe, it, expect } from 'vitest'; // The bug: the grid used a fixed `max-h-[calc(100vh-280px)]` that GUESSES the // height of the chrome above it. The real chrome (auth banner + scope-statistics // panel + "How to read") is taller than 280px, so the grid was too tall and the -// page got a second scrollbar next to the grid's own. The fix measures the real -// remaining viewport (clientHeight minus the grid's document-top) and caps the -// grid's height with an inline maxHeight — in BOTH matrix orientations. +// page got a second scrollbar next to the grid's own. The fix caps the grid with +// a measured inline maxHeight — from the shared useViewportFitHeight hook, in +// EVERY matrix orientation. The hook's own behaviour (including that it never +// returns more than the available space) is covered by +// src/hooks/useViewportFitHeight.test.jsx. const here = dirname(fileURLToPath(import.meta.url)); const sources = { MatrixView: readFileSync(join(here, 'MatrixView.jsx'), 'utf8'), RotatedMatrixView: readFileSync(join(here, 'RotatedMatrixView.jsx'), 'utf8'), + RollupMatrixView: readFileSync(join(here, 'RollupMatrixView.jsx'), 'utf8'), }; describe('matrix grid height — no double scrollbar', () => { @@ -24,12 +27,16 @@ describe('matrix grid height — no double scrollbar', () => { it('does not use the fixed max-h-[calc(100vh-280px)] magic number', () => { expect(src).not.toContain('max-h-[calc(100vh-280px)]'); }); - it('measures the real layout height (documentElement.clientHeight)', () => { - expect(src).toContain('document.documentElement.clientHeight'); + it('takes its cap from the shared measuring hook', () => { + expect(src).toContain("import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'"); + expect(src).toMatch(/const gridMaxH = useViewportFitHeight\(/); }); it('caps the grid with a measured inline maxHeight', () => { expect(src).toMatch(/maxHeight: gridMaxH/); }); + it('does not re-introduce a hand-rolled height floor', () => { + expect(src).not.toMatch(/Math\.max\(\s*\d+\s*,\s*(vh|avail)/); + }); }); } }); diff --git a/app/ui/src/components/RollupMatrixView.jsx b/app/ui/src/components/RollupMatrixView.jsx index 6776aefa2..3c5dbdca4 100644 --- a/app/ui/src/components/RollupMatrixView.jsx +++ b/app/ui/src/components/RollupMatrixView.jsx @@ -1,5 +1,6 @@ -import { useMemo, useState, useCallback, useRef, useLayoutEffect } from 'react'; +import { useMemo, useState, useCallback, useRef } from 'react'; import { useAuth } from '@ui/auth/AuthGate'; +import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'; import { friendlyLabel } from '@ui/utils/formatters'; import { getAccessPackageColor } from '@ui/utils/colors'; import { exportRollupToExcel } from '@ui/utils/exportRollupToExcel'; @@ -301,24 +302,7 @@ export default function RollupMatrixView({ // (matches MatrixView). overflow-auto then gives both scrollbars, including // the horizontal one when the columns are wider than the screen. const scrollRef = useRef(null); - const [gridMaxH, setGridMaxH] = useState(null); - useLayoutEffect(() => { - const measure = () => { - const el = scrollRef.current; - if (!el) return; - const footer = document.querySelector('footer'); - const below = (footer ? footer.getBoundingClientRect().height : 0) + 28; - const vh = document.documentElement.clientHeight; - const gridTop = el.getBoundingClientRect().top + window.scrollY; - setGridMaxH(Math.max(240, vh - gridTop - below)); - }; - measure(); - const raf = requestAnimationFrame(measure); - window.addEventListener('resize', measure); - let ro; - if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(measure); ro.observe(document.body); } - return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', measure); if (ro) ro.disconnect(); }; - }, [columns.length, visibleRoles.length]); + const gridMaxH = useViewportFitHeight(scrollRef, [columns.length, visibleRoles.length]); const trailingCols = visibleRoles.length + 3; // resource + # + Description (+ roles handled separately) diff --git a/app/ui/src/components/RotatedMatrixView.jsx b/app/ui/src/components/RotatedMatrixView.jsx index ad8eca91f..d77ee3606 100644 --- a/app/ui/src/components/RotatedMatrixView.jsx +++ b/app/ui/src/components/RotatedMatrixView.jsx @@ -13,7 +13,8 @@ // Everything else (filter chip, share link, Excel export hook, basic // per-cell membership-type badges) works the same as the default view. -import { useMemo, useCallback, useState, useLayoutEffect, useRef } from 'react'; +import { useMemo, useCallback, useState, useRef } from 'react'; +import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'; import MatrixToolbar from './matrix/MatrixToolbar'; import MatrixFilterSummary from './matrix/MatrixFilterSummary'; import MatrixCell from './matrix/MatrixCell'; @@ -61,31 +62,10 @@ export default function RotatedMatrixView({ const filterIsApplied = filter !== null && filter !== undefined; // Cap the grid to the remaining viewport so only the grid scrolls, not the - // page too (mirrors MatrixView). Measure the grid's real document-top rather - // than guessing the chrome height with a fixed max-h. + // page too (mirrors MatrixView). const rootRef = useRef(null); const gridRef = useRef(null); - const [gridMaxH, setGridMaxH] = useState(null); - useLayoutEffect(() => { - const measure = () => { - const el = gridRef.current; - if (!el) return; - const footer = document.querySelector('footer'); - const below = (footer ? footer.getBoundingClientRect().height : 0) + 28; - const vh = document.documentElement.clientHeight; - const gridTop = el.getBoundingClientRect().top + window.scrollY; - setGridMaxH(Math.max(240, vh - gridTop - below)); - }; - measure(); - const raf = requestAnimationFrame(measure); - window.addEventListener('resize', measure); - let ro; - if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(measure); - ro.observe(document.body); - } - return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', measure); if (ro) ro.disconnect(); }; - }, [filterIsApplied]); + const gridMaxH = useViewportFitHeight(gridRef, [filterIsApplied]); // Same client-side managed-state toggle as MatrixView. const filteredData = useMemo(() => { diff --git a/app/ui/src/hooks/useViewportFitHeight.js b/app/ui/src/hooks/useViewportFitHeight.js new file mode 100644 index 000000000..8f1ef4a41 --- /dev/null +++ b/app/ui/src/hooks/useViewportFitHeight.js @@ -0,0 +1,86 @@ +import { useLayoutEffect, useState } from 'react'; + +// Cap a scroll container to the viewport space that is actually left below it, +// so ONLY that container scrolls and the page never gets a second scrollbar. +// +// The matrix grids used to guess the chrome height with a fixed +// `max-h-[calc(100vh-280px)]`. The real chrome (auth banner + scope statistics + +// "How to read this matrix") is taller than the guess, so the grid was too tall +// and the page scrolled next to the grid's own scrollbar. Everything here is +// measured instead of guessed — including the space the layout still needs +// *below* the grid. + +// Below this many pixels a capped grid is no longer a grid — one header row and +// a sliver of data. When that little is left (tall chrome on a short viewport) +// the cap is dropped entirely: the grid renders at its natural height and the +// PAGE scrolls. Either way exactly one scrollbar is in play — capping to a +// floor taller than the available space is what produced two. +export const MIN_USABLE_HEIGHT = 200; + +/** + * Space left for `el` between its top edge and the bottom of the viewport, + * minus the app footer and the bottom padding of the
it lives in. + * Returns null when there is nothing to measure. + */ +export function measureAvailableHeight(el) { + if (!el) return null; + const doc = el.ownerDocument || document; + const win = doc.defaultView || window; + + // What still has to fit underneath the container. + const footer = doc.querySelector('footer'); + const footerH = footer ? footer.getBoundingClientRect().height : 0; + const main = typeof el.closest === 'function' ? el.closest('main') : null; + const mainPad = main ? parseFloat(win.getComputedStyle(main).paddingBottom) : 0; + + // clientHeight is the real layout height. getBoundingClientRect().top is + // viewport-relative, so on a scrolled page it reads too small and would cap + // the container too tall — a self-sustaining overflow. scrollY makes it + // document-relative. + const top = el.getBoundingClientRect().top + win.scrollY; + + // Never rounded up to a floor: a cap taller than the space available is the + // page overflow this exists to prevent (a 240px floor with 200px of room + // overflows the page by 40px). + return Math.max(0, doc.documentElement.clientHeight - top - footerH - (Number.isFinite(mainPad) ? mainPad : 0)); +} + +/** + * Measured max-height (px) for the element in `ref`, kept up to date on resize + * and on any layout change above it (header content loads late, panels toggle). + * + * Returns null when the element should not be capped at all — before the first + * measurement, and when less than MIN_USABLE_HEIGHT is left. Collapsing a panel + * above the grid re-measures and hands the space straight back. + * + * @param {{current: HTMLElement|null}} ref the scroll container + * @param {Array} deps extra re-measure triggers + * @returns {number|null} px cap, or null for "do not cap" + */ +export default function useViewportFitHeight(ref, deps = []) { + const [maxHeight, setMaxHeight] = useState(null); + + useLayoutEffect(() => { + const measure = () => { + const h = measureAvailableHeight(ref.current); + if (h !== null) setMaxHeight(h >= MIN_USABLE_HEIGHT ? h : null); + }; + measure(); + // The first paint can land before late chrome (banners, stats) is laid out. + const raf = requestAnimationFrame(measure); + window.addEventListener('resize', measure); + let ro; + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(measure); // body: anything above the grid shifts it down + ro.observe(document.body); + } + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', measure); + if (ro) ro.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return maxHeight; +} diff --git a/app/ui/src/hooks/useViewportFitHeight.test.jsx b/app/ui/src/hooks/useViewportFitHeight.test.jsx new file mode 100644 index 000000000..c1474ac81 --- /dev/null +++ b/app/ui/src/hooks/useViewportFitHeight.test.jsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@ui/test-utils/renderWithProviders'; +import useViewportFitHeight, { measureAvailableHeight, MIN_USABLE_HEIGHT } from './useViewportFitHeight'; + +// Regression cover for the matrix double-scrollbar bug: the grid must be capped +// to the space that is REALLY left below it, never to a floor that is taller +// than that space (which is what put a second scrollbar on the page). + +/** + * Build a
+ grid +
layout with controllable geometry. + * @param {{gridTop:number, viewport:number, footer:number, mainPad:string}} geo + */ +function layout({ gridTop = 300, viewport = 800, footer = 40, mainPad = '24px' } = {}) { + document.body.innerHTML = `
`; + const grid = document.getElementById('grid'); + const foot = document.querySelector('footer'); + grid.getBoundingClientRect = () => ({ top: gridTop, bottom: gridTop, height: 0 }); + foot.getBoundingClientRect = () => ({ top: 0, bottom: footer, height: footer }); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(viewport); + return grid; +} + +afterEach(() => { + vi.restoreAllMocks(); + window.scrollY = 0; + document.body.innerHTML = ''; +}); + +describe('measureAvailableHeight', () => { + it('returns null without an element', () => { + expect(measureAvailableHeight(null)).toBeNull(); + }); + + it('subtracts the footer and main’s bottom padding from the space below the grid', () => { + const grid = layout({ gridTop: 300, viewport: 800, footer: 40, mainPad: '24px' }); + expect(measureAvailableHeight(grid)).toBe(800 - 300 - 40 - 24); + }); + + it('never returns more than the available space (no floor to overflow the page)', () => { + // Tall chrome on a short viewport: only 36px left. A floor (the old code used + // 160/240) would return more than that and push the page into a second + // scrollbar. + const grid = layout({ gridTop: 700, viewport: 800, footer: 40, mainPad: '24px' }); + expect(measureAvailableHeight(grid)).toBe(36); + }); + + it('clamps to zero when the chrome already fills the viewport', () => { + const grid = layout({ gridTop: 900, viewport: 800 }); + expect(measureAvailableHeight(grid)).toBe(0); + }); + + it('measures the grid top document-relative so a scrolled page is not over-capped', () => { + const grid = layout({ gridTop: 100, viewport: 800, footer: 40, mainPad: '24px' }); + window.scrollY = 200; // grid scrolled up: viewport-relative top would read 100 + expect(measureAvailableHeight(grid)).toBe(800 - 300 - 40 - 24); + }); + + it('copes with no footer and a non-numeric main padding', () => { + document.body.innerHTML = '
'; + const grid = document.getElementById('grid'); + grid.getBoundingClientRect = () => ({ top: 100 }); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800); + expect(measureAvailableHeight(grid)).toBe(700); + }); + + it('copes with an element outside a
', () => { + document.body.innerHTML = '
'; + const grid = document.getElementById('grid'); + grid.getBoundingClientRect = () => ({ top: 50 }); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(600); + expect(measureAvailableHeight(grid)).toBe(550); + }); +}); + +describe('useViewportFitHeight', () => { + it('caps the element to the measured space and re-measures on resize', () => { + const grid = layout({ gridTop: 300, viewport: 800, footer: 40, mainPad: '24px' }); + const ref = { current: grid }; + const view = renderHook(() => useViewportFitHeight(ref, [])); + expect(view.result.current).toBe(436); + + // Viewport grows → the grid may grow with it. + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(1000); + act(() => { window.dispatchEvent(new Event('resize')); }); + expect(view.result.current).toBe(636); + }); + + it('re-measures when something above the grid changes size', () => { + const observers = []; + vi.stubGlobal('ResizeObserver', class { + constructor(cb) { this.cb = cb; observers.push(this); } + observe() {} + disconnect() { this.disconnected = true; } + }); + const grid = layout({ gridTop: 300, viewport: 800, footer: 40, mainPad: '24px' }); + const ref = { current: grid }; + const view = renderHook(() => useViewportFitHeight(ref, [])); + expect(observers).toHaveLength(1); + + // A panel above the grid expands, pushing the grid down. + grid.getBoundingClientRect = () => ({ top: 500 }); + act(() => { observers[0].cb(); }); + expect(view.result.current).toBe(236); + + view.unmount(); + expect(observers[0].disconnected).toBe(true); + }); + + it('stays null while there is no element to measure', () => { + const view = renderHook(() => useViewportFitHeight({ current: null }, [])); + expect(view.result.current).toBeNull(); + }); + + it('drops the cap when too little space is left, so the page scrolls instead', () => { + // Tall chrome on a short viewport. Capping here would leave a grid a few + // pixels high; not capping lets it render full height with the page as the + // single scroller. Either way there is never a second scrollbar. + const grid = layout({ gridTop: 700, viewport: 800, footer: 40, mainPad: '24px' }); + const view = renderHook(() => useViewportFitHeight({ current: grid }, [])); + expect(view.result.current).toBeNull(); + }); + + it('caps again as soon as the space above is freed up', () => { + const grid = layout({ gridTop: 700, viewport: 800, footer: 40, mainPad: '24px' }); + const view = renderHook(() => useViewportFitHeight({ current: grid }, [])); + expect(view.result.current).toBeNull(); + + // A panel above the grid collapses (e.g. "How to read this matrix"). + grid.getBoundingClientRect = () => ({ top: 300 }); + act(() => { window.dispatchEvent(new Event('resize')); }); + expect(view.result.current).toBe(436); + }); + + it('caps at exactly the usable minimum', () => { + const viewport = MIN_USABLE_HEIGHT + 364; + const grid = layout({ gridTop: 300, viewport, footer: 40, mainPad: '24px' }); + const view = renderHook(() => useViewportFitHeight({ current: grid }, [])); + expect(view.result.current).toBe(MIN_USABLE_HEIGHT); + }); +}); From 7432e5a6cf7eecea29377116c5ce1607b8dfe8ff Mon Sep 17 00:00:00 2001 From: IdentityAtlas DoR agent Date: Wed, 5 Aug 2026 06:48:10 +0000 Subject: [PATCH 07/14] fix: address requestor feedback (#370) --- app/ui/diag.tmp.mjs | 38 ----- app/ui/e2e/matrix.spec.js | 130 ++++++++++++++++ app/ui/shot.tmp.mjs | 10 -- app/ui/src/components/MatrixView.jsx | 17 +- .../components/MatrixView.scrollbar.test.js | 20 ++- app/ui/src/components/RollupMatrixView.jsx | 13 +- .../RollupMatrixView.mount.test.jsx | 58 +++++++ app/ui/src/components/RotatedMatrixView.jsx | 17 +- app/ui/src/components/matrix/CellBadges.jsx | 98 ------------ .../src/components/matrix/CellMarkerStrip.jsx | 82 ++++++++++ .../matrix/CellMarkerStrip.mount.test.jsx | 88 +++++++++++ .../components/matrix/GridResizeHandle.jsx | 46 ++++++ .../matrix/GridResizeHandle.mount.test.jsx | 61 ++++++++ app/ui/src/components/matrix/MatrixCell.jsx | 22 +-- .../matrix/MatrixCell.mount.test.jsx | 12 ++ .../src/components/matrix/MatrixGroupRow.jsx | 25 +-- .../matrix/MatrixGroupRow.mount.test.jsx | 19 ++- app/ui/src/components/matrix/MatrixLegend.jsx | 22 ++- app/ui/src/components/matrix/cellMarkers.js | 40 ++++- app/ui/src/hooks/useBusinessRoleFold.js | 21 ++- app/ui/src/hooks/useResizableGridHeight.js | 101 ++++++++++++ .../src/hooks/useResizableGridHeight.test.jsx | 147 ++++++++++++++++++ app/ui/src/hooks/useViewportFitHeight.js | 31 +++- .../src/hooks/useViewportFitHeight.test.jsx | 27 ++++ changes/dor-issue-370.md | 3 + docs/architecture/matrix.md | 79 ++++++++-- 26 files changed, 1003 insertions(+), 224 deletions(-) delete mode 100644 app/ui/diag.tmp.mjs delete mode 100644 app/ui/shot.tmp.mjs delete mode 100644 app/ui/src/components/matrix/CellBadges.jsx create mode 100644 app/ui/src/components/matrix/CellMarkerStrip.jsx create mode 100644 app/ui/src/components/matrix/CellMarkerStrip.mount.test.jsx create mode 100644 app/ui/src/components/matrix/GridResizeHandle.jsx create mode 100644 app/ui/src/components/matrix/GridResizeHandle.mount.test.jsx create mode 100644 app/ui/src/hooks/useResizableGridHeight.js create mode 100644 app/ui/src/hooks/useResizableGridHeight.test.jsx diff --git a/app/ui/diag.tmp.mjs b/app/ui/diag.tmp.mjs deleted file mode 100644 index 18cb7b2f1..000000000 --- a/app/ui/diag.tmp.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { chromium } from '@playwright/test'; -const b = await chromium.launch(); -const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); -const filter = { rowType:'principal', orientation:'rows-as-resources', subject:{include:[],exclude:[]}, resource:{include:[],exclude:[]} }; -await p.goto('http://localhost:5173/#matrix?filter=' + encodeURIComponent(JSON.stringify(filter))); -await p.waitForLoadState('networkidle'); -await p.locator('table').first().waitFor({ timeout: 40000 }); -await p.waitForTimeout(2000); -const out = await p.evaluate(() => { - const de = document.documentElement; - const grid = [...document.querySelectorAll('div')].find(el => { - const s = getComputedStyle(el); - return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 2; - }); - const r = grid.getBoundingClientRect(); - const footer = document.querySelector('footer'); - const chain = []; - let el = grid; - while (el && el !== de) { - const s = getComputedStyle(el); - const b = el.getBoundingClientRect(); - chain.push({ tag: el.tagName, cls: (el.className||'').toString().slice(0,80), top: Math.round(b.top+scrollY), h: Math.round(b.height), pt: s.paddingTop, pb: s.paddingBottom, mt: s.marginTop, mb: s.marginBottom, gap: s.gap, disp: s.display, ovY: s.overflowY }); - el = el.parentElement; - } - return { - pageOverflow: de.scrollHeight - de.clientHeight, - clientH: de.clientHeight, scrollH: de.scrollHeight, bodyScrollH: document.body.scrollHeight, - gridTop: Math.round(r.top + scrollY), gridH: Math.round(r.height), gridMaxH: grid.style.maxHeight, - gridBottom: Math.round(r.bottom + scrollY), - footerH: footer ? Math.round(footer.getBoundingClientRect().height) : null, - footerTop: footer ? Math.round(footer.getBoundingClientRect().top+scrollY) : null, - footerBottom: footer ? Math.round(footer.getBoundingClientRect().bottom+scrollY) : null, - footerMB: footer ? getComputedStyle(footer).marginBottom : null, - chain, - }; -}); -console.log(JSON.stringify(out, null, 2)); -await b.close(); diff --git a/app/ui/e2e/matrix.spec.js b/app/ui/e2e/matrix.spec.js index 6ac69e2a8..f74f403e0 100644 --- a/app/ui/e2e/matrix.spec.js +++ b/app/ui/e2e/matrix.spec.js @@ -411,6 +411,10 @@ test.describe('Matrix — fold business-role resources', () => { const chip = page.locator(`tbody button[title^="Granted by business role:"][title*="${moved.role}"]`).first(); await expect(chip).toBeVisible({ timeout: 20000 }); + // Requestor feedback on #370: the chip is a marker, not a name — "BR" for + // one role, "BR+N" for several. The names stay in the tooltip. + await expect(chip).toHaveText(/^BR(\+\d+)?$/); + // Leave the browser profile clean for the next test. await page.evaluate(() => { for (const k of Object.keys(localStorage)) { @@ -418,6 +422,132 @@ test.describe('Matrix — fold business-role resources', () => { } }); }); + + // Requestor feedback on #370: the white "covered by N business roles" bubble + // was drawn over the labels of the cells around it. Every marker now lives in + // a strip the cell reserves for it, so no marker can reach another cell — or + // the badge underneath it. + test('no cell marker is drawn over another label', async ({ page }) => { + await openFoldableGrid(page); + // Fold the roles so the deviation counts are on screen alongside the + // role-count bubbles and the gap markers — the busiest the grid ever gets. + await foldAll(page).click(); + await expect(unfoldAll(page)).toBeVisible(); + + const overlaps = await page.evaluate(() => { + const markers = [...document.querySelectorAll('tbody td span.absolute > span')] + .filter(s => s.textContent.trim() !== ''); + const escaped = []; + const inside = (m, cell) => { + const a = m.getBoundingClientRect(); + const b = cell.getBoundingClientRect(); + // 1px of slack for sub-pixel rounding and collapsed borders. + return a.left >= b.left - 1 && a.right <= b.right + 1 + && a.top >= b.top - 1 && a.bottom <= b.bottom + 1; + }; + for (const m of markers) { + const cell = m.closest('td'); + if (!inside(m, cell)) escaped.push({ text: m.textContent, cls: m.className }); + // The badge row starts below the strip, so a marker can never sit on it. + for (const badge of cell.querySelectorAll(':scope > span:not(.absolute)')) { + const a = m.getBoundingClientRect(); + const b = badge.getBoundingClientRect(); + const hit = a.left < b.right - 1 && a.right > b.left + 1 + && a.top < b.bottom - 1 && a.bottom > b.top + 1; + if (hit) escaped.push({ text: m.textContent, over: badge.textContent }); + } + } + return { count: markers.length, escaped }; + }); + + expect(overlaps.count, 'the folded grid must show at least one marker').toBeGreaterThan(0); + expect(overlaps.escaped).toEqual([]); + + await unfoldAll(page).click(); + }); +}); + +// ─── Resizing the matrix (#370) ─────────────────────────────────────────────── +// +// The measured "fit the rest of the window" height is a default, not a verdict: +// how much of the window the grid deserves next to the panels above it is the +// analyst's call. The grip under the grid makes it theirs, and remembers it. +test.describe('Matrix — resizing the grid height', () => { + test.setTimeout(90000); + + const gridHeight = (page) => page.evaluate(() => { + const el = document.querySelector('div[style*="max-height"]'); + return el ? Math.round(el.getBoundingClientRect().height) : 0; + }); + + const grip = (page) => page.getByRole('button', { name: 'Resize the matrix height' }); + + async function openMatrix(page) { + const filter = { + rowType: 'principal', + orientation: 'rows-as-resources', + subject: { include: [], exclude: [] }, + resource: { include: [], exclude: [] }, + }; + await page.goto('about:blank'); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/#matrix?filter=' + encodeURIComponent(JSON.stringify(filter))); + await page.waitForLoadState('networkidle'); + try { + await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); + } catch { + return false; + } + // Collapse "How to read this matrix" so the chrome leaves the grid a + // measurable cap to start from (the same setup the scrollbar spec uses). + const legend = page.getByRole('button', { name: /How to read this matrix/i }).first(); + await expect(legend).toBeVisible({ timeout: 20000 }); + if (await legend.getAttribute('aria-expanded') === 'true') await legend.click(); + await page.waitForTimeout(1500); // let the measuring effect settle + return true; + } + + test('dragging the grip resizes the grid, and the height is remembered', async ({ page }) => { + test.skip(!await openMatrix(page), 'matrix grid did not render (no data)'); + + const before = await gridHeight(page); + const box = await grip(page).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 - 200, { steps: 10 }); + await page.mouse.up(); + + await expect.poll(() => gridHeight(page)).toBeLessThan(before); + const shrunk = await gridHeight(page); + + // Still exactly one scroller — a resized grid is not a broken layout. + expect(await page.evaluate(() => { + const de = document.documentElement; + return de.scrollHeight - de.clientHeight > 4; + })).toBe(false); + + // The choice survives a reload. + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('table').first()).toBeVisible({ timeout: 40000 }); + await expect.poll(() => gridHeight(page)).toBe(shrunk); + + // "Fit to window" hands the decision back to the measured fit. + await page.getByRole('button', { name: 'Fit to window' }).click(); + await expect.poll(() => gridHeight(page)).toBe(before); + await expect(page.getByRole('button', { name: 'Fit to window' })).toHaveCount(0); + }); + + test('the arrow keys resize it too, so the grip is not mouse-only', async ({ page }) => { + test.skip(!await openMatrix(page), 'matrix grid did not render (no data)'); + + const before = await gridHeight(page); + await grip(page).focus(); + await page.keyboard.press('ArrowUp'); + await expect.poll(() => gridHeight(page)).toBeLessThan(before); + await page.keyboard.press('Escape'); + await expect.poll(() => gridHeight(page)).toBe(before); + }); }); // ─── Regression: no double scrollbar behind the matrix grid ──────────────────── diff --git a/app/ui/shot.tmp.mjs b/app/ui/shot.tmp.mjs deleted file mode 100644 index 258eaca1a..000000000 --- a/app/ui/shot.tmp.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { chromium } from '@playwright/test'; -const b = await chromium.launch(); -const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); -const filter = { rowType:'principal', orientation:'rows-as-resources', subject:{include:[],exclude:[]}, resource:{include:[],exclude:[]} }; -await p.goto('http://localhost:5173/#matrix?filter=' + encodeURIComponent(JSON.stringify(filter))); -await p.waitForLoadState('networkidle'); -await p.locator('table').first().waitFor({ timeout: 40000 }); -await p.waitForTimeout(2000); -await p.screenshot({ path: '/tmp/matrix-800.png', fullPage: true }); -await b.close(); diff --git a/app/ui/src/components/MatrixView.jsx b/app/ui/src/components/MatrixView.jsx index 4db532870..db9b018c6 100644 --- a/app/ui/src/components/MatrixView.jsx +++ b/app/ui/src/components/MatrixView.jsx @@ -8,7 +8,8 @@ import { useAuth } from '@ui/auth/AuthGate'; import { useMatrixRowOrder } from '@ui/hooks/useMatrixRowOrder'; import { useNestedGroupExpand, MAX_NEST_LEVEL } from '@ui/hooks/useNestedGroupExpand'; import { useBusinessRoleFold } from '@ui/hooks/useBusinessRoleFold'; -import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'; +import useResizableGridHeight from '@ui/hooks/useResizableGridHeight'; +import GridResizeHandle from './matrix/GridResizeHandle'; import MatrixToolbar from './matrix/MatrixToolbar'; import MatrixLegend from './matrix/MatrixLegend'; import MatrixFilterSummary from './matrix/MatrixFilterSummary'; @@ -931,9 +932,11 @@ export default function MatrixView({ const filterIsApplied = filter !== null && filter !== undefined; // Cap the grid's height to the remaining viewport so ONLY the grid scrolls, - // never the page too. + // never the page too — until the analyst drags the grip below the grid to a + // height of their own, which then wins and is remembered. const rootRef = useRef(null); - const gridMaxH = useViewportFitHeight(scrollRef, [filterIsApplied, users.length]); + const gridHeight = useResizableGridHeight(scrollRef, [filterIsApplied, users.length]); + const gridMaxH = gridHeight.height; return (
@@ -977,6 +980,7 @@ export default function MatrixView({ No assignments match the current filter. Adjust the subjects or resources to widen the view.
) : ( + <>
{refreshing && (
@@ -1054,6 +1058,13 @@ export default function MatrixView({
)} + {hasMembership && ( - <> - {[...membershipTypes].map(type => { - const ind = TYPE_COLORS[type]; - if (!ind) return ?; - const clickable = type === 'Indirect' && !!onExplainInherited; - return ( - { e.stopPropagation(); onExplainInherited(cellKey); } : undefined} - title={clickable ? 'Show how this inherited access was derived' : undefined} - className={`inline-block rounded-sm text-center font-bold ${membershipTypes.size === 1 ? 'w-4 h-4 text-[9px] leading-4' : 'w-[9px] h-[14px] text-[7px] leading-[14px]'} ${clickable ? 'cursor-pointer ring-1 ring-white/50 hover:ring-2 hover:ring-white' : ''}`} - style={{ backgroundColor: ind.bg, color: ind.text }} - > - {ind.letter} - - ); - })} - - )} - {provisioningGap && ( - - ! - - )} - {apCount > 1 && ( - - {apCount} - + )} - +
@@ -166,6 +209,7 @@ export default function MatrixGroupRow({ onClick={() => onOpenDetail?.('resource', group.realGroupId || group.id, group.displayName)}> {group.displayName}
+
0 ? 'relative' : undefined }}> + style={{ width: '24px', minWidth: '24px', position: extra > 0 || short > 0 ? 'relative' : undefined }}> {n > 0 ? {n} : ·} +
)} + + )} {pathExplain && (
{ expect(src).not.toContain('max-h-[calc(100vh-280px)]'); }); it('takes its cap from the shared measuring hook', () => { - expect(src).toContain("import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'"); - expect(src).toMatch(/const gridMaxH = useViewportFitHeight\(/); + expect(src).toContain("import useResizableGridHeight from '@ui/hooks/useResizableGridHeight'"); + expect(src).toMatch(/const gridHeight = useResizableGridHeight\(/); + expect(src).toMatch(/const gridMaxH = gridHeight\.height/); }); it('caps the grid with a measured inline maxHeight', () => { expect(src).toMatch(/maxHeight: gridMaxH/); }); + // Requestor feedback on #370: the measured fit is a default, not a + // verdict — every orientation offers the same grip to resize it. + it('offers the shared resize grip', () => { + expect(src).toContain("import GridResizeHandle from './matrix/GridResizeHandle'"); + expect(src).toMatch(/ { expect(src).not.toMatch(/Math\.max\(\s*\d+\s*,\s*(vh|avail)/); }); diff --git a/app/ui/src/components/RollupMatrixView.jsx b/app/ui/src/components/RollupMatrixView.jsx index 3c5dbdca4..bf66cb265 100644 --- a/app/ui/src/components/RollupMatrixView.jsx +++ b/app/ui/src/components/RollupMatrixView.jsx @@ -1,6 +1,6 @@ import { useMemo, useState, useCallback, useRef } from 'react'; import { useAuth } from '@ui/auth/AuthGate'; -import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'; +import useResizableGridHeight from '@ui/hooks/useResizableGridHeight'; import { friendlyLabel } from '@ui/utils/formatters'; import { getAccessPackageColor } from '@ui/utils/colors'; import { exportRollupToExcel } from '@ui/utils/exportRollupToExcel'; @@ -10,6 +10,7 @@ import MatrixScopePanel from './matrix/MatrixScopePanel'; import MatrixLegend from './matrix/MatrixLegend'; import MatrixFilterSummary from './matrix/MatrixFilterSummary'; import MatrixToolbar from './matrix/MatrixToolbar'; +import GridResizeHandle from './matrix/GridResizeHandle'; // Roll-up matrix: the subject (column) axis is aggregated by an attribute (e.g. // department). Rows are resources; each cell is the count of distinct subjects @@ -302,7 +303,8 @@ export default function RollupMatrixView({ // (matches MatrixView). overflow-auto then gives both scrollbars, including // the horizontal one when the columns are wider than the screen. const scrollRef = useRef(null); - const gridMaxH = useViewportFitHeight(scrollRef, [columns.length, visibleRoles.length]); + const gridHeight = useResizableGridHeight(scrollRef, [columns.length, visibleRoles.length]); + const gridMaxH = gridHeight.height; const trailingCols = visibleRoles.length + 3; // resource + # + Description (+ roles handled separately) @@ -705,6 +707,13 @@ export default function RollupMatrixView({
+ + ); } diff --git a/app/ui/src/components/RollupMatrixView.mount.test.jsx b/app/ui/src/components/RollupMatrixView.mount.test.jsx index 755036836..90009ccf2 100644 --- a/app/ui/src/components/RollupMatrixView.mount.test.jsx +++ b/app/ui/src/components/RollupMatrixView.mount.test.jsx @@ -158,6 +158,64 @@ describe('RollupMatrixView (mounted)', () => { expect(screen.getByText(/No assignments match the current filter/i)).toBeInTheDocument(); }); + // A context (org-tree) roll-up: columns are context nodes rather than plain + // attribute values, and the drill path is navigable. + describe('context roll-up (org tree)', () => { + const contextRollup = (overrides = {}) => makeRollup({ + rollupKind: 'context', + attribute: 'manager-hierarchy', + groupValues: ['node-eng', 'node-sales'], + counts: [ + { resourceId: 'res-1', groupValue: 'node-eng', directCount: 5, governedCount: 2 }, + { resourceId: 'res-2', groupValue: 'node-sales', directCount: 2, governedCount: 0 }, + ], + groupTotals: [{ groupValue: 'node-eng', total: 20 }, { groupValue: 'node-sales', total: 9 }], + nodes: [ + { id: 'node-eng', displayName: 'Acme · Engineering (Manager, Ada)', total: 20, childCount: 3, directMembers: 4, depth: 2, pathIds: ['root', 'node-eng'], pathNames: ['Acme', 'Acme · Engineering'] }, + { id: 'node-sales', displayName: 'Acme · Sales (Manager, Bo)', total: 9, childCount: 0, directMembers: 9, depth: 2, pathIds: ['root', 'node-sales'], pathNames: ['Acme', 'Acme · Sales'] }, + ], + breadcrumb: [{ id: 'root', displayName: 'Acme' }, { id: 'node-eng', displayName: 'Acme · Engineering (Manager, Ada)' }], + ...overrides, + }); + + it('labels the columns with the deepest org segment and shows the drill path', () => { + renderView({ rollup: contextRollup() }); + expect(screen.getByText('Drill path:')).toBeInTheDocument(); + // Column headers use the short label, not the full "A · B (Manager)" path. + expect(screen.getAllByText('Engineering').length).toBeGreaterThan(0); + expect(screen.getByText('Sales')).toBeInTheDocument(); + }); + + it('zooms out to an earlier step of the drill path', async () => { + const { onFilterChange } = renderView({ rollup: contextRollup() }); + await userEvent.setup().click(screen.getByTitle('Zoom out to Acme')); + expect(onFilterChange).toHaveBeenCalledWith(expect.objectContaining({ rollupPath: [] })); + }); + + it('zooms into a node that has sub-teams', async () => { + const { onFilterChange } = renderView({ rollup: contextRollup() }); + await userEvent.setup().click(screen.getByTitle(/Zoom into Engineering/)); + expect(onFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ rollupPath: ['node-eng'] }), + ); + }); + + it('expands and collapses an org in place in the layered view', async () => { + const { onFilterChange } = renderView({ rollup: contextRollup({ layered: true, maxDepth: 2 }) }); + const user = userEvent.setup(); + // The team header splits the team into its sub-teams… + await user.click(screen.getByTitle(/Click to split Engineering into its 3 sub-teams/)); + expect(onFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ rollupExpanded: ['node-eng'] }), + ); + // …and the merged ancestor header collapses the branch back. + await user.click(screen.getAllByTitle(/Collapse Acme back into one column/)[0]); + expect(onFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ rollupExpanded: [] }), + ); + }); + }); + it('renders the roles-only variant with business roles on the rows', () => { renderView({ rollup: makeRollup({ diff --git a/app/ui/src/components/RotatedMatrixView.jsx b/app/ui/src/components/RotatedMatrixView.jsx index d77ee3606..36821742e 100644 --- a/app/ui/src/components/RotatedMatrixView.jsx +++ b/app/ui/src/components/RotatedMatrixView.jsx @@ -14,7 +14,8 @@ // per-cell membership-type badges) works the same as the default view. import { useMemo, useCallback, useState, useRef } from 'react'; -import useViewportFitHeight from '@ui/hooks/useViewportFitHeight'; +import useResizableGridHeight from '@ui/hooks/useResizableGridHeight'; +import GridResizeHandle from './matrix/GridResizeHandle'; import MatrixToolbar from './matrix/MatrixToolbar'; import MatrixFilterSummary from './matrix/MatrixFilterSummary'; import MatrixCell from './matrix/MatrixCell'; @@ -62,10 +63,12 @@ export default function RotatedMatrixView({ const filterIsApplied = filter !== null && filter !== undefined; // Cap the grid to the remaining viewport so only the grid scrolls, not the - // page too (mirrors MatrixView). + // page too — and let the analyst drag it to a height of their own (mirrors + // MatrixView). const rootRef = useRef(null); const gridRef = useRef(null); - const gridMaxH = useViewportFitHeight(gridRef, [filterIsApplied]); + const gridHeight = useResizableGridHeight(gridRef, [filterIsApplied]); + const gridMaxH = gridHeight.height; // Same client-side managed-state toggle as MatrixView. const filteredData = useMemo(() => { @@ -183,6 +186,7 @@ export default function RotatedMatrixView({ No assignments match the current matrix. Adjust the subjects or resources to widen the view. ) : ( + <>
{refreshing && (
@@ -313,6 +317,13 @@ export default function RotatedMatrixView({
+ + )}
); diff --git a/app/ui/src/components/matrix/CellBadges.jsx b/app/ui/src/components/matrix/CellBadges.jsx deleted file mode 100644 index 24ef055f9..000000000 --- a/app/ui/src/components/matrix/CellBadges.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import { extraAccessTitle, missingAccessTitle, overGrantTitle } from './cellMarkers'; - -// The corner markers a matrix cell can carry. Colour is the whole language: -// -// amber, on the left — FEWER permissions than the business role assigns -// red, on the right — MORE permissions than the business role assigns -// -// A cell can carry both at once (a business role that grants several resources -// can be short in one and over in another for the same subject), so the two -// never share a corner. Kept out of MatrixCell so the aggregate (folded-column) -// cell in MatrixGroupRow renders exactly the same markers. - -// Access a folded business role hides but does NOT grant — shown as a count on -// the folded role's own cell so folding can never quietly swallow the very -// thing a role-mining review is hunting for. -export function ExtraAccessBadge({ count }) { - if (!count) return null; - return ( - - {count} - - ); -} - -// The mirror: memberships a folded business role assigns that the subject does -// not have. Same idea, opposite direction — under-provisioning stays visible -// through the fold too. -export function MissingAccessBadge({ count }) { - if (!count) return null; - return ( - - {count} - - ); -} - -// A business role expects a membership this subject does not have. -function GapMarker() { - return ( - - ! - - ); -} - -// The subject holds a standing membership where the role only grants -// just-in-time eligibility. Shares the bottom-right "more than the role -// assigns" corner with the folded-role count, which only ever appears on a -// folded role's own row — so the two never draw over each other. -function OverGrantMarker({ expected }) { - return ( - - + - - ); -} - -// How many business roles cover this cell, when more than one does. -function ApCountBadge({ count }) { - if (!(count > 1)) return null; - return ( - - {count} - - ); -} - -export default function CellBadges({ - provisioningGap, overGrant, apCount, extraAccessCount, missingAccessCount, -}) { - return ( - <> - {provisioningGap && } - {overGrant && !extraAccessCount && } - - - - - ); -} diff --git a/app/ui/src/components/matrix/CellMarkerStrip.jsx b/app/ui/src/components/matrix/CellMarkerStrip.jsx new file mode 100644 index 000000000..45c960d27 --- /dev/null +++ b/app/ui/src/components/matrix/CellMarkerStrip.jsx @@ -0,0 +1,82 @@ +import { + extraAccessTitle, missingAccessTitle, overGrantTitle, hasCellMarkers, +} from './cellMarkers'; + +// Every marker an intersection cell can carry is drawn here, in ONE strip along +// the top of the cell, above the D/I/E badge — see cellMarkers.js for the +// geometry that reserves the strip and for why the markers left the corners. + +function Marker({ className, title, children }) { + return ( + + {children} + + ); +} + +// Keeps the three slots in place when a cell carries only some of them. +function EmptySlot() { + return +
+ Markers sit in a strip along the top of a cell, above its badge — always in the same + three places: left = fewer than the business role + assigns, centre = how many roles cover the + cell, right = more than the role assigns. +
- Covered by more than one business role (the number shows how many). + Centre — covered by more than one business role (the number shows how many; the cell's tooltip names them).
- Provisioning gap — a business role expects this membership but the user doesn't have it. + Left — Provisioning gap: a business role expects this membership but the user doesn't have it.
- More than the role assigns — the business role grants just-in-time (Eligible) access here, but the subject holds a standing membership. + Right — More than the role assigns: the business role grants just-in-time (Eligible) access here, but the subject holds a standing membership.
- On a folded business role, bottom-right — more is handed out below than the role hands out: the number counts the folded resources this subject holds outside the role. + On a folded business role, right — more is handed out below than the role hands out: the number counts the folded resources this subject holds outside the role.
- On a folded business role, bottom-left — fewer: the number counts the folded resources the role assigns this subject but they do not have. A subject can carry both counts at once. + On a folded business role, left — fewer: the number counts the folded resources the role assigns this subject but they do not have. A subject can carry both counts at once.
- The business role that grants this resource, named on the row itself when the row does not sit directly under that role (after you drag it elsewhere, for example). + This resource is granted by a business role — named on the row itself when the row does not sit directly under that role (after you drag it elsewhere, for example). Hover it for the role's name; click it to open the role.
- + - A resource granted by more than one business role names them all, and stays on screen until every one of those roles is folded — a folded role then reports “N of M resources folded” and says which role is still showing the rest. + A resource granted by more than one business role counts them — the tooltip names them all — and stays on screen until every one of those roles is folded; a folded role then reports “N of M resources folded” and says which role is still showing the rest.
diff --git a/app/ui/src/components/matrix/cellMarkers.js b/app/ui/src/components/matrix/cellMarkers.js index ad23980e2..b2d61c213 100644 --- a/app/ui/src/components/matrix/cellMarkers.js +++ b/app/ui/src/components/matrix/cellMarkers.js @@ -1,5 +1,41 @@ -// Wording of the matrix cell markers, kept out of the cell components so both -// the cell and the aggregate (folded-column) cell explain a marker identically. +// Wording and geometry of the matrix cell markers, kept out of the cell +// components so both the cell and the aggregate (folded-column) cell explain +// and place a marker identically. +// +// Every marker lives in ONE strip along the top of the cell, above the D/I/E +// badge, in three fixed slots — so where a marker sits always means the same +// thing: +// +// left — amber: FEWER permissions than the business role assigns +// centre — white: how many business roles cover this cell (only when > 1) +// right — red: MORE permissions than the business role assigns +// +// The markers used to hang off the cell's corners with negative offsets, so +// each one was drawn partly over the cell above and the cell to its right — the +// white "covered by N roles" bubble landed straight on the neighbours' badges +// (requestor feedback on #370). Reserving the strip is what makes an overlap +// impossible: the strip owns the top 8px of the 24px cell, the badge row owns +// the other 16, and nothing is ever painted outside the cell's own box. + +export const MARKER_STRIP_HEIGHT = 8; +export const CELL_SIZE = 24; + +// The geometry every intersection cell shares — the marker strip on top, the +// badge row below it. +export const CELL_BOX_STYLE = { + position: 'relative', + width: `${CELL_SIZE}px`, + minWidth: `${CELL_SIZE}px`, + height: `${CELL_SIZE}px`, + padding: `${MARKER_STRIP_HEIGHT}px 0 0`, +}; + +// Does this cell have anything to put in the strip? Cells that don't skip it +// entirely — on a full grid that is the overwhelming majority of them. +export function hasCellMarkers({ apCount, provisioningGap, overGrant, extraAccessCount, missingAccessCount }) { + return apCount > 1 || !!provisioningGap || !!overGrant + || extraAccessCount > 0 || missingAccessCount > 0; +} const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`; diff --git a/app/ui/src/hooks/useBusinessRoleFold.js b/app/ui/src/hooks/useBusinessRoleFold.js index 9a104e869..987c4769f 100644 --- a/app/ui/src/hooks/useBusinessRoleFold.js +++ b/app/ui/src/hooks/useBusinessRoleFold.js @@ -199,17 +199,24 @@ export function summariseFolds(rows, rolesByChild, childCounts, folded, roleName const info = new Map(); for (const [roleId, total] of childCounts) info.set(roleId, { total, hidden: 0, shownBy: new Set() }); for (const { roles, hidden } of foldedRowParents(rows, rolesByChild, foldedSet)) { - const stillShowing = roles.filter(id => !foldedSet.has(id)); - for (const id of roles) { - const entry = foldedSet.has(id) ? info.get(id) : null; - if (!entry) continue; - if (hidden) entry.hidden++; - else for (const other of stillShowing) entry.shownBy.add(other); - } + tallyFoldedRow(info, roles, hidden, foldedSet); } return nameSharingRoles(info, roleNames); } +// One resource row a fold reached, against every folded role that grants it: +// either the fold took the row away, or a role that is still expanded is +// keeping it on screen — and that role gets named on the folded one. +function tallyFoldedRow(info, roles, hidden, foldedSet) { + const stillShowing = roles.filter(id => !foldedSet.has(id)); + for (const id of roles) { + const entry = foldedSet.has(id) ? info.get(id) : null; + if (!entry) continue; + if (hidden) entry.hidden++; + else for (const other of stillShowing) entry.shownBy.add(other); + } +} + // The roles granting a resource that its own row does not already sit under, // as [{id, name}] — what the row has to say for itself once position stops // answering the question. Rows are draggable and keep their new position, so a diff --git a/app/ui/src/hooks/useResizableGridHeight.js b/app/ui/src/hooks/useResizableGridHeight.js new file mode 100644 index 000000000..4f3e037de --- /dev/null +++ b/app/ui/src/hooks/useResizableGridHeight.js @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import useViewportFitHeight, { MIN_USABLE_HEIGHT } from './useViewportFitHeight'; + +// How tall the matrix grid is. Two answers, in this order: +// +// 1. the height the analyst dragged it to, remembered across sessions; +// 2. otherwise the measured "fit the rest of the window" height that +// useViewportFitHeight works out (which is also what the reset goes back +// to). +// +// The measured fit is a good default but only a default: a matrix is read +// alongside the panels above it, and how much of the window each deserves is a +// judgement call the analyst makes, not one we can measure (requestor feedback +// on #370). Dragging never disables the measuring hook — it only overrides it — +// so "Fit to window" always has something to go back to. + +export const HEIGHT_STORAGE_KEY = 'fgraph-matrix-height'; + +// One arrow-key press. Roughly four matrix rows, so the keyboard path is usable +// without being unbearably slow over a full window. +export const RESIZE_STEP = 100; + +// Guards a stored/dragged value: never smaller than a grid that still shows +// something, never so tall that a stray drag leaves an unreachable page. +export const MAX_GRID_HEIGHT = 10000; + +export const clampGridHeight = (px) => + Math.min(MAX_GRID_HEIGHT, Math.max(MIN_USABLE_HEIGHT, Math.round(px))); + +export function readStoredHeight(storageKey = HEIGHT_STORAGE_KEY) { + try { + const raw = localStorage.getItem(storageKey); + if (raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? clampGridHeight(n) : null; + } catch { + return null; // storage disabled (private mode) — fall back to the fit + } +} + +function writeStoredHeight(storageKey, value) { + try { + if (value == null) localStorage.removeItem(storageKey); + else localStorage.setItem(storageKey, String(value)); + } catch { /* storage disabled — the height still applies for this session */ } +} + +/** + * Height (px) for the grid in `ref`, resizable by the user and persisted. + * + * @param {{current: HTMLElement|null}} ref the scroll container + * @param {Array} deps extra re-measure triggers, as for useViewportFitHeight + * @param {string} storageKey where the chosen height is remembered + * @returns {{height: number|null, isCustom: boolean, startDrag: (clientY:number)=>void, + * resizeBy: (delta:number)=>void, reset: ()=>void}} + */ +export default function useResizableGridHeight(ref, deps = [], storageKey = HEIGHT_STORAGE_KEY) { + const fitHeight = useViewportFitHeight(ref, deps); + const [custom, setCustom] = useState(() => readStoredHeight(storageKey)); + + // Torn down on unmount so a drag that is still in progress can't keep + // listening on the window. + const endDragRef = useRef(null); + useEffect(() => () => endDragRef.current?.(), []); + + const apply = useCallback((next) => { + const value = next == null ? null : clampGridHeight(next); + setCustom(value); + writeStoredHeight(storageKey, value); + }, [storageKey]); + + // What a resize starts from: the chosen height, else whatever the grid is + // actually rendering at right now, else the fit it is about to be given (a + // zero rect means "not laid out yet", not "zero pixels tall"). + const currentHeight = useCallback(() => { + if (custom != null) return custom; + const rendered = ref.current?.getBoundingClientRect?.().height || 0; + return rendered > 0 ? rendered : (fitHeight ?? MIN_USABLE_HEIGHT); + }, [custom, fitHeight, ref]); + + const resizeBy = useCallback((delta) => apply(currentHeight() + delta), [apply, currentHeight]); + const reset = useCallback(() => apply(null), [apply]); + + const startDrag = useCallback((clientY) => { + endDragRef.current?.(); + const startHeight = currentHeight(); + const onMove = (e) => apply(startHeight + (e.clientY - clientY)); + const end = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', end); + window.removeEventListener('pointercancel', end); + endDragRef.current = null; + }; + endDragRef.current = end; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', end); + window.addEventListener('pointercancel', end); + }, [apply, currentHeight]); + + return { height: custom ?? fitHeight, isCustom: custom != null, startDrag, resizeBy, reset }; +} diff --git a/app/ui/src/hooks/useResizableGridHeight.test.jsx b/app/ui/src/hooks/useResizableGridHeight.test.jsx new file mode 100644 index 000000000..cde8adae4 --- /dev/null +++ b/app/ui/src/hooks/useResizableGridHeight.test.jsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@ui/test-utils/renderWithProviders'; +import useResizableGridHeight, { + HEIGHT_STORAGE_KEY, MAX_GRID_HEIGHT, clampGridHeight, readStoredHeight, +} from './useResizableGridHeight'; +import { MIN_USABLE_HEIGHT } from './useViewportFitHeight'; + +// Requestor feedback on #370: how much of the window the matrix gets is the +// analyst's call, not something we can measure for them. The measured fit stays +// the default; dragging the grip overrides it and is remembered. + +/** Layout with a grid whose measured fit is `viewport - gridTop`. */ +function layout({ gridTop = 300, viewport = 800, rendered = 0 } = {}) { + document.body.innerHTML = '
'; + const grid = document.getElementById('grid'); + grid.getBoundingClientRect = () => ({ top: gridTop, height: rendered }); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(viewport); + return { current: grid }; +} + +const drag = (from, to) => { + const move = new Event('pointermove'); + move.clientY = to; + window.dispatchEvent(move); + return from; +}; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + document.body.innerHTML = ''; +}); + +describe('clampGridHeight / readStoredHeight', () => { + it('never allows a grid smaller than a usable one, or an unreachable page', () => { + expect(clampGridHeight(10)).toBe(MIN_USABLE_HEIGHT); + expect(clampGridHeight(MAX_GRID_HEIGHT + 5000)).toBe(MAX_GRID_HEIGHT); + expect(clampGridHeight(420.4)).toBe(420); + }); + + it('ignores a missing or unusable stored value', () => { + expect(readStoredHeight(HEIGHT_STORAGE_KEY)).toBeNull(); + localStorage.setItem(HEIGHT_STORAGE_KEY, 'not-a-number'); + expect(readStoredHeight(HEIGHT_STORAGE_KEY)).toBeNull(); + localStorage.setItem(HEIGHT_STORAGE_KEY, '640'); + expect(readStoredHeight(HEIGHT_STORAGE_KEY)).toBe(640); + }); + + it('survives storage being unavailable', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('denied'); }); + expect(readStoredHeight(HEIGHT_STORAGE_KEY)).toBeNull(); + }); +}); + +describe('useResizableGridHeight', () => { + it('falls back to the measured fit while the analyst has not chosen a height', () => { + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + expect(view.result.current.height).toBe(500); + expect(view.result.current.isCustom).toBe(false); + }); + + it('opens at the height chosen last time', () => { + localStorage.setItem(HEIGHT_STORAGE_KEY, '720'); + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + expect(view.result.current.height).toBe(720); + expect(view.result.current.isCustom).toBe(true); + }); + + it('grows and shrinks by dragging, and remembers where the drag ended', () => { + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + + act(() => { view.result.current.startDrag(400); }); + act(() => { drag(400, 550); }); // 150px further down + expect(view.result.current.height).toBe(650); + expect(localStorage.getItem(HEIGHT_STORAGE_KEY)).toBe('650'); + + act(() => { drag(400, 350); }); // back up past the start + expect(view.result.current.height).toBe(450); + + act(() => { window.dispatchEvent(new Event('pointerup')); }); + act(() => { drag(400, 900); }); // released — no longer tracking + expect(view.result.current.height).toBe(450); + }); + + it('starts a drag from the height the grid is actually rendering at', () => { + const ref = layout({ gridTop: 300, viewport: 800, rendered: 240 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + act(() => { view.result.current.startDrag(100); }); + act(() => { drag(100, 160); }); + expect(view.result.current.height).toBe(300); + }); + + it('resizes by keyboard steps and hands the height back on reset', () => { + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + + act(() => { view.result.current.resizeBy(100); }); + expect(view.result.current.height).toBe(600); + act(() => { view.result.current.resizeBy(-250); }); + expect(view.result.current.height).toBe(350); + + act(() => { view.result.current.reset(); }); + expect(view.result.current.height).toBe(500); // back to the measured fit + expect(view.result.current.isCustom).toBe(false); + expect(localStorage.getItem(HEIGHT_STORAGE_KEY)).toBeNull(); + }); + + it('will not let a drag shrink the grid below a usable one', () => { + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + act(() => { view.result.current.startDrag(400); }); + act(() => { drag(400, -2000); }); + expect(view.result.current.height).toBe(MIN_USABLE_HEIGHT); + }); + + it('keeps the chosen height even when storage refuses to remember it', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('denied'); }); + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + act(() => { view.result.current.resizeBy(120); }); + expect(view.result.current.height).toBe(620); + }); + + it('stops listening for the drag when the matrix goes away mid-drag', () => { + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + const removed = vi.spyOn(window, 'removeEventListener'); + act(() => { view.result.current.startDrag(400); }); + view.unmount(); + expect(removed.mock.calls.map(c => c[0])).toEqual( + expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']), + ); + }); + + it('drops a half-finished drag when a new one starts', () => { + const ref = layout({ gridTop: 300, viewport: 800 }); + const view = renderHook(() => useResizableGridHeight(ref, [])); + act(() => { view.result.current.startDrag(400); }); + act(() => { view.result.current.startDrag(100); }); + act(() => { drag(100, 150); }); + expect(view.result.current.height).toBe(550); // one 50px move, not two + }); +}); diff --git a/app/ui/src/hooks/useViewportFitHeight.js b/app/ui/src/hooks/useViewportFitHeight.js index 8f1ef4a41..e60f9b5fe 100644 --- a/app/ui/src/hooks/useViewportFitHeight.js +++ b/app/ui/src/hooks/useViewportFitHeight.js @@ -17,9 +17,35 @@ import { useLayoutEffect, useState } from 'react'; // floor taller than the available space is what produced two. export const MIN_USABLE_HEIGHT = 200; +/** + * Everything that still has to fit UNDERNEATH the container inside its own + * layout — the resize grip below the matrix grid, say. Walks the in-flow + * siblings below `el` at every level up to the
(or the body), adding the + * row gap that separates them. Out-of-flow siblings (a fixed modal, an absolute + * overlay) take no space and are skipped. + * + * Missing this is how a "fit the window" cap overflows the page by exactly the + * height of whatever was added below the grid. + */ +function spaceBelowInFlow(el, win, doc) { + const stop = (typeof el.closest === 'function' ? el.closest('main') : null) || doc.body; + let total = 0; + for (let node = el; node && node !== stop && node.parentElement; node = node.parentElement) { + const gap = parseFloat(win.getComputedStyle(node.parentElement).rowGap) || 0; + for (let sib = node.nextElementSibling; sib; sib = sib.nextElementSibling) { + const s = win.getComputedStyle(sib); + if (s.position === 'fixed' || s.position === 'absolute' || s.display === 'none') continue; + if (sib.tagName === 'FOOTER') continue; // already subtracted on its own + total += (sib.getBoundingClientRect().height || 0) + gap; + } + } + return total; +} + /** * Space left for `el` between its top edge and the bottom of the viewport, - * minus the app footer and the bottom padding of the
it lives in. + * minus the app footer, the bottom padding of the
it lives in, and + * whatever is laid out below it. * Returns null when there is nothing to measure. */ export function measureAvailableHeight(el) { @@ -32,6 +58,7 @@ export function measureAvailableHeight(el) { const footerH = footer ? footer.getBoundingClientRect().height : 0; const main = typeof el.closest === 'function' ? el.closest('main') : null; const mainPad = main ? parseFloat(win.getComputedStyle(main).paddingBottom) : 0; + const below = spaceBelowInFlow(el, win, doc); // clientHeight is the real layout height. getBoundingClientRect().top is // viewport-relative, so on a scrolled page it reads too small and would cap @@ -42,7 +69,7 @@ export function measureAvailableHeight(el) { // Never rounded up to a floor: a cap taller than the space available is the // page overflow this exists to prevent (a 240px floor with 200px of room // overflows the page by 40px). - return Math.max(0, doc.documentElement.clientHeight - top - footerH - (Number.isFinite(mainPad) ? mainPad : 0)); + return Math.max(0, doc.documentElement.clientHeight - top - footerH - below - (Number.isFinite(mainPad) ? mainPad : 0)); } /** diff --git a/app/ui/src/hooks/useViewportFitHeight.test.jsx b/app/ui/src/hooks/useViewportFitHeight.test.jsx index c1474ac81..33c282fec 100644 --- a/app/ui/src/hooks/useViewportFitHeight.test.jsx +++ b/app/ui/src/hooks/useViewportFitHeight.test.jsx @@ -64,6 +64,33 @@ describe('measureAvailableHeight', () => { expect(measureAvailableHeight(grid)).toBe(700); }); + // The resize grip lives below the grid. Forgetting it overflows the page by + // exactly its height — the double scrollbar this whole hook exists to avoid. + it('leaves room for whatever is laid out below the grid', () => { + document.body.innerHTML = '
' + + '
'; + const grid = document.getElementById('grid'); + grid.getBoundingClientRect = () => ({ top: 300 }); + document.getElementById('grip').getBoundingClientRect = () => ({ height: 28 }); + document.querySelector('footer').getBoundingClientRect = () => ({ height: 40 }); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800); + expect(measureAvailableHeight(grid)).toBe(800 - 300 - 40 - 24 - 28); + }); + + it('ignores siblings that are out of flow, hidden, or the footer itself', () => { + document.body.innerHTML = '
' + + '' + + '' + + '
'; + const grid = document.getElementById('grid'); + grid.getBoundingClientRect = () => ({ top: 300 }); + for (const id of ['modal', 'hidden', 'inner']) { + document.getElementById(id).getBoundingClientRect = () => ({ height: 100 }); + } + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800); + expect(measureAvailableHeight(grid)).toBe(500 - 100); // only the