From 47d1bf14904f37724d65a0355e0261def88c6103 Mon Sep 17 00:00:00 2001 From: zergzorg Date: Thu, 21 May 2026 17:35:19 +0300 Subject: [PATCH] Add enterprise SCIM deprovisioning guard --- .../README.md | 42 +++ .../acceptance-notes.md | 25 ++ enterprise-scim-deprovisioning-guard/demo.js | 81 +++++ enterprise-scim-deprovisioning-guard/index.js | 180 +++++++++++ .../package.json | 12 + .../reports/demo.mp4 | Bin 0 -> 5762 bytes .../reports/deprovisioning-review-packet.json | 283 ++++++++++++++++++ .../reports/deprovisioning-review-report.md | 43 +++ .../reports/summary.svg | 16 + .../requirements-map.md | 18 ++ .../sample-data.js | 85 ++++++ enterprise-scim-deprovisioning-guard/test.js | 36 +++ 12 files changed, 821 insertions(+) create mode 100644 enterprise-scim-deprovisioning-guard/README.md create mode 100644 enterprise-scim-deprovisioning-guard/acceptance-notes.md create mode 100644 enterprise-scim-deprovisioning-guard/demo.js create mode 100644 enterprise-scim-deprovisioning-guard/index.js create mode 100644 enterprise-scim-deprovisioning-guard/package.json create mode 100644 enterprise-scim-deprovisioning-guard/reports/demo.mp4 create mode 100644 enterprise-scim-deprovisioning-guard/reports/deprovisioning-review-packet.json create mode 100644 enterprise-scim-deprovisioning-guard/reports/deprovisioning-review-report.md create mode 100644 enterprise-scim-deprovisioning-guard/reports/summary.svg create mode 100644 enterprise-scim-deprovisioning-guard/requirements-map.md create mode 100644 enterprise-scim-deprovisioning-guard/sample-data.js create mode 100644 enterprise-scim-deprovisioning-guard/test.js diff --git a/enterprise-scim-deprovisioning-guard/README.md b/enterprise-scim-deprovisioning-guard/README.md new file mode 100644 index 00000000..1a6ca40c --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/README.md @@ -0,0 +1,42 @@ +# Enterprise SCIM Deprovisioning Guard + +This module adds a focused SCIM/HRIS deprovisioning guard for SCIBASE issue +[#19](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/19). It helps enterprise +admins reconcile identity lifecycle events against SCIBASE roles, API tokens, +export connectors, seat licenses, compute quota, and pending publication +ownership. + +The slice is intentionally narrow. It does not duplicate API change governance, +connector certification, secret rotation, compute quota governance, AI model +governance, funder reporting export, incident response, dashboard attribution, +policy exception review, IRB consent governance, initiative tags, or data export +approval queues already submitted for the same issue. + +## What It Checks + +- HRIS leaver or contract-ended status against SCIM active state. +- Protected enterprise roles after termination or department transfer. +- Active API tokens past the termination grace window. +- Export connector ownership for DSpace, Invenio, Zenodo, and PubMed Central. +- Enterprise seat license reclaim readiness. +- Compute quota reassignment or reclaim actions. +- Pending publication owner transfer before exports proceed. + +## Local Usage + +```bash +cd enterprise-scim-deprovisioning-guard +npm run check +npm test +npm run demo +``` + +`npm run demo` writes reviewer artifacts under `reports/`: + +- `deprovisioning-review-packet.json` +- `deprovisioning-review-report.md` +- `summary.svg` +- `demo.mp4` + +All examples use synthetic identity data. No HRIS, SSO, SCIM, payment, KYC, or +private account integrations are called. diff --git a/enterprise-scim-deprovisioning-guard/acceptance-notes.md b/enterprise-scim-deprovisioning-guard/acceptance-notes.md new file mode 100644 index 00000000..92310450 --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/acceptance-notes.md @@ -0,0 +1,25 @@ +# Acceptance Notes + +## Reviewer Checklist + +- Self-contained under `enterprise-scim-deprovisioning-guard/`. +- Dependency-free Node.js implementation. +- Synthetic enterprise identity data only. +- Tests cover clear, review, and revoke decisions. +- Demo artifacts include JSON, Markdown, SVG, and MP4 outputs. + +## Commands Run + +```bash +npm run check +npm test +npm run demo +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 reports/demo.mp4 +git diff --check +``` + +## Limitations + +- This is a deterministic local guard, not a live SCIM or HRIS connector. +- Production integration should replace synthetic events with signed SCIBASE + identity lifecycle and connector audit logs. diff --git a/enterprise-scim-deprovisioning-guard/demo.js b/enterprise-scim-deprovisioning-guard/demo.js new file mode 100644 index 00000000..9a46bae8 --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/demo.js @@ -0,0 +1,81 @@ +const fs = require("node:fs") +const path = require("node:path") +const { spawnSync } = require("node:child_process") +const { evaluateEnterpriseIdentities } = require("./index") +const { deprovisioningPolicy, identities } = require("./sample-data") + +const reportsDir = path.join(__dirname, "reports") +fs.mkdirSync(reportsDir, { recursive: true }) + +const packet = evaluateEnterpriseIdentities({ identities, deprovisioningPolicy }) +const { summary } = packet + +fs.writeFileSync( + path.join(reportsDir, "deprovisioning-review-packet.json"), + `${JSON.stringify(packet, null, 2)}\n`, +) + +const markdown = [ + "# Enterprise SCIM Deprovisioning Guard Report", + "", + `Generated identities: ${summary.totalIdentities}`, + `Clear: ${summary.clear}`, + `Needs review: ${summary.review}`, + `Needs revocation: ${summary.revoke}`, + `Admin actions: ${summary.adminActions}`, + `Active tokens at risk: ${summary.activeTokensAtRisk}`, + `Audit digest: \`${packet.audit.digest}\``, + "", + "## Identity Decisions", + ...packet.decisions.flatMap((decision) => [ + "", + `### ${decision.id}: ${decision.email}`, + `- Status: ${decision.status}`, + `- HRIS status: ${decision.hrisStatus}`, + `- SCIM active: ${decision.scimActive}`, + `- Protected roles: ${decision.protectedRoles.join(", ") || "none"}`, + `- Findings: ${decision.findings.map((finding) => finding.code).join(", ") || "none"}`, + `- First action: ${decision.adminActions[0]?.message || "none"}`, + ]), + "", +] + +fs.writeFileSync(path.join(reportsDir, "deprovisioning-review-report.md"), markdown.join("\n")) + +const svg = ` + + Enterprise SCIM Deprovisioning Guard + Synthetic enterprise identity review packet for SCIBASE issue #19 + + ${summary.clear} + clear + + ${summary.review} + review + + ${summary.revoke} + revoke + Checks: HRIS status, SCIM active flag, protected roles, API tokens, export connectors, seats, quota, publications. + Digest ${packet.audit.digest.slice(0, 24)}... + +` +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg) + +const ffmpeg = spawnSync("ffmpeg", [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x111827:s=960x540:d=6:r=15", + "-vf", + "drawbox=x=48:y=170:w=250:h=150:color=0x0f766e@1:t=fill,drawbox=x=355:y=170:w=250:h=150:color=0xb45309@1:t=fill,drawbox=x=662:y=170:w=250:h=150:color=0x991b1b@1:t=fill,drawbox=x=48:y=370:w=864:h=18:color=0x38bdf8@1:t=fill", + "-pix_fmt", + "yuv420p", + path.join(reportsDir, "demo.mp4"), +], { stdio: "ignore" }) + +if (ffmpeg.status !== 0) { + console.warn("ffmpeg video generation failed; summary.svg and JSON/Markdown reports were still generated.") +} + +console.log(`Wrote enterprise deprovisioning artifacts to ${reportsDir}`) diff --git a/enterprise-scim-deprovisioning-guard/index.js b/enterprise-scim-deprovisioning-guard/index.js new file mode 100644 index 00000000..de6abdaa --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/index.js @@ -0,0 +1,180 @@ +const crypto = require("node:crypto") + +const GENERATED_AT = "2026-05-21T14:45:00.000Z" + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]` + } + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}` + } + return JSON.stringify(value) +} + +function digestFor(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex") +} + +function hoursSince(value, now = GENERATED_AT) { + return Math.max(0, (new Date(now).getTime() - new Date(value).getTime()) / 3600000) +} + +function finding(code, severity, message, detail = {}) { + return { code, severity, message, detail } +} + +function action(code, owner, message) { + return { code, owner, message } +} + +function terminalTimestamp(identity) { + return identity.terminationAt || identity.transferAt || GENERATED_AT +} + +function isLeaver(identity) { + return ["terminated", "contract-ended"].includes(identity.hrisStatus) +} + +function isTransfer(identity) { + return identity.hrisStatus === "department-transfer" +} + +function evaluateIdentity(identity, policy) { + const findings = [] + const terminalAt = terminalTimestamp(identity) + const hoursOpen = hoursSince(terminalAt) + const activeProtectedRoles = identity.roles.filter((role) => policy.protectedRoles.includes(role)) + const activeTokens = identity.apiTokens.filter((token) => token.active) + const activeConnectors = identity.exportConnectors.filter((connector) => connector.active) + + if (isLeaver(identity) && identity.scimActive) { + findings.push(finding("SCIM_ACCOUNT_STILL_ACTIVE", "blocker", "SCIM account remains active after HRIS leaver event.", { + hrisStatus: identity.hrisStatus, + hoursSinceEvent: Number(hoursOpen.toFixed(1)), + })) + } + + if (isLeaver(identity) && activeProtectedRoles.length > 0) { + findings.push(finding("PROTECTED_ROLE_NOT_REVOKED", "blocker", "Leaver still has protected enterprise roles.", { + roles: activeProtectedRoles, + })) + } + + if (isLeaver(identity) && activeTokens.length > 0 && hoursOpen > policy.maxTokenAgeAfterTerminationHours) { + findings.push(finding("ACTIVE_API_TOKEN_AFTER_TERMINATION", "blocker", "Active API tokens remain past the termination grace window.", { + tokenIds: activeTokens.map((token) => token.id), + hoursSinceTermination: Number(hoursOpen.toFixed(1)), + })) + } + + if (activeConnectors.length > 0 && (isLeaver(identity) || isTransfer(identity))) { + findings.push(finding("EXPORT_CONNECTOR_OWNER_REVIEW", isLeaver(identity) ? "blocker" : "warning", "External export connector ownership needs reassignment or approval.", { + connectors: activeConnectors.map((connector) => ({ id: connector.id, system: connector.system })), + })) + } + + if (identity.seatLicense.active && isLeaver(identity) && hoursOpen > policy.maxSeatGraceHours) { + findings.push(finding("SEAT_LICENSE_RECLAIM_OVERDUE", "warning", "Enterprise seat license should be reclaimed after leaver grace period.", { + plan: identity.seatLicense.plan, + hoursSinceTermination: Number(hoursOpen.toFixed(1)), + })) + } + + if (identity.computeQuota.active && (isLeaver(identity) || isTransfer(identity))) { + findings.push(finding("COMPUTE_QUOTA_REASSIGNMENT_NEEDED", isLeaver(identity) ? "blocker" : "warning", "Active compute quota must be reclaimed or moved to a new accountable owner.", { + remainingHours: identity.computeQuota.remainingHours, + })) + } + + const publicationsNeedingTransfer = identity.pendingPublications.filter((publication) => publication.needsOwnerTransfer) + if (publicationsNeedingTransfer.length > 0 && (isLeaver(identity) || isTransfer(identity))) { + findings.push(finding("PUBLICATION_OWNER_TRANSFER_NEEDED", "warning", "Pending publications need a replacement accountable owner before export.", { + publicationIds: publicationsNeedingTransfer.map((publication) => publication.id), + })) + } + + if (isTransfer(identity) && activeProtectedRoles.length > 0) { + findings.push(finding("TRANSFER_PROTECTED_ROLE_REVIEW", "warning", "Department transfer should review elevated enterprise roles.", { + roles: activeProtectedRoles, + department: identity.department, + })) + } + + const blockers = findings.filter((item) => item.severity === "blocker") + const warnings = findings.filter((item) => item.severity === "warning") + const status = blockers.length > 0 ? "revoke" : (warnings.length > 0 ? "review" : "clear") + + const decision = { + id: identity.id, + email: identity.email, + status, + hrisStatus: identity.hrisStatus, + scimActive: identity.scimActive, + protectedRoles: activeProtectedRoles, + activeTokens: activeTokens.map((token) => token.id), + activeExportConnectors: activeConnectors.map((connector) => connector.id), + findings, + adminActions: buildAdminActions(status, findings), + } + + return { + ...decision, + auditDigest: digestFor(decision), + } +} + +function buildAdminActions(status, findings) { + if (status === "clear") { + return [action("NO_DEPROVISIONING_ACTION", "enterprise-admin", "Identity is aligned with HRIS and SCIM state.")] + } + + const actions = [] + for (const item of findings) { + if (item.code === "SCIM_ACCOUNT_STILL_ACTIVE") { + actions.push(action("DISABLE_SCIM_ACCOUNT", "identity-admin", "Disable the SCIM account and sync downstream roles.")) + } else if (item.code === "ACTIVE_API_TOKEN_AFTER_TERMINATION") { + actions.push(action("REVOKE_API_TOKENS", "security-admin", "Revoke active API tokens tied to the leaver account.")) + } else if (item.code === "PROTECTED_ROLE_NOT_REVOKED") { + actions.push(action("REMOVE_PROTECTED_ROLES", "enterprise-admin", "Remove protected enterprise roles or transfer them to an approved owner.")) + } else if (item.code === "EXPORT_CONNECTOR_OWNER_REVIEW") { + actions.push(action("TRANSFER_EXPORT_CONNECTOR_OWNER", "integration-admin", "Assign export connector ownership to an active accountable owner.")) + } else if (item.code === "COMPUTE_QUOTA_REASSIGNMENT_NEEDED") { + actions.push(action("RECLAIM_COMPUTE_QUOTA", "finance-ops", "Reclaim or reassign compute quota before further usage.")) + } else if (item.code === "PUBLICATION_OWNER_TRANSFER_NEEDED") { + actions.push(action("TRANSFER_PUBLICATION_OWNER", "research-office", "Assign replacement owner for pending publication exports.")) + } else { + actions.push(action(`ADDRESS_${item.code}`, "enterprise-admin", item.message)) + } + } + + return [...new Map(actions.map((item) => [item.code, item])).values()] +} + +function evaluateEnterpriseIdentities({ identities, deprovisioningPolicy }) { + const decisions = identities.map((identity) => evaluateIdentity(identity, deprovisioningPolicy)) + const summary = { + totalIdentities: decisions.length, + clear: decisions.filter((decision) => decision.status === "clear").length, + review: decisions.filter((decision) => decision.status === "review").length, + revoke: decisions.filter((decision) => decision.status === "revoke").length, + adminActions: decisions.reduce((sum, decision) => sum + decision.adminActions.length, 0), + activeTokensAtRisk: decisions.reduce((sum, decision) => sum + decision.activeTokens.length, 0), + } + + return { + generatedAt: GENERATED_AT, + policy: deprovisioningPolicy, + summary, + decisions, + audit: { + source: "synthetic-enterprise-scim-deprovisioning-review", + digest: digestFor({ summary, decisions }), + }, + } +} + +module.exports = { + evaluateEnterpriseIdentities, + evaluateIdentity, +} diff --git a/enterprise-scim-deprovisioning-guard/package.json b/enterprise-scim-deprovisioning-guard/package.json new file mode 100644 index 00000000..79240d8c --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "enterprise-scim-deprovisioning-guard", + "version": "1.0.0", + "description": "SCIM/HRIS deprovisioning guard for SCIBASE enterprise tooling", + "private": true, + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + }, + "license": "MIT" +} diff --git a/enterprise-scim-deprovisioning-guard/reports/demo.mp4 b/enterprise-scim-deprovisioning-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2960c663020a9062ee20436c2f5559d18ac200d3 GIT binary patch literal 5762 zcmeHLe^39GLF<4RM1rUrC25*?uzUQ>=_Yqj6=T}bR-J^VIret z$LOH*``DOJX!q%P7vDMGRn*!ryOZdeb@|JHJvW=M5v;881eKdZs9AQqo$zGkSZ;=66zqn_?GYFalL#tr zI9A0Ar9RyQn5^)Acd4uZYIZ6xw2ZF?+q6B042y0_@Y7Tdp|QN5XDf6%o5a&p85~oU z*L*NsW!-)5Vq9fuR`n8+2Lg-Tf(C8R${>ky>n=-fx4L(_SM&L#u} zRtYi!77=VQ<&Xe)IZ-X7>{Nd3YBVv_d%peahT@voGtN(l-9`Hs2DTl3B=FeFPW#`o z=kC9PkTvp289z~7Q?RdDUI(MCDAQ@pM=iBBBB3@m-@4lJjpdWAJ=Terw@3YS@2&dJ zS`xZXZV@(i9_Y+z`{%Mw#}^;Gb^YF>D}|4HP771Ueg2QmpRo(`Zpbl-WoHt%6WW4~ zb=wZMyqNZ>W9?TB^OJABe*OK!zgrPHn|SNxq^S6g_d>v+SnjnGSTY@z$R|UwWdv=aG_YzgT@@*25OdC3)`c zXOB5h|6c0bDeE5} zr*5H=3-&p8cGXRK1xU2R(H!SVQln zs2?4RdoZedtu1xK&SST(SvImW{y5>l?zS^|Z*F+$Su+& ze7Y%Te9WqU`LFJ++1veA;Pi=vzqj6gyJmL%d%K)?QKCNm&czQaoafdDA8VNP+v9b< zm)dx?c5%xtciJ|L&$+L%2QB{Y5jo)M< z>~j$$_(ZpX#O((p-PPnhbL0+b)ln;fC+!yJrh_+prH zkYSk2r4cEwj6%7@plpsGnX=h7l=6@}BgvN{q~KZ9XsxW!TG8!%>=R+5GiT%64-yJtIG zWr<-pNRLDre0(Izru0!L!x>vJ9Hr@H-{u^ev60Q$4>1F4Y|fE3N!ZK!Q79w(a*$!z zBxC#X+fJXceL1!-M?4DfrTIVFmo=f@T@A+V+jl4a*a?T)UA!-cqjVXRckR9$j?(n< zuI)=X)VpK#z`$->WqSEvy)UP`Ht!Fok2}=+tJeomM%5#9k1ok_B`}LprH_NUU$Mdu zuPF+$ApE&g1~j01P}_M&b2upqq1Yh27nK3{j9zANLUQ$$ur=TvTn6zB$7^kem!C9a zU^>s~8c1opsKHyjQNRMW8yB8)Y_h}&3?PKW4fcI5==jjGBgQzJj}ukX1VZ@zJR;+I zIeuR+V*H%QW1W~o@WSQ=A*oU_jP{>)I%i^O9K03c!B8+hp+Xf2Q;u1s=^hc}Lz=Ed z76j|Of&Zd_8khbyjSg!&xLpJyesHl&eiHO^?HTs`42q-(QSjjZ5=_weP6@#eY?KFD zg%%FY2>lhMmO{O!%XuYn<&{L7DL`XI(-Tst1`CSyT{`!tneDf4-o&FPDvX9#2u}n* zN$?IEj`F?7p^Zz5rW-4OWvo+-P&Ag4z%jfsxP-??b+dPLK@a!CDj9WBxQ?`O6XS+0 zsK; + + Enterprise SCIM Deprovisioning Guard + Synthetic enterprise identity review packet for SCIBASE issue #19 + + 1 + clear + + 1 + review + + 2 + revoke + Checks: HRIS status, SCIM active flag, protected roles, API tokens, export connectors, seats, quota, publications. + Digest 7f833ceb0c632024edc51860... + diff --git a/enterprise-scim-deprovisioning-guard/requirements-map.md b/enterprise-scim-deprovisioning-guard/requirements-map.md new file mode 100644 index 00000000..fc082b5c --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/requirements-map.md @@ -0,0 +1,18 @@ +# Requirements Map + +| Issue #19 capability | Coverage in this module | +| --- | --- | +| Admin dashboards | Emits clear/review/revoke identity lifecycle decisions for enterprise admins. | +| API and webhooks | Models HRIS/SCIM lifecycle events and downstream connector ownership review. | +| HRIS and ORCID/personnel sync | Reconciles HRIS status against SCIM active state and SCIBASE roles. | +| Institutional repositories | Reviews export connector ownership for repository integrations. | +| Usage stats and compute usage | Flags active compute quota that must be reclaimed or reassigned. | +| Compliance tracking | Produces deterministic audit packets and admin actions for access governance. | + +## Non-Overlap Notes + +This is an identity lifecycle control. It avoids duplicating existing #19 work +around enterprise API changes, connector certification, secret rotation, +compute quota policy, model governance, funder export, incident response, +dashboard attribution, policy exceptions, IRB consent, initiative tags, and +data export approval queues. diff --git a/enterprise-scim-deprovisioning-guard/sample-data.js b/enterprise-scim-deprovisioning-guard/sample-data.js new file mode 100644 index 00000000..1de76ac7 --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/sample-data.js @@ -0,0 +1,85 @@ +const deprovisioningPolicy = { + maxTokenAgeAfterTerminationHours: 2, + maxSeatGraceHours: 24, + protectedRoles: ["org-admin", "billing-admin", "export-admin", "publication-owner"], + exportSystems: ["DSpace", "Invenio", "Zenodo", "PubMed Central"], +} + +const identities = [ + { + id: "USR-OK-001", + email: "active.researcher@example.edu", + hrisStatus: "active", + scimActive: true, + department: "Bioinformatics", + roles: ["researcher"], + apiTokens: [ + { id: "tok-active-1", active: true, lastRotatedAt: "2026-05-01T12:00:00Z" }, + ], + exportConnectors: [], + seatLicense: { plan: "enterprise-researcher", active: true, lastChangedAt: "2026-05-01T12:00:00Z" }, + computeQuota: { active: true, remainingHours: 20 }, + pendingPublications: [], + }, + { + id: "USR-REVOKE-002", + email: "departed.admin@example.edu", + hrisStatus: "terminated", + terminationAt: "2026-05-21T06:00:00Z", + scimActive: false, + department: "Chemistry", + roles: ["org-admin", "export-admin"], + apiTokens: [ + { id: "tok-admin-9", active: true, lastRotatedAt: "2026-04-10T08:00:00Z" }, + ], + exportConnectors: [ + { id: "exp-zenodo-4", system: "Zenodo", ownerEmail: "departed.admin@example.edu", active: true }, + ], + seatLicense: { plan: "enterprise-admin", active: true, lastChangedAt: "2026-01-01T00:00:00Z" }, + computeQuota: { active: true, remainingHours: 180 }, + pendingPublications: [ + { id: "PUB-77", stage: "journal-export", needsOwnerTransfer: true }, + ], + }, + { + id: "USR-REVIEW-003", + email: "visiting.scholar@example.edu", + hrisStatus: "contract-ended", + terminationAt: "2026-05-20T18:00:00Z", + scimActive: true, + department: "Materials", + roles: ["researcher", "publication-owner"], + apiTokens: [ + { id: "tok-visitor-2", active: false, lastRotatedAt: "2026-05-19T08:00:00Z" }, + ], + exportConnectors: [], + seatLicense: { plan: "enterprise-researcher", active: true, lastChangedAt: "2026-05-01T00:00:00Z" }, + computeQuota: { active: false, remainingHours: 0 }, + pendingPublications: [ + { id: "PUB-91", stage: "preprint-deposit", needsOwnerTransfer: true }, + ], + }, + { + id: "USR-RECLAIM-004", + email: "transferred.pi@example.edu", + hrisStatus: "department-transfer", + transferAt: "2026-05-21T07:30:00Z", + scimActive: true, + department: "Physics", + roles: ["billing-admin", "researcher"], + apiTokens: [ + { id: "tok-pi-3", active: true, lastRotatedAt: "2026-05-19T06:00:00Z" }, + ], + exportConnectors: [ + { id: "exp-dspace-2", system: "DSpace", ownerEmail: "transferred.pi@example.edu", active: true }, + ], + seatLicense: { plan: "enterprise-admin", active: true, lastChangedAt: "2026-02-01T00:00:00Z" }, + computeQuota: { active: true, remainingHours: 75 }, + pendingPublications: [], + }, +] + +module.exports = { + deprovisioningPolicy, + identities, +} diff --git a/enterprise-scim-deprovisioning-guard/test.js b/enterprise-scim-deprovisioning-guard/test.js new file mode 100644 index 00000000..4fecaa90 --- /dev/null +++ b/enterprise-scim-deprovisioning-guard/test.js @@ -0,0 +1,36 @@ +const assert = require("node:assert/strict") +const { evaluateEnterpriseIdentities } = require("./index") +const { deprovisioningPolicy, identities } = require("./sample-data") + +const packet = evaluateEnterpriseIdentities({ identities, deprovisioningPolicy }) + +assert.equal(packet.summary.totalIdentities, 4) +assert.equal(packet.summary.clear, 1) +assert.equal(packet.summary.review, 1) +assert.equal(packet.summary.revoke, 2) +assert.match(packet.audit.digest, /^[a-f0-9]{64}$/) + +const active = packet.decisions.find((decision) => decision.id === "USR-OK-001") +assert.equal(active.status, "clear") +assert.equal(active.findings.length, 0) +assert.equal(active.adminActions[0].code, "NO_DEPROVISIONING_ACTION") + +const departed = packet.decisions.find((decision) => decision.id === "USR-REVOKE-002") +assert.equal(departed.status, "revoke") +assert.ok(departed.findings.some((finding) => finding.code === "PROTECTED_ROLE_NOT_REVOKED")) +assert.ok(departed.findings.some((finding) => finding.code === "ACTIVE_API_TOKEN_AFTER_TERMINATION")) +assert.ok(departed.findings.some((finding) => finding.code === "EXPORT_CONNECTOR_OWNER_REVIEW")) +assert.ok(departed.adminActions.some((action) => action.code === "REVOKE_API_TOKENS")) + +const visitor = packet.decisions.find((decision) => decision.id === "USR-REVIEW-003") +assert.equal(visitor.status, "revoke") +assert.ok(visitor.findings.some((finding) => finding.code === "SCIM_ACCOUNT_STILL_ACTIVE")) +assert.ok(visitor.findings.some((finding) => finding.code === "PUBLICATION_OWNER_TRANSFER_NEEDED")) + +const transfer = packet.decisions.find((decision) => decision.id === "USR-RECLAIM-004") +assert.equal(transfer.status, "review") +assert.ok(transfer.findings.some((finding) => finding.code === "TRANSFER_PROTECTED_ROLE_REVIEW")) +assert.ok(transfer.findings.some((finding) => finding.code === "COMPUTE_QUOTA_REASSIGNMENT_NEEDED")) +assert.ok(transfer.adminActions.some((action) => action.code === "RECLAIM_COMPUTE_QUOTA")) + +console.log("enterprise-scim-deprovisioning-guard tests passed")