diff --git a/user-project-management/access-invitation-policy-auditor/README.md b/user-project-management/access-invitation-policy-auditor/README.md new file mode 100644 index 00000000..f285e506 --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/README.md @@ -0,0 +1,25 @@ +# Access Invitation Policy Auditor + +This prototype audits invitation, role, identity, and object-level access controls for scientific project workspaces. + +It checks synthetic project access records for: + +- active invitations past expiry +- role escalation without owner approval +- restricted dataset download bypasses +- institutional-only access from mismatched SAML domains +- elevated roles without ORCID or institutional verification +- anonymous access to private or invitation-only projects +- invite, membership, and object-permission audit-log gaps +- restricted datasets without explicit download-deny rules +- stale external collaborators retaining elevated roles + +## Run + +```sh +node user-project-management/access-invitation-policy-auditor/test.js +node user-project-management/access-invitation-policy-auditor/demo.js +node user-project-management/access-invitation-policy-auditor/make-demo-video.js +``` + +All fixtures are synthetic. The module performs no account, identity-provider, private project, credential, SAML, ORCID, or external API access. diff --git a/user-project-management/access-invitation-policy-auditor/demo.js b/user-project-management/access-invitation-policy-auditor/demo.js new file mode 100644 index 00000000..019d74d9 --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/demo.js @@ -0,0 +1,60 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { auditAccessPolicy, renderMarkdownReport } = require("./index"); +const { projectAccessPacket } = require("./sample-data"); + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvg(report) { + const maxWidth = 720; + const riskWidth = Math.round((report.summary.riskScore / 100) * maxWidth); + const criticalWidth = Math.round((report.summary.criticalFindings / report.summary.findings) * maxWidth); + const highWidth = Math.round((report.summary.highFindings / report.summary.findings) * maxWidth); + + return ` + + + Access Invitation Policy Audit + ${escapeXml(report.project.title)} + Decision: ${escapeXml(report.summary.decision)} + + Audit risk + + + Critical findings + + + High findings + + + + ${report.summary.invitationsReviewed} invites • ${report.summary.membershipsReviewed} members • ${report.summary.objectPermissionsReviewed} object rules + Audit digest ${report.summary.auditDigest} + +`; +} + +function main() { + const report = auditAccessPolicy(projectAccessPacket); + const reportsDir = path.join(__dirname, "reports"); + fs.mkdirSync(reportsDir, { recursive: true }); + fs.writeFileSync(path.join(reportsDir, "access-invitation-policy-audit.json"), JSON.stringify(report, null, 2)); + fs.writeFileSync(path.join(reportsDir, "access-invitation-policy-audit.md"), renderMarkdownReport(report)); + fs.writeFileSync(path.join(reportsDir, "access-invitation-policy-summary.svg"), renderSvg(report)); + console.log(`decision=${report.summary.decision} riskScore=${report.summary.riskScore} findings=${report.summary.findings}`); + console.log(`reports=${reportsDir}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { renderSvg }; diff --git a/user-project-management/access-invitation-policy-auditor/index.js b/user-project-management/access-invitation-policy-auditor/index.js new file mode 100644 index 00000000..7d701305 --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/index.js @@ -0,0 +1,267 @@ +"use strict"; + +const crypto = require("crypto"); + +const ROLE_RANK = { + viewer: 1, + reviewer: 2, + contributor: 3, + admin: 4, + owner: 5, +}; + +const WEIGHTS = { + critical: 26, + high: 18, + medium: 10, + low: 5, +}; + +function addFinding(findings, finding) { + findings.push({ + severity: finding.severity || "medium", + recommendation: recommendationFor(finding.code), + ...finding, + }); +} + +function recommendationFor(code) { + const recommendations = { + EXPIRED_INVITE_ACTIVE: "Disable the invitation and rotate the access token.", + ROLE_ESCALATION_WITHOUT_APPROVAL: "Require owner approval before increasing collaborator role scope.", + OBJECT_PERMISSION_BYPASS: "Apply object-level deny rules before project-level role grants.", + INSTITUTION_SCOPE_MISMATCH: "Restrict institutional-only access to verified SAML domains.", + UNVERIFIED_IDENTITY_PRIVILEGE: "Require ORCID or institutional verification before granting elevated roles.", + ANONYMOUS_PRIVATE_ACCESS: "Block anonymous sessions from private or invitation-only workspaces.", + AUDIT_LOG_GAP: "Write an immutable audit-log event for every invite, role, and object-permission change.", + DATA_DOWNLOAD_NOT_RESTRICTED: "Set explicit deny-download policy for restricted datasets.", + STALE_EXTERNAL_COLLABORATOR: "Expire external collaborator access after inactivity or invite window close.", + }; + return recommendations[code] || "Route this item for project-access review."; +} + +function auditAccessPolicy(project, options = {}) { + const now = new Date(options.now || project.auditTime || new Date().toISOString()); + const findings = []; + const auditEventKeys = new Set((project.auditLog || []).map((event) => event.key)); + const identities = new Map((project.identities || []).map((identity) => [identity.userId, identity])); + const objectRules = project.objectPermissions || []; + + for (const invite of project.invitations || []) { + if (invite.status === "active" && new Date(invite.expiresAt).getTime() < now.getTime()) { + addFinding(findings, { + code: "EXPIRED_INVITE_ACTIVE", + severity: "critical", + inviteId: invite.id, + userId: invite.userId, + message: `Invitation ${invite.id} is active after expiry ${invite.expiresAt}.`, + evidence: invite, + }); + } + + if (invite.role && invite.requestedRole && roleRank(invite.role) > roleRank(invite.requestedRole) && !invite.ownerApprovalId) { + addFinding(findings, { + code: "ROLE_ESCALATION_WITHOUT_APPROVAL", + severity: "high", + inviteId: invite.id, + userId: invite.userId, + message: `Invitation ${invite.id} grants ${invite.role} after ${invite.requestedRole} request without owner approval.`, + evidence: invite, + }); + } + + const identity = identities.get(invite.userId); + if (["admin", "owner", "contributor"].includes(invite.role) && (!identity || (!identity.orcidVerified && !identity.samlVerified))) { + addFinding(findings, { + code: "UNVERIFIED_IDENTITY_PRIVILEGE", + severity: "high", + inviteId: invite.id, + userId: invite.userId, + message: `${invite.userId} has elevated role ${invite.role} without verified ORCID or SAML identity.`, + evidence: { invite, identity }, + }); + } + + if (!auditEventKeys.has(`invite:${invite.id}`)) { + addFinding(findings, { + code: "AUDIT_LOG_GAP", + severity: "medium", + inviteId: invite.id, + userId: invite.userId, + message: `Invitation ${invite.id} has no immutable audit-log event.`, + evidence: invite, + }); + } + } + + for (const membership of project.memberships || []) { + const identity = identities.get(membership.userId); + if (project.visibility === "institutional-only" && (!identity?.samlVerified || identity.institutionDomain !== project.institutionDomain)) { + addFinding(findings, { + code: "INSTITUTION_SCOPE_MISMATCH", + severity: "critical", + userId: membership.userId, + message: `${membership.userId} has institutional-only access without matching verified SAML domain.`, + evidence: { membership, identity, institutionDomain: project.institutionDomain }, + }); + } + + if (membership.anonymous && ["private", "invitation-only", "institutional-only"].includes(project.visibility)) { + addFinding(findings, { + code: "ANONYMOUS_PRIVATE_ACCESS", + severity: "critical", + userId: membership.userId, + message: `Anonymous membership ${membership.userId} can access ${project.visibility} project.`, + evidence: membership, + }); + } + + const inactiveDays = daysBetween(membership.lastActiveAt, now.toISOString()); + if (membership.external && inactiveDays > (project.policy?.externalInactivityLimitDays || 30) && membership.role !== "viewer") { + addFinding(findings, { + code: "STALE_EXTERNAL_COLLABORATOR", + severity: "medium", + userId: membership.userId, + message: `External collaborator ${membership.userId} has ${inactiveDays} inactive days with ${membership.role} role.`, + evidence: membership, + }); + } + + if (!auditEventKeys.has(`member:${membership.userId}:${membership.role}`)) { + addFinding(findings, { + code: "AUDIT_LOG_GAP", + severity: "medium", + userId: membership.userId, + message: `Membership ${membership.userId}/${membership.role} has no role audit-log event.`, + evidence: membership, + }); + } + } + + for (const rule of objectRules) { + const membership = (project.memberships || []).find((entry) => entry.userId === rule.userId); + if (!membership) continue; + + if (rule.objectType === "dataset" && rule.action === "download" && rule.effect === "allow" && project.restrictedDatasets?.includes(rule.objectId)) { + addFinding(findings, { + code: "OBJECT_PERMISSION_BYPASS", + severity: "critical", + userId: rule.userId, + objectId: rule.objectId, + message: `${rule.userId} can download restricted dataset ${rule.objectId}.`, + evidence: { rule, membership }, + }); + } + + if (rule.objectType === "dataset" && project.restrictedDatasets?.includes(rule.objectId)) { + const hasExplicitDeny = objectRules.some( + (candidate) => + candidate.objectId === rule.objectId && + candidate.userId === rule.userId && + candidate.action === "download" && + candidate.effect === "deny" + ); + if (!hasExplicitDeny) { + addFinding(findings, { + code: "DATA_DOWNLOAD_NOT_RESTRICTED", + severity: "high", + userId: rule.userId, + objectId: rule.objectId, + message: `${rule.objectId} lacks explicit download deny for ${rule.userId}.`, + evidence: rule, + }); + } + } + + if (!auditEventKeys.has(`object:${rule.id}`)) { + addFinding(findings, { + code: "AUDIT_LOG_GAP", + severity: "low", + userId: rule.userId, + objectId: rule.objectId, + message: `Object permission ${rule.id} has no audit-log event.`, + evidence: rule, + }); + } + } + + const riskScore = Math.min(100, findings.reduce((score, finding) => score + WEIGHTS[finding.severity], 0)); + const decision = riskScore >= 70 ? "hold-project-access" : riskScore >= 35 ? "repair-access-policy" : "access-policy-ready"; + const auditDigest = crypto + .createHash("sha256") + .update(JSON.stringify(findings.map((finding) => [finding.code, finding.inviteId, finding.userId, finding.objectId]).sort())) + .digest("hex") + .slice(0, 16); + + return { + project: { + id: project.id, + title: project.title, + visibility: project.visibility, + }, + summary: { + decision, + riskScore, + auditDigest, + invitationsReviewed: (project.invitations || []).length, + membershipsReviewed: (project.memberships || []).length, + objectPermissionsReviewed: objectRules.length, + findings: findings.length, + criticalFindings: findings.filter((finding) => finding.severity === "critical").length, + highFindings: findings.filter((finding) => finding.severity === "high").length, + }, + findings, + accessGates: [ + { gate: "Invitations expire and cannot silently escalate roles.", passed: !findings.some((finding) => ["EXPIRED_INVITE_ACTIVE", "ROLE_ESCALATION_WITHOUT_APPROVAL"].includes(finding.code)) }, + { gate: "Institutional and private workspaces reject anonymous or mismatched identities.", passed: !findings.some((finding) => ["INSTITUTION_SCOPE_MISMATCH", "ANONYMOUS_PRIVATE_ACCESS"].includes(finding.code)) }, + { gate: "Restricted datasets cannot be downloaded by object-permission bypass.", passed: !findings.some((finding) => ["OBJECT_PERMISSION_BYPASS", "DATA_DOWNLOAD_NOT_RESTRICTED"].includes(finding.code)) }, + { gate: "Elevated roles require verified identity.", passed: !findings.some((finding) => finding.code === "UNVERIFIED_IDENTITY_PRIVILEGE") }, + { gate: "Invite, membership, and object-permission changes are audit logged.", passed: !findings.some((finding) => finding.code === "AUDIT_LOG_GAP") }, + ], + }; +} + +function roleRank(role) { + return ROLE_RANK[String(role || "").toLowerCase()] || 0; +} + +function daysBetween(leftIso, rightIso) { + return Math.round((new Date(rightIso).getTime() - new Date(leftIso).getTime()) / 86400000); +} + +function renderMarkdownReport(report) { + const lines = [ + `# Access Invitation Policy Audit: ${report.project.title}`, + "", + `- Decision: ${report.summary.decision}`, + `- Risk score: ${report.summary.riskScore}`, + `- Visibility: ${report.project.visibility}`, + `- Invitations reviewed: ${report.summary.invitationsReviewed}`, + `- Memberships reviewed: ${report.summary.membershipsReviewed}`, + `- Object permissions reviewed: ${report.summary.objectPermissionsReviewed}`, + `- Audit digest: ${report.summary.auditDigest}`, + "", + "## Findings", + "", + ]; + + for (const finding of report.findings) { + lines.push(`- [${finding.severity}] ${finding.code}${finding.userId ? ` (${finding.userId})` : ""}`); + lines.push(` - ${finding.message}`); + lines.push(` - Recommendation: ${finding.recommendation}`); + } + + lines.push("", "## Access Gates", ""); + for (const gate of report.accessGates) { + lines.push(`- [${gate.passed ? "x" : "!"}] ${gate.gate}`); + } + + return `${lines.join("\n")}\n`; +} + +module.exports = { + auditAccessPolicy, + renderMarkdownReport, + roleRank, + daysBetween, +}; diff --git a/user-project-management/access-invitation-policy-auditor/make-demo-video.js b/user-project-management/access-invitation-policy-auditor/make-demo-video.js new file mode 100644 index 00000000..2f502802 --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/make-demo-video.js @@ -0,0 +1,101 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { auditAccessPolicy } = require("./index"); +const { projectAccessPacket } = require("./sample-data"); + +const WIDTH = 1280; +const HEIGHT = 720; +const FPS = 12; +const FRAMES = 48; + +function rgb(hex) { + const value = Number.parseInt(hex.replace("#", ""), 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +} + +function canvas(fill) { + const buffer = Buffer.alloc(WIDTH * HEIGHT * 3); + const [r, g, b] = rgb(fill); + for (let i = 0; i < buffer.length; i += 3) { + buffer[i] = r; + buffer[i + 1] = g; + buffer[i + 2] = b; + } + return buffer; +} + +function rect(buffer, x, y, width, height, color) { + const [r, g, b] = rgb(color); + const left = Math.max(0, Math.floor(x)); + const top = Math.max(0, Math.floor(y)); + const right = Math.min(WIDTH, Math.floor(x + width)); + const bottom = Math.min(HEIGHT, Math.floor(y + height)); + for (let row = top; row < bottom; row += 1) { + for (let col = left; col < right; col += 1) { + const index = (row * WIDTH + col) * 3; + buffer[index] = r; + buffer[index + 1] = g; + buffer[index + 2] = b; + } + } +} + +function writeFrame(file, buffer) { + fs.writeFileSync(file, Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`), buffer])); +} + +function main() { + const report = auditAccessPolicy(projectAccessPacket); + const reportsDir = path.join(__dirname, "reports"); + const framesDir = path.join(reportsDir, "ppm-frames"); + fs.rmSync(framesDir, { recursive: true, force: true }); + fs.mkdirSync(framesDir, { recursive: true }); + + const bars = [ + { y: 252, width: 860 * (report.summary.riskScore / 100), color: "#ff6b6b" }, + { y: 352, width: 860 * (report.summary.criticalFindings / report.summary.findings), color: "#ffa94d" }, + { y: 452, width: 860 * (report.summary.highFindings / report.summary.findings), color: "#7cc4ff" }, + { y: 552, width: 860 * (report.summary.objectPermissionsReviewed / 4), color: "#ffcf5a" }, + ]; + + for (let frame = 0; frame < FRAMES; frame += 1) { + const progress = Math.min(1, (frame + 1) / 30); + const buffer = canvas("#07111f"); + rect(buffer, 54, 52, 1172, 616, "#101c2f"); + rect(buffer, 54, 52, 1172, 4, "#29415f"); + rect(buffer, 54, 664, 1172, 4, "#29415f"); + rect(buffer, 54, 52, 4, 616, "#29415f"); + rect(buffer, 1222, 52, 4, 616, "#29415f"); + rect(buffer, 88, 104, 760, 42, "#ffffff"); + rect(buffer, 88, 170, 560, 22, "#b9c8dd"); + rect(buffer, 88, 214, 560, 22, "#ffcf5a"); + for (const bar of bars) { + rect(buffer, 88, bar.y, 860, 36, "#273854"); + rect(buffer, 88, bar.y, bar.width * progress, 36, bar.color); + } + rect(buffer, 1002, 250, 72, 72, "#ff6b6b"); + rect(buffer, 1092, 250, 72, 72, "#ff6b6b"); + rect(buffer, 1002, 372, 162, 48, "#ffa94d"); + rect(buffer, 1002, 492, 162, 48, "#7cc4ff"); + writeFrame(path.join(framesDir, `frame-${String(frame).padStart(3, "0")}.ppm`), buffer); + } + + const output = path.join(reportsDir, "demo.mp4"); + const result = spawnSync( + "ffmpeg", + ["-y", "-framerate", String(FPS), "-i", path.join(framesDir, "frame-%03d.ppm"), "-pix_fmt", "yuv420p", "-movflags", "+faststart", output], + { stdio: "inherit" } + ); + if (result.status !== 0) { + throw new Error("ffmpeg failed to create demo video"); + } + fs.rmSync(framesDir, { recursive: true, force: true }); + console.log(`demo video=${output}`); +} + +if (require.main === module) { + main(); +} diff --git a/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json new file mode 100644 index 00000000..66ffef8c --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.json @@ -0,0 +1,332 @@ +{ + "project": { + "id": "project-clinical-dataset", + "title": "Clinical Biomarker Collaboration Workspace", + "visibility": "institutional-only" + }, + "summary": { + "decision": "hold-project-access", + "riskScore": 100, + "auditDigest": "9164efd10da8a9b9", + "invitationsReviewed": 2, + "membershipsReviewed": 4, + "objectPermissionsReviewed": 3, + "findings": 16, + "criticalFindings": 6, + "highFindings": 4 + }, + "findings": [ + { + "severity": "critical", + "recommendation": "Disable the invitation and rotate the access token.", + "code": "EXPIRED_INVITE_ACTIVE", + "inviteId": "invite-001", + "userId": "external-ravi", + "message": "Invitation invite-001 is active after expiry 2026-05-20T00:00:00Z.", + "evidence": { + "id": "invite-001", + "userId": "external-ravi", + "status": "active", + "requestedRole": "reviewer", + "role": "admin", + "ownerApprovalId": "", + "expiresAt": "2026-05-20T00:00:00Z" + } + }, + { + "severity": "high", + "recommendation": "Require owner approval before increasing collaborator role scope.", + "code": "ROLE_ESCALATION_WITHOUT_APPROVAL", + "inviteId": "invite-001", + "userId": "external-ravi", + "message": "Invitation invite-001 grants admin after reviewer request without owner approval.", + "evidence": { + "id": "invite-001", + "userId": "external-ravi", + "status": "active", + "requestedRole": "reviewer", + "role": "admin", + "ownerApprovalId": "", + "expiresAt": "2026-05-20T00:00:00Z" + } + }, + { + "severity": "high", + "recommendation": "Require ORCID or institutional verification before granting elevated roles.", + "code": "UNVERIFIED_IDENTITY_PRIVILEGE", + "inviteId": "invite-001", + "userId": "external-ravi", + "message": "external-ravi has elevated role admin without verified ORCID or SAML identity.", + "evidence": { + "invite": { + "id": "invite-001", + "userId": "external-ravi", + "status": "active", + "requestedRole": "reviewer", + "role": "admin", + "ownerApprovalId": "", + "expiresAt": "2026-05-20T00:00:00Z" + }, + "identity": { + "userId": "external-ravi", + "orcidVerified": false, + "samlVerified": false, + "institutionDomain": "" + } + } + }, + { + "severity": "medium", + "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.", + "code": "AUDIT_LOG_GAP", + "inviteId": "invite-001", + "userId": "external-ravi", + "message": "Invitation invite-001 has no immutable audit-log event.", + "evidence": { + "id": "invite-001", + "userId": "external-ravi", + "status": "active", + "requestedRole": "reviewer", + "role": "admin", + "ownerApprovalId": "", + "expiresAt": "2026-05-20T00:00:00Z" + } + }, + { + "severity": "critical", + "recommendation": "Restrict institutional-only access to verified SAML domains.", + "code": "INSTITUTION_SCOPE_MISMATCH", + "userId": "external-ravi", + "message": "external-ravi has institutional-only access without matching verified SAML domain.", + "evidence": { + "membership": { + "userId": "external-ravi", + "role": "admin", + "external": true, + "anonymous": false, + "lastActiveAt": "2026-04-01T18:00:00Z" + }, + "identity": { + "userId": "external-ravi", + "orcidVerified": false, + "samlVerified": false, + "institutionDomain": "" + }, + "institutionDomain": "northstar.edu" + } + }, + { + "severity": "medium", + "recommendation": "Expire external collaborator access after inactivity or invite window close.", + "code": "STALE_EXTERNAL_COLLABORATOR", + "userId": "external-ravi", + "message": "External collaborator external-ravi has 57 inactive days with admin role.", + "evidence": { + "userId": "external-ravi", + "role": "admin", + "external": true, + "anonymous": false, + "lastActiveAt": "2026-04-01T18:00:00Z" + } + }, + { + "severity": "medium", + "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.", + "code": "AUDIT_LOG_GAP", + "userId": "external-ravi", + "message": "Membership external-ravi/admin has no role audit-log event.", + "evidence": { + "userId": "external-ravi", + "role": "admin", + "external": true, + "anonymous": false, + "lastActiveAt": "2026-04-01T18:00:00Z" + } + }, + { + "severity": "critical", + "recommendation": "Restrict institutional-only access to verified SAML domains.", + "code": "INSTITUTION_SCOPE_MISMATCH", + "userId": "guest-anon", + "message": "guest-anon has institutional-only access without matching verified SAML domain.", + "evidence": { + "membership": { + "userId": "guest-anon", + "role": "viewer", + "external": true, + "anonymous": true, + "lastActiveAt": "2026-05-27T18:00:00Z" + }, + "identity": { + "userId": "guest-anon", + "orcidVerified": false, + "samlVerified": false, + "institutionDomain": "" + }, + "institutionDomain": "northstar.edu" + } + }, + { + "severity": "critical", + "recommendation": "Block anonymous sessions from private or invitation-only workspaces.", + "code": "ANONYMOUS_PRIVATE_ACCESS", + "userId": "guest-anon", + "message": "Anonymous membership guest-anon can access institutional-only project.", + "evidence": { + "userId": "guest-anon", + "role": "viewer", + "external": true, + "anonymous": true, + "lastActiveAt": "2026-05-27T18:00:00Z" + } + }, + { + "severity": "medium", + "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.", + "code": "AUDIT_LOG_GAP", + "userId": "guest-anon", + "message": "Membership guest-anon/viewer has no role audit-log event.", + "evidence": { + "userId": "guest-anon", + "role": "viewer", + "external": true, + "anonymous": true, + "lastActiveAt": "2026-05-27T18:00:00Z" + } + }, + { + "severity": "critical", + "recommendation": "Restrict institutional-only access to verified SAML domains.", + "code": "INSTITUTION_SCOPE_MISMATCH", + "userId": "partner-lee", + "message": "partner-lee has institutional-only access without matching verified SAML domain.", + "evidence": { + "membership": { + "userId": "partner-lee", + "role": "contributor", + "external": true, + "anonymous": false, + "lastActiveAt": "2026-05-26T18:00:00Z" + }, + "identity": { + "userId": "partner-lee", + "orcidVerified": true, + "samlVerified": true, + "institutionDomain": "partnerlab.org" + }, + "institutionDomain": "northstar.edu" + } + }, + { + "severity": "critical", + "recommendation": "Apply object-level deny rules before project-level role grants.", + "code": "OBJECT_PERMISSION_BYPASS", + "userId": "external-ravi", + "objectId": "dataset-phi-raw", + "message": "external-ravi can download restricted dataset dataset-phi-raw.", + "evidence": { + "rule": { + "id": "rule-001", + "userId": "external-ravi", + "objectType": "dataset", + "objectId": "dataset-phi-raw", + "action": "download", + "effect": "allow" + }, + "membership": { + "userId": "external-ravi", + "role": "admin", + "external": true, + "anonymous": false, + "lastActiveAt": "2026-04-01T18:00:00Z" + } + } + }, + { + "severity": "high", + "recommendation": "Set explicit deny-download policy for restricted datasets.", + "code": "DATA_DOWNLOAD_NOT_RESTRICTED", + "userId": "external-ravi", + "objectId": "dataset-phi-raw", + "message": "dataset-phi-raw lacks explicit download deny for external-ravi.", + "evidence": { + "id": "rule-001", + "userId": "external-ravi", + "objectType": "dataset", + "objectId": "dataset-phi-raw", + "action": "download", + "effect": "allow" + } + }, + { + "severity": "low", + "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.", + "code": "AUDIT_LOG_GAP", + "userId": "external-ravi", + "objectId": "dataset-phi-raw", + "message": "Object permission rule-001 has no audit-log event.", + "evidence": { + "id": "rule-001", + "userId": "external-ravi", + "objectType": "dataset", + "objectId": "dataset-phi-raw", + "action": "download", + "effect": "allow" + } + }, + { + "severity": "high", + "recommendation": "Set explicit deny-download policy for restricted datasets.", + "code": "DATA_DOWNLOAD_NOT_RESTRICTED", + "userId": "partner-lee", + "objectId": "dataset-consent-linkage", + "message": "dataset-consent-linkage lacks explicit download deny for partner-lee.", + "evidence": { + "id": "rule-002", + "userId": "partner-lee", + "objectType": "dataset", + "objectId": "dataset-consent-linkage", + "action": "view", + "effect": "allow" + } + }, + { + "severity": "low", + "recommendation": "Write an immutable audit-log event for every invite, role, and object-permission change.", + "code": "AUDIT_LOG_GAP", + "userId": "partner-lee", + "objectId": "dataset-consent-linkage", + "message": "Object permission rule-002 has no audit-log event.", + "evidence": { + "id": "rule-002", + "userId": "partner-lee", + "objectType": "dataset", + "objectId": "dataset-consent-linkage", + "action": "view", + "effect": "allow" + } + } + ], + "accessGates": [ + { + "gate": "Invitations expire and cannot silently escalate roles.", + "passed": false + }, + { + "gate": "Institutional and private workspaces reject anonymous or mismatched identities.", + "passed": false + }, + { + "gate": "Restricted datasets cannot be downloaded by object-permission bypass.", + "passed": false + }, + { + "gate": "Elevated roles require verified identity.", + "passed": false + }, + { + "gate": "Invite, membership, and object-permission changes are audit logged.", + "passed": false + } + ] +} \ No newline at end of file diff --git a/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md new file mode 100644 index 00000000..e88f5dfe --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-audit.md @@ -0,0 +1,68 @@ +# Access Invitation Policy Audit: Clinical Biomarker Collaboration Workspace + +- Decision: hold-project-access +- Risk score: 100 +- Visibility: institutional-only +- Invitations reviewed: 2 +- Memberships reviewed: 4 +- Object permissions reviewed: 3 +- Audit digest: 9164efd10da8a9b9 + +## Findings + +- [critical] EXPIRED_INVITE_ACTIVE (external-ravi) + - Invitation invite-001 is active after expiry 2026-05-20T00:00:00Z. + - Recommendation: Disable the invitation and rotate the access token. +- [high] ROLE_ESCALATION_WITHOUT_APPROVAL (external-ravi) + - Invitation invite-001 grants admin after reviewer request without owner approval. + - Recommendation: Require owner approval before increasing collaborator role scope. +- [high] UNVERIFIED_IDENTITY_PRIVILEGE (external-ravi) + - external-ravi has elevated role admin without verified ORCID or SAML identity. + - Recommendation: Require ORCID or institutional verification before granting elevated roles. +- [medium] AUDIT_LOG_GAP (external-ravi) + - Invitation invite-001 has no immutable audit-log event. + - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change. +- [critical] INSTITUTION_SCOPE_MISMATCH (external-ravi) + - external-ravi has institutional-only access without matching verified SAML domain. + - Recommendation: Restrict institutional-only access to verified SAML domains. +- [medium] STALE_EXTERNAL_COLLABORATOR (external-ravi) + - External collaborator external-ravi has 57 inactive days with admin role. + - Recommendation: Expire external collaborator access after inactivity or invite window close. +- [medium] AUDIT_LOG_GAP (external-ravi) + - Membership external-ravi/admin has no role audit-log event. + - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change. +- [critical] INSTITUTION_SCOPE_MISMATCH (guest-anon) + - guest-anon has institutional-only access without matching verified SAML domain. + - Recommendation: Restrict institutional-only access to verified SAML domains. +- [critical] ANONYMOUS_PRIVATE_ACCESS (guest-anon) + - Anonymous membership guest-anon can access institutional-only project. + - Recommendation: Block anonymous sessions from private or invitation-only workspaces. +- [medium] AUDIT_LOG_GAP (guest-anon) + - Membership guest-anon/viewer has no role audit-log event. + - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change. +- [critical] INSTITUTION_SCOPE_MISMATCH (partner-lee) + - partner-lee has institutional-only access without matching verified SAML domain. + - Recommendation: Restrict institutional-only access to verified SAML domains. +- [critical] OBJECT_PERMISSION_BYPASS (external-ravi) + - external-ravi can download restricted dataset dataset-phi-raw. + - Recommendation: Apply object-level deny rules before project-level role grants. +- [high] DATA_DOWNLOAD_NOT_RESTRICTED (external-ravi) + - dataset-phi-raw lacks explicit download deny for external-ravi. + - Recommendation: Set explicit deny-download policy for restricted datasets. +- [low] AUDIT_LOG_GAP (external-ravi) + - Object permission rule-001 has no audit-log event. + - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change. +- [high] DATA_DOWNLOAD_NOT_RESTRICTED (partner-lee) + - dataset-consent-linkage lacks explicit download deny for partner-lee. + - Recommendation: Set explicit deny-download policy for restricted datasets. +- [low] AUDIT_LOG_GAP (partner-lee) + - Object permission rule-002 has no audit-log event. + - Recommendation: Write an immutable audit-log event for every invite, role, and object-permission change. + +## Access Gates + +- [!] Invitations expire and cannot silently escalate roles. +- [!] Institutional and private workspaces reject anonymous or mismatched identities. +- [!] Restricted datasets cannot be downloaded by object-permission bypass. +- [!] Elevated roles require verified identity. +- [!] Invite, membership, and object-permission changes are audit logged. diff --git a/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg new file mode 100644 index 00000000..97429d6c --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/reports/access-invitation-policy-summary.svg @@ -0,0 +1,20 @@ + + + + Access Invitation Policy Audit + Clinical Biomarker Collaboration Workspace + Decision: hold-project-access + + Audit risk + + + Critical findings + + + High findings + + + + 2 invites • 4 members • 3 object rules + Audit digest 9164efd10da8a9b9 + diff --git a/user-project-management/access-invitation-policy-auditor/reports/demo.mp4 b/user-project-management/access-invitation-policy-auditor/reports/demo.mp4 new file mode 100644 index 00000000..84bec9b5 Binary files /dev/null and b/user-project-management/access-invitation-policy-auditor/reports/demo.mp4 differ diff --git a/user-project-management/access-invitation-policy-auditor/sample-data.js b/user-project-management/access-invitation-policy-auditor/sample-data.js new file mode 100644 index 00000000..304d5d45 --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/sample-data.js @@ -0,0 +1,125 @@ +"use strict"; + +const projectAccessPacket = { + id: "project-clinical-dataset", + title: "Clinical Biomarker Collaboration Workspace", + visibility: "institutional-only", + institutionDomain: "northstar.edu", + auditTime: "2026-05-28T20:00:00Z", + policy: { + externalInactivityLimitDays: 30, + }, + restrictedDatasets: ["dataset-phi-raw", "dataset-consent-linkage"], + identities: [ + { + userId: "owner-maya", + orcidVerified: true, + samlVerified: true, + institutionDomain: "northstar.edu", + }, + { + userId: "external-ravi", + orcidVerified: false, + samlVerified: false, + institutionDomain: "", + }, + { + userId: "guest-anon", + orcidVerified: false, + samlVerified: false, + institutionDomain: "", + }, + { + userId: "partner-lee", + orcidVerified: true, + samlVerified: true, + institutionDomain: "partnerlab.org", + }, + ], + invitations: [ + { + id: "invite-001", + userId: "external-ravi", + status: "active", + requestedRole: "reviewer", + role: "admin", + ownerApprovalId: "", + expiresAt: "2026-05-20T00:00:00Z", + }, + { + id: "invite-002", + userId: "partner-lee", + status: "active", + requestedRole: "viewer", + role: "contributor", + ownerApprovalId: "approval-123", + expiresAt: "2026-06-10T00:00:00Z", + }, + ], + memberships: [ + { + userId: "owner-maya", + role: "owner", + external: false, + anonymous: false, + lastActiveAt: "2026-05-28T18:00:00Z", + }, + { + userId: "external-ravi", + role: "admin", + external: true, + anonymous: false, + lastActiveAt: "2026-04-01T18:00:00Z", + }, + { + userId: "guest-anon", + role: "viewer", + external: true, + anonymous: true, + lastActiveAt: "2026-05-27T18:00:00Z", + }, + { + userId: "partner-lee", + role: "contributor", + external: true, + anonymous: false, + lastActiveAt: "2026-05-26T18:00:00Z", + }, + ], + objectPermissions: [ + { + id: "rule-001", + userId: "external-ravi", + objectType: "dataset", + objectId: "dataset-phi-raw", + action: "download", + effect: "allow", + }, + { + id: "rule-002", + userId: "partner-lee", + objectType: "dataset", + objectId: "dataset-consent-linkage", + action: "view", + effect: "allow", + }, + { + id: "rule-003", + userId: "owner-maya", + objectType: "code", + objectId: "analysis-notebook", + action: "edit", + effect: "allow", + }, + ], + auditLog: [ + { key: "invite:invite-002", at: "2026-05-18T10:00:00Z" }, + { key: "member:owner-maya:owner", at: "2026-05-01T10:00:00Z" }, + { key: "member:partner-lee:contributor", at: "2026-05-18T10:00:00Z" }, + { key: "object:rule-003", at: "2026-05-19T10:00:00Z" }, + ], +}; + +module.exports = { + projectAccessPacket, +}; diff --git a/user-project-management/access-invitation-policy-auditor/test.js b/user-project-management/access-invitation-policy-auditor/test.js new file mode 100644 index 00000000..4726b193 --- /dev/null +++ b/user-project-management/access-invitation-policy-auditor/test.js @@ -0,0 +1,33 @@ +"use strict"; + +const assert = require("assert"); +const { auditAccessPolicy, renderMarkdownReport, roleRank, daysBetween } = require("./index"); +const { projectAccessPacket } = require("./sample-data"); + +assert.strictEqual(roleRank("owner") > roleRank("viewer"), true); +assert.strictEqual(daysBetween("2026-04-01T18:00:00Z", "2026-05-28T20:00:00Z"), 57); + +const report = auditAccessPolicy(projectAccessPacket); +const codes = new Set(report.findings.map((finding) => finding.code)); + +assert.strictEqual(report.summary.decision, "hold-project-access"); +assert.strictEqual(report.summary.riskScore, 100); +assert.ok(codes.has("EXPIRED_INVITE_ACTIVE"), "missing expired invite detection"); +assert.ok(codes.has("ROLE_ESCALATION_WITHOUT_APPROVAL"), "missing role escalation detection"); +assert.ok(codes.has("OBJECT_PERMISSION_BYPASS"), "missing object permission bypass detection"); +assert.ok(codes.has("INSTITUTION_SCOPE_MISMATCH"), "missing institution domain validation"); +assert.ok(codes.has("UNVERIFIED_IDENTITY_PRIVILEGE"), "missing verified identity privilege detection"); +assert.ok(codes.has("ANONYMOUS_PRIVATE_ACCESS"), "missing anonymous private access detection"); +assert.ok(codes.has("AUDIT_LOG_GAP"), "missing audit log gap detection"); +assert.ok(codes.has("DATA_DOWNLOAD_NOT_RESTRICTED"), "missing restricted dataset deny detection"); +assert.ok(codes.has("STALE_EXTERNAL_COLLABORATOR"), "missing stale collaborator detection"); + +const repeat = auditAccessPolicy(projectAccessPacket); +assert.strictEqual(report.summary.auditDigest, repeat.summary.auditDigest, "digest should be stable"); + +const markdown = renderMarkdownReport(report); +assert.ok(markdown.includes("Access Invitation Policy Audit")); +assert.ok(markdown.includes("hold-project-access")); +assert.ok(markdown.includes(report.summary.auditDigest)); + +console.log("access-invitation-policy-auditor tests passed");