Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions enterprise-scim-deprovisioning-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions enterprise-scim-deprovisioning-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions enterprise-scim-deprovisioning-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#111827"/>
<text x="48" y="78" fill="#f9fafb" font-family="Arial" font-size="34" font-weight="700">Enterprise SCIM Deprovisioning Guard</text>
<text x="48" y="124" fill="#cbd5e1" font-family="Arial" font-size="18">Synthetic enterprise identity review packet for SCIBASE issue #19</text>
<rect x="48" y="170" width="250" height="150" rx="14" fill="#0f766e"/>
<text x="78" y="230" fill="#ecfeff" font-family="Arial" font-size="56" font-weight="700">${summary.clear}</text>
<text x="78" y="270" fill="#ccfbf1" font-family="Arial" font-size="22">clear</text>
<rect x="355" y="170" width="250" height="150" rx="14" fill="#b45309"/>
<text x="385" y="230" fill="#fff7ed" font-family="Arial" font-size="56" font-weight="700">${summary.review}</text>
<text x="385" y="270" fill="#ffedd5" font-family="Arial" font-size="22">review</text>
<rect x="662" y="170" width="250" height="150" rx="14" fill="#991b1b"/>
<text x="692" y="230" fill="#fef2f2" font-family="Arial" font-size="56" font-weight="700">${summary.revoke}</text>
<text x="692" y="270" fill="#fee2e2" font-family="Arial" font-size="22">revoke</text>
<text x="48" y="390" fill="#e5e7eb" font-family="Arial" font-size="20">Checks: HRIS status, SCIM active flag, protected roles, API tokens, export connectors, seats, quota, publications.</text>
<text x="48" y="430" fill="#9ca3af" font-family="Arial" font-size="16">Digest ${packet.audit.digest.slice(0, 24)}...</text>
</svg>
`
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}`)
180 changes: 180 additions & 0 deletions enterprise-scim-deprovisioning-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
}
12 changes: 12 additions & 0 deletions enterprise-scim-deprovisioning-guard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
Binary file not shown.
Loading