Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

77 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HelloID Analytics

A self-hostable, dependency-free dashboard for HelloID exports. Drop any HelloID export on the page and it shows the most analysis that export can carry — every import is optional, and each one deepens the model rather than gating it.

The reconciliation (ReconciliationReport.csv) is the base of the access analysis: the account ↔ entitlement ↔ person graph, risk scoring, licence drift pricing, and the diff against any earlier import. The vault (people and contracts) powers the people and organisation views on its own — org walker, department scorecards, workforce analytics, attestation packs — and, joined with the reconciliation, makes rule conditions evaluable and separates leavers from unowned accounts. The business rules split entitlements into modelled, draft-only and unmodelled; granted entitlements and historic actions distinguish "granted outside HelloID" from "granted by HelloID and not recorded", and turn a missing entitlement into a failed grant on a date; the product catalogue and product assignments connect Service Automation requests and approvals to the access they explain.

A view whose exports are missing is not hidden: it wears a lock in the sidebar and renders a page naming exactly which export unlocks it, what that export contains and where in HelloID it lives. Load only a vault and the people views work; add the reconciliation later and everything merges — the model is rebuilt from whatever is loaded, so import order never changes the outcome.

Serve it from a laptop or any static host — it is plain files, with no build step, no CDN and no back end. Wherever it runs, the exports are read and analysed in the browser that opened the page: they are never uploaded to the host serving it.

Demo data

make-demo-set.py writes a fictional organisation into demo/: six exports describing the same invented people, so the vault, the rules and the activity files actually join. Where that directory is published, the Imports view offers to load all six in one click. While it is loaded, a banner sits above every view and every page of the board report carries a printed mark, because a PDF outlives the tab it came from.

python3 make-demo-set.py                 # -> demo/*.csv, demo/vault.json, demo/manifest.json
python3 make-demo-set.py --rows 20000    # a larger tenant

Everything in it is invented — names from a fixed word list, employee numbers counting up from 500000, avo.local as the domain. It is the only CSV/JSON the shipped nginx config is allowed to serve; every other export path is denied.

Run it

./serve.sh            # http://localhost:8123

Opening index.html (or the bundled single file) directly from disk also works, but browsers give file:// pages an opaque origin, so snapshots cannot be persisted and the sample loader is hidden. Over http both work.

Import in the top bar opens a slot per export — what it is, what it unlocks, where in HelloID it comes from, and whether it is loaded. A slot only accepts its own kind of file, and every slot can be replaced or removed independently; removing the reconciliation keeps the snapshot archive, so the Snapshots view can bring any earlier import back. Dragging a file anywhere onto the page still routes it on content.

No reconciliation export is committed to this repository — they carry account and person names, so *.csv is gitignored. Drop your own export in this folder and the "load the export from this folder" button picks it up (it looks for ReconciliationReport.csv, then sample-recon.csv); drag-and-drop works regardless of where the file sits.

Sample data

python3 make-sample.py                  # 5,000 rows -> sample-recon.csv
python3 make-sample.py --rows 50000     # for performance work
python3 make-sample.py --systems 2      # multi-system export
python3 make-sample.py --seed 7 -o next.csv   # a second run to diff against the first

Fictional names, HelloID-shaped group naming, and enough of every case that all fifteen finding rules have something to fire on: unowned admin accounts, disabled accounts still in licence groups, stacked SKUs, a security baseline at ~88% coverage, break-glass groups with two members, rows already dispositioned in HelloID. Output is deterministic per seed, so two seeds give you a pair of exports to try the diff on. The generated file is gitignored like any other export.

Distribute it

python3 build.py

produces two things in dist/:

Artefact What it is Use it when
reconciliation-analytics.html ~270 KB single file, every script, stylesheet and the logo inlined mailing it, dropping it on a share, handing it to a customer. Double-click opens it; drag the CSV on. Snapshots stay in the tab unless it is served over http.
reconciliation-analytics-1.0.0.zip the folder form, plus serve.sh the customer wants diffing across sessions, or you want to keep editing it

Both are static: no install, no runtime, no network access. If you want it permanently available for a team, the folder can also be dropped on any static host (an internal IIS vdir, S3, a share served over http) — there is no back end to deploy.

Languages

Everything — interface, findings, board report and the Markdown export — is available in English and Dutch, switchable from the top bar. Numbers, currency and dates follow the selected language. The default follows the browser. All strings live in js/i18n.js; adding a third language means adding one more block there.

Board report

The first view is a print-ready board report: A4 pages, plain language, no scores without a sentence explaining what they mean. Cover with the overall assessment, a one-page summary, a traffic-light table of themes, the money page, change since the previous review, a numbered recommendation list with effort and yearly saving per action, and a short method page. Export PDF opens the browser print dialog — choose Save as PDF, A4 portrait. Organisation, author and date are filled in above the sheets and are not printed.

Branding

Three slots, because a square app mark and a wordmark are not interchangeable:

Slot File Used for
icon assets/icon.svg | .png top bar, browser tab
logo assets/logo.svg | .png wordmark on light backgrounds
logo (dark) assets/logo-light.svg | .png wordmark on the report cover's dark chip

Files dropped in assets/ are detected automatically and inlined into the single-file bundle by build.py. Anything uploaded under Settings → Report branding is stored in the browser as a data URI, which is what makes it survive into the printed PDF. Each slot falls back to the next; with none at all the app shows a neutral gradient tile. The same panel sets the product name shown in the title bar.

Hosting it

Dockerfile + docker-compose.yml serve the folder from nginx on loopback, fronted by a Cloudflare tunnel — see DEPLOY.md. Only index.html, css/, js/ and assets/ enter the image; .dockerignore keeps exports out and nginx returns 403 for .csv/.json outside /assets/ in case one is ever copied onto the host.

Nothing needs protecting server-side: the page ships no data, makes no network calls, and every visitor starts empty and drags in their own exports.

Configuration review on import

Every import stops at a review step before any number is shown. It reports how much of this export the current settings actually describe — permissions categorised, accounts classified, groups priced — and proposes rules mined from the export's own naming:

  • permission categories from prefixes that no current rule matches, with a sensitivity pre-filled from the prefix itself where it is recognisable (BEH-, SRV-, SEC-…)
  • account classes from recurring name shapes. Marker vocabulary (adm-, svc-, ext_) is proposed from two accounts up; anything else has to be common enough in the export to look deliberate. firstname.lastname is treated as a naming convention, not a marker, so surnames are not proposed as classes.
  • groups that look priceable but have no price yet, so licence spend is not silently zero

Each proposal shows how many names it matches right now, is editable before it is applied, and nothing is written until you select it. Continue without changes skips the lot; a checkbox turns the step off for future imports (re-enable in Settings by resetting).

A pattern tester sits on the same page and in Settings: type a regex, pick permission or account names, and see the match count, a sample of hits, and a warning when the pattern is invalid or matches everything. Every rule row in Settings also shows its live match count against the loaded import.

Business rules

Drop a HelloID business-rule export (Name,EntitlementCount,PersonsLatestEvaluation,Categories,Status,Conditions,Entitlements) on the same page — it routes by header, no second import button — and it is joined against the reconciliation export on distinguished path, falling back to system + name for systems that export none (Exchange Online, TOPdesk, Azure).

Rules stack, so an entitlement counts as described when any rule grants it; there is no precedence to resolve. That splits drift into two problems with opposite fixes:

Meaning Fix
A live rule grants it The model is right, the rule is not reaching these people Conditions or evaluation
Only a draft rule grants it The model exists, switched off Publish it
No rule grants it Gap in the model Write the rule

Five findings come out of the join: entitlements a rule grants that the target system does not have, draft rules already covering live drift, live rules that match nobody while their groups have holders, access no rule describes, and failed grants traced to the rule that should have delivered them.

Conditions are parsed (Department.ExternalId, one of, Custom.Vrijgesteld, not only, Time frame) and shown, but not evaluated — deciding who matches needs personnel data neither export carries.

Proposed rules

The backlog says which groups no rule describes; it does not say how to describe them, and one rule per group would be the wrong answer. js/roles.js mines frequent itemsets (apriori, level-wise with pruning) over account entitlement sets, restricted to the unmodelled groups, and proposes the bundles that accounts hold together.

Two choices decide whether the output is useful rather than merely true: rank on cohesion (how often the rarest group in a bundle brings the rest with it) rather than frequency, since the most frequent bundles are the groups everyone has; and keep maximal bundles, capped per role family, since every subset of a frequent bundle is itself frequent.

Each proposal carries its members, its exceptions (accounts holding all but one group — the rule either absorbs them or exposes them), the assignments it would bring under management, and its licence cost. Proposals export in the HelloID rule format so they can sit next to the real ones. The condition is left empty on purpose: which department or title should receive the bundle lives in HR data, and the member list is the evidence to write it from.

Mining is cached per model, samples above 5,000 accounts, and raises its support floor with the population — 97 ms on the 5k export, ~50 ms at 50k and 200k.

Explanations

The point of loading the other files. A reconciliation export states differences without saying what caused them, so every row arrives with the same weight. js/explain.js walks each row and attaches its strongest available justification — a rule that grants it, a role bundle it travels in, a former owner, a link to someone's main account, a disposition already recorded in HelloID — and reports what is left.

Explanations are ranked by how much they settle: strong (cause identified, next action mechanical), likely (rests on a name match or a statistical pattern), weak (narrows the question without closing it).

The residue is the deliverable. Rows nothing accounts for are what genuinely need a person, they are grouped by account rather than listed one by one, and their count is a better progress measure than the raw finding total because it falls as the model improves rather than as the data changes.

Each input raises the share, measured on the demo trio:

Loaded Rows explained
reconciliation only 21%
+ business rules 24%
+ vault 32%

Vault export

Drop a HelloID Vault export (JSON: { Persons: [...], Departments: [...] }) on the page and the analysis stops being limited to what the target system shows. DisplayName is Name (ExternalId) — the same string the reconciliation export puts in its Person column — so the two join without fuzzy matching.

What the vault makes possible:

  • Conditions become evaluable. js/evaluate.js resolves each clause against real attributes: Department.ExternalId, Title.*, Location.Name, Type.Code, Custom.*, Person: active, and the Time frame window against contract dates. A rule that selects nobody now reports which clause excluded everyone.
  • Drift per person, not per group. Expected entitlements are the union over every rule a person matches (rules stack), compared against what their accounts actually hold.
  • Leavers. Every contract ended while an account is still enabled — invisible to the reconciliation export alone, which knows the account is on but not that the employment behind it is over.
  • Unowned accounts get names. js/correlate.js scores unowned accounts against vault people. The most useful class is former employees: correlation is the first thing that breaks when someone leaves the source, so their account arrives as an orphan with no trace of who it was.
  • Secondary accounts get linked. An adm- or function account rarely has its own person record, so reconciliation calls it ownerless. It is not — the owner is the person holding the main account, and that is who should be recertifying it and losing it on departure.

A clause whose facet the engine does not recognise makes the rule indeterminate for that person, never silently true. Matching is scored and never automatic: ties are reported as ties rather than guessed.

Vault exports carry person and contract data, so vault*.json is gitignored alongside the CSV exports.

Settings file

Settings live in the browser, which means a bundle opened straight from disk starts fresh every run. Settings → Export settings file writes the whole tunable model — price book, taxonomy, account classes, risk weights, effort model, branding, language — to one JSON file. Import it from the same panel, or just drop it anywhere on the page.

Performance

Measured in Chrome on the real 5k-row export and on synthetic 50k/200k exports:

Rows Accounts Parse Build (graph, risk, cost, findings) Views Heap
5,188 450 24 ms 47 ms < 30 ms ~40 MB
50,000 3,936 147 ms 466 ms < 130 ms ~135 MB
200,000 15,798 750 ms 480 ms < 200 ms ~790 MB

A 50k import is about six tenths of a second end to end. Peer similarity — the one quadratic part — uses an index of each account's rarest groups instead of comparing every pair, which is what keeps 50k at 0.5 s rather than 2.7 s. Where every group an account holds is ubiquitous, there is no comparable account and the outlier column reads rather than inventing a maximum. The overview scatter samples above 4,000 accounts and says so in its subtitle.

What it reads

The parser auto-detects the delimiter (, ; tab |), handles quoted fields and BOM, and maps headers by alias, so exports from other HelloID versions or locales still load. Only AccountUserName and Issue are strictly required.

Column Used for
System Multi-system exports are kept separate; keys are system + account.
Person Taken verbatim as the display name; drives the identity-coverage metric. Employee ids come from the vault, which states them as a field.
AccountDisplayName, AccountUserName Account identity and class detection.
AccountEnabled Dormant-but-entitled and licence-waste detection.
PermissionDisplayName NAME (dn/path) is split into group name and OU path.
Issue Account unmanaged / Permission unmanaged / Permission missing.
Resolution Rows already dispositioned in HelloID are surfaced separately.

Rows are not equal. One Account unmanaged row describes an identity; a Permission unmanaged row describes one edge of that identity's entitlement graph. The model rolls rows up into 3 entity types — accounts, permissions, persons — and every number in the UI is computed on entities, not on line counts.

Before HelloID: the directory import

The chicken-and-egg problem of pre-implementation analysis: the useful exports need HelloID, and implementing HelloID well needs the analysis. Two read-only collector scripts break the loop — collect-ad.ps1 (on-prem AD, plain Get-ADUser/Get-ADGroup) and collect-entra.ps1 (Entra ID via Graph, delegated read-only scopes documented in docs/ENTRA-CONSENT.md). The customer runs one script, gets one JSON file, and drags it in; nothing is installed and nothing leaves their hands.

The import synthesizes both sides of the model from that one file: memberships (nested groups flattened to effective holdings, the via-chain kept) become reconciliation rows — all Permission unmanaged, which before an IAM system is the literal truth — and the users' attributes (employeeType, department, title, manager, extensionAttribute1-15, phones, address) become a pseudo-vault, so classification, naming analysis, employee categories, licence pricing and the org views all run. Both substitutes step aside the moment a real export of their kind is loaded.

The Conventions → Attributes tab is the point of the exercise: per attribute the fill and value distribution, and the value → group pairs strong enough (≥5 people, ≥80% holding, clearly above baseline) to become HelloID business-rule conditions or connector mappings. make-directory.py generates a fictional envelope to try it without a tenant.

The models

All three are assumptions, all three are editable in Settings, and every change re-scores the loaded snapshot immediately.

Risk

Each account gets a 0–100 score summed from named components, visible in the account drawer:

  • Unowned identity — unmanaged account, no linked person while enabled, privileged and unowned. Scaled by account class (admin ×2.4, test ×1.8, external ×1.7, service ×1.6).
  • Dormant but entitled — disabled account keeping group memberships, extra if licensed.
  • Entitlements outside the IAM model — unmanaged assignments with diminishing returns and a cap, multiplied by the average sensitivity of what is held.
  • Missing entitlements — granted by rule, absent on the account.
  • Rare entitlements — groups held by ≤3 accounts.
  • Peer-group outlier — Jaccard similarity against the closest account by shared entitlements; a profile with no neighbour is an undocumented exception.
  • Stacked licence SKUs — more than one licence group on one account.

Permissions get their own score from sensitivity, share of unmanaged assignments, share of unowned/disabled holders, blast radius and rarity.

The overall score is 0.40 × weighted mean account risk + 0.35 × share of accounts at high/critical + 0.25 × identity coverage gap, shown as a table on the Risk view.

Cost

The price book matches group names by regex and costs them per holder per month. Unpriced groups count as zero, so every total is a floor, never an inflated estimate. Waste is split by confidence:

  • Hard (recoverable now) — disabled accounts still in licence groups; stacked SKUs net of the richest one.
  • Exposure — spend on unowned but enabled accounts. Not a saving until someone decides the account should not exist.

Clean-up effort is minutes-per-work-item × loaded hourly rate, which gives the payback period on the recoverable waste.

Findings

Fifteen rules over the graph, ordered by severity then by money at stake. Each carries what it is, why it matters, the remediation, and the affected entities (exportable to CSV). The whole analysis exports as a Markdown report from the Risk view.

Diff

Every reconciliation import is stored as a snapshot in IndexedDB. The baseline is picked on the Diff view (each new import auto-baselines against the previous one); companions are not versioned, so a historic diff uses the currently loaded vault and rules on both sides. Selecting a baseline rebuilds that snapshot's full graph and compares entities, not rows: accounts added/removed/changed, entitlements granted and revoked per account, membership movement per group, findings that grew, shrank, appeared or resolved, and the cost delta. Importing a file that is byte-identical to an existing snapshot reuses it instead of duplicating.

Snapshots export to JSON (Snapshots view) so they can be moved between machines or kept under version control.

Layout

index.html          shell + script order
css/app.css         tokens (light & dark both selected), components
js/i18n.js          every user-visible string, EN + NL, with {param} interpolation
js/util.js          formatting, DOM helper, tooltips, CSV/download
js/brand.js         logo + report title block
js/parse.js         RFC4180 parser, delimiter sniffing, header aliasing
js/config.js        taxonomy, account classes, price book, risk weights (localStorage)
js/model.js         entity graph, peer similarity, summary
js/risk.js          account + permission scoring
js/cost.js          spend, waste buckets, effort, payback
js/findings.js      the rule engine
js/diff.js          snapshot comparison
js/store.js         IndexedDB snapshots (+ in-memory fallback, JSON import/export)
js/charts.js        SVG/DOM charts: bars, stacked, histogram, scatter, line, heatmap
js/table.js         sortable/filterable/paged table with CSV export
js/views.js         analyst views + drawers + Markdown report
js/board.js         the printable board report
js/app.js           state, routing, language switch, import pipeline
build.py            single-file + zip bundler
make-sample.py      synthetic export generator (no real data ships here)
devserve.py         no-cache static server used by serve.sh

Known limits

  • Default prices are public EUR list prices, not your contract. Correct them first if the cost numbers are going in front of anyone.
  • Peer-similarity is exact pairwise and switches itself off above ~8M candidate pairs (roughly 10k+ accounts sharing common groups); the outlier signal then reads 0.
  • PermissionConfigurationDisplayName and SubPermissionDisplayName are parsed and kept on the record but are empty in AD exports, so nothing is built on them yet.
  • Account-class and category detection is regex on naming convention. If your naming differs, fix the patterns in Settings before trusting the class rollups.

About

Self-hostable HelloID analytics: reconciliation, vault, business rules, granted entitlements, historic actions and the entitlement catalogue in one browser page — risk, licence cost, rule coverage, explanations for every finding, import diffing and a printable board report (EN/NL). Exports are read in the browser, never uploaded.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages