Skip to content

Demo Dataset & End-to-End Validation

This whole page is a spoiler

It documents the dataset in full — including the records that exist only to be the wrong answer. If you want to play Capture the Flag, go there instead and don't read on. (The answer key itself is collapsed further down, but the tables above it give plenty away.)

A purpose-built synthetic dataset for testing every feature of Identity Atlas. Fully controlled — every record, relationship, and edge case is intentional, so tests can assert on exact values.

It also backs the public demo environment and hides the Capture-the-Flag scenarios from issue #705 — see Capture-the-Flag scenarios below. That is why several records exist purely as distractors: a flag is only interesting if a plausible wrong answer sits next to the right one.

How it's generated

test/demo-dataset/Generate-DemoDataset.ps1 is a thin orchestrator. Each domain lives in its own file under parts/, dot-sourced in order and appending into one shared state object via record builders (Add-DemoResource, Add-DemoAssignment, …), so the shape of each record is defined once:

Part Owns
DemoState.ps1 New-DemoGuid, the state accumulator, the record builders
DemoOrg.ps1 Systems, the context tree, people, identities
DemoEntraBase.ps1 Entra groups / directory roles / app roles / group ownership
DemoGovernance.ps1 IGA catalogs, business roles, policies, certifications
DemoSalesScenario.ps1 The Sales role-mining scenario (flags 1–7)
DemoRoleDrift.ps1 Role holders with fewer / more access than their business role assigns
DemoSharedGrants.ps1 The overlap between two business roles — one group and one app role granted by both
DemoConsent.ps1 OAuth consent + shadow IT (flags 11–12)
DemoSap.ps1 The SAP ERP system (flag 8)
DemoAzure.ps1 The AzureRM system (flag 10)
DemoVolume.ps1 Opt-in high-cardinality slice — see The volume slice

GUIDs are a pure function of a seed string (New-DemoGuid), so any part can reference another part's record without an ordering contract, and the whole dataset is byte-stable across runs.

demo-company.json is a build artifact, not a source file. It is gitignored — always regenerate rather than relying on a copy on disk.

The volume slice (opt-in)

Everything described on the rest of this page is the standard dataset: 46 resources, hand-reasoned, pinned to exact counts by Verify-DemoDataset.ps1. DemoVolume.ps1 is the one part that is not generated by default. It runs only with Generate-DemoDataset.ps1 -IncludeVolume, and appends:

  • ~520 Entra groups SG-Vol-0001…, each with its own description, one Direct assignment each (rotating over the provisioned employees, so each is a real matrix row);
  • one sentinel group SG-Zzz-Cap-Probe, description Zzz - beyond the preloaded 500 (#928 probe) — chosen to sort alphabetically last.

That takes the dataset past 500 distinct Resources.description values, which is the threshold above which filter and attribute dropdowns serve a page of values and flag the column as truncated. Below it, that behaviour is invisible, so an environment loaded from the standard dataset cannot demonstrate or verify it — see Matrix → Attribute values for the walkthrough this slice enables.

Turn it on per environment from Admin → Crawlers → Demo Dataset → "Also load high-cardinality test data" (the crawler's includeVolumeData config key). To take it back off, clean the database and re-run with the box unticked — a plain re-run only soft-deletes the extra groups, and attribute-value discovery reads the whole table, so their descriptions would linger in the filter dropdowns.

Because it is appended last and only on request, the CTF answers, the exact row counts, the Pester suite and the E2E suite are all unaffected; test/unit/DemoDataset.Tests.ps1 asserts that explicitly.


The Company: Fortigi Demo Corp

A mid-size technology consultancy with 26 employees across 6 departments. Small enough to reason about, large enough to exercise all features.

Org Chart

graph TB
    CEO["Anna Bakker<br/>CEO<br/>E0001"]
    CTO["Bob Chen<br/>CTO<br/>E0002"]
    CFO["Clara Dijkstra<br/>CFO<br/>E0003"]
    CSO["David El-Amin<br/>CSO<br/>E0004"]
    COO["Eva Fischer<br/>COO<br/>E0005"]

    CEO --> CTO
    CEO --> CFO
    CEO --> CSO
    CEO --> COO

    subgraph Engineering
        CTO --> TL1["Fatih Gunay<br/>Team Lead Platform<br/>E0010"]
        CTO --> TL2["Grace Huang<br/>Team Lead Security<br/>E0011"]
        TL1 --> DEV1["Hassan Ibrahim<br/>Developer<br/>E0020"]
        TL1 --> DEV2["Ingrid Jensen<br/>Developer<br/>E0021"]
        TL1 --> DEV3["Jun Kobayashi<br/>Developer<br/>E0022"]
        TL2 --> SEC1["Karen Lee<br/>Security Engineer<br/>E0023"]
        TL2 --> SEC2["Lars Muller<br/>SOC Analyst<br/>E0024"]
    end

    subgraph Finance
        CFO --> FM["Maria Novak<br/>Finance Manager<br/>E0012"]
        FM --> ACC1["Niels Olsen<br/>Accountant<br/>E0025"]
        FM --> ACC2["Olivia Park<br/>Accountant<br/>E0026"]
    end

    subgraph Sales
        CSO --> SM["Paul Quinn<br/>Sales Manager<br/>E0013"]
        SM --> SR1["Rachel Smith<br/>Account Exec<br/>E0027"]
        SM --> SR2["Stefan Tanaka<br/>Account Exec<br/>E0028"]
        SM --> SR3["Piet Jansen<br/>Account Exec<br/>E0032"]
        SM --> SR4["Sanne Vermeer<br/>Sales Dev Rep<br/>E0033"]
    end

    subgraph Operations
        COO --> OM["Ursula Visser<br/>Ops Manager<br/>E0014"]
        OM --> OPS1["Victor Wang<br/>SysAdmin<br/>E0029"]
        OM --> OPS2["Wendy Xu<br/>SysAdmin<br/>E0030"]
        OM --> OPS3["Tom Bakker<br/>Logistics Coord<br/>E0034"]
    end

    subgraph Marketing
        CEO --> MK1["Nadia Haddad<br/>Marketing Specialist<br/>E0035"]
    end

Edge Cases Built Into the Dataset

Scenario Employee Why It Matters
CEO with no manager E0001 Anna Bakker Root of org tree; manager reference is null
External contractor E0040 Yuki Zhao principalType: ExternalUser; no identity correlation
Disabled account E0041 Alex Former accountEnabled: false; left the company; should still show in history
Service principal SVC-001 Deploy Pipeline Non-human; principalType: ServicePrincipal; member of admin groups
AI agent AI-001 Copilot Assistant principalType: AIAgent; member of data access groups
Multi-system identity E0020 Hassan Ibrahim Has accounts in EntraID, the IGA system and SAP; identity correlation links them
Shared mailbox SM-001 info@fortigidemo.com principalType: SharedMailbox; owned by E0027
Manager in different dept E0014 Ursula Visser Reports to COO but manages Operations — tests cross-dept context
Employee with no assignments E0031 Zara Intern A new hire not yet provisioned: flagged noAccess in the generator, so she is excluded from every department group and business-role grant. She still exists as a principal and appears in the Engineering context, but holds zero resource assignments — the deliberate zero-assignment edge case
Never-revoked role after a transfer E0034 Tom Bakker Moved from Sales to Operations but kept his BR-Sales assignment — the "nobody cleaned it up" case
Worst-case identity E0032 Piet Jansen Role-inherited CRM access, a never-expiring password, and consent to a risky app. Deliberately threads through flags 4, 9, 11 and 12
Provisioning gap Everyone BR-Employee-Base Contains FortigiGraph-App, but nobody holds an effective assignment on it — so it renders as a gap under the matrix's Gaps toggle rather than as access. Deliberate; do not "fix" it

Systems

System Type Content
Fortigi Demo EntraID EntraID Principals (users, SPs, AI agents), groups, directory roles, app consent
Fortigi Demo HR HR Identities, the department context tree, employment
Fortigi Demo IGA IGA Business roles, governed assignments, certifications
Fortigi Demo SAP ERP SAP SAP accounts + roles, correlated to identities
Fortigi Demo Azure AzureRM Azure scope tree + RBAC role assignments, region-tagged

Why IGA and not Omada. A business role is the same concept whether it comes from Omada, midPoint or SailPoint, so the demo names the source generically — the CTF is identical in every case (issue #705, Rob's review). This is demo data only; the real Omada crawler under tools/crawlers/omada/ is a separate thing and is unaffected.

System ids are placeholders. Systems.id is a SERIAL, so the ids the database hands out are only 1..N on a pristine database. The generator emits placeholders plus a metadata.systemKeys index; Ingest-DemoDataset.ps1 posts Systems first, reads the real ids back from the API response, and remaps every reference before posting anything else. Rows are then posted per system, because a full sync reconciles against the envelope's systemId.

Context Trees (Independent)

HR Department Tree (linked to Identities):

Fortigi Demo Corp
├── Engineering
│   ├── Platform Team
│   └── Security Team
├── Finance
├── Sales
└── Operations

EntraID Admin Units (linked to Principals):

AU-Netherlands

Resources

Resource Type System Purpose
SG-AllEmployees Group EntraID All active employees
SG-Engineering Group EntraID Engineering department
SG-Finance Group EntraID Finance department
SG-VPN-Access Group EntraID VPN access group
SG-Admin-Tier0 Group EntraID Tier 0 admin — high risk
SG-PAM-Users Group EntraID PAM access
Global Administrator EntraDirectoryRole EntraID Entra directory role
SharePoint Admin EntraDirectoryRole EntraID Entra directory role
FortigiGraph-App AppRole EntraID App role for this product. (Name predates the Identity Atlas rename; the generator still emits FortigiGraph-App.)
SAP-Finance-Role AppRole EntraID SAP financial access
BR-Employee-Base BusinessRole IGA Base employee access package
BR-Engineering-Tools BusinessRole IGA Dev tools access package
BR-Finance-Systems BusinessRole IGA Financial systems access
BR-Admin-Privileged BusinessRole IGA Privileged admin access
BR-Sales BusinessRole IGA Sales role — Contains SG-Sales + SG-CRM-Users
BR-Service-Desk BusinessRole IGA Service desk role — Contains the two service desk groups as a membership, SG-Servicedesk-Admin as eligibility only, and the Ticketing-Agent app role it shares with BR-IT-Operations. The role-drift scenario hangs off it
SG-Engineering GroupOwnership EntraID Owners of the Engineering group (a GroupOwnership resource is named after the group it owns)
SG-Finance GroupOwnership EntraID Owners of the Finance group
SG-Admin-Tier0 GroupOwnership EntraID Owners of the Tier-0 admin group
SG-Sales Group EntraID Sales department group — granted by BR-Sales
SG-CRM-Users Group EntraID CRM access — granted by BR-Sales (flag 4)
SG-Sales-SharePoint Group EntraID Ad-hoc grant held directly by 5 of 6 Sales — the role candidate (flag 6)
SG-Finance-Reports Group EntraID Sensitive cross-department finance access — the over-privileged trap (flag 7)
SG-Servicedesk-Tools Group EntraID Granted by BR-Service-Desk as a membership
SG-Servicedesk-KB Group EntraID Granted by BR-Service-Desk as a membership — the one two holders never got (fewer than the role assigns)
SG-Servicedesk-Admin Group EntraID Granted by BR-Service-Desk as eligibility; one holder has it as a standing membership (more than the role assigns)
BR-IT-Operations BusinessRole IGA IT operations role — overlaps BR-Service-Desk on two resources. The shared-grant scenario hangs off it
Ticketing-Agent AppRole EntraID Granted by BR-Service-Desk and BR-IT-Operations — the app role in two business roles
SG-Monitoring-Tools Group EntraID Granted by BR-IT-Operations alone, so folding that role still takes a row away
FileSync Pro Application EntraID Third-party app, unverified publisher — the risky one
Files.ReadWrite.All DelegatedPermission EntraID The risky consent scope (flags 11–12)
Contoso Timesheets Application EntraID Approved app, verified publisher — the control
User.Read DelegatedPermission EntraID Low-risk scope on the control app
SAP_FI_ACCOUNTANT / SAP_SD_SALES / SAP_MM_VIEWER / SAP_BASIS_ADMIN SAPRole SAP ERP SAP role per module
Fortigi Demo Tenant AzureScope AzureRM Azure scope-tree root
rg-prod-eastus / rg-prod-westeurope AzureResourceGroup AzureRM Region-tagged (azureLocation) resource groups
stprodeastus01 / stprodweu01 AzureResource AzureRM Storage accounts under each RG
<Role> @ <scope> (×4) AzureRoleAssignment AzureRM The synthetic "role at scope" capability RBAC hangs off (flag 10)

Assignments (Who Has What)

Principal Resource Type Notes
Every employee except the intern (25) SG-AllEmployees Direct E0031 is the deliberate zero-assignment case — see Edge Cases
Engineering employees except the intern (8) SG-Engineering Direct By department (includes the CTO E0002); the unprovisioned intern E0031 is excluded
Every Finance employee (4) SG-Finance Direct By department
E0029, E0030 (SysAdmins) SG-VPN-Access Direct Held without BR-Engineering-Tools behind it — see Held outside the role that grants it
E0002 (CTO) SG-Admin-Tier0 Direct CTO is a member of the critical Tier-0 admin group
E0029 (SysAdmin) SG-Admin-Tier0 Direct Member of high-risk group
SVC-001 (Deploy Pipeline) SG-Admin-Tier0 Direct Service principal in admin group
E0002 (CTO) Global Administrator Direct Directory role assignment
Every employee except the intern (25) BR-Employee-Base Direct (governed=true) Via business role — governance is the governed flag, not an assignment type
Engineering employees except the intern (8) BR-Engineering-Tools Direct (governed=true) Via business role
Everyone holding BR-Engineering-Tools (8) SG-VPN-Access Indirect The materialised role-derived access. Without it the role's Contains edge reads as eight provisioning gaps on a group the role does hand them
E0029 (SysAdmin) BR-Admin-Privileged Eligible PIM-eligible, not active
E0010 → SG-Engineering; E0012 → SG-Finance; E0002 + E0029 → SG-Admin-Tier0 GroupOwnership Direct Ownership is a Direct assignment on a synthetic GroupOwnership resource, never an Owner type
The 6 Sales members + E0034 Tom Bakker + E0035 Nadia Haddad BR-Sales Direct (governed=true) The two outsiders are flag 3's answer — a role assignment that survived a transfer / a project
Everyone holding BR-Sales (8) SG-Sales, SG-CRM-Users Indirect The materialised role-derived access. The Contains edge supplies the why; this row is the access itself
5 of the 6 Sales members (not E0033) SG-Sales-SharePoint Direct Ad-hoc — the role candidate (flag 6). Held by most, not all, which is what keeps it out of flag 2's shared set
E0013, E0027, E0028, E0032 + all 4 Finance SG-Finance-Reports Direct The over-privileged trap (flag 7)
E0032, E0027, E0020, E0030, E0025 Files.ReadWrite.All Direct OAuth consent (flag 11). Migration 045 rewrote OAuth2Grant → Direct, so consent is a Direct assignment
E0029, E0021, E0022, E0024 User.Read Direct Consent to the clean control app — E0029 is flag 12's trap
10 SAP accounts SAP roles Direct Skewed Finance 4 / Sales 3 / Ops 2 / Eng 1 (flag 8)
E0029 + SVC-001 → eastus; E0030 → eastus storage; E0020, E0010, E0029 → westeurope AzureRoleAssignment Direct Flag 10. E0029 spans both regions on purpose
E0014, E0029, E0030, E0034 BR-Service-Desk Direct (governed=true) The role-drift cast — see Fewer and more than the role assigns
E0014, E0029 SG-Servicedesk-Tools, SG-Servicedesk-KB (Indirect) + SG-Servicedesk-Admin (Eligible) Indirect / Eligible Exactly what the role assigns — the control
E0034 (Tom Bakker) SG-Servicedesk-Tools only Indirect Fewer than the role assigns: never provisioned into the KB or the admin eligibility
E0030 (Wendy Xu) SG-Servicedesk-Tools (Indirect), SG-Servicedesk-Admin (Direct) Indirect / Direct Both directions at once: no KB (fewer) and a standing membership where the role only grants eligibility (more)
E0024 (Lars Muller) SG-Servicedesk-Tools Direct Holds one of the role's resources without holding the role — access the role does not account for
E0010, E0029, E0030 BR-IT-Operations Direct (governed=true) The shared-grant cast — E0010 holds this role only, E0029/E0030 hold both roles. See One resource, two business roles
E0010, E0029, E0030 SG-Monitoring-Tools Indirect What only BR-IT-Operations grants
E0010, E0014, E0029, E0030 Ticketing-Agent Indirect The shared app role. E0034 is left out — one more thing his role assigns that he never got
E0010 SG-Servicedesk-Tools Indirect The shared group. E0029/E0030 already hold it through BR-Service-Desk — a second role covering a membership adds coverage, not a second assignment

Resource Relationships

Parent Child Type Notes
BR-Employee-Base SG-AllEmployees Contains Business role grants group
BR-Employee-Base FortigiGraph-App Contains Business role grants app role — deliberately with no effective assignment, so it renders as a provisioning gap
BR-Engineering-Tools SG-Engineering Contains
BR-Engineering-Tools SG-VPN-Access Contains Materialised as Indirect for every role holder; the two SysAdmins hold the same group directly, outside the role
BR-Finance-Systems SG-Finance Contains
BR-Finance-Systems SAP-Finance-Role Contains
BR-Admin-Privileged SG-Admin-Tier0 Contains
BR-Admin-Privileged SG-PAM-Users Contains
BR-Sales SG-Sales Contains Business role grants the Sales group
BR-Sales SG-CRM-Users Contains Business role grants CRM — this edge is flag 4's answer
BR-Service-Desk SG-Servicedesk-Tools Contains (roleName='Member') A standing membership
BR-Service-Desk SG-Servicedesk-KB Contains (roleName='Member') A standing membership
BR-Service-Desk SG-Servicedesk-Admin Contains (roleName='Eligible Member') Just-in-time only — roleName is what makes a standing membership on it read as more than the role assigns
BR-Service-Desk Ticketing-Agent Contains (roleName='Member') The app role it shares with BR-IT-Operations
BR-IT-Operations Ticketing-Agent Contains (roleName='Member') The second grant of the same app role
BR-IT-Operations SG-Servicedesk-Tools Contains (roleName='Member') The second grant of the same group
BR-IT-Operations SG-Monitoring-Tools Contains (roleName='Member') Granted by this role alone
FileSync Pro Files.ReadWrite.All DelegatesScope App → the scope consented to it
Contoso Timesheets User.Read DelegatesScope The control app
Fortigi Demo Tenant → rg-prod-eastus / rg-prod-westeurope → their storage accounts Contains Contains The Azure scope tree (4 edges)
SG-Engineering SG-AllEmployees GrantsAccessTo Nested group
SG-Engineering SG-Engineering (ownership) HasOwnership Group → its GroupOwnership resource
SG-Finance SG-Finance (ownership) HasOwnership
SG-Admin-Tier0 SG-Admin-Tier0 (ownership) HasOwnership

Governance

Entity Data
Catalog: "Employee Access" Contains BR-Employee-Base, BR-Engineering-Tools, BR-Finance-Systems, BR-Sales, BR-Service-Desk, BR-IT-Operations
Catalog: "Privileged Access" Contains BR-Admin-Privileged
Policy: "Auto-assign all employees" On BR-Employee-Base, scope: all, auto-approve
Policy: "Manager approval" On BR-Engineering-Tools, requires manager approval
Policy: "Dual approval" On BR-Admin-Privileged, requires manager + security team
Policy: "Sales role — manager approval" On BR-Sales, requires manager approval
Certification: Q1 2026 Review Reviewed E0029's access to BR-Admin-Privileged — decision: Approve
Certification: Q1 2026 Review Reviewed E0041's access to BR-Employee-Base — decision: Deny (left company)
Certification: Q1 2026 Review Reviewed E0013's access to SG-Finance-Reports — decision: Approve, "Sales Manager needs pipeline revenue reporting; approved for this role only". This is the evidence for flag 7's why

Identity Correlation

Identity Principals Correlation
Hassan Ibrahim (E0020) E0020 (EntraID), E0020-iga (IGA), HIBRAHIM (SAP) employeeId match — the three-system case
Clara Dijkstra (E0003) E0003 (EntraID), CDIJKSTRA (SAP) employeeId match
Yuki Zhao (Contractor) E0040 (EntraID) No identity — external, uncorrelated
Deploy Pipeline SVC-001 (EntraID) No identity — non-human

SAP accounts carry no department and no friendly name — just an SAP user id like CDIJKSTRA. That is what a real ERP account list looks like, and it is what makes flag 8 hard from a raw export: the only route from an SAP account to a department is through the identity. Ten employees have one, skewed Finance 4 / Sales 3 / Operations 2 / Engineering 1.


The realism slice (opt-in)

Generate-DemoDataset.ps1 -IncludeRealism, or the Include the realism slice tick on the demo crawler (includeRealismData). Off by default.

Why it exists. Everything above is a 26-person company in which every record tells one story. That is what makes it a good fixture and a bad measuring stick: most questions an analyst asks have zero or one possible answer on it, no first name occurs twice, nobody ever changed jobs, and no group has ever lost a member. A custom report or a chat answer can be wrong and still score a pass. The realism slice grows a second, messier company on top of the first so that questions have answers of several sizes and a wrong reading produces a visibly wrong row count. It is the environment the report generator and the Ask assistant are measured in — see The report generator.

What it adds (counts are from the generator, which is deterministic):

People ~600 staff over ten departments and ~55 teams, plus 40 guests from three partner companies, 30 leavers and a dozen non-human accounts
Accounts per person one to three: an Entra account, often an on-premises AD account, sometimes a CRM account, occasionally a separate admin account — all linked into one identity
Systems two more that keep their own accounts (on-premises AD, a CRM), beside Azure which keeps none and grants access to Entra principals
Groups ~180 in naming families (License-*, SG-<dept>-*, SG-<team>-*, APP-*, PRJ-*, DL-*, Teams groups), with security / mail-enabled flags, owners on about a quarter, six empty ones and one holding the whole company
Nesting all three kinds: a group inside a group (indirect memberships), Azure scope inheritance (a role on a resource group reaching everything inside it), and a business role synchronising groups and application roles to its holders
Roles 15 Entra directory roles (three held by nobody), 26 business roles granting groups and application roles, ~40 eligible (PIM) assignments
Governance governed assignments with a deliberate provisioning gap, and two attestation campaigns — one closed, one running — with recommendations, decisions and undecided rows
History Simulate-AccessChanges.sql spreads the grants over six months, removes about one membership in sixteen on project, application and distribution groups, and brings a few back

What makes it realistic rather than merely large — each of these is a property a question depends on, and each is absent from the standard slice:

  • Job titles do not predict access. Half the titles are generic (Medewerker, Specialist, Consultant); people on one team hold nearly the same things whatever their title, and two people with the same title on different teams hold very different things. One member of a team in five holds something nobody else on the team has, so the clusters are not perfect.
  • Careers leave residue. About 150 people moved department at some point and kept one to three groups of the department they left. Their account says Finance and their access says Engineering.
  • Projects outlive themselves. Ten of the 24 project groups finished, some years ago, and every member still holds them.
  • Leavers. Half the disabled accounts still hold everything they held on their last day.
  • Names collide. 31 people share one first name and 15 full names occur more than once, so pinning a name to a person genuinely needs asking — while the pool is walked without repeats so duplicates stay the exception rather than the rule.
  • Sign-ins spread from today to over a year ago, including accounts that never signed in (a row with no timestamp) and accounts with no row at all. Those are different facts and the reports tell them apart.

What it deliberately does NOT do. It never touches anything above it: the same ids, the same ordering, the same 26-person company. Generating without the switch produces the standard dataset byte for byte, which is what test/unit/DemoRealism.Tests.ps1 asserts first, because Verify-DemoDataset.ps1's exact row counts, the Capture-the-Flag answers and the E2E suite all pin those numbers. With the slice on, those exact-count checks no longer apply.

Two things it made visible in the standard slice, left alone so the pinned counts hold:

  1. The two standard guests are principalType = 'ExternalUser'. Entra models a B2B guest as a User with userType = 'Guest', which is also how the report catalog's user entity finds one — so a guest question over the standard data comes back empty however right the pipeline is. The realism slice's guests are modelled the Entra way.
  2. The standard groups set neither securityEnabled nor mailEnabled, so "mail-enabled groups that are not security groups" cannot be answered over them at all.

Capture-the-Flag scenarios

The dataset carries the twelve CTF scenarios from issue #705. The design principle: every flag must be hard from a raw export and easy in Identity Atlas. Answers are asserted in two places, so a dataset change can never silently move one:

  • test/unit/DemoDataset.Tests.ps1 — over the generated JSON.
  • test/demo-dataset/Verify-DemoDataset.ps1 — over the ingested database (the CTF* checks).

Player-facing page is Capture the Flag

That page publishes the questions and hints only. This page is the engineering reference and spells out the answers. If you intend to play, stop here.

Spoilers — the answer key
# Question Answer Where it's answered
1 How many identities does Sales have? 6 active Sales scope resolves to 7 — the 7th (Alex Former) is disabled
2 How many assignments do all Sales users share? 5 ⚠️ no shared-by-all statistic exists yet — data-complete, product gap
3 Which two users outside Sales share that set? Tom Bakker, Nadia Haddad ⚠️ same gap as flag 2
4 Why does Piet have the CRM permission? Inherited via BR-Sales Matrix cell: Indirect badge + access-package overlay
5 Which shared assignments are role-based? SG-Sales, SG-CRM-Users Matrix Governed / Non-governed toggle
6 Which assignment could be added to the role? SG-Sales-SharePoint Non-governed toggle on the Sales scope
7 Which looks addable but should NOT be? SG-Finance-Reports — sensitive, cross-department, only the manager is certified for it Same toggle; the certification + description supply the why
8 Which department has the most SAP users? Finance (4, vs Sales 3) Identity correlation — SAP accounts carry no department
9 Which accounts have never-expiring passwords? 5 Matrix filter on ext.passwordNeverExpires
10 Who has access to a resource in Azure US? Victor Wang, Wendy Xu, Deploy Pipeline Resource filter on ext.azureLocation = eastus
11 Who consented to Files.ReadWrite.All? 5 users risky-consent plugin → "Risky Consent — High" → Direct Members
12 Who consented to a risky app and has a never-expiring password? Piet Jansen, Wendy Xu One matrix: resource filter on the risky-consent context + subject filter on ext.passwordNeverExpires

Why the distractors matter

A flag is only hard because a plausible wrong answer sits next to the right one. That is why several records exist purely to be wrong.

Spoilers — the traps
Flag The trap
1 A disabled Sales leaver makes the naive count 7 instead of 6
6 vs 7 Both are ad-hoc direct grants held by most of Sales. Only the risk + the department boundary separate the candidate from the trap
8 Sales (3) is close enough to Finance (4) that you have to count
10 rg-prod-westeurope sits next to rg-prod-eastus, and Victor Wang holds roles in both — so "whoever isn't in westeurope" is not a shortcut
12 Victor Wang has a never-expiring password and consented to an app — but a clean one. Ignore the "risky" half and you get 3 instead of 2

Determinism (issue #705, risk R1)

Flags 11–12 need "FileSync Pro is risky" to be true on every run. No LLM is involved: the risky-consent context plugin classifies permissions from a curated map (riskyConsentRiskMap.js), and Files.ReadWrite.All is in its HIGH_RISK set. FileSync Pro's publisher is Default Directory — unverified — so the plugin's offline heuristic also files it under "Risky App Consent — Suspicious" with no threat-feed call needed. Contoso Timesheets is the control: User.Read classifies Low, its publisher is verified, and four consenters keep it above the low-prevalence threshold.

The plugin is not run by seeding — trigger it explicitly (POST /api/context-plugins/risky-consent/run) or from Admin → Plugins.

Role-derived access is materialised

Holding BR-Sales does not by itself give anyone SG-CRM-Users. The matrix matview (migration 049) derives managed-by-role from Contains + holding the role, but only for cells that already exist; a Contains child with no effective assignment renders as a provisioning gap, not as access.

So role-derived access is emitted as explicit Indirect assignments and a Contains edge. The assignment is the access; the edge is the why. Drop either and flag 4 has no answer. See docs/architecture/matrix.md.

Fewer and more than the role assigns

Matching access is the easy case. DemoRoleDrift.ps1 supplies the two that a role-mining review actually hunts for, on one business role — BR-Service-Desk, which grants SG-Servicedesk-Tools and SG-Servicedesk-KB as memberships and SG-Servicedesk-Admin as eligibility only (roleName='Eligible Member' on the Contains edge). The fourth thing it grants — the Ticketing-Agent app role — belongs to the shared-grant scenario below:

Person What they have What the matrix shows
Ursula Visser (E0014), Victor Wang (E0029) All three, exactly as assigned Nothing — the control, so the deviations don't read as the norm
Tom Bakker (E0034) Tools only Fewer: two of the three groups the role assigns him are missing — as is the Ticketing-Agent app role it shares with BR-IT-Operations
Wendy Xu (E0030) Tools, plus Admin as a standing membership; no KB Fewer and more at once — the case the folded role row has to summarise in both directions
Lars Muller (E0024) Tools, without holding the role Access no business role of his accounts for — the red "held outside business-role governance" mark on that cell, and part of the role's red count once it is folded

Under-provisioning is modelled by leaving an assignment out: a Contains child with no effective assignment is what the grid reads as fewer. Over- provisioning needs the roleName on the edge — without it every child reads as "standing membership expected" and holding one permanently is exactly right. See matrix.md → "Fewer and more than the role assigns" for how each is rendered.

Held outside the role that grants it

The third thing the matrix says about a resource a business role hands out: someone holds it who the role does not hand it to. SG-VPN-Access carries that case, and both halves of it are load-bearing.

The two SysAdmins are deliberately not people without governance — they hold BR-Employee-Base, BR-Service-Desk and BR-IT-Operations. None of those grants SG-VPN-Access; only BR-Engineering-Tools does. That is the whole point of the case, and why the marker's tooltip reports what it evaluated — no business role assigns this resource to this subject, and BR-Engineering-Tools "carries no assignment of it for this subject" — rather than asserting that they do not hold that role. The claim the tooltip used to make about role membership was both unchecked and, for anyone who does hold the granting role while its grant is missing from their assignments, wrong (requestor feedback on #370).

Who What they have What the matrix shows
The 8 BR-Engineering-Tools holders The VPN group, Indirect, through the role Governed access, in the role's colour — no marker
Victor Wang (E0029), Wendy Xu (E0030) The VPN group, Direct, without the role A red count on those two cells: a membership no business role assigns them — not BR-Engineering-Tools, which grants the group, and not one of the three roles they do hold

Drop the Indirect rows and every engineer turns into a provisioning gap on a group their role really does grant — eight bogus gaps that bury the two deliberate ones (BR-Employee-Base → FortigiGraph-App, and the role drift above). Give the SysAdmins the role and the "held outside the role" example disappears instead. Verify-DemoDataset.ps1 and test/unit/DemoDataset.Tests.ps1 guard both halves.

See matrix.md → "Fewer and more than the role assigns" for how it is rendered, folded and unfolded.

One resource, two business roles

Real catalogues overlap — the same group or application role is handed out by more than one business role. DemoSharedGrants.ps1 builds that overlap between BR-Service-Desk and a second role, BR-IT-Operations:

Resource Granted by Why it is there
SG-Servicedesk-Tools (Group) BR-Service-Desk + BR-IT-Operations The group in two roles
Ticketing-Agent (AppRole) BR-Service-Desk + BR-IT-Operations The application role in two roles
SG-Monitoring-Tools (Group) BR-IT-Operations only So the two roles' blocks are not identical

The holders make the overlap non-trivial: Victor Wang (E0029) and Wendy Xu (E0030) hold both roles, while Fatih Gunay (E0010) holds BR-IT-Operations only — so the shared rows cannot be attributed to the service desk alone.

Two properties of the data are the point, and Verify-DemoDataset.ps1 guards both:

  • A membership covered by two roles is one assignment. Victor's SG-Servicedesk-Tools row is emitted once; the second role adds a second Contains edge, i.e. coverage, not a second grant. That is why DemoSharedGrants.ps1 skips the memberships DemoRoleDrift.ps1 already emitted.
  • Each role also grants something exclusively, so "fold one role" and "fold both" are visibly different states.

The grid draws the two shared resources under both roles — one row in each role's block, each carrying the BR chip that points at the other role — so the overlap is visible without hunting for it, and folding one role removes only its own copies.

See matrix.md → "One resource, several business roles" for what the grid does with it.


Dataset Format

The dataset is a single JSON file that maps directly to the Ingest API endpoints. The nightly test script reads it and POSTs each section to the appropriate endpoint.

File: test/demo-dataset/demo-company.json

{
  "metadata": {
    "company": "Fortigi Demo Corp",
    "version": "2.0",
    "description": "Synthetic dataset for E2E testing and the public demo (issue #705)",
    "systemKeys": [
      { "key": "entra", "systemType": "EntraID", "tenantId": "demo-tenant-001" }
    ],
    "entityCounts": {
      "systems": 5,
      "principals": 45,
      "resources": 46,
      "resourceAssignments": 176,
      "resourceRelationships": 27,
      "identities": 27,
      "identityMembers": 38,
      "contexts": 9,
      "contextMembers": 35,
      "governanceCatalogs": 2,
      "assignmentPolicies": 4,
      "certificationDecisions": 3
    }
  },
  "systems": [ ... ],
  "principals": [ ... ],
  "resources": [ ... ],
  "resourceAssignments": [ ... ],
  "resourceRelationships": [ ... ],
  "identities": [ ... ],
  "identityMembers": [ ... ],
  "contexts": [ ... ],
  "governanceCatalogs": [ ... ],
  "assignmentPolicies": [ ... ],
  "certificationDecisions": [ ... ]
}

Verification Tests

After ingesting the demo dataset, test/demo-dataset/Verify-DemoDataset.ps1 executes these verification checks — it runs on every PR, in the integration job of pr-integration.yml. Every check has an expected value derived from the dataset definition above.

Table Row Counts

Counted with deletedAt IS NULL on Principals, Resources and ResourceAssignments. Those three tables soft-delete (040_soft_delete.sql): a removed entity is kept as a tombstone rather than dropped, so an unfiltered COUNT(*) also counts leavers and deleted resources. The other tables have no lifecycle column and are counted as-is.

Table Expected Composition
Systems 5 Entra ID + HR + IGA + SAP ERP + AzureRM
Principals 45 26 employees + 1 disabled + 1 contractor + 1 ServicePrincipal + 1 AIAgent + 1 SharedMailbox + 1 IGA account + 10 SAP accounts + 3 app service principals
Resources 46 Entra 10 + group-ownership 3 + business roles 7 + Sales 4 + role drift 3 + shared grants 2 + consent 4 + SAP 4 + Azure 9
ResourceAssignments 176 Direct + Indirect (role-derived) + Eligible; the governed=true ones are the business-role memberships
ResourceRelationships 27 21 Contains + 1 GrantsAccessTo + 3 HasOwnership + 2 DelegatesScope
Identities 27 26 employees + 1 disabled
IdentityMembers 38 27 Entra + 1 IGA + 10 SAP
Contexts 9 1 root + 5 departments + 2 teams + 1 admin unit — all variant='synced'
GovernanceCatalogs 2 Employee + Privileged
AssignmentPolicies 4 Auto-assign + Manager approval + Dual approval + Sales
CertificationDecisions 3 2 approve + 1 deny

These are exact, because the generator is deterministic. If one fails after a dataset change, that is the point: regenerate, confirm the new number is intended, and update it in the same PR.

Counting Contexts

Filter on variant='synced' to get the 9 above. A bare SELECT COUNT(*) FROM "Contexts" returns more: the API creates manual Tag roots at bootstrap, and the context-algorithm plugins emit generated contexts once the worker runs (the risky-consent plugin adds two). Only the synced ones come from this dataset.

Relationship Integrity

Check Expected
Every principal has a systemId that exists in Systems 0 orphans
Every resource has a systemId that exists in Systems 0 orphans
Every assignment's resourceId exists in Resources 0 orphans
Every assignment's principalId exists in Principals 0 orphans
Every identity member's identityId exists in Identities 0 orphans
Every identity member's principalId exists in Principals 0 orphans
Every context's parentContextId (if set) exists in Contexts 0 orphans
Every principal's managerId (if set) exists in Principals 0 orphans

Specific Business Logic

Check Expected
Governed business-role assignments exist (governed=true, count ≥ 10) True
CTO (E0002) has assignmentType='Direct' on Global Administrator role True
SysAdmin (E0029) has assignmentType='Eligible' on BR-Admin-Privileged True
Contractor (E0040) has NO entry in Identities True
Service principal (SVC-001) has principalType='ServicePrincipal' True
AI agent (AI-001) has principalType='AIAgent' True
Disabled account (E0041) has accountEnabled=0 True
Intern (E0031) has 0 resource assignments True
Group ownership exists — GroupOwnership resources with Direct owner assignments True
BR-Employee-Base contains SG-AllEmployees (relationship) True
BR-Admin-Privileged is in catalog "Privileged Access" True
Engineering context has parent = "Fortigi Demo Corp" True
Platform Team context has parent = "Engineering" True
At least one identity links to 2+ principals across systems True
Sales resolves to 6 active identities, 7 including the leaver (flag 1) True
Piet has Indirect (not Direct) on SG-CRM-Users (flag 4) True
SG-Sales-SharePoint is NOT a Contains child of BR-Sales (flag 6) True
SG-Finance-Reports is held across 2+ departments (flag 7) True
SAP accounts carry no department (flag 8) True
5 principals have ext.passwordNeverExpires='true' (flag 9) True
3 principals hold an eastus Azure role (flag 10) True
5 principals consented to Files.ReadWrite.All (flag 11) True
2 principals are risky-consenters with never-expiring passwords, and the "any consent" trap is wider at 3 (flag 12) True
Every DelegatedPermission's clientSpId resolves to a Principal True
BR-Service-Desk grants 4 resources, one of them Eligible Member only True
SG-Servicedesk-Tools (Group) and Ticketing-Agent (AppRole) each have 2 Contains parents True
A membership covered by two roles is stored once, not twice True
One BR-IT-Operations holder (E0010) does not hold BR-Service-Desk True

UI Verification (Playwright)

Check How
Resources page shows 39 resources (excluding BusinessRoles) Count rows, filter by non-BusinessRole
Business Roles page shows 7 business roles Count rows
Users page shows 45 principals Count visible or total indicator
Matrix shows data (not "0 users x 0 resources") Assert text not present
Click CTO user → detail page opens Navigate, check heading
CTO detail shows "Global Administrator" in memberships Assert membership listed
Click SG-Admin-Tier0 group → detail page shows 2 members Navigate, count members
BR-Employee-Base detail shows 2 resource grants (Contains) Check relationships section
Org chart shows Fortigi Demo Corp as root Check tree root
Swagger UI loads at /api/docs HTTP 200
Crawlers page loads (admin tab) Navigate, check heading
Sync log shows entries from the demo ingest Check table has rows

Implementation

Step 1: Generate the Dataset

Script: test/demo-dataset/Generate-DemoDataset.ps1

Generates demo-company.json with all entities. Uses deterministic GUIDs so the same IDs are generated every time, allowing tests to assert on specific values.

Step 2: Ingest Script

Script: test/demo-dataset/Ingest-DemoDataset.ps1

Reads demo-company.json and POSTs each section to the Ingest API in dependency order: 1. Systems 2. Contexts 3. Principals 4. Resources 5. ResourceAssignments 6. ResourceRelationships 7. Identities + IdentityMembers 8. Governance (catalogs, policies, certifications) 9. Refresh views

Step 3: Verification Script

Script: test/demo-dataset/Verify-DemoDataset.ps1

Runs all row count, relationship integrity, business logic, and API verification checks. Returns pass/fail per check with details.

Step 4: Playwright E2E Specs

Specs: app/ui/e2e/

Browser-based checks that verify the ingested data shows up correctly in the UI.

Step 5: Integration into Nightly Runner

Add to Run-NightlyLocal.ps1 between the Docker provisioning and Playwright phases: 1. Generate dataset 2. Ingest via API 3. Run verification script 4. Run demo-specific Playwright specs