Skip to content

chore: bump version to 5.31.20260518.1457 - #150

Closed
mvaneijken wants to merge 204 commits into
feature/omada-odata-crawlerfrom
main
Closed

chore: bump version to 5.31.20260518.1457#150
mvaneijken wants to merge 204 commits into
feature/omada-odata-crawlerfrom
main

Conversation

@mvaneijken

Copy link
Copy Markdown
Collaborator

No description provided.

github-actions Bot and others added 30 commits April 17, 2026 12:10
…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>
…ng-between-user-tabs-does-not-upd

Fix #92: Switching between user tabs does not update visible user data
…rt-ui-does-not-show-complete-orga

Fix #94: Org Chart UI does not show complete organization – Departments missing when scrolled
…-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)
github-actions Bot and others added 15 commits April 30, 2026 12:24
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>
Copilot AI review requested due to automatic review settings May 18, 2026 18:33
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';

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@mvaneijken
mvaneijken requested review from Copilot and removed request for TaekeK and WimvandenHeijkant May 18, 2026 18:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@mvaneijken
mvaneijken requested a review from Copilot May 18, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@TaekeK
TaekeK self-requested a review May 19, 2026 07:33

@TaekeK TaekeK left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Er staat geen description in deze PR. Wat is dit? Version bump is automatisch, dus de titel lijkt ook niet te kloppen.

@TaekeK
TaekeK marked this pull request as draft May 19, 2026 07:35
@TaekeK TaekeK closed this May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants