diff --git a/institutional-access-recertification/README.md b/institutional-access-recertification/README.md new file mode 100644 index 0000000..29f23e4 --- /dev/null +++ b/institutional-access-recertification/README.md @@ -0,0 +1,25 @@ +# Institutional Access Recertification + +This module adds a focused User & Project Management slice for recertifying institutional and external collaborator access to sensitive scientific project spaces. + +## What it checks + +- Approved institutional domains and named external sponsors +- Current SAML assertions for elevated roles +- MFA requirements for owner, admin, editor, and data-steward access +- Verified ORCID linkage for researcher identity +- Required training and IRB approval expiry +- Project access review windows +- Stale pending invitations +- Object-level grants that exceed the member's role policy +- Deterministic audit digest for review records + +## Run locally + +```bash +npm run check +npm test +npm run demo +``` + +The sample data intentionally includes an external collaborator with expired institutional evidence, missing compliance approvals, and an object grant that exceeds the project policy. diff --git a/institutional-access-recertification/demo.js b/institutional-access-recertification/demo.js new file mode 100644 index 0000000..52d2c05 --- /dev/null +++ b/institutional-access-recertification/demo.js @@ -0,0 +1,24 @@ +"use strict"; + +const sampleBundle = require("./sample-data.json"); +const { + analyzeInstitutionalAccess, +} = require("./src/institutional-access-recertification"); + +const result = analyzeInstitutionalAccess(sampleBundle); + +console.log(`Project: ${result.projectName}`); +console.log(`Decision: ${result.decision}`); +console.log(`Audit digest: ${result.auditDigest}`); +console.log(""); +console.log("Member actions:"); +for (const action of result.memberActions) { + console.log(`- ${action.email}: ${action.recommendedAction} (${action.blockerCount} blocker, ${action.warningCount} warning)`); +} +console.log(""); +console.log("Findings:"); +for (const finding of result.findings) { + console.log(`- [${finding.severity}] ${finding.memberId} ${finding.id}: ${finding.title}`); + console.log(` detail: ${finding.detail}`); + console.log(` remediation: ${finding.remediation}`); +} diff --git a/institutional-access-recertification/docs/access-recertification-demo.mp4 b/institutional-access-recertification/docs/access-recertification-demo.mp4 new file mode 100644 index 0000000..c3c4427 Binary files /dev/null and b/institutional-access-recertification/docs/access-recertification-demo.mp4 differ diff --git a/institutional-access-recertification/docs/requirement-map.md b/institutional-access-recertification/docs/requirement-map.md new file mode 100644 index 0000000..0445254 --- /dev/null +++ b/institutional-access-recertification/docs/requirement-map.md @@ -0,0 +1,15 @@ +# Requirement Map + +| User & Project Management requirement | Implementation | +| --- | --- | +| User identity and linked researcher profiles | Members include institutional domains, SAML assertion expiry, ORCID verification timestamps, MFA posture, and email identity. | +| Project spaces and collaborators | `project` models allowed institution domains, review cadence, required training, and IRB protocols; `members` model internal and external collaborators. | +| Role-based access control | Elevated roles trigger SSO and MFA controls, and member roles are compared against object-level policies. | +| Object-level permissions | `objectPolicies` defines allowed roles per sensitive dataset/notebook, and `object-grant-exceeds-role` flags over-broad grants. | +| External collaborator management | External members require sponsors, current evidence, and stricter overdue review handling. | +| Invitations | Pending invitations older than the configured TTL are surfaced for cleanup. | +| Audit logs and review evidence | Every analysis returns member actions, findings, and a deterministic `sha256` audit digest. | + +## Demo Video + +The PR includes `docs/access-recertification-demo.mp4`, a real terminal walkthrough running the local check, test, and demo scripts. diff --git a/institutional-access-recertification/package.json b/institutional-access-recertification/package.json new file mode 100644 index 0000000..d8822f6 --- /dev/null +++ b/institutional-access-recertification/package.json @@ -0,0 +1,12 @@ +{ + "name": "institutional-access-recertification", + "version": "1.0.0", + "description": "Institutional collaborator access recertification for scientific project spaces.", + "main": "src/institutional-access-recertification.js", + "scripts": { + "check": "node --check src/institutional-access-recertification.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + }, + "license": "MIT" +} diff --git a/institutional-access-recertification/sample-data.json b/institutional-access-recertification/sample-data.json new file mode 100644 index 0000000..ce3fcfc --- /dev/null +++ b/institutional-access-recertification/sample-data.json @@ -0,0 +1,93 @@ +{ + "now": "2026-05-15T00:00:00.000Z", + "project": { + "id": "project-cell-atlas-17", + "name": "Cross-institution cell atlas replication", + "allowedInstitutionDomains": ["scibase.ai", "university.example"], + "requiredTraining": ["human-subjects-data", "controlled-data-handling"], + "requiredIrbProtocols": ["IRB-2026-CELL-17"], + "reviewIntervalDays": 90, + "pendingInvitationTtlDays": 14 + }, + "objectPolicies": [ + { + "objectId": "dataset:restricted-cell-atlas", + "allowedRoles": ["owner", "admin", "data-steward"] + }, + { + "objectId": "notebook:analysis-qc", + "allowedRoles": ["owner", "admin", "editor", "data-steward"] + } + ], + "members": [ + { + "id": "member-001", + "primaryEmail": "mira@scibase.ai", + "institutionDomain": "scibase.ai", + "external": false, + "externalSponsor": null, + "roles": ["owner", "data-steward"], + "mfaEnabled": true, + "samlAssertionExpiresAt": "2026-10-01T00:00:00.000Z", + "orcidVerifiedAt": "2026-01-15T12:00:00.000Z", + "trainingCertifications": [ + { "name": "human-subjects-data", "expiresAt": "2026-12-31T00:00:00.000Z" }, + { "name": "controlled-data-handling", "expiresAt": "2026-12-31T00:00:00.000Z" } + ], + "irbApprovals": [ + { "protocolId": "IRB-2026-CELL-17", "expiresAt": "2026-12-01T00:00:00.000Z" } + ], + "objectGrants": [ + { "objectId": "dataset:restricted-cell-atlas", "permission": "write" } + ], + "invitationStatus": "accepted", + "invitedAt": "2026-01-02T00:00:00.000Z", + "lastAccessReviewAt": "2026-04-01T00:00:00.000Z" + }, + { + "id": "member-002", + "primaryEmail": "leo@partner-lab.example", + "institutionDomain": "partner-lab.example", + "external": true, + "externalSponsor": "mira@scibase.ai", + "roles": ["editor"], + "mfaEnabled": false, + "samlAssertionExpiresAt": "2026-03-01T00:00:00.000Z", + "orcidVerifiedAt": "2026-02-10T09:30:00.000Z", + "trainingCertifications": [ + { "name": "human-subjects-data", "expiresAt": "2026-02-01T00:00:00.000Z" }, + { "name": "controlled-data-handling", "expiresAt": "2026-11-30T00:00:00.000Z" } + ], + "irbApprovals": [], + "objectGrants": [ + { "objectId": "dataset:restricted-cell-atlas", "permission": "read" }, + { "objectId": "notebook:analysis-qc", "permission": "write" } + ], + "invitationStatus": "accepted", + "invitedAt": "2025-11-01T00:00:00.000Z", + "lastAccessReviewAt": "2026-01-10T00:00:00.000Z" + }, + { + "id": "member-003", + "primaryEmail": "guest@university.example", + "institutionDomain": "university.example", + "external": true, + "externalSponsor": "mira@scibase.ai", + "roles": ["viewer"], + "mfaEnabled": true, + "samlAssertionExpiresAt": "2026-09-01T00:00:00.000Z", + "orcidVerifiedAt": null, + "trainingCertifications": [ + { "name": "human-subjects-data", "expiresAt": "2026-12-31T00:00:00.000Z" }, + { "name": "controlled-data-handling", "expiresAt": "2026-12-31T00:00:00.000Z" } + ], + "irbApprovals": [ + { "protocolId": "IRB-2026-CELL-17", "expiresAt": "2026-12-01T00:00:00.000Z" } + ], + "objectGrants": [], + "invitationStatus": "pending", + "invitedAt": "2026-04-20T00:00:00.000Z", + "lastAccessReviewAt": "2026-03-20T00:00:00.000Z" + } + ] +} diff --git a/institutional-access-recertification/src/institutional-access-recertification.js b/institutional-access-recertification/src/institutional-access-recertification.js new file mode 100644 index 0000000..d65983f --- /dev/null +++ b/institutional-access-recertification/src/institutional-access-recertification.js @@ -0,0 +1,302 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const ELEVATED_ROLES = new Set(["owner", "admin", "editor", "data-steward"]); + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function stableHash(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function requireFields(object, fields, label) { + const missing = fields.filter((field) => object[field] === undefined || object[field] === null); + if (missing.length > 0) { + throw new Error(`${label} is missing required field(s): ${missing.join(", ")}`); + } +} + +function parseDate(value) { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid date: ${value}`); + return parsed; +} + +function daysBetween(earlier, later) { + return Math.floor((later.getTime() - earlier.getTime()) / 86400000); +} + +function finding(severity, id, memberId, title, detail, remediation) { + return { severity, id, memberId, title, detail, remediation }; +} + +function assertBundle(bundle) { + requireFields(bundle, ["project", "members", "objectPolicies"], "access review bundle"); + requireFields(bundle.project, [ + "id", + "name", + "allowedInstitutionDomains", + "requiredTraining", + "requiredIrbProtocols", + "reviewIntervalDays", + ], "project"); +} + +function hasElevatedRole(member) { + return member.roles.some((role) => ELEVATED_ROLES.has(role)); +} + +function isExpired(dateValue, now) { + const date = parseDate(dateValue); + return !date || date <= now; +} + +function hasCurrentTraining(member, trainingName, now) { + return member.trainingCertifications.some((certification) => { + return certification.name === trainingName && !isExpired(certification.expiresAt, now); + }); +} + +function hasCurrentIrb(member, protocolId, now) { + return member.irbApprovals.some((approval) => { + return approval.protocolId === protocolId && !isExpired(approval.expiresAt, now); + }); +} + +function evaluateIdentity(member, project, now) { + const findings = []; + const allowedDomain = project.allowedInstitutionDomains.includes(member.institutionDomain); + + if (!allowedDomain && !member.externalSponsor) { + findings.push(finding( + "blocker", + "external-sponsor-missing", + member.id, + "External collaborator has no named sponsor", + `${member.primaryEmail} uses ${member.institutionDomain}, which is outside the approved institution domains.`, + "Assign an internal sponsor or remove project access until sponsorship is documented.", + )); + } + + if (hasElevatedRole(member) && isExpired(member.samlAssertionExpiresAt, now)) { + findings.push(finding( + "blocker", + "saml-assertion-expired", + member.id, + "Elevated role has expired institutional assertion", + `${member.primaryEmail} holds ${member.roles.join(", ")} but the SAML assertion is expired or missing.`, + "Refresh institutional SSO evidence before renewing elevated project roles.", + )); + } + + if (hasElevatedRole(member) && !member.mfaEnabled) { + findings.push(finding( + "blocker", + "mfa-required-for-elevated-role", + member.id, + "Elevated project access lacks MFA", + `${member.primaryEmail} has elevated roles without multi-factor authentication.`, + "Require MFA before retaining owner, admin, editor, or data-steward access.", + )); + } + + if (!member.orcidVerifiedAt) { + findings.push(finding( + "warning", + "orcid-verification-missing", + member.id, + "Research identity has no verified ORCID link", + `${member.primaryEmail} is missing a verified ORCID timestamp.`, + "Verify ORCID before the next access review cycle.", + )); + } + + return findings; +} + +function evaluateComplianceEvidence(member, project, now) { + const findings = []; + + for (const trainingName of project.requiredTraining) { + if (!hasCurrentTraining(member, trainingName, now)) { + findings.push(finding( + "blocker", + "training-expired-or-missing", + member.id, + "Required collaborator training is expired or missing", + `${member.primaryEmail} lacks current ${trainingName} certification.`, + "Collect current training evidence before renewing access.", + )); + } + } + + for (const protocolId of project.requiredIrbProtocols) { + if (!hasCurrentIrb(member, protocolId, now)) { + findings.push(finding( + "blocker", + "irb-approval-expired-or-missing", + member.id, + "Required IRB approval is expired or missing", + `${member.primaryEmail} lacks current approval for ${protocolId}.`, + "Confirm IRB coverage or remove access to regulated project objects.", + )); + } + } + + return findings; +} + +function evaluateReviewWindow(member, project, now) { + const findings = []; + const lastReviewedAt = parseDate(member.lastAccessReviewAt); + const daysSinceReview = lastReviewedAt ? daysBetween(lastReviewedAt, now) : Infinity; + + if (daysSinceReview > project.reviewIntervalDays) { + findings.push(finding( + member.external ? "blocker" : "warning", + "access-review-overdue", + member.id, + "Access recertification window is overdue", + `${member.primaryEmail} was last reviewed ${Number.isFinite(daysSinceReview) ? daysSinceReview : "never"} days ago.`, + "Run sponsor recertification and record the review decision.", + )); + } + + if (member.invitationStatus === "pending") { + const invitedAt = parseDate(member.invitedAt); + const ageDays = invitedAt ? daysBetween(invitedAt, now) : Infinity; + if (ageDays > project.pendingInvitationTtlDays) { + findings.push(finding( + "warning", + "stale-invitation", + member.id, + "Pending project invitation is stale", + `${member.primaryEmail} has a pending invitation older than ${project.pendingInvitationTtlDays} days.`, + "Expire the invitation or ask the sponsor to resend it with fresh justification.", + )); + } + } + + return findings; +} + +function evaluateObjectGrants(member, objectPolicies) { + const findings = []; + + for (const grant of member.objectGrants) { + const policy = objectPolicies.find((item) => item.objectId === grant.objectId); + if (!policy) { + findings.push(finding( + "blocker", + "unknown-object-grant", + member.id, + "Object grant points to an unknown object", + `${member.primaryEmail} has ${grant.permission} on ${grant.objectId}, which has no policy record.`, + "Remove the grant or register the object policy before recertification.", + )); + continue; + } + + const allowed = policy.allowedRoles.some((role) => member.roles.includes(role)); + if (!allowed) { + findings.push(finding( + "blocker", + "object-grant-exceeds-role", + member.id, + "Object-level grant exceeds member role policy", + `${member.primaryEmail} has ${grant.permission} on ${grant.objectId} without one of: ${policy.allowedRoles.join(", ")}.`, + "Revoke the object grant or assign a role explicitly allowed by the object policy.", + )); + } + } + + return findings; +} + +function summarizeMember(member, findings) { + const memberFindings = findings.filter((item) => item.memberId === member.id); + const blockerCount = memberFindings.filter((item) => item.severity === "blocker").length; + const warningCount = memberFindings.filter((item) => item.severity === "warning").length; + let action = "renew"; + + if (blockerCount > 0) { + action = member.external ? "revoke-until-recertified" : "renew-after-evidence"; + } else if (warningCount > 0) { + action = "manual-review"; + } + + return { + memberId: member.id, + email: member.primaryEmail, + roles: member.roles, + external: member.external, + blockerCount, + warningCount, + recommendedAction: action, + }; +} + +function decide(findings) { + if (findings.some((item) => item.severity === "blocker")) return "access-changes-required"; + if (findings.some((item) => item.severity === "warning")) return "manual-review"; + return "recertified"; +} + +function analyzeInstitutionalAccess(bundle, options = {}) { + assertBundle(bundle); + const now = parseDate(options.now || bundle.now || new Date().toISOString()); + const findings = []; + + for (const member of bundle.members) { + requireFields(member, [ + "id", + "primaryEmail", + "institutionDomain", + "roles", + "trainingCertifications", + "irbApprovals", + "objectGrants", + ], `member ${member.id || "(unknown)"}`); + findings.push(...evaluateIdentity(member, bundle.project, now)); + findings.push(...evaluateComplianceEvidence(member, bundle.project, now)); + findings.push(...evaluateReviewWindow(member, bundle.project, now)); + findings.push(...evaluateObjectGrants(member, bundle.objectPolicies)); + } + + const memberActions = bundle.members.map((member) => summarizeMember(member, findings)); + const decision = decide(findings); + const auditDigest = stableHash({ + projectId: bundle.project.id, + decision, + memberActions, + findings, + evaluatedAt: now.toISOString(), + }); + + return { + projectId: bundle.project.id, + projectName: bundle.project.name, + evaluatedAt: now.toISOString(), + decision, + findings, + memberActions, + auditDigest: `sha256:${auditDigest}`, + }; +} + +module.exports = { + analyzeInstitutionalAccess, + stableHash, + stableStringify, +}; diff --git a/institutional-access-recertification/test.js b/institutional-access-recertification/test.js new file mode 100644 index 0000000..dd8641a --- /dev/null +++ b/institutional-access-recertification/test.js @@ -0,0 +1,54 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const sampleBundle = require("./sample-data.json"); +const { + analyzeInstitutionalAccess, + stableHash, +} = require("./src/institutional-access-recertification"); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function ids(result) { + return new Set(result.findings.map((finding) => finding.id)); +} + +const flagged = analyzeInstitutionalAccess(sampleBundle); +const flaggedIds = ids(flagged); + +assert.equal(flagged.decision, "access-changes-required"); +assert.match(flagged.auditDigest, /^sha256:[a-f0-9]{64}$/); +assert(flaggedIds.has("saml-assertion-expired")); +assert(flaggedIds.has("mfa-required-for-elevated-role")); +assert(flaggedIds.has("training-expired-or-missing")); +assert(flaggedIds.has("irb-approval-expired-or-missing")); +assert(flaggedIds.has("access-review-overdue")); +assert(flaggedIds.has("object-grant-exceeds-role")); +assert(flaggedIds.has("orcid-verification-missing")); +assert.equal( + flagged.memberActions.find((action) => action.memberId === "member-002").recommendedAction, + "revoke-until-recertified", +); + +const recertifiedBundle = clone(sampleBundle); +recertifiedBundle.members[1].roles = ["data-steward"]; +recertifiedBundle.members[1].mfaEnabled = true; +recertifiedBundle.members[1].samlAssertionExpiresAt = "2026-10-01T00:00:00.000Z"; +recertifiedBundle.members[1].trainingCertifications[0].expiresAt = "2026-12-31T00:00:00.000Z"; +recertifiedBundle.members[1].irbApprovals = [ + { protocolId: "IRB-2026-CELL-17", expiresAt: "2026-12-01T00:00:00.000Z" }, +]; +recertifiedBundle.members[1].lastAccessReviewAt = "2026-05-01T00:00:00.000Z"; +recertifiedBundle.members[2].orcidVerifiedAt = "2026-05-01T00:00:00.000Z"; +recertifiedBundle.members[2].invitedAt = "2026-05-10T00:00:00.000Z"; + +const recertified = analyzeInstitutionalAccess(recertifiedBundle); +assert.equal(recertified.decision, "recertified"); +assert.equal(recertified.findings.length, 0); +assert.equal(recertified.memberActions.every((action) => action.recommendedAction === "renew"), true); +assert.notEqual(flagged.auditDigest, recertified.auditDigest); +assert.equal(stableHash({ b: ["x"], a: 1 }), stableHash({ a: 1, b: ["x"] })); + +console.log("institutional access recertification tests passed");