chore: bump version to 5.31.20260518.1457 - #150
Closed
mvaneijken wants to merge 204 commits into
Closed
Conversation
…ties When switching between user, resource, or other detail tabs, React was reusing the same component instance with stale state. Added unique key props to force component remount, ensuring data updates immediately. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Changed the org chart container from `flex justify-center` to `inline-flex min-w-full justify-center` to ensure all department nodes remain accessible during horizontal scrolling. The previous centering behavior caused edge departments to fall outside the scrollable area. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…d of [object Object]
…user-tabs-does-not-upd
…ng-between-user-tabs-does-not-upd Fix #92: Switching between user tabs does not update visible user data
…not-show-complete-orga
…isplays-object-object-
…rt-ui-does-not-show-complete-orga Fix #94: Org Chart UI does not show complete organization – Departments missing when scrolled
…isplays-object-object-
…-activity-displays-object-object- Fix #96: Sign in Activity displays [object Object] in user Extended Attributes
Fix bump-version workflow to use PAT to bypass branch protection
- Introduce release/vX.Y branch model: main builds push :edge, release branches push :latest so customers only receive intentional releases - Add cut-release.yml workflow to create release branches from main and publish the initial X.Y.0.0 image automatically - Update bump-version.yml to increment patch (X.Y.P.0) on release branches and minor+timestamp on main - Update docker-publish.yml to push :edge or :latest based on source branch - Add IMAGE_TAG env var to docker-compose.prod.yml for channel selection - Show amber edge badge in UI footer for dev builds - Add PR checks to release/** branch PRs (pr.yml, pr-integration.yml) - Add branch protection ruleset for release/** via setup-branch-protection.sh - Update README and docker-setup.md with .env setup and channel docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
columnCache.js queried information_schema with lowercase table names
('principals', 'resources') but the v5 migration creates quoted
PascalCase tables ("Principals", "Resources"). Postgres is
case-sensitive on quoted identifiers, so every lookup returned zero
columns and the filter dropdown collapsed to just the synthetic tag
field.
Also dropped the now-unused snakeToCamel helper — columns are
already camelCase in the real schema — and the stale SYSTEM_COLS
export (routes define their own local copies).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins the PostgreSQL table names ("Principals"/"Resources") and the
camelCase system-column exclusion list that column discovery must
use. These are the exact points where the filter-dropdown regression
slipped in, so a future snake_case/lowercase change will fail CI
instead of silently returning an empty column list.
Tests are unit-only (mocked db.query) and run as part of the existing
`unit-js` Vitest job on every PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-level scalar keys inside the Principals/Resources extendedAttributes JSONB column are now discovered alongside real columns and surfaced in the Users and Resources filter dropdowns under an `ext.<key>` namespace (e.g. ext.userType, ext.onPremisesSyncEnabled, ext.extensionAttribute5). - columnCache.js — new discoverExtendedAttrValues() runs in parallel with the existing column-values scan and contributes ext.<key> entries to the same cached result. Only string/number/boolean jsonb types are considered; object/array keys like signInActivity and groupTypes are skipped. Key names are validated with the same SAFE_IDENT_RE as everywhere else so the inlined JSON-path can't be an injection vector. - tags.js — buildFilterWhere now recognises `ext.<key>` filter fields, validates the suffix against the same regex, and emits `"extendedAttributes"->>'key' = @param`. Real columns continue to go through the whitelist check. The helper is now exported so resources.js can reuse it instead of duplicating the filter loop. - resources.js — drops the inlined filter loop and calls the shared helper, picking up ext filter support for free. - useEntityPage.js — humanises `ext.<key>` field names as "<Name> (ext)" so they're visually distinct from real columns in the field picker. Unit tests (vitest) cover ext-key discovery SQL shape, the scalar-type filter, unsafe-key rejection, and buildFilterWhere's ext-prefix path with injection-attempt fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r-dropdown Fix Users/Resources filter dropdown after Postgres migration
Service principals (enterprise-app SPs, managed identities, AI agents) own a large share of role assignments in Entra and Azure but were not previously ingested by the Entra crawler. This adds a dedicated sync phase so they land in the Principals table alongside user accounts, which is a prerequisite for the forthcoming Azure RM crawler (role assignments there frequently target SPs). - tools/powershell-sdk/helpers/Get-FGServicePrincipalType.ps1 — new classifier implementing the CLAUDE.md taxonomy. Rules apply in order: (1) servicePrincipalType == 'ManagedIdentity', (2) well-known AI platform tags, (3) built-in + caller-supplied display-name heuristics, (4) default ServicePrincipal. WorkloadIdentity is out of scope — it can't be decided from an SP object alone. - tools/crawlers/entra-id/Start-EntraIDCrawler.ps1 — gated SP sync block behind the existing $SyncServicePrincipals switch. Fetches /servicePrincipals with a targeted $select, classifies each entry, and submits one full-sync batch per principalType (required because the ingest API's scoped delete takes one value at a time). Extra SP-specific fields (appId, publisherName, tags, etc.) go into extendedAttributes so they surface in the new ext.* filter dropdowns. New -AINamePatterns parameter lets operators extend the classifier with tenant-specific naming conventions. - app/api/src/routes/jobs.js — exposes 'servicePrincipals' as a selectable object type in the crawler wizard; Application.Read.All and Directory.Read.All are the gating permissions. - setup/docker/Invoke-CrawlerJob.ps1 — maps selectedObjects .servicePrincipals and the optional aiNamePatterns config onto the crawler parameters. - test/unit/IdentityAtlas.Tests.ps1 — seven Pester tests pinning the classification rules, including priority ordering (MI wins over AI tags), word-boundary guards on 'gpt' / 'bot' to avoid false positives like "GPTools", and custom-pattern support. Verified with Pester (145/145) and Vitest (112/112). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Microsoft Entra Agent ID (GA 2025) surfaces AI agents as service principals stamped with specific tags. Before this change our SP classifier missed them — Entra Agent ID-managed SPs would have landed in Principals as generic ServicePrincipal, losing the signal that they're non-human AI identities. Adds three markers to Get-FGServicePrincipalType: - AgenticInstance (exact tag — individual agent instance) - AgenticApp (exact tag — agentic application) - power-virtual-agents-* (prefix — PVA stamps per-instance GUIDs) Pester coverage extended with two new cases pinning the exact-tag path for AgenticInstance / AgenticApp and the prefix-match path for Power Virtual Agents. 9/9 classifier tests pass. No Graph endpoint changes — Entra Agent ID is already reachable via the existing /servicePrincipals fetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After enabling Service Principal sync the Principals table balloons —
in local testing a mid-size tenant goes from ~4.5k users to 5.5k+ rows
with SPs and managed identities mixed in. The Users page became hard to
use because everything was in one list.
Adds a sub-tab bar above the Tags / Filters rows with five options:
All / Users / Service Principals / Managed Identities / AI Agents.
Selecting a tab narrows the view to that principalType; 'All' leaves
the list unfiltered.
- useEntityPage gains a `baseFilters` option. Page-level filters are
merged into the API `filtersObj` alongside the user-driven
`activeFilters`, and page/selection reset when they change. User
filters still win on key collision.
- UsersPage passes `{ principalType: <tab> }` as baseFilters when the
tab is not 'all', hides `principalType` from the ordinary filter
dropdown to keep a single source of truth, and persists the tab in
the URL hash (`#users?type=ServicePrincipal`) so reloads and deep
links keep the same view — the same pattern the Admin page uses.
- The new tab bar matches the Admin page's underlined-pill style.
Verified against the live stack with a real tenant:
All 1085
User 47
ServicePrincipal 981
AIAgent 50
ManagedIdentity 7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
My previous commit hid principalType from the filter dropdown unconditionally. That's correct when a specific tab is active (the tab is the authoritative selector) but wrong on 'All' — where no type is pinned and users rightly expect to be able to add it as a regular filter. The hide now applies only on non-'All' tabs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The crawler chunks at 5,000 records per batch. When extendedAttributes is populated — especially on Service Principal batches (appId, tags, servicePrincipalNames, publisherName, homepage, etc.) — a single batch commonly lands between 15 and 30 MB. The previous 10 MB cap aborted the crawl with HTTP 413 on any real-world SP-enabled tenant. 50 MB gives ~5x headroom over observed sizes without losing a sane upper bound. Record count is still independently capped at 50,000 by the ingest validator. The crawler's error line now also prints the payload size so the next 413 (or any other failure) surfaces the context needed to diagnose whether we're close to the limit again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets a tenant admin go from "I want this data in Excel" to a working
workbook in two clicks: Admin → Data → Excel Power Query Workbook →
Generate token & download workbook. The downloaded .xlsx contains a
Settings sheet with the API URL and a freshly-minted read-only token
already filled in, plus one tab per object type with paginated Power
Query M code ready to paste into Power Query Editor.
Three pieces ship together because they only become useful in
combination:
1. Read-only API tokens (`fgr_…`)
- New `ReadApiKeys` table (migration 016). Stores SHA-256 hash +
display prefix only; plaintext is shown to the operator exactly
once at creation.
- `authMiddleware` accepts `fgr_` bearers but only on GET requests
and only on non-admin endpoints. POST and /api/admin/* with a
read token both 403. A leaked workbook can read but never mutate
and never reach token management itself.
- Admin endpoints: list / create / revoke at /api/admin/read-tokens.
2. Bulk list endpoints
- /api/assignments, /api/identity-members, /api/resource-relationships
all paginated `?limit=1000&offset=N` (max 10 000 per page) with
optional `?systemId` filter, returning the same `{data, total}`
envelope every other list endpoint uses.
3. Excel workbook generator
- POST /api/admin/data-export/workbook mints a token AND streams a
ready-to-use .xlsx in a single response. Workbook contains
README + Settings + 7 data sheets (Systems, Principals, Resources,
Assignments, Identities, IdentityMembers, ResourceRelationships).
- M code is pre-written and references the Settings sheet's
BaseUrl/AuthToken named ranges, so token rotation = update one
cell. /systems uses an array-shaped template; the rest use the
paginated `{data,total}` walker.
This is the MVP. A follow-up will replace the M-as-text presentation
with a hand-built XLSX template that auto-loads queries on Excel
open — the rest of the plumbing (token auth, bulk endpoints, admin UI,
download endpoint) is identical.
Tests: 16 new vitest cases (token format/hash determinism + workbook
round-trip via exceljs). Manually smoke-tested end-to-end against the
live local stack: token creation returned plaintext, used it as a
bearer to read /users (7,911 rows), downloaded a 14 KB workbook with
all 9 sheets and both named ranges intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\d is a GNU grep extension not supported on all POSIX grep implementations, causing valid inputs like "5.2" to fail the Major.Minor validation check. Closes #104 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-validation bugfix: fix cut release version validation regex (#104)
Scans JavaScript/TypeScript (API + UI) for security vulnerabilities and quality issues on every PR and weekly on main. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces VERSION_BUMP_PAT (personal token) with Fortigi CI Bot app token in bump-version, cut-release, and cut-hotfix. The app is a bypass actor on the main ruleset so automated pushes work, while human contributors cannot push directly regardless of org role. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: add .gitattributes to enforce LF line endings Sets `* text=auto eol=lf` so all text files are normalized to LF on commit and checkout. Also adds .aider* to .gitignore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: revert .aider* from .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changelog fragment for normalize-line-endings branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Taeke <claude.ai@taeke.eu> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…146) * feat: three-way theme toggle (Light/Auto/Dark) + WCAG 2.0 tag colors Replaces the binary dark-mode switch with a three-button segmented control (Light / Auto / Dark). Auto mode follows the OS color-scheme preference via matchMedia and updates live without a page reload. Legacy darkMode localStorage key is migrated to themeMode on first load. TAG_COLORS shifted to Tailwind 700–800 tier to meet WCAG 2.0 AA (≥4.5:1 contrast on white). AP_COLORS and TYPE_COLORS were audited and pass without changes. Closes #136 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add WCAG 2.0 AA rule and update Dark Mode section in CLAUDE.md Documents the light-theme contrast requirement (≥4.5:1 for normal text) and updates the theme hook description to reflect the three-way toggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: WCAG contrast violations + dark-mode logo Additional WCAG 2.0 AA fixes across the UI: text-gray-400 → gray-500, text-red-400 → red-600, text-indigo-500 → indigo-700, text-blue-500 → blue-700, text-amber-600 → amber-700, and the risk tier "None" badge (gray-400 → gray-500). Adds logo-dark.png for dark mode: "Identity" text recolored to white, white anti-aliasing fringe removed from the text area, brain/shield pixel-identical to the original. DashboardPage and App header now swap to logo-dark.png when isDark is true. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Taeke <claude.ai@taeke.eu> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: split CLAUDE.md into per-area subdirectory guides Root CLAUDE.md cut from 1110 to 251 lines. PowerShell conventions moved to Functions/CLAUDE.md, React/dark-mode rules to app/ui/CLAUDE.md, API test-locally and migration rules to app/api/CLAUDE.md. Maintenance analysis section removed — resolved items deleted, open items extracted to docs/maintenance-backlog.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changelog fragment for CLAUDE.md split Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Taeke <claude.ai@taeke.eu> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…leanups (#147) * Add rule mining discussion handover doc Captures the in-progress design discussion for assignment rule prediction / role candidate suggestions so it can be picked up in a local setup. Includes locked-in decisions, the contrastive- scoring simplification, algorithm options surveyed, plugin contract sketch, and the open questions still to resolve. * Matrix tab: wizard-driven subject/resource filter Replaces the inline matrix filters with a 3-step modal that picks the row type (User or Identity), include/exclude conditions for the subjects, and include/exclude conditions for the resources — backed by contexts and attributes. The matrix stays empty until a filter is applied, and the size of the sub-selection is shown live at every step. Org-wide saved filters live in the new SavedMatrixFilters table; the full filter is encoded in the URL so the Share Link button still works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix: useMatrix sent bare filter as body, server expected {filter:…} The wizard's preview call already wrapped the body correctly, but the data fetch in useMatrix.js was posting the JSON-stringified filter as the entire body. The server's parseFilter() reads req.body.filter, so the wrapped shape is required — without it every Apply returned a 400 "Invalid filter body" and the matrix tab fell into the full-page error state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Matrix filter chip: drop duplicate button, show saved/unsaved state The toolbar already had an Adjust-filter button right above the matrix; the summary chip carried a second one. Dropped the toolbar's copy so there's exactly one entry point. The summary chip now matches the current filter (by canonical JSON) against the saved-filters list and shows either the saved name (green badge) or a "Not saved" warning (amber badge). The list is re-fetched on every filter change so a save-from-wizard immediately reflects in the chip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Matrix tab: rename 'filter' to 'matrix', add orientation toggle User-facing copy now consistently uses 'matrix' instead of 'filter' — what's being configured is the matrix itself (its scope and layout), not a filter on top of one. Saved filters surface as 'Saved matrices', 'Adjust filter' becomes 'Adjust matrix', the wizard title flips to 'Create matrix' / 'Adjust matrix' based on whether one already exists, etc. The DB table name stays SavedMatrixFilters for backward compat. Step 1 of the wizard now picks both the subject type (User/Identity) and the orientation (resources-as-rows vs subjects-as-rows). When the analyst has many subjects and few resources, the rotated layout puts subjects on the row axis so vertical scrolling does the work. The rotated layout is rendered by a new RotatedMatrixView — a deliberately simpler renderer than MatrixView. AP / SOLL columns, owner-row splitting, nested-group expansion, and Excel export aren't ported (they're tied to the resources-as-rows shape). Switching back via the wizard restores all of them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix: exclude conditions were also dropping rows with NULL in the field The exclude side wrapped the include SQL in NOT (...), but in Postgres NOT (col IN (val)) returns NULL when col is NULL — and NULL evaluates as falsy in WHERE, so rows with no value in the field were silently excluded. The user-visible symptom: "exclude accountLabel in (Personal, Non-Personal)" also threw away every user where accountLabel was empty, even though those users clearly don't match either listed value. Fixed by switching the wrapping from NOT (...) to (...) IS NOT TRUE, which evaluates to TRUE for both FALSE and NULL — rows with no value are kept. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix /identities/by-user — others query referenced non-existent columns The secondary query in /identities/by-user/:userId selected `userId` and `userPrincipalName` directly from IdentityMembers, but that table stores `principalId` (UUID) and has no UPN column. The query 500'd, the whole endpoint returned 500, and the user-detail graph silently showed "Identity 0" even when the link existed. Fix joins through Principals to get the UPN and aliases `principalId` as `userId` so the existing frontend keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix Tag-all-matching: postgres-incompatible SQL in assign-by-filter The /tags/:id/assign-by-filter endpoint had been silently broken since the v5 postgres migration: - referenced the dropped temporal `ValidTo` column on Principals/Resources, - used the SQL-Server-only `@@ROWCOUNT` system variable for the response, - left camelCase column and table identifiers unquoted (postgres lowercases unquoted identifiers, so `e.displayName` resolved to `e.displayname` which doesn't exist). Any one of those would 500 the request — the UI just saw a generic error and showed "Tagged 0", so the bug went unreported for a while. Replaces `LIKE` with `ILIKE` so search matches the SQL-Server-era case-insensitive behaviour the original code assumed. Reads the inserted count from the compat layer's `rowsAffected[0]` instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix OAuth2 Grants fanout: show scope, not client The shape function picked `clientDisplayName` and `clientSpId` for the graph fanout, so all N grants from one client collapsed into a single "Microsoft Graph PowerShell" node and clicking it opened the client SP detail page — not the scope. The backend already returns the per-scope Resource id + displayName ("User.Read.All on Microsoft Graph", etc.); switching to those makes each satellite a distinct, clickable node that opens the scope's detail page (where extendedAttributes already exposes scope, grantId, target API, and consenting client). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Matrix shows every assignment type by default Migration 013 hardcoded a UNION ALL of Direct/Owner/Eligible/Governed in the matrix matview, so OAuth2Grant rows (per-user delegated-permission consents) and any future assignment type silently never appeared in the matrix. Replaces the hardcoded UNION with a single SELECT from ResourceAssignments with no assignmentType filter — every type flows through automatically. Same column shape, same indexes, same compat alias view, so no caller needs to change. The matview is recreated WITH NO DATA; bootstrap's refreshMatrixViews() populates it on first boot after the migration applies. Also: extended TYPE_COLORS with Governed (G) and OAuth2Grant (A) so those cells render with a labelled badge instead of a generic `?`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Dev compose: restart postgres + web automatically The dev docker-compose.yml only had `restart: unless-stopped` on the worker, so after a host reboot or any other stop event the postgres and web containers stayed down until someone ran `docker compose up -d` again. The worker survived, which made the failure mode confusing — the stack looked half-up. docker-compose.prod.yml already had `restart: unless-stopped` on all three services, so production deployments were already fine. Bringing the dev file in line. `unless-stopped` (not `always`) so that an explicit `docker compose down` still leaves containers stopped — the right default for a dev sidekick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Crawler: import Entra app role assignments The Entra ID crawler had a stub "Apps & AppRoles" wizard option that wasn't wired to anything — selecting it was a no-op. As a result no app role assignments ever made it into the matrix; users would see their group memberships and OAuth consents, but never "User X has the Reader role on Salesforce". New crawler phase (SyncAppRoles, opt-in switch): 1. Iterate /servicePrincipals and keep enterprise apps (those with a non-empty appRoles[] or appRoleAssignmentRequired=true). 2. For each, fetch /servicePrincipals/{id}/appRoleAssignedTo. 3. Emit one AppRole Resource per (sp, appRoleId) with a deterministic UUID over (spId, appRoleId); displayName like "Reader on Salesforce". 4. Emit Application → AppRole relationship (relationshipType=HasAppRole) — distinct from BusinessRole 'Contains' so scoped full-sync deletes don't collide. 5. Emit ResourceAssignments(AppRole) for user-typed assignments. 6. For group-typed assignments, fetch /transitiveMembers once per unique group and expand to per-user AppRoleViaGroup rows so the matrix can show indirect access without a recursive matview. ServicePrincipal-typed assignments are skipped for v1. Ingest validation extended with AppRole, AppRoleViaGroup assignmentTypes and HasAppRole relationshipType. Matrix badge colors added (R for direct app role, lighter R for via-group). The wizard's existing "appsAppRoles" checkbox is now wired to the new SyncAppRoles param in Invoke-CrawlerJob.ps1, so toggling it from the Admin → Crawlers UI takes effect on the next run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Matrix badges: D/I only — drop G/A/R for source attributes Several assignmentType values were source-attribute labels (Governed, OAuth2Grant, AppRole, AppRoleViaGroup) that conflate the user's *relationship type* (Direct/Eligible/Owner/...) with the *source* of the assignment (governance / consent / app-role-via-group). The resource type already conveys the source — a row of resourceType 'BusinessRole' is obviously governance, 'DelegatedPermission' is obviously a consent, 'AppRole' is obviously an app role. The badge should report only HOW the user has the resource. Rewrites the matview output: Governed, OAuth2Grant, AppRole -> Direct (user directly holds it) AppRoleViaGroup -> Indirect (inherited via group) ResourceAssignments raw rows untouched, ingest validation enums untouched, scoped-delete by assignmentType untouched. Only the matrix's displayed membershipType is renamed. managedByAccessPackage still uses the raw type so cell coloring is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix matrix nested-group expand: case-sensitive LIKE blocked it The /api/groups-with-nested and /api/group/:id/nested-groups endpoints filtered `principalType LIKE '%group%'` to find rows where a group is a member of another group. PostgreSQL `LIKE` is case-sensitive, and the ingest pipeline stores `principalType='Group'` (capital G). The pattern '%group%' never matched, so the matrix UI saw an empty groupsWithNested set and never rendered the expand toggle — even on tenants that have 1500+ nested group memberships (sidekick-3 had 1598). Same SQL-Server-vs-postgres case-sensitivity gotcha as the recent Tag-all-matching repair. Switching to ILIKE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Matrix expand: fan out app roles a group grants The nested-groups expand only showed parent groups — even though a group can also grant its members an app role (Group has AppRole X → members inherit X). The /api/groups-with-nested and /group/:id/nested-groups endpoints both filtered to assignmentType='Direct', which excluded group→AppRole edges. Two changes: 1. Endpoints: drop the assignmentType='Direct' filter. Any row where principalType is 'Group' counts as "the group is assigned to this resource" — works for nested groups today, and any future group-as-principal type (directory roles, etc.) without further edits. 2. Crawler: also write the group→AppRole edge itself in ResourceAssignments (principalType='Group', assignmentType='AppRole') so the nested expand can find it directly instead of reconstructing from the per-user AppRoleViaGroup rows. The matrix grid itself INNER-JOINs Principals on principalId, so group-typed rows are filtered out and don't pollute the user view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tests + docs cleanup for the branch's new types and bug-fix patterns Tests: * validation.test.js — assignmentType enum coverage now includes OAuth2Grant, AppRole, AppRoleViaGroup; relationshipType coverage includes DelegatesScope and HasAppRole. * New routes/likeAudit.test.js — static scan that fails if a route handler reintroduces plain LIKE on principalType / displayName / description / email / userPrincipalName / resourceType. Three case-sensitivity bugs landed in 2026 on this exact pattern. * Test-EntraIdCrawler.ps1 — removes the "appsAppRoles not yet emitted" stale comment, adds AppRole presence assertion, /identities/by-user 200 smoke check, and a matrix-badge invariant that fails if the matview leaks source-attribute types. Docs: * CLAUDE.md — replaces the dead `Sync-FGEntraAppRoleAssignment.ps1` reference with a note pointing to the v5 Node-side crawler phase. The Application.Read.All permission line is rewritten the same way. Adds resourceType / assignmentType / relationshipType tables to the Universal Data Model section so the full enum is in one place. * docs/architecture/ingest-api.md — current enum values for all three discriminator columns. * New docs/architecture/matrix.md — pulls the matrix model (badge-collapse rules, owner-row split, why groups don't appear as columns, expand semantics, performance notes) into one place instead of leaving it scattered across migration comments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix risk-scoring search: case-sensitive LIKE + unquoted camelCase The 5 risk-scoring subpage list endpoints (users / resources / business-roles / org-units / identities) all used the same broken pattern in their search filter: p.displayName LIKE @search OR p.email LIKE @search Two postgres-vs-SQL-Server bugs in one expression: - LIKE is case-sensitive, so 'aliCe' would never match 'Alice'. - `p.displayName` (unquoted) lowercases to `p.displayname`, which is not a real column in v5 — the columns were created quoted camelCase. Flipping to ILIKE and quoting the camelCase identifiers. Caught by the new postgres-LIKE audit test landed in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix CI failures on PR #147 Three independent issues, one commit: 1. Migration 026 — dedup matrix matview output. Migration 025's CASE-based collapse (Governed/OAuth2Grant/AppRole → Direct, AppRoleViaGroup → Indirect) could produce two output rows with the same (resourceId, principalId, membershipType) PK when the raw data had two assignment types that collapsed to the same display value. Load test's 1.5M-row dataset triggered this. Fix: GROUP BY the output PK with bool_or on managedByAccessPackage. 2. Playwright — the Matrix tab now lands on the wizard empty state until a filter is applied, so the existing "table is visible" test walked into a UI that has no table. The test now opens the wizard via the "Create matrix" button and clicks through Setup → Subject → Resource → Apply with default settings. Two unrelated tests (navigation, org-chart) used `getByRole('button', { name: 'Matrix' })` without `exact: true`, which matched the wizard's "Create matrix" button too and tripped strict-mode. Both fixed by adding the flag. 3. CodeQL — flagged the new matrixRouter as "performs authorization without rate limiting". Added a permissive authedApiLimiter (600 req/min per IP) on the `/api/*` prefix. Bounds DoS against token validation without throttling normal interactive use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Playwright: accept empty-state as a valid matrix render The previous attempt to walk the wizard inside the test hung in CI on the "Create matrix" click — likely racing the modal transition / data prefetch. The smoke-test intent is just "matrix page rendered without crashing", so we now accept either: - a rendered <table> (a saved filter exists), or - the "Pick a slice to inspect" empty-state heading (no filter yet). Both prove the page rendered. Wizard-walk testing is left to a future dedicated test that can wait properly on the modal lifecycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bump authedApiLimiter to 6000 req/min 600 req/min was too tight for CI: Playwright runs all tests against one source IP (the localhost dev server), and the parallel workers fan out enough API calls to hit the cap during the run. The users-page 'create tag' test surfaced this — its page rendered with "0 total" users (the /api/users response was 429'd), then the Create POST went through but the tag never appeared because the list refresh also got rate-limited. 6000 req/min (100 r/s sustained) is still defensive against DoS / credential-stuffing on the auth middleware (still satisfies CodeQL's js/missing-rate-limiting rule), but doesn't bite normal interactive use or parallel CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
* Dashboard: Trends tab with growth-over-time charts
Adds a "Trends" sub-tab to the Dashboard page plotting daily snapshots
of:
- % of assignments that are governed (headline chart, 0-100% Y axis)
- User / Resource / Assignment counts (three smaller charts)
- Governed-assignment raw count
Data captured by the scheduler once per UTC day to a new table
`DashboardSnapshots` (migration 027). The snapshot writer reuses the
same fast pg_class.reltuples + targeted COUNT(*) approach as the
existing /admin/dashboard-stats endpoint so the daily write is
sub-second even on tenants with 1.5M assignment rows.
No historical backfill: pre-v6 history coverage was partial (composite-
PK tables only got triggers in migration 018), and a reconstructed
early section would tell a misleading story. Charts start populated on
the day this version ships and grow from there.
UI:
- New TimeSeriesChart component (hand-rolled SVG, dark-mode aware,
no new frontend dependency)
- New DashboardTrendsTab component (lazy-loaded so the dashboard's
first paint stays cheap)
- Tab strip on DashboardPage between Overview (existing content)
and Trends
API:
- GET /api/admin/dashboard-timeseries?days=N — returns last N days
of snapshots (default 90, cap 730), ordered ascending
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Dashboard Trends: tests + docs
- New Playwright spec dashboard.spec.js covers tab switching, Trends
chart rendering, and the range selector. The empty-state message is
rendered inside the SVG, so the SVG count is the load-bearing
assertion (not the path/line itself).
- New docs/architecture/dashboard-trends.md describes the daily snapshot
architecture, the deliberate no-backfill decision, the snapshot
capture lifecycle, the API surface, and the chart rendering details.
- Pointers in app/ui/CLAUDE.md so future contributors find the new
components.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Dashboard: tab strip uses role=tablist, not <nav>
The new dashboard tab strip wrapped its buttons in <nav>, which made
the page have two <nav> elements — the existing top-level navigation
plus the new tab strip. Existing Playwright tests (navigation.spec.js)
select the top-level nav with `locator('nav')` and tripped strict-mode
on the second element.
Tabs aren't site navigation; they're an internal control. Switching to
role="tablist" + role="tab" on the buttons is semantically more correct
AND keeps the existing nav selector unambiguous.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Dashboard e2e: use getByRole('tab') not 'button'
After switching the tab strip to role="tab" / role="tablist" (commit
95cf46a), the tab buttons no longer register as `button` in the a11y
tree — Playwright's getByRole('button', { name: 'Overview' }) returns
zero matches and the test fails. Updated to getByRole('tab', …).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| const text = buf.toString('utf8'); | ||
| const truncated = (offset + length) < totalLength; | ||
| return res.json({ text, offset, totalLength, truncated, exists: true }); | ||
| } finally { |
| const out = await dryRun(plugin.name, req.body || {}); | ||
| res.json(out); | ||
| } catch (err) { | ||
| console.error(`POST /context-plugins/${req.params.name}/dry-run failed:`, err.message); |
| const runId = await enqueueRun(plugin.name, req.body || {}, triggeredBy); | ||
| res.status(202).json({ runId, status: 'queued' }); | ||
| } catch (err) { | ||
| console.error(`POST /context-plugins/${req.params.name}/run failed:`, err.message); |
Comment on lines
+50
to
+51
| `Ingest validation failed [${entityType}] (${body.syncMode || 'full'} mode): ` + | ||
| `${recResult.errors.length} record error(s) — first ${Math.min(5, recResult.errors.length)}: ${preview}` |
| const runId = await enqueueRun(plugin.name, req.body || {}, triggeredBy); | ||
| res.status(202).json({ runId, status: 'queued' }); | ||
| } catch (err) { | ||
| console.error(`POST /context-plugins/${req.params.name}/run failed:`, err.message); |
| const out = await dryRun(plugin.name, req.body || {}); | ||
| res.json(out); | ||
| } catch (err) { | ||
| console.error(`POST /context-plugins/${req.params.name}/dry-run failed:`, err.message); |
| displayName: 'Manager Hierarchy', | ||
| description: | ||
| 'Builds a tree of Principals from their managerId chain. One node per ' + | ||
| 'manager; members are their direct reports. Requires that Principals.' + |
| const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | ||
| const VARIANTS = new Set(['synced', 'generated', 'manual']); | ||
| const TARGET_TYPES = new Set(['Identity', 'Resource', 'Principal', 'System']); | ||
| const ADDED_BY = new Set(['sync', 'algorithm', 'analyst']); |
| @@ -0,0 +1,232 @@ | |||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |||
mvaneijken
requested review from
Copilot
and removed request for
TaekeK and
WimvandenHeijkant
May 18, 2026 18:34
TaekeK
self-requested a review
May 19, 2026 07:33
TaekeK
requested changes
May 19, 2026
TaekeK
left a comment
Contributor
There was a problem hiding this comment.
Er staat geen description in deze PR. Wat is dit? Version bump is automatisch, dus de titel lijkt ook niet te kloppen.
TaekeK
marked this pull request as draft
May 19, 2026 07:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.