READ-ONLY Okta license reconciliation report.
We’re over our Okta license count (1070 activated vs 952 licensed). This tool enumerates every Okta user, joins them against the two HR tabs (Active + Terminated), and buckets each account so we can see — before touching anything — where the reclaimable seats actually are.
⚠️ This script never writes to Okta. It only issues
GETrequests. There is intentionally no deactivation code. Deactivation, if we do it, will be a separate opt-in step designed after we’ve reviewed this report. Run the report with a Read-Only Admin token.
It reuses the same conventions as the sibling Okta scripts
(okta_profile_sync, okta_entity_status):
paginated GET, the same CSV dialect, the same SSWS auth helper, and a
Confluence-friendly markdown report + JSON audit log.
Okta bills on activated users. A user consumes a seat if their status is
ACTIVE, PROVISIONED, RECOVERY, PASSWORD_EXPIRED, LOCKED_OUT, or
SUSPENDED. STAGED (never activated) and DEPROVISIONED (deactivated) do
not consume a seat. Deactivating a user → DEPROVISIONED → frees the seat.
Suspending does not free a seat.
| Bucket | Meaning | Reclaim? |
|---|---|---|
| 🤖 SERVICE | login matches a service/system pattern (trinetapi@, *-bot, *-mdm, .consultant, …) |
Never — required, breaks integrations |
| 🏃 SPINOUT | HR Entity is on the spinout list | Review with HR |
| ✅ FTE | on the Active Employees tab | Keep |
| 🔴 TERMINATED | on the Terminated tab, still activated in Okta | Highest-confidence reclaim |
| 💤 DORMANT | not on either tab, no login in > N days | Investigate |
| ❓ ORPHAN | not on either tab, logged in recently | Manual — likely contractors |
Absence from the Active tab is ambiguous — it means either “terminated”
or “never was an FTE” (service accounts, contractors, advisors). The
trinetapi@ service accounts that feed the HR sheet itself are absent from it
by design; deactivating them on absence would break the sync. That’s why this
tool buckets on explicit signals (the Terminated tab, spinout entities,
dormancy) and treats orphans as review-only.
npm install
cp .env.example .env # fill in OKTA_DOMAIN + a READ-ONLY OKTA_API_TOKEN
# Export the HR sheet tabs to CSV (Active Employees + Terminated), then:
node reconcile-licenses.js --active ./active.csv --terminated ./terminated.csv
# One tab is enough to start:
node reconcile-licenses.js --active ./active.csv
| Flag | Default | Purpose |
|---|---|---|
--active <csv> |
— | Active Employees tab export |
--terminated <csv> |
— | Terminated tab export (the high-confidence signal) |
--dormant-days <n> |
90 |
“no login in > n days” cutoff for the DORMANT bucket |
--licensed <n> |
952 |
licensed seat count, for the over/under headline |
At least one of --active / --terminated is required.
reclaim-report-<ts>.md — paste into Confluence/Jira; license position,
reclaim candidates by bucket, and per-account detail tables.reclaim-log-<ts>.json — full per-account data for every bucket.Both are gitignored (they contain user data).
SERVICE_PATTERNS near the top of the script.SPINOUT_ENTITIES (mirrors EXCLUDE_ENTITIES in
okta_profile_sync).EMAIL_COLS / COL. The join key is the work email
(vs Okta login) — the Active tab uses Preferred Work Email, the Terminated
tab uses Work Email, so both are tried — with EEID as a fallback. The
Terminated tab’s Termination Date is surfaced in that bucket’s table.
Personal Email is ignored. The parser warns if no usable key is found.First dry run: 2,023 users pulled, 1,081 activated vs 952 licensed → +129 over. Offboarding is healthy — 375 of 387 terminated-tab people are already deprovisioned; only 12 slipped through. So the overage is structural, not dead ex-employees. It breaks down roughly as:
grau@ericschmidt.com,
active 5d ago despite a 2025 term date → possible rehire; apprentice@oceanhourfarm.org
is a shared role account).projecteagle.net + Sentala/Svitla/Napatree): ~23 —
reclaim when the spinout completes; confirm timing with HR.vtest@, zoomdemo@, vvv@, clee@ 1068d,
expointerns@…): ~15 — safe reclaim.aschembri@bluecityholdings + @schmidtocean): ~10 — dedup.soi-team.org/schmidtocean.org, incl. rovsubastian@, portcaptain@),
SCSP, the Foundation, Eric’s personal office: ~50+ — keep. TriNet just
doesn’t manage them. Closing the full 129 by deactivation alone isn’t possible
without touching these — that part is a business decision.Realistic low-risk reclaim pool: ~55–65 seats, not 129.
SERVICE_PATTERNS — automation/API accounts (trelicaapi@,
oktaread@, google-*, *_srv, *integration@), resource/room accounts,
and shared role mailboxes now classify as service (never reclaim).SPINOUT_DOMAINS (projecteagle.net, sentala.org, hlevel.ai) —
spinout family now clusters even with a blank HR Entity (11 → 53 matched).CONTRACTOR_PATTERNS: .consultant@, .fpt@,
.advisor@) — split out of SERVICE. The 1st run buried ~95 contractors under
“service”, which made the count look absurd (316/164) and hid finished
engagements. Active contractors are kept; dormant ones (>90d) are reclaim
candidates (~18 surfaced).KNOWN_INTERNAL_DOMAINS + every domain seen in
the Active sheet) — an active account on a confirmed Hillspire-family
domain (e.g. scsp.ai, soi-team.org, schmidtocean.org) that doesn’t match
an HR-sheet row is now a new 🏢 INTERNAL → keep bucket, not an orphan.
Catches real staff whose Okta login differs from their sheet email (SCSP
initials logins, SOI/vessel accounts). Dormancy still wins — a stale internal
account stays a DORMANT reclaim candidate, so this never shields dead accounts.--new-account-days, default 30) — an account with no
login yet but created within the grace window is a freshly-provisioned new
hire awaiting first login, NOT dormant. New 🆕 NEW → keep bucket. Wins over
TERMINATED too, to catch email-reuse collisions (a new hire’s login matching a
departed person on the Terminated tab). Added after Wave 1 wrongly deactivated
4 new-hire accounts (2 still locked out when found 2026-06-22) — see
activate-user.js for the undo.deactivate-user.js — one user; dry-run default, --confirm, refuses service accts.deactivate-batch.js — reviewed CSV of logins; dry-run default, --confirm,
--limit, service denylist, rate-limited, idempotent, JSON audit log.activate-user.js — undo: reactivate a DEPROVISIONED user (dry-run default,
--confirm). Use for false-positives like new hires caught in a wave.deactivation-report.js — audit log → Jira-friendly markdown + PDF.After director review (she approved the deactivation policy — see below), build
the opt-in deactivation step: a separate --deactivate run that acts ONLY on a
human-reviewed CSV, with SERVICE_PATTERNS/CONTRACTOR_PATTERNS as hard
backstops, batch limits, and an audit log.
projecteagle.net users, EXCEPT review any with a
login in the last 7–10 days. Keep hLevel and Sentala for now.Resuming on another machine:
git pullthis branch gets you the code + this plan. You must re-create.env(token) and re-export both CSVs locally — they’re gitignored and don’t travel with the repo.