Reference architecture
The system is seven decoupled layers. The single most important decision:
the dashboard never calls an IAM API. Collectors gather, a store remembers, a transform publishes,
and the pages read one static, versioned document. A dashboard holding live credentials to your identity
stack is an attack surface; a dashboard reading metrics.json is a webpage.
| Layer | Responsibility | Reference implementation |
|---|---|---|
| Source registry | Routes every source to exactly one collector; enforces the count-once rule | registry.json in repo |
| Secrets backbone | Read-only service accounts, one per system, resolved at runtime | PAM / secrets vault (existing) |
| Collectors | One script per system; uniform output envelope | Python · Lambda (cloud) + domain-joined runner (AD/LDAP) |
| Snapshot store | Append-only, period-keyed raw counts; never overwritten | DynamoDB / versioned repo dir |
| Manual intake | Modeled + cited figures via reviewed templates | PR-reviewed JSON templates |
| Transform + publish | Derives, validates, emits the data contract; fails loudly | Lambda on EventBridge schedule |
| Data contract | Versioned schema keyed by metric ID; the SPA's only input | metrics.json + JSON Schema |
Every collector emits the same envelope regardless of source, which is what keeps the transform simple:
{
"source_id": "okta-prod",
"collected_at": "2026-07-01T06:00:14Z",
"collector_version": "1.4.0",
"counts": { "apps_active_sso": 180, "users_mfa_enrolled": 11240, ... }
}
Operating model
The rule that makes the model simple and repeatable: the system computes every state except one, and the admin owns exactly one verb per object type.
Connections — Configure → Test → Apply
- Configure saves metadata as a draft. Tenant URLs, client IDs, scopes, and a vault path reference — never a credential value.
- Test runs one scoped, read-only probe. It exists as a separate verb so a typo is caught before it poisons a collection cycle.
- Apply commits the verified connection to the source registry. Collectors include it from the next cycle. A connection whose last collection failed shows Degraded — computed, never hidden.
Metrics — Blocked → Collecting → Staged → Published
- Blocked: one or more required sources unconfigured. The chips show exactly which.
- Collecting: sources active, awaiting the first validated snapshot. Automatic.
- Staged: data flowing, visible to admins only. This is where "that ratio can't be right" gets caught before an executive ever sees it.
- Published: the single human promotion, per metric, reversible, audited.
The cascade
Derived metrics and page composites are never toggled directly — an artifact's state is the minimum of its inputs' states. A derived metric can be published only when every input is published; unpublishing an input automatically demotes everything above it. Composites (the kill chain, the hero stat) render each sub-element whose inputs are all published and show "not yet instrumented" for the rest — the dashboard depicts its own instrumentation coverage in the same visual language it uses for identity coverage.
The exec guarantees
- Every visible number has passed a human staged review.
- Every gap is explicit — and carries the registry's onboarding target date where one exists.
- The dashboard only ever grows or explains itself. A silently vanishing number is a credibility incident; unpublish leaves the placeholder.
The admin's month
Connections green (fight any Degraded) → scheduled collection runs → review staged deltas — the console flags any metric that moved beyond its threshold → publish decisions → done. Per planning cycle, the manual intake templates land and flow through the same staged review. Nothing depends on memory or heroics.
Source registry & the count-once rule
| Registry state | Authoritative counter | Feeds |
|---|---|---|
| connected | ISC Search — the only counter for that source. Direct collectors forbidden. | Governed + Transition (ISC's own correlated / uncorrelated split) |
| enumerable | Direct collector (AD / Graph / ldap3) — everything it counts is Transition by definition | Category A · each disconnected source's count is its onboarding business case, sized in identities |
| inferred | No enumeration — app counts, spend signals | Category B |
The cutover ritual
When a source flips from enumerable to connected, run the direct collector one final cycle against the ISC count for that source. Variance within tolerance → retire the collector, flip the registry state. Variance outside tolerance → the ISC aggregation config has a gap (filter scoping, OU exclusions, correlation rules) and you caught it before the dashboard published a wrong number. Dual-counting is legitimate exactly once — at handoff.
System recipes
One recipe per system of record: auth setup, the least-privilege service account spec, endpoints per metric, and the gotchas that cost a day each if learned the hard way. Vendor URLs and versions current as of this revision — verify against the vendor's developer docs at build time.
SailPoint ISC
Auth & service account
OAuth2 client credentials via a Personal Access Token pair, scoped read-only
(sp:scopes: idn:accounts:read, idn:search:read class of scopes). Store the client secret in the
vault; the connection record holds only the tenant URL, client ID, and vault path.
Core technique — Search aggregations, not entity paging
ISC Search runs on an Elasticsearch backend and covers identities, roles, access profiles, entitlements, events, and account activities. Use aggregation queries so a governed-count is one call, not a 50,000-record pull:
# POST /v3/search — count identities by lifecycle state (feeds 1.4 / 1.5) { "indices": ["identities"], "query": { "query": "attributes.cloudLifecycleState:active" }, "aggregationsDsl": { "by_state": { "terms": { "field": "attributes.cloudLifecycleState.exact" } } } }
| Feeds | Endpoint / technique |
|---|---|
| 1.4 · 1.5 | Search aggregation on identities by lifecycle + correlation; GET /v3/accounts?filters=uncorrelated eq true for the transition split within connected sources |
| 2.8 | Search aggregation — avg entitlement count per identity |
| 2.9 | SoD policy violation APIs — count of active violations |
| 2.5 · 2.6 | Entitlement joins against the crown-jewel app list (+ attack-path tooling where present) |
CyberArk
Auth — two deployment paths
- Self-hosted:
POST /PasswordVault/API/Auth/CyberArk/Logon(or LDAP/SAML variants) returns a session token used in theAuthorizationheader of every subsequent call. - Privilege Cloud (ISPSS): a CyberArk Identity service user obtains an OAuth bearer token from the Identity tenant, then calls the same PasswordVault API surface.
| Feeds | Endpoint / technique |
|---|---|
| 2.7 (numerator) | GET /PasswordVault/API/Accounts — vaulted account count, paged, filtered to privileged platforms |
| 1.4 (PAM row) | Same call grouped by safe/platform |
| 2.7 (denominator) | Not CyberArk. Directory-privileged accounts from the AD recipe — vaulted ÷ directory-privileged is the honest ratio; CyberArk only knows accounts it already has |
HashiCorp Vault
Auth & policy
AppRole with a policy granting exactly list on the KV metadata/ paths in scope plus
read on sys/internal/counters/activity. Listing KV v2 requires the list capability on the
/metadata/ path specifically — that sentence is the collector's entire ACL.
| Feeds | Endpoint / technique |
|---|---|
| 1.4 (secrets row) | Recursive LIST {mount}/metadata/ per mount — secret counts |
| NHI ratio | GET /v1/sys/internal/counters/activity — entity vs non-entity clients per namespace and mount: a native human-vs-machine consumer split, free |
Okta
Auth & service account
An API service app with scoped OAuth 2.0 tokens — okta.apps.read, okta.users.read,
okta.logs.read and nothing else. Okta recommends scoped OAuth over legacy SSWS tokens precisely
because the scopes bound what the bearer can touch.
| Feeds | Endpoint / technique |
|---|---|
| 1.7 · 1.8 | GET /api/v1/apps — active apps by signOnMode = the SSO-governed app count |
| MFA row | GET /api/v1/users/{id}/factors per user — enrolled factor coverage |
| 2.3 (auth stage) | System Log GET /api/v1/logs — auth event signals |
Link response headers —
follow them verbatim; constructing your own page URLs silently undercounts large orgs. MFA coverage is a
per-user fan-out (no bulk report API): iterate with backoff, or sample and label the confidence.Entra ID (Microsoft Graph)
Auth & service account
App registration, client-credentials flow, application permissions: User.Read.All,
Reports.Read.All, AuditLog.Read.All, Application.Read.All.
| Feeds | Endpoint / technique |
|---|---|
| 1.3 · 1.6 | GET /v1.0/users with $filter/$count — enabled / member / guest splits; stale via signInActivity.lastSignInDateTime (premium license) |
| MFA row | GET /v1.0/reports/authenticationMethods/userRegistrationDetails — per-user isMfaRegistered, isMfaCapable, isPasswordlessCapable |
| NHI row | GET /v1.0/servicePrincipals — every service principal is an NHI; cloud-side inventory |
$filters need the ConsistencyLevel: eventual
header. Follow @odata.nextLink; handle 429 throttling with backoff.Active Directory (on-prem)
No REST API, and not reachable from cloud CI — the AD collector runs on a domain-joined runner inside
the network (PowerShell AD module or Python ldap3 against the DCs). This is the one collector class
that forces the hybrid-runner note in the architecture.
# Privileged group membership — the honest denominator for 2.7 Get-ADGroupMember "Domain Admins" -Recursive | Measure-Object # Stale accounts — lastLogonTimestamp older than 90 days Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly | Measure-Object # On-prem NHI denominator — SPN-bearing accounts (also the kerberoastable set) Get-ADUser -Filter { ServicePrincipalName -like "*" } | Measure-Object
| Feeds | Technique |
|---|---|
| 2.7 (denom) | Recursive privileged group enumeration (Domain / Enterprise / Schema Admins + delegated tiers) |
| 2.3 (cred stage) | Credential hygiene flags — ancient pwdLastSet, password-never-expires, Kerberos pre-auth disabled |
| Cat. A counts | Enabled / disabled / stale user + computer counts per disconnected domain |
lastLogonTimestamp replicates with up to ~14 days of slop —
document it as approximate; never present staleness as exact.Generic LDAP + adapter contracts
One parameterized ldap3 collector serves every instance. Each registers a small config —
host, read-only bind DN (vault path), base DN, object-class filter, and the two or three count queries it must
answer. Workday, SIEM, and CMDB/CASB follow the same pattern one level up: the pipeline defines the
fields it needs (headcount for denominators, incident timestamps for 2.10, app inventory for 1.7) and each
org fulfills the contract with its own export mechanism — Workday RaaS report, SIEM saved search, flat-file drop.
Prescribing exact endpoints there would be false precision.
Metric crosswalk
The machine-readable heart of the system: every metric → required sources → capture class → cadence.
This same document (crosswalk.json) drives the console's dependency engine and the collector
configuration — the guide renders it, the console executes it, so they cannot drift. Rendered
excerpt (full 36-row table generates from the data file):
| ID | Metric | Requires | Class | Cadence |
|---|---|---|---|---|
| 1.4 | Governed count (per capability) | isc · okta · cyberark · vault | hard | Monthly |
| 1.1 | Governed identity % (hero) | ← 1.4 · 1.5 · 1.6 | derived | Monthly |
| 1.7 | App governance split | okta · cmdb | estimate | Monthly |
| 1.11 | Cost per managed identity | intake · ← 1.4 | modeled | Quarterly |
| 2.3 | Stage readiness (C/D/B) | okta · cyberark · vault · isc · siem | derived | Monthly |
| 2.7 | Standing privilege ratio | cyberark · ad | hard | Monthly |
| 2.10 | MTTD + MTTC (our clock) | siem | target | Per incident |
| 2.11 | Attacker tempo | cited | cited | Annual |
Capture-class distribution across the 36: roughly 40% fully automatable from the four core systems plus directories, 25% derived for free once those land, and the remainder honestly manual — modeled finance figures and annual citations through the intake path. Say that out loud on the page; "here's what can't be automated and why" is half the value of the playbook.
Manual intake
Modeled and cited metrics enter through structured template files submitted for review — every number gets a reviewer and a timestamp before it can stage. Manual data goes through the same versioned, reviewed gate as automated data; it just originates from a human instead of an API.
| Template | Feeds | Cadence | Reviewer |
|---|---|---|---|
| Finance cost model | 1.10–1.14 · 1.22 | Quarterly | Finance partner |
| Program business cases | 1.15–1.19 · 2.13 | Per cycle | Program lead |
| Industry benchmark refresh | 2.11 · 2.12 | Annual | Any admin — record source + retrieval date |
Productionizing
This environment runs the full control plane — auth, roles, lifecycle, publish gates, audit — with simulated collectors behind the same interface real ones implement. Taking it live inside your organization is a swap, not a rebuild:
- Collectors: replace the simulator Lambda with the per-system recipes in chapter 4, one at a time. The registry, lifecycle, and console don't change.
- Identity: federate the user pool to your IdP; map
exec/admingroups to directory groups. The sandbox role stays — every admin console deserves a safe place to learn. - Secrets: point the vault-path references at your PAM/secrets platform; grant the collector execution role read on exactly those paths.
- Runners: stand up the domain-joined runner for AD/LDAP; everything else runs from your cloud scheduler of choice.
- Snapshot store: the DynamoDB layout graduates to your warehouse when volume or BI integration demands it — the data contract is the stable seam.