SysEngineering

okta_license_cleanup

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 GET requests. 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.

How licenses work

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.

Buckets (first match wins)

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

Why we don’t just “deactivate everyone not in the sheet”

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.

Usage

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

Flags

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.

Output

Both are gitignored (they contain user data).

Tuning

First-run findings (2026-06-09) & next steps

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:

Realistic low-risk reclaim pool: ~55–65 seats, not 129.

Code tweaks — DONE (2nd + 3rd run)

  1. Expanded SERVICE_PATTERNS — automation/API accounts (trelicaapi@, oktaread@, google-*, *_srv, *integration@), resource/room accounts, and shared role mailboxes now classify as service (never reclaim).
  2. SPINOUT_DOMAINS (projecteagle.net, sentala.org, hlevel.ai) — spinout family now clusters even with a blank HR Entity (11 → 53 matched).
  3. Duplicate detector — groups activated accounts by employeeNumber/name.
  4. Contractor bucket (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).
  5. Deprovisioned accounts excluded — they don’t consume a seat and are skipped entirely from all buckets/counts/report sections.
  6. Internal-domain rescue (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.
  7. New-hire guard (--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.

Deactivation / undo tooling

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.

Director-approved deactivation policy (2026-06-11)

Resuming on another machine: git pull this 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.