Matrix view — architecture¶
Before this page
Assumes you have read The app at a glance. Brand new? Start at The words you need first.
Status: current as of May 2026. Companion to
013_matrix_matviews_and_indexes.sql,024_matrix_view_all_assignment_types.sql,046_owner_as_resource.sql,049_governed_intent_rows.sql,061_business_role_covers_itself.sql.
The grid¶
Rows are resources. Columns are principals (users / service principals / managed identities — not groups, see below). Each cell describes whether the column-principal has the row-resource and, if so, how.
alice bob carol ...
Sales Team D <- group membership
Reader on CRM D I <- app role (direct vs. via group)
Owner @ Sales Team D <- ownership is its own resource
DelegatedPerm: … D <- OAuth2 user consent
(That last cell is an OAuth2Grant row — it renders as D, since the badge only carries how the user holds it; the DelegatedPermission resource type carries the what. See "Badge collapse" below.)
Default row visibility¶
Not every resourceType belongs on the row axis. A business role (Entra access package, Omada business role, SailPoint access profile) is governance intent — the SOLL side — and the grid already renders it as an access-package column. Its members also hold a real Direct assignment on the role itself (migration 049), so without a rule the same role appears twice in one matrix: once per axis. It is therefore hidden from the resource axis by default.
The rule is one deny-list, HIDDEN_BY_DEFAULT_RESOURCE_TYPES in app/api/src/lib/resourceVisibility.js, applied at a single choke point: the resource fragment buildSubqueries (routes/matrix/shared.js) hands to every matrix mode. That one call site covers the flat grid, all roll-ups, the context zoom, the attribute fold, the scope stats / breakdown / timeline, the wizard preview, the inherited-access fold and the nested-group expansion — plus the unscoped "X of Y resources" totals, so the counts always match the rows that render. The Resources list (routes/resources/list.js) is the second caller of the same helper.
It is a deny-list, not an allow-list: resourceType is an open vocabulary (CSV / Omada / midPoint / Azure crawlers emit arbitrary types), so anything unknown stays visible. NULL counts as visible too.
Two ways to see the rows anyway:
| Override | Where |
|---|---|
filter.includeBusinessRoles: true |
The wizard's Resources step — "Show business roles as foldable rows". A property of the matrix, not of the viewer, so it is stored in SavedMatrixFilters (no schema change — the filter is jsonb) and a shared matrix renders identically for everyone. |
A resource-scope include condition on resourceType naming the type |
The wizard's "+ Attribute" picker. Explicit scope wins, so a deliberate "which access packages do people hold" matrix stays buildable. |
The "Business roles only" roll-up is unaffected: its rows come from vw_UserPermissionAssignmentViaBusinessRole keyed by business role, not from the resource axis. Ownership types are deliberately not on the deny-list — see "Owner rows are their own resource" below.
Data source¶
A single materialized view: vw_ResourceUserPermissionAssignments. Refreshed at end-of-sync via POST /api/ingest/refresh-views, and at web boot via bootstrap.js → ensureMatrixViewsPopulated().
When the views get rebuilt, and why there is no timer¶
Three triggers, all crawl-aware. A clock-driven refresh was asked for and deliberately not built; the reasoning is here so nobody re-litigates it from scratch.
| Trigger | When | Cost |
|---|---|---|
| End of every crawl | Whatever the run's verdict — a failed verification, or a crawl that threw, still refreshes. Rows commit batch by batch, so they are durable before any verdict exists, and a view that does not reflect them is strictly worse than one that does | One refresh per run |
| Web boot | Only when a view is unusable: never populated (first boot), or populated and empty while the rows it is built from exist. A populated, non-empty view is left alone. The rebuild runs in the background so it cannot delay the port bind | Nothing in the common case |
| Periodic timer | Not built | — |
Why ispopulated alone was not enough. A matview refreshed once while the database was empty is populated and empty, and stays that way forever — nothing rebuilds it, because the only question startup asked was "has this ever been populated". A customer's install carried 42.6 M assignments behind two 40 kB views until someone refreshed by hand. Startup now also asks each view whether it holds any row, and when it does not, asks a cheap base-table probe whether it should. A WorkerConfig cooldown bounds how often a probe-driven rebuild can happen, so an over-claiming probe cannot spend three minutes on every container restart; a view that was never built bypasses the cooldown.
Why no hourly timer. Measured at 42.6 M assignments: a full rebuild of both views plus ANALYZE is 3 min 17 s and a 7.6 GB result. A full crawl of a source that size runs for about sixteen hours, so an hourly timer means roughly 48 minutes of full-table work and ~8 GB of scratch churn during the crawl, competing with an ingest pipeline that is already the bottleneck — and every one of those refreshes publishes a half-loaded matrix, which reads worse than an obviously stale one. Refreshing at the end of each run plus rebuilding an unusable view at boot covers the case the timer was asked for ("the matrix went stale because a crawl failed") without any of that. If a timer is ever added it must skip while a crawl job is running and skip when nothing has changed since the last refresh — a staleness test, not a clock.
CONCURRENTLY is decided per view, and never for an empty one. It keeps readers on the old contents but builds the new ones into a temp table and diffs them, which is the worst possible shape for filling an empty view — every row of the result is an insert found by a full outer join — while buying nothing, because the readers it protects are reading nothing. So a populated-but-empty view gets a plain one-pass REFRESH and its populated sibling still gets CONCURRENTLY.
Shape:
| Column | Purpose |
|---|---|
resourceId |
Row (joined to Resources for displayName etc.) |
principalId |
Column (joined to Principals — group-typed rows fall out at the JOIN) |
principalType |
Passed through; legacy filter against '#microsoft.graph.group' is dead in v5 but harmless |
membershipType |
What badge to render — see badge collapse below |
managedByAccessPackage |
Drives AP color overlay on the cell |
The view itself is a single SELECT FROM "ResourceAssignments" with no assignmentType filter — every type stored in the base table flows through automatically. The legacy hardcoded UNION of Direct/Owner/Eligible/Governed was removed in migration 024 so future assignment types don't need a migration to surface.
Resource metadata columns (Contexts pinned left, # | Type | Description right)¶
Every resource row carries read-only metadata either side of the grid. The
pinned info block is drag handle | Resource Name | Contexts; the right-side
block is # | Type | Description. Contexts lists the Contexts the
resource belongs to — group category, tags, clusters, business processes — so
the category of a group is visible without an export. It sits in the pinned
block (where resource Type used to be) so it stays on screen while the grid
scrolls horizontally; Type moved to the right-side block in exchange.
It rides the matrix response as a sidecar, not a per-row fetch: the handler
returns resourceContexts: [{ resourceId, contexts: [{ id, displayName, contextType }] }],
computed by one indexed batch query over ContextMembers → Contexts
(fetchResourceContexts in app/api/src/matrix/resourceContexts.js), scoped to
the resources actually on the grid and to memberType = 'Resource'. The same
join backs GET /api/resources/:id/contexts. Rows are server-sorted by
contextType, then displayName; the cell shows the first two as chips and
collapses the rest behind a +N toggle, while the flat grid's Excel export
writes the full comma-joined list.
Only Resource-targeted memberships appear — an Identity- or Principal-targeted
context (a department, an org unit) is a property of the people in a group, not of
the group, and never shows on a resource row. The column is display-only: filtering
by context stays in the filter wizard's context picker (buildContextClause in
matrix/filterSql.js).
Every matrix shape carries it, not just the flat grid. A roll-up aggregates
the subject axis — its rows are still resources (or business roles, which are
resources too), so they have Contexts to show. All six roll-up response paths
therefore ship the sidecar from the same builder, and RollupMatrixView pins the
column beside its row labels exactly as the flat grid does. It is one extra
indexed query per request, over the roll-up's own (smaller) resource list — the
alternative, looking the contexts up from the client, would have been a
per-resource fetch over a view that renders hundreds of rows.
Badge collapse — what each letter actually means¶
The user reads three letters. The DB stores a handful of assignment types. The translation lives in migration 049's CASE expression and is intentionally lossy:
Raw assignmentType in ResourceAssignments |
Displayed membershipType |
Badge |
|---|---|---|
Direct |
Direct |
D |
Indirect |
Indirect |
I |
Eligible |
Eligible |
E |
OAuth2Grant |
Direct |
D |
AppRole |
Direct |
D |
AppRoleViaGroup |
Indirect |
I |
DirectoryRole |
Direct |
D |
DirectoryRoleEligible |
Eligible |
E |
There is no O (Owner) badge and no Governed badge — both concepts were retired as assignment types and never reach this table:
- Ownership is a
Directassignment on a separateGroupOwnershipresource (migration 046), so an owner shows up as a normal Direct cell on the ownership row — see Owner rows are their own resource below. - Governed access is a
Directassignment carrying thegoverned=trueflag (migration 049); the flag drives the AP color overlay, not a badge.
The rationale: the resource type already says what kind of membership it is (BusinessRole, DelegatedPermission, AppRole, GroupOwnership). The badge is reserved for how the user holds the resource — Direct / Indirect / Eligible — not why the assignment exists.
Cell coloring is independent. managedByAccessPackage is computed from the governed=true flag (via the SOLL join in migration 049) so the AP color overlay correctly marks governed cells regardless of the badge.
The three-letter alphabet was chosen for an explicit reason: E (Eligible) is not "current access". A user with an Eligible row could activate via PIM but right now has nothing. Folding Eligible into Direct would misrepresent reality and was rejected in design discussion.
Expand semantics¶
A group can appear as a principal of other resources — that's how nested group membership and group-assigned app roles work. The matrix grid hides these rows (the column INNER JOINs Principals, and groups live in Resources). The expand affordance surfaces them:
GET /api/groups-with-nested— returns every group ID that is itself a principal on at least one row inResourceAssignments(anyassignmentType).GET /api/group/:id/nested-groups— returns every resource that group is a principal on, plus the user-level memberships of those resources.
Clicking the chevron on a group row fans out:
1. Nested parent groups — groups this one is a member of. Members of the row's group inherit them.
2. App roles — app roles assigned to this group. Members inherit them via the per-user AppRoleViaGroup rows (badge I).
The endpoints don't filter by assignmentType — any future "group-as-principal" type (directory roles assigned to groups, governed BRs assigned to groups, …) automatically surfaces.
Why groups don't appear as columns¶
Principals is users + service principals + managed identities. Groups live in Resources (as resourceType='Group'). When a group is itself a principal of an assignment (e.g. nested membership), the row's principalId is a group's UUID — which has no matching Principals.id, so the matrix endpoint's INNER JOIN Principals drops the row. The expand endpoints query ResourceAssignments directly to surface those rows.
This is why we can store both:
- (resourceId=AppRole_X, principalId=Group_Y, assignmentType='AppRole') — Group Y has the role
- (resourceId=AppRole_X, principalId=User_Z, assignmentType='AppRoleViaGroup', extendedAttributes.viaGroupId='Group_Y') — User Z has the role because they're in Group Y
…without polluting the user-facing matrix grid. The first row is invisible there; the second row paints User Z's cell with an I badge.
Owner rows are their own resource¶
A user who is both a member and an owner of a group holds two separate resources: the group itself (a Direct membership) and a synthetic GroupOwnership resource named Owner @ <group> (also a Direct membership). Migration 046 rewrote the old assignmentType='Owner' rows into Direct assignments on this ownership resource, linked back to the group by a HasOwnership relationship — mirroring how an AppRole hangs off its Application. The matrix therefore shows ownership as its own row, with a normal D badge, rather than a separate O-type cell on the group row. No client-side row-splitting is involved.
The same shape, and the same row, is what every other ownership type produces:
ServicePrincipalOwnership / ApplicationOwnership from the Entra app-owner phase
(linked by HasAppOwnership), and ResourceOwnership from the SQL connector, whose
resourceType is whatever the operator's statement loaded, so one generic ownership type
covers all of them and the owned type rides on
extendedAttributes.ownedResourceType. The list every consumer filters on is
app/api/src/lib/ownershipTypes.js
— nothing here needs to know which crawler wrote the row.
Ownership types stay visible under "Default row visibility" above, and the distinction is the point of that section: a business-role row duplicates something the matrix already shows as a column, while an ownership row is the only place the matrix shows who controls a group. Hiding it would remove information, not a duplicate.
Access-package columns — role scopes badge like memberships¶
The right-hand access-package block is driven by ResourceRelationships (relationshipType='Contains'), whose roleName holds the role scope as the source system names it — Graph stamps accessPackageResourceRole.displayName verbatim, so Member, Owner and Eligible Member all occur. That name maps to a badge letter the same way everywhere:
roleName contains |
Badge |
|---|---|
eligible |
E |
anything else (Member, Owner, empty) |
D |
There is no O badge here either — for the same reason as the membership badges: ownership is its own resource, and a package granting a group's Owner role still grants access the subject holds today. The mapping lives in getApRoleBadge (app/ui/src/utils/accessPackageStyles.js) and is shared by the grid (MatrixGroupRow.jsx) and the Excel export (exportToExcel.js) so the file can't disagree with the screen; migration 049 applies the same %eligible% rule server-side when it derives governed-intent rows.
Performance notes¶
- The matview is refreshed
CONCURRENTLYonce it holds rows; a first build, and a rebuild of a view that is populated but empty, are non-concurrent (see When the views get rebuilt). - The unique covering index
(resourceId, principalId, membershipType)is required forREFRESH CONCURRENTLYand also makes the matrix endpoint's per-principal lookups index-only. - The Contexts sidecar is one extra indexed query per flat-grid request (
ix_ContextMembers_member), bounded by the grid's distinct resources — computed once per resource, never per cell. - The recursive CTE that previously expanded nested groups inside the matview was removed in 013 — it was the dominant cost on the load-test dataset and produced the same matrix for tenants without group-in-group nesting. Group-level expansion happens lazily at click time via the
/nested-groupsendpoint instead.
Identity rows¶
The matrix can run with identities as subjects instead of individual principals. The choice is made in step 1 of the matrix filter wizard (rowType: 'principal' | 'identity'):
- User accounts (
principal) — each subject is one Principal (a single account). Best for clean-up sweeps and per-account audits. - Identities (
identity) — each subject is one correlated person, unioning across their linked accounts. A cell is filled if any underlying account has the assignment. Best for role-mining and birthright analysis.
When the orientation puts subjects on the column axis, an identity column can be expanded into per-account sub-columns. Clicking the chevron on an identity header (MatrixColumnHeaders.jsx) loads GET /api/identities/:id/account-matrix, which returns the identity's linked accounts plus each account's (resourceId, membershipType) rows drawn from the same vw_ResourceUserPermissionAssignments view the principal matrix uses — so the account sub-columns render cells identical to a principal-scoped matrix. The account sub-columns are visually tinted (blue) and labelled displayName · accountType to distinguish them from the identity columns around them.
Expanding is a drill-down, not an extra column¶
Expanding replaces the identity's column with one column per linked account, the way an org grouping expands into the columns it contains (#1212). The identity's combined ("all accounts") column is what collapsing gives back — it is never shown next to the accounts it rolls up, which would be the same access counted twice on screen.
columnModel.js owns this: buildColumns() emits the account columns instead of the identity, and each one carries its parent on parent (an identity whose account list comes back empty keeps its own column, so a subject can never vanish from the grid).
The accounts header row¶
The accounts hang under their identity rather than beside it. The header is where the parent reappears, and MatrixColumnHeaders.helpers.js's splitAccountColumns() is what puts it there:
- The identity re-enters the names row at the position of its first account column, and its
<th>spans exactlyaccountscolumns. - A second header row (
MatrixAccountsRow.jsx) sits directly under the names row and fills that span with one blue cell per account. - Every other header cell on the names row — the corner/Resource Name/Contexts cells, non-expanded subjects, aggregates, access-package labels and the # / Type / Description block — carries
rowSpan=2while the accounts row exists, so no blank band appears beside them. The row only exists while at least one identity is expanded; otherwise the header renders exactly as before. - The accounts row sits after the names row, so the sticky
<thead>pins it along with the names row for free. Its height must not be added to the grouping offset below — that would push the header out of view and bring back the grey-band-on-scroll bug.
An account column that carries no parent stays on the names row: it still owns a body column, and every header row has to keep adding up to the body's width.
The linked-account count on an identity header¶
An identity header shows the number of accounts it expands into, as a small grey count above the rotated name (the same treatment the roll-up matrix gives its group headers), with the number spelled out in the header tooltip. Only identity columns that have linked accounts get one — a plain account column expands into nothing, and neither does an identity with no accounts.
The count must be readable before expanding — it is what tells an analyst which identities are worth a click — so it cannot come from /api/identities/:id/account-matrix, which is only fetched on expand. /api/matrix/data therefore ships an accountCount with every identity row (accountCountJoin / accountCountSelect in routes/matrix/data.js). Principal-row matrices get no such column: there, a subject already is an account.
It is counted live from IdentityMembers, the same table /api/identities/:id/account-matrix reads, so the badge always states the exact number of columns that expanding will produce. Do not read the denormalised Identities.accountCount here, tempting as it looks: only the account-linking engine writes that column, and only for the identities a given run newly linked. Every identity whose accounts arrived from a crawler, a CSV import or an analyst decision still carries NULL — which is most real data, the demo dataset included — and reading it made the count render as nothing at all on exactly the multi-account identities it exists to flag (#1212). The identities list, identity detail page and risk-score list still display the stored column and remain subject to that staleness; they are not fed by this query.
The aggregate is grouped once and LEFT JOINed rather than correlated per row — the flat grid emits one row per (subject, resource) assignment, so a scalar subquery would re-count the same identity thousands of times per request.
Nothing is counted client-side: matrixModel.js carries the value from the row onto the subject, and subjectAccountCount() in MatrixColumnHeaders.helpers.js decides whether it is worth showing. The count stays up while the identity is expanded, where it describes the span below it.
Context picker filtered by row type¶
The wizard's "+ Context" picker is filtered by the subject row type so an analyst can only pick contexts that actually apply to the rows:
| Row type | Subject-side contexts offered | Resource-side contexts offered |
|---|---|---|
principal |
Principal |
Resource, System |
identity |
Identity |
Resource, System |
(Resource/System contexts always apply to the resource axis; Identity and Principal contexts to the subject axis.)
Identity extension-attribute filtering¶
The subject-condition step also offers an "+ Attribute" filter. When rowType=identity, the column list comes from GET /api/matrix/columns?entity=Identity (loaded lazily the first time the analyst switches to identities), so identities can be narrowed by their own attributes (department, jobTitle, companyName, city, country, employeeId, …) and by identity tag. Switching row type clears the subject conditions, since the available columns differ between principals and identities.
Which fields the "+ Attribute" picker offers¶
The picker (app/ui/src/components/matrix/AttributePicker.jsx) lists every discovered column that carries a value list, minus a small hidden set: id, principalId, resourceId, identityId — opaque identifiers nobody filters by hand.
displayName is not hidden: filtering down to the specific subjects or resources you mean by name is a core role-mining ask (#927), and every layer beneath the picker already supports it (the columns endpoint serves the column with its values, buildAttributeClause in app/api/src/matrix/filterSql.js accepts it, and a displayName condition round-trips through a saved matrix unchanged). On a tenant with more names than fit one page, the value search below applies to it like any other column.
The wizard's Sort / roll-up options are a different list (attributeOptions() in MatrixFilterWizard.jsx) and do still exclude displayName — grouping by a near-unique value produces one group per row and nothing to fold.
Attribute values — paged discovery, not a silent cap¶
A column can have far more distinct values than any dropdown can ship (description on Resources collects descriptions from every resource type, so a real tenant runs to tens of thousands). GET /api/matrix/columns therefore serves one page of values per column — the alphabetically first 500 by default — and sets truncated: true on any column that has more. Two rules follow:
- The page is ordered, never arbitrary. The per-column subquery orders inside the
LIMIT(db/columnCache.js). Without that, Postgres is free to return any 500 distinct values, and the list an analyst browses alphabetically has unpredictable holes — a value they can see on the Excel export is simply absent, while later values are present (#928). - Everything outside the page stays reachable.
GET /api/matrix/column-values?entity=&column=&q=runs a bounded substring search (case-insensitive, 50 results) over all distinct values of one column. The "+ Attribute" picker filters the preloaded page client-side as you type, and for atruncatedcolumn additionally merges in server-side matches. The Field dropdown showsdescription (500+)for a truncated column so the count reads as a floor, not a total.
column is validated against the discovered columns / ext.* keys before it is interpolated into the SQL; the search term is always bound.
Verifying it on a deployment that has fewer than 500 distinct values¶
The capped path only appears once a column holds more distinct values than the page, so a test environment with a couple of hundred resources shows nothing. There is no need to create resources by hand — resources only enter Identity Atlas through crawlers, and there is no bulk-create UI. Two supported ways to get there instead:
| What you change | Who can do it | What you end up looking at | |
|---|---|---|---|
| A. Load high-cardinality demo data | A checkbox on the Demo Dataset crawler | Anyone with Admin access, from the browser | A real column with more than 500 distinct descriptions — the reporter's scenario at full size |
| B. Lower the page size | MATRIX_VALUE_PAGE_SIZE on the web container |
Whoever can recreate the container | The same behaviour at miniature scale, on whatever data is already loaded |
A. Load high-cardinality demo data (no configuration change)¶
Admin → Crawlers → Demo Dataset → tick "Also load high-cardinality test data" → Load Demo Data. The generator's opt-in volume slice (test/demo-dataset/parts/DemoVolume.ps1, switched on by Generate-DemoDataset.ps1 -IncludeVolume) appends ~520 extra groups SG-Vol-0001…, each with its own description, plus one sentinel group SG-Zzz-Cap-Probe whose description starts with Zzz so it sorts alphabetically last and is therefore guaranteed to fall outside the preloaded page.
Then walk the reporter's path — Matrix → Adjust matrix → Next → Next → + Attribute:
- The Field entry reads
description (500+)— the+marks the count as a floor, so the list is a page rather than everything. - The listed values are the alphabetically first 500, in order — no holes anywhere in the middle.
- Type
Zzzinto Search values.Zzz - beyond the preloaded 500 (#928 probe)comes back from the server even though it is not in the page. Tick it, add the filter, and the matrix showsSG-Zzz-Cap-Probe.
The slice is off by default: the public demo, the Capture-the-Flag answers, Verify-DemoDataset.ps1's exact row counts and the E2E suite all assume the standard 39-resource company. test/unit/DemoDataset.Tests.ps1 pins both halves: the slice crosses 500 with the sentinel behind the page, and the default dataset is unchanged without the switch.
To undo it, re-run the Demo Dataset crawler with the box unticked and clean the database first (Admin → Danger Zone → Clean Database). A plain re-run soft-deletes the extra groups, which is enough to clear them from the resource lists and the matrix — but value discovery reads the whole table, so their descriptions would keep appearing in the dropdowns until the rows are actually gone.
B. Lower the page size¶
The page size is the MATRIX_VALUE_PAGE_SIZE environment variable on the web container (default 500, maximum 5000; anything unparseable, zero or negative falls back to the default). It is the cache key alongside the 5-minute TTL, so a changed value takes effect on the next request rather than after the TTL expires.
Lowering it reproduces the same behaviour on any dataset without touching the data at all — useful when the deployment is connected to a real tenant and loading demo data is not an option. Both compose files pass the variable through, so an already-built stack only needs its web container recreated — no rebuild, no data changes:
# In the stack's directory, with the same -f flags it was started with
MATRIX_VALUE_PAGE_SIZE=5 docker compose up -d web
Then, in the matrix wizard's "+ Attribute" picker, any field with more than five distinct values behaves exactly as description does in a large tenant:
- The Field dropdown reads
description (5+)— the+marks the count as a floor. - The picker says "Showing the first 5 values of more than can be listed", and those five are the alphabetically first ones — no holes.
- Typing part of a sixth, out-of-page value into Search values finds it (that request is
GET /api/matrix/column-values) and it can be ticked and added as a filter.
Restore the variable (or drop it) and recreate the container to return to the 500-value default. app/api/contract-tests/columnValuesSmallTenant.contract.test.js runs this exact recipe against a real database with twelve descriptions and a page size of five.
Independently of the page size, two things are checkable on any deployment, without changing its configuration: GET /api/matrix/columns?entity=Resource returns description values in alphabetical order (the ordering whose absence caused the holes), and GET /api/matrix/column-values?entity=Resource&column=description&q=<text> — the request the picker's search box makes — returns matches for text stored anywhere in the tenant.
Filter shape — normalised at the wizard boundary¶
A matrix filter reaches the wizard from four places and only one of them is guaranteed to carry every field:
| Source | Completeness |
|---|---|
| The wizard's own Apply | complete |
A saved matrix (SavedMatrixFilters) |
may predate a field, or be seeded with only a few |
The #matrix?filter=… URL |
shared from an older build, or hand-edited |
| The org-wide default matrix (auto-applied without opening the wizard) | as seeded — Ingest-DemoDataset.ps1 seeds rowType/orientation/subject/resource and nothing else |
The wizard's steps read those fields directly (sortAttributes.length,
subject.include, …), so every filter entering wizard state — the initialFilter
prop and a matrix loaded from the saved-matrix dropdown — goes through
normalizeMatrixFilter() in app/ui/src/utils/matrixFilter.js
first. It fills missing fields from EMPTY_FILTER, drops wrongly-typed values,
and deep-copies the source so wizard edits can't mutate the applied filter. The
grid-side consumers (MatrixView, sortUsers, the Excel export) already fall
back to DEFAULT_SORT on their own, so a partial filter renders — it was only
the wizard that assumed the full shape.
The strip above the grid¶
Everything between the tab bar and the matrix is one row
(MatrixFilterSummary) — "a matrix is a document":
[<Matrix name> ▾] [Unsaved changes] [Shared with N ▾] ····· 45 users × 39 resources · 127 cells [Adjust]
- The name menu (
MatrixNameBar→SavedMatrixMenu) — the saved matrix on screen, or "Unsaved matrix". It lists every saved matrix (current one marked) and holds the document verbs: New matrix…, and for the current saved matrix Rename…, Duplicate… and Delete…. - Unsaved changes — only for a matrix loaded from a saved one that has since
diverged from it (
currentSavedMatrixinshareState.js); it opens the wizard on its last (save/share) step. A never-saved matrix has no chip. - Shared with N — only for a shared saved matrix, for someone who may share; it opens the recipients panel. Creating a share is the wizard's last step.
- The three live counts, and Adjust (accessible name "Adjust matrix").
The wizard is opened through onAdjustFilter(options?): no options = the matrix
on screen, first step; { step } = that step; { fresh: true } = a new, empty
matrix (wizardOpening in App.helpers.js, initialStep on the wizard).
With no matrix on screen the tab shows Open a matrix (OpenMatrixList):
every saved matrix, one click to open, plus New matrix. The org default still
auto-applies; the wizard is no longer thrown open on arrival.
It was two stacked bars, with the scope-statistics panel under them, which put three bars and ~240px between the tab bar and the first row of data — the feedback that reopened #1202 called the result "quite a mess".
The scope-statistics panel (MatrixScopePanel) is now opt-in per matrix:
it renders only for a filter carrying showTrends: true, ticked on the wizard's
Sort step. See Scope Statistics.
The flag is part of the filter — saved, shared and URL-carried with it — not a
viewer preference, so one saved matrix opens the same way for everyone.
Matrix identity — comparing two filters¶
"Is this the matrix I saved?" is asked by the strip's name menu
(MatrixNameBar, via matchSavedMatrix / currentSavedMatrix in
components/matrix/shareState.js), which labels the applied matrix with its
saved name — and, when it is shared, with how many people see it — or "Unsaved
matrix", and marks a loaded matrix that has since changed "Unsaved changes". Filters are compared with matrixFilterFingerprint() — canonical
(key-order-independent) JSON of the normalised filter, minus the view-state
keys rollupExpanded / rollupCollapsed / rollupLevel / rollupPath /
foldAttributes.
Never compare filters with raw JSON.stringify:
- the applied filter is always the full shape while a stored one may carry only
a few fields (and a
managedkey the filter itself doesn't have), so a raw compare made a matrix stop matching the saved row it was loaded from the moment the analyst opened the wizard and applied without changing anything; - the view-state keys say where the analyst is in the matrix (which groups are folded, how far they've drilled), not which matrix it is, and the wizard rewrites them on every apply.
Adjusting without changing anything¶
Opening "Adjust matrix", walking the steps and applying without touching a
control has to be a no-op — every step renders, and the matrix that comes back
is the same one, still carrying its saved name. That contract is what the two
regressions above broke, and it's covered end-to-end in
app/ui/e2e/matrix.spec.js
("Matrix — adjust without changing anything") for each way a filter reaches the
wizard: the org-wide default, a shared link, an identity matrix, and a second
adjust of the wizard's own output.
Sorting, folding & server-side aggregation¶
The column axis can get very wide (one column per subject). Three mechanisms keep it usable at scale; which one is in play depends on the wizard's Sort step and the matrix size.
Per-subject sort + fold (client-side, small matrices)¶
In the default per-subject grid (MatrixView.jsx) the columns can be sorted by
1–6 attributes (sortAttributes, e.g. department then jobTitle). Each sort
attribute becomes a merged header row above the subject names
(computeAttributeSpans in matrix/sortUsers.js). A sort group can then be
folded into a single aggregate count column in place (collapsedGroups); the
aggregate column shows the number of child groups, the user count, and a per-row
count of Direct assignments. ▾/↳ explode an aggregate back into its members
(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)¶
Everything in this section and the five that follow it exists only in a matrix that opted into business-role rows —
filter.includeBusinessRoles, the wizard's "Show business roles as foldable rows" (see Default row visibility). The one flag turns on the rows and the whole layer that reads them: the fold, the under-role nesting, theBRchips, the deviation markers and the under-role Excel layout. Without it the grid holds no business-role rows, so none of it can be drawn, and the matrix renders exactly as it did before the layer existed. The gate is the data, not a pile of UI conditionals: with the rows gone,analyseRoleRowsfinds no foldable role, no row carries aroleParentId, and every affordance below disappears on its own. The two places that needed saying out loud — the over-grant mark and the legend entries — take an explicitshowBusinessRoles, so "the default matrix is the old matrix" is a property a test can pin rather than an emergent one.
The column fold above collapses columns; the business-role fold collapses
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 business roles toggle in
the grid's header corner (above the row labels; it flips to Unfold business
roles once folded) 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
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-<filter>), the mechanism the custom row order uses — so two different matrix slices keep independent fold state. - A resource granted by several roles has a row under each of them, so folding one role takes away only that role's copy — see 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.
- 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 byContains. - A resource a role grants belongs to that role's block.
buildRoleLayoutdraws it directly under the role — indented, with the elbow, exactly like an expanded nested group — and under every role that grants it, never also as a loose row somewhere else. It is the role rows and the resources no role grants that the AP staircase and the custom drag order position; a role's children travel with it and carry no drag handle of their own (the rule nested sub-rows already followed). - A business role nested inside another one keeps its own place rather than being filed under its parent — otherwise folding the parent would take its fold chevron off screen with it.
- 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 on the right of the cell's marker strip (folded resources the
subject holds that this role does not account for) and an amber count on the
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. - Both counts are roll-ups of marks the rows carry themselves, so unfolding a role never makes a finding disappear: the resource rows show the same amber gap and the same red "held outside business-role governance" mark on their own cells. See Fewer and more than the role assigns.
- The Excel export follows the rows, unfolded. A matrix with business-role rows exports the layout the grid lays out — a resource appears under every role that grants it — but always with nothing folded, so folding stays a reading aid and can never leave access out of a file an access review is signed off against. A matrix without them exports the plain row order, exactly as it did before.
Which business role does this row belong to?¶
The row's position answers it: a resource sits under the role that grants it, under each of them if there are several. Two things say the rest:
- Every resource row a business role grants states all its roles in the row
tooltip (
Granted by business role: …) — including the ones whose copy of the row is elsewhere in the grid. - A row whose resource is also granted by another role carries a chip next to the resource name; clicking it opens that role. A resource only one role grants carries no chip — the layout already says everything.
- The chip is a marker, not a name:
BRfor one other granting role,BR+3for three. The names are in its tooltip (and behind the click). Role names are long, and the resource-name column is the one column that has to stay readable, so the count goes on the grid and the names stay one hover away. The tooltip doubles as the chip's accessible name.
Both come from buildRoleLayout in
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 — one membership, stored once; what the second role adds is coverage.
The grid shows the resource under every role that grants it. One row filed under the "first" role and a chip pointing at the rest made the overlap something you had to go looking for, and made a fold ambiguous — it could take away fewer rows than the role granted, so the fold chip had to hedge with "N of M". Drawing the resource once per granting role removes both problems (requestor feedback on #370). Duplication is in the rendering only: the membership, the counts, the scope statistics and the Excel export are all still per resource, not per role.
| Where | What it does |
|---|---|
| Row position | One row under each granting role, indented as its child. The rows are identical — same cells, same badges, same detail link |
| SOLL columns | Each of those rows shows a badge under every role that grants the resource, so the overlap is readable straight off the grid |
| Cell colour | The cell carries a count bubble — "covered by n business roles" — in the centre of its marker strip, and takes the colour of the first role in managedByPackages for that cell |
The BR+N chip |
Each row counts the other roles that grant it and names them in its tooltip, so one row leads to the rest |
| Folding | Folding a role takes away only that role's copies; the copies under the other roles stay exactly where they are |
| The fold chip | Always reads "N resources folded" — a role's fold takes exactly what the role grants, so it can never overstate or understate |
The deviation tallies follow the same rule: a folded role tallies every resource
it grants, because it hid every one of its own rows. buildRoleLayout produces
the rows, the fold summary and the folded-row lists in one pass, so what a role
reports and what it actually took away cannot 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 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.
Fewer is the one marker that predates this layer, and it is unconditional:
the amber ! provisioning gap has always been drawn against the SOLL columns,
which every matrix has. Everything else in the table arrives with the
business-role rows. More is the only one that had to be told so — it is
computable from the columns alone, so it takes the explicit showBusinessRoles
rather than appearing in a matrix that never asked for the layer. Outside
and the two folded counts need no flag: a row only carries roleGrantIds when
it is drawn under a role, and a fold count needs a folded role to sit on. A
default matrix therefore carries exactly the markers it always did.
| Deviation | Where it shows | Marker |
|---|---|---|
| Fewer — the role assigns a membership the subject does not have | On the resource's own cell | Amber ! in the strip's left slot (the provisioning gap), tooltip naming the expected type |
| Fewer, while the role is folded | On the folded role's cell | Amber count in the strip's left slot |
More — the role grants eligibility (roleName contains "Eligible") but the subject holds a standing membership |
On the resource's own cell | Red + in the strip's right slot |
| Outside — a business role hands this resource out, and no business role carries an assignment of it for this subject: they hold it by some other route | On the resource's own cell | Red count in the strip's right slot — how many roles grant the row, tooltip naming them |
| More / outside, while the role is folded | On the folded role's cell | Red count in the strip's right slot (folded resources held over what the role assigns, plus those held with no business role assigning them to the subject) |
The comparison lives in
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.
DirectandIndirectare 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.
A third statement shares the red slot: held outside business-role governance. The resource is one a business role hands out — the row says so, and the SOLL column agrees — but no business role carries an assignment of it for this subject, so the membership stands outside the governance that is supposed to cover it. This is deliberately the same finding a folded role reports as its red count, said on the resource's own row so folding and unfolding a role never change what the grid claims: fold the role and the marks on the rows it hides roll up into one count on the role's row; unfold it and they go back to the cells they came from.
heldOutsideRole in coverageDeviation.js clears the mark as soon as any
business role covers the cell — not only one of the roles that have a row in this
matrix. A role can only cover a cell by granting that resource to a subject who
holds the role, so a covering role explains the membership whether or not it is
in the current scope; suppressing on the granting rows alone marked cells red
that a role outside the scope already accounted for.
The wording says what was evaluated, and nothing more. The finding is about
the assignments of the business role that grants this resource: the role hands
the resource out, and it carries no assignment of it for this subject. That is
not the same claim as "the subject does not hold that role", which is what the
tooltip used to close on — and which is plainly wrong whenever the subject does
hold the role while the role's grant is missing from their assignments
(requestor feedback on #370). So heldOutsideRole also returns
holdsGrantingRole, read off the coverage view's self arm
(061
makes a role cover its own cell exactly when the subject holds it), and the two
cases are worded separately:
holdsGrantingRole |
Tooltip leads with |
|---|---|
false — no granting role of the subject's could be established |
no business role assigns this resource to this subject |
true — the subject does hold a role that grants the resource |
this subject holds a business role that grants this resource, but the role does not assign it to them |
Both then name the granting role(s) and say the same thing about them: they
carry no assignment of this resource for this subject. Neither ever asserts
that a role is not held. The true case is what a stale coverage matview looks
like from the grid — the role assignment has landed but
vw_UserPermissionAssignmentViaBusinessRole has not been refreshed since — so
the marker describes it accurately instead of blaming the subject.
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¶
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
Containsrelationship, so it can never arrive as a (role, resource) pair fromGET /api/access-package-groups;MatrixViewfills the diagonal when it builds the SOLL mapping. - Its cells are coloured governed.
061_business_role_covers_itself.sqladds a self arm tovw_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.
The cell marker strip¶
An intersection cell is 24×24px and carries two things: the D/I/E badge for how the access is held, and up to three markers about it. They do not share pixels — the cell reserves the top 8px as a marker strip and gives the badge row the other 16. The strip has three fixed slots, so where a marker sits always means the same thing:
| Slot | Colour | Meaning |
|---|---|---|
| Left | Amber | Fewer than the business role assigns — the ! provisioning gap, or a folded role's count |
| Centre | White | Covered by more than one business role (the number says how many; the cell tooltip names them) |
| Right | Red | More than the business role assigns — the + over-grant, a count of the roles that grant this resource without granting it to this subject, or a folded role's count |
Markers used to hang off the cell's corners on negative offsets, which drew each
one partly over the row above and the column to the right — the white count
bubble landed straight on its neighbours' badges. Nothing is painted outside the
cell's own box now, so no marker can overlap another label; the geometry lives in
matrix/cellMarkers.js
(CELL_BOX_STYLE) and is shared by the ordinary cell and the aggregate
(folded-column) cell, so the two can't drift apart. app/ui/e2e/matrix.spec.js
asserts the invariant on the busiest grid the demo data can produce.
Grid height¶
The grid is capped to the space the window really has left below it
(useViewportFitHeight measures it — chrome, footer, <main> padding and
whatever is laid out under the grid, including the resize grip), so exactly one
of the grid and the page ever scrolls.
That measurement is a default, not a verdict: how much of the window the
grid deserves next to the legend — and the scope-statistics panel, when the
matrix asks for it — is a judgement call. The grip under the grid
(matrix/GridResizeHandle.jsx)
resizes it by drag or arrow keys; the chosen height overrides the fit, is
remembered in localStorage under fgraph-matrix-height, and is handed back to
the measured fit by Fit to window (or double-click / Home / Esc).
useResizableGridHeight composes the two and is shared by all three
orientations, so resizing behaves the same in the per-subject, rotated and
roll-up grids.
Size gate¶
Folding does not shrink the fetch, so a flat per-subject matrix has a hard size
limit. MatrixFilterWizard.jsx (matrixIsBlocked) blocks an oversized flat
matrix (> BLOCK_ASSIGNMENTS); the server adds a backstop that returns 413
rather than overflowing JSON.stringify (V8's ~512 MB max string length) past
MAX_FLAT_ROWS rows. Only server-aggregated views (below) are exempt — they
return counts, never per-subject rows, so they load at any size.
Layered server-aggregated views (large matrices)¶
Two views aggregate on the server and render through RollupMatrixView.jsx as a
stacked, expand-in-place grid (columns = groups, cells = Direct counts). They
share the same payload shape (layered: true, nodes[] with pathIds/pathNames
/depth, counts[], maxDepth) so they use one renderer:
| View | Trigger | Tree | Default depth | Server cut |
|---|---|---|---|---|
| Manager Hierarchy | sortHierarchy: {contextId} |
a ManagerHierarchy Context tree |
top level (1 row); expand to drill deeper | buildContextCutSql — root's children, with any expanded node replaced by its children (rollupExpanded) |
| Attribute fold | foldAttributes (set by the wizard for an oversized foldable matrix) |
the chosen sortAttributes |
full depth (all attribute rows shown); fold to collapse | attributeCut.js — each subject's visible tuple, truncated at the first folded prefix (rollupCollapsed) |
Note the inverse defaults: the hierarchy starts shallow and expands (depth
unknown); attribute fold starts at full depth and collapses (depth = the
attributes you picked). Counts are computed only for the visible frontier
(buildContextRollupSql / buildAttrCutCellsSql), so the payload stays small
regardless of subtree size. The sortHierarchy → context roll-up translation
lives in the /api/matrix/data handler in matrix.js.
Whole-axis fold level (attribute fold). Clicking a header value folds or
unfolds one group. That is not enough on an axis of hundreds of columns, and
unfolding a group drops it straight to the deepest attribute — so the attribute
fold also carries rollupLevel: how many attribute levels are on screen. The
grid corner (ColumnLevelControls in matrix/GridCornerControls.jsx, rendered
through the shared ColumnAxisControls) steps it one level at a time in either
direction, with a level/maxLevel readout and the ends disabled.
The cap is applied on the server, by truncating the attribute list before any
SQL is built — so a capped fold genuinely groups by fewer expressions and returns
fewer columns, rather than the client hiding rows it already paid for. The
response reports level (visible) and maxLevel (how deep it could go);
maxDepth stays "how many header rows to draw", which is now the capped depth.
Moving the level clears rollupCollapsed, because "all columns one level" would
otherwise leave a hand-folded group behind at a level the corner isn't reporting.
The Manager-Hierarchy view keeps its expand-only model: its tree depth is
unbounded, so there is no maxLevel to count towards.
Empty-branch hiding. A column only appears if at least one in-scope resource has a Direct count for that node's subtree, so scoping the matrix to a few resources drops the org branches / attribute groups those resources aren't used in.
Scoped header counts. A Manager-Hierarchy column header shows
direct / total members — and both are assignment-scoped
(buildContextScopedMemberCountsSql): only people who actually hold a shown
resource, so the header agrees with the cells and with the member drill-down.
Sticky headers. With many header rows, only the deepest (layered views) or the names row (per-subject grid) stays pinned on vertical scroll; the upper grouping rows scroll away.
Excel export¶
Both renderers export an .xlsx that mirrors the on-screen header stack: one
header row per shown level (every sort attribute / every org level). On-screen
merged spans are written as the same value repeated across each column — cells
are not merged in the file (exportRollupToExcel.js, exportToExcel.js). All
externally-influenced cells route through safeCell (formula-injection guard).
The per-subject export mirrors the grid's column order: Contexts (every context
name, comma-joined and untruncated) sits in the info block next to Resource Name,
and the right-side block is #, Type, Description.
Related references¶
- Crawler emits —
tools/crawlers/entra-id/Start-EntraIDCrawler.ps1(phasesAssignments,PIM,Governance,OAuth2Grants,AppRoles) - Frontend renderer —
app/ui/src/components/MatrixView.jsxandapp/ui/src/components/matrix/* - Badge color map —
app/ui/src/utils/colors.js(TYPE_COLORS) - Permissions endpoint —
app/api/src/routes/permissions.js(GET /api/permissions,GET /api/groups-with-nested,GET /api/group/:id/nested-groups)