Synthetic Industry

Inspectable example · updated 2026-10-12

Synthetic leavers check: eight staff, a directory and three apps, five leaver accounts and one unlisted account

Invented staff list and exports, a short script anyone can run, and the result: which leavers still have an active account, and one account not on the staff list.

An example, not a customer case study. Scope and evidence limitations are described below.

What this example is

Everything here is invented: the people, the firm name and the addresses belong to nobody. The script reads a staff list and four user exports, the kind of files you can run yourself from each app's admin page, and prints the leavers who still have an active account. It changes nothing, and it is a specification for what the check should find, not evidence that any real check was run.

The setup

Three of the eight people at an invented firm have left: dev on 30 June, eli on 15 August and fay on 30 September 2026. The report date is 12 October 2026 and the grace period is seven days. The directory and three apps each give a user list with a status and a last sign-in date, and the directory also holds one account, contractor-x, that is not on the staff list.

The script and what it prints

Run it with Node.js. It lists each leaver whose leave date is more than seven days before the report date and whose account in a system is still active, and lists separately any account not on the staff list, which is a question for the owner and not a leaver. Eli's chat account is disabled, so it is not listed.

If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.

// Synthetic example: staff list against a directory and three apps. Run with Node.js: node leavers.js
const report = '2026-10-12', graceDays = 7;
const staff = [
  ['ana@demo-firm.invalid', ''], ['ben@demo-firm.invalid', ''], ['cara@demo-firm.invalid', ''],
  ['dev@demo-firm.invalid', '2026-06-30'], ['eli@demo-firm.invalid', '2026-08-15'], ['fay@demo-firm.invalid', '2026-09-30'],
  ['gus@demo-firm.invalid', ''], ['hana@demo-firm.invalid', ''],
];
const exportsBySystem = {
  Directory: [['ana', 'active', '2026-10-10'], ['ben', 'active', '2026-10-09'], ['cara', 'active', '2026-10-11'], ['eli', 'active', '2026-08-14'], ['gus', 'active', '2026-10-12'], ['hana', 'active', '2026-10-12'], ['contractor-x', 'active', '2026-09-01']],
  Chat: [['ana', 'active', '2026-10-10'], ['ben', 'active', '2026-10-09'], ['dev', 'active', '2026-06-29'], ['eli', 'disabled', '2026-08-15'], ['gus', 'active', '2026-10-11'], ['hana', 'active', '2026-10-12']],
  Design: [['ben', 'active', '2026-10-01'], ['cara', 'active', '2026-10-05'], ['fay', 'active', '2026-03-02'], ['gus', 'active', '2026-09-28']],
  Accounting: [['ana', 'active', '2026-10-08'], ['cara', 'active', '2026-10-11'], ['dev', 'active', '2026-06-12'], ['fay', 'active', '2026-09-29']],
};
const day = s => Date.parse(s + 'T00:00:00Z');
const known = new Map(staff.map(([email, left]) => [email.split('@')[0], left]));
const rows = [], unlisted = [];
for (const [system, list] of Object.entries(exportsBySystem)) for (const [user, status, lastSignIn] of list) {
  if (!known.has(user)) { unlisted.push([system, user, status, lastSignIn]); continue; }
  const left = known.get(user);
  if (left && status === 'active' && day(report) - day(left) > graceDays * 86400000) rows.push([user, left, system, status, lastSignIn]);
}
rows.sort((a, b) => a[0].localeCompare(b[0]) || a[2].localeCompare(b[2]));
console.log('person | left | system | status | last sign-in');
for (const r of rows) console.log(r.join(' | '));
console.log('Not on the staff list (ask the owner):');
for (const u of unlisted) console.log(u.join(' | '));

--- output ---
person | left | system | status | last sign-in
dev | 2026-06-30 | Accounting | active | 2026-06-12
dev | 2026-06-30 | Chat | active | 2026-06-29
eli | 2026-08-15 | Directory | active | 2026-08-14
fay | 2026-09-30 | Accounting | active | 2026-09-29
fay | 2026-09-30 | Design | active | 2026-03-02
Not on the staff list (ask the owner):
Directory | contractor-x | active | 2026-09-01

How to read the result

Five rows need a decision from whoever owns each system: dev in two apps, eli in the directory, and fay in two apps, and no current member of staff is listed. The unlisted account is a question, not a finding, because a contractor may have a good reason to hold it. The result shows a moment in time, and it only covers the systems whose exports were supplied.

  • Five leaver rows.
  • One unlisted account to ask about.
  • Zero current staff listed.

What this example does not cover

It does not disable anything, and it does not decide who should have access. The paid job runs the same match across your own exports, adds the export row for every entry, and tests the match first on a synthetic set in which your admin plants ten leavers that we have not seen. Your admin confirms a sample against the live consoles.

Sources and limits

  • Microsoft Learn: Microsoft Entra built-in roles Checked 2026-10-12.
    • Global Reader is described as able to read everything that a Global Administrator can, but not update anything.
    • Reports Reader is described as able to read sign-in and audit reports, and Security Reader as able to read security information and reports in Microsoft Entra ID and Office 365. The Reports Reader text adds that holders have no admin permissions to configure settings or to access product-specific admin centers like Exchange.
    • On the roles table, Global Reader and Security Reader carry the privileged-role label and Reports Reader does not.
  • Google for Developers: Reports API authorization Checked 2026-10-12.
    • The Reports API lists two scopes, admin.reports.audit.readonly (view audit reports) and admin.reports.usage.readonly (view usage reports), and both are read-only.