From e9635159554b15d394b2fa7cdb512fc85cfb36e9 Mon Sep 17 00:00:00 2001 From: Ethan Miller Date: Fri, 22 May 2026 03:02:12 -0400 Subject: [PATCH] Add project break-glass access guard --- project-break-glass-access-guard/README.md | 34 ++ .../acceptance-notes.md | 19 + project-break-glass-access-guard/demo.js | 31 ++ project-break-glass-access-guard/index.js | 363 ++++++++++++++++++ project-break-glass-access-guard/package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 11623 bytes .../reports/reviewer-packet.md | 65 ++++ .../reports/summary.json | 143 +++++++ .../reports/summary.svg | 26 ++ .../requirements-map.md | 19 + .../sample-data.js | 110 ++++++ project-break-glass-access-guard/test.js | 49 +++ 12 files changed, 872 insertions(+) create mode 100644 project-break-glass-access-guard/README.md create mode 100644 project-break-glass-access-guard/acceptance-notes.md create mode 100644 project-break-glass-access-guard/demo.js create mode 100644 project-break-glass-access-guard/index.js create mode 100644 project-break-glass-access-guard/package.json create mode 100644 project-break-glass-access-guard/reports/demo.mp4 create mode 100644 project-break-glass-access-guard/reports/reviewer-packet.md create mode 100644 project-break-glass-access-guard/reports/summary.json create mode 100644 project-break-glass-access-guard/reports/summary.svg create mode 100644 project-break-glass-access-guard/requirements-map.md create mode 100644 project-break-glass-access-guard/sample-data.js create mode 100644 project-break-glass-access-guard/test.js diff --git a/project-break-glass-access-guard/README.md b/project-break-glass-access-guard/README.md new file mode 100644 index 00000000..829479f3 --- /dev/null +++ b/project-break-glass-access-guard/README.md @@ -0,0 +1,34 @@ +# Project Break-Glass Access Guard + +This module provides a focused User & Project Management slice for SCIBASE issue +#11. It evaluates emergency break-glass access requests for locked or +time-sensitive scientific workspaces before temporary owner/admin access is +granted. + +## What It Covers + +- Owner/admin emergency access requests for locked project spaces. +- Reason-code and reason-detail requirements. +- Fresh MFA gates for sensitive temporary access. +- Least-privilege object scopes with short expiry windows. +- Protected data-class review holds. +- Sponsor approval or after-hours justification. +- Immutable audit receipts and mandatory post-access review. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +Generated artifacts are written to `reports/`: + +- `summary.json` +- `reviewer-packet.md` +- `summary.svg` +- `demo.mp4` + +All data is synthetic and does not touch real identity providers, user accounts, +project permissions, or production workspaces. diff --git a/project-break-glass-access-guard/acceptance-notes.md b/project-break-glass-access-guard/acceptance-notes.md new file mode 100644 index 00000000..166f3d6c --- /dev/null +++ b/project-break-glass-access-guard/acceptance-notes.md @@ -0,0 +1,19 @@ +# Acceptance Notes + +The guard classifies requests as: + +- `approved`: temporary scoped access can be granted. +- `approve_with_review`: after-hours access can proceed with mandatory owner review. +- `steward_review`: protected or risky access requires steward approval first. +- `denied`: request does not meet break-glass entry requirements. + +Validation commands: + +```bash +npm run check +npm test +npm run demo +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height,pix_fmt -of default=noprint_wrappers=1 reports/demo.mp4 +git diff --check +git diff --cached --check +``` diff --git a/project-break-glass-access-guard/demo.js b/project-break-glass-access-guard/demo.js new file mode 100644 index 00000000..0a00c996 --- /dev/null +++ b/project-break-glass-access-guard/demo.js @@ -0,0 +1,31 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + analyzeBreakGlassAccess, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { sampleBreakGlassPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = analyzeBreakGlassAccess(sampleBreakGlassPacket, { + asOf: "2026-05-22T12:00:00.000Z", +}); + +fs.writeFileSync( + path.join(reportsDir, "summary.json"), + `${JSON.stringify(result, null, 2)}\n` +); +fs.writeFileSync( + path.join(reportsDir, "reviewer-packet.md"), + renderMarkdownReport(result) +); +fs.writeFileSync( + path.join(reportsDir, "summary.svg"), + renderSvgSummary(result) +); + +console.log("project break-glass access guard demo artifacts written"); +console.log(`audit digest: ${result.auditDigest}`); diff --git a/project-break-glass-access-guard/index.js b/project-break-glass-access-guard/index.js new file mode 100644 index 00000000..1845f858 --- /dev/null +++ b/project-break-glass-access-guard/index.js @@ -0,0 +1,363 @@ +const crypto = require("node:crypto"); + +const APPROVED = "approved"; +const APPROVE_WITH_REVIEW = "approve_with_review"; +const STEWARD_REVIEW = "steward_review"; +const DENIED = "denied"; + +const ALLOWED_ROLES = new Set(["Owner", "Admin"]); +const ALLOWED_REASON_CODES = new Set([ + "incident_response", + "submission_deadline", + "compliance_deadline", + "owner_unavailable", +]); + +function requireFields(value, fields, label) { + for (const field of fields) { + if (value[field] === undefined || value[field] === null || value[field] === "") { + throw new Error(`missing required ${label} field: ${field}`); + } + } +} + +function parseDate(value, label) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`invalid date for ${label}`); + } + return date; +} + +function minutesBetween(a, b) { + return Math.round((b.getTime() - a.getTime()) / 60000); +} + +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 createAuditDigest(result) { + const payload = { + asOf: result.asOf, + totals: result.totals, + decisions: result.decisions.map((decision) => ({ + id: decision.id, + projectId: decision.projectId, + status: decision.status, + expiresInMinutes: decision.expiresInMinutes, + reasons: decision.reasons, + })), + }; + + return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex"); +} + +function projectLookup(projects) { + const lookup = new Map(); + for (const project of projects) { + requireFields( + project, + ["projectId", "owners", "admins", "protectedDataClasses", "lockedSections"], + "project" + ); + lookup.set(project.projectId, project); + } + return lookup; +} + +function hasProtectedDataRequest(request, project) { + return (request.requestedDataClasses || []).some((dataClass) => + project.protectedDataClasses.includes(dataClass) + ); +} + +function hasLeastPrivilegeScope(request) { + const scopes = request.requestedScopes || []; + return scopes.length > 0 && scopes.every((scope) => /:(read|write)$/.test(scope)); +} + +function approvedByProjectOwner(request, project) { + const approvals = new Set(request.sponsorApprovals || []); + return project.owners.some((owner) => approvals.has(owner)); +} + +function evaluateRequest(request, context) { + requireFields( + request, + [ + "id", + "projectId", + "requesterId", + "requesterRole", + "reasonCode", + "reasonDetail", + "requestedScopes", + "requestedDataClasses", + "requestedAt", + "expiresAt", + "mfaVerifiedAt", + ], + "break-glass request" + ); + + const project = context.projects.get(request.projectId); + const requestedAt = parseDate(request.requestedAt, `requestedAt for ${request.id}`); + const expiresAt = parseDate(request.expiresAt, `expiresAt for ${request.id}`); + const mfaVerifiedAt = parseDate(request.mfaVerifiedAt, `mfaVerifiedAt for ${request.id}`); + const postReviewDueAt = parseDate( + request.postReviewDueAt, + `postReviewDueAt for ${request.id}` + ); + const reasons = []; + const actions = []; + const base = { + id: request.id, + projectId: request.projectId, + requesterId: request.requesterId, + requesterRole: request.requesterRole, + reasonCode: request.reasonCode, + requestedScopes: request.requestedScopes, + requestedDataClasses: request.requestedDataClasses, + expiresInMinutes: expiresAt && requestedAt ? minutesBetween(requestedAt, expiresAt) : 0, + status: DENIED, + reasons, + actions, + }; + + if (!project) { + return { + ...base, + status: DENIED, + reasons: ["project is not registered"], + actions: ["Reject break-glass request"], + }; + } + + if (!ALLOWED_ROLES.has(request.requesterRole)) { + reasons.push("requester role is not allowed to initiate break-glass access"); + } + + if (!ALLOWED_REASON_CODES.has(request.reasonCode)) { + reasons.push("reason code is not recognized for emergency access"); + } + + if (request.reasonDetail.trim().length < 24) { + reasons.push("reason detail is too short for audit review"); + } + + if (!hasLeastPrivilegeScope(request)) { + reasons.push("requested scopes are missing or not least-privilege read/write scopes"); + } + + const mfaAgeMinutes = minutesBetween(mfaVerifiedAt, requestedAt); + if (mfaAgeMinutes < 0 || mfaAgeMinutes > 30) { + reasons.push("MFA verification is stale for emergency access"); + } + + if (base.expiresInMinutes <= 0 || base.expiresInMinutes > 120) { + reasons.push("break-glass expiry must be positive and no longer than 120 minutes"); + } + + if (!Array.isArray(request.auditReceiptIds) || request.auditReceiptIds.length === 0) { + reasons.push("immutable audit receipt is missing"); + } + + if (!postReviewDueAt || minutesBetween(requestedAt, postReviewDueAt) > 24 * 60) { + reasons.push("post-access review is missing or due more than 24 hours later"); + } + + const protectedData = hasProtectedDataRequest(request, project); + const ownerApproved = approvedByProjectOwner(request, project); + const requesterIsOwner = project.owners.includes(request.requesterId); + + if (protectedData) { + reasons.push("request touches protected project data classes"); + } + + if (!ownerApproved && !requesterIsOwner && !request.afterHoursJustification) { + reasons.push("missing owner sponsor approval or after-hours justification"); + } + + if (reasons.some((reason) => /not allowed|stale|missing|no longer|protected/.test(reason))) { + return { + ...base, + status: protectedData ? STEWARD_REVIEW : DENIED, + reasons, + actions: [ + protectedData + ? "Route to steward review before any access is granted" + : "Deny request and preserve audit denial receipt", + "Notify project owner and institutional admin", + "Require corrected scope, MFA, expiry, evidence, or sponsorship", + ], + }; + } + + if (!ownerApproved && !requesterIsOwner && request.afterHoursJustification) { + return { + ...base, + status: APPROVE_WITH_REVIEW, + reasons: ["after-hours emergency access can proceed with mandatory owner review"], + actions: [ + "Grant temporary scoped access", + "Queue owner review within 24 hours", + "Auto-expire access and attach audit receipt", + ], + }; + } + + return { + ...base, + status: APPROVED, + reasons: ["break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates"], + actions: [ + "Grant temporary scoped access", + "Auto-expire access at requested expiry", + "Preserve immutable audit receipt and post-access review task", + ], + }; +} + +function analyzeBreakGlassAccess(packet, options = {}) { + requireFields(packet, ["projects", "requests"], "break-glass packet"); + if (!Array.isArray(packet.projects) || !Array.isArray(packet.requests)) { + throw new Error("projects and requests must be arrays"); + } + + const asOf = parseDate(options.asOf || packet.asOf || new Date().toISOString(), "asOf"); + const context = { + projects: projectLookup(packet.projects), + }; + const decisions = packet.requests.map((request) => evaluateRequest(request, context)); + const totals = decisions.reduce( + (acc, decision) => { + acc.totalRequests += 1; + acc.byStatus[decision.status] = (acc.byStatus[decision.status] || 0) + 1; + if (decision.status === APPROVED || decision.status === APPROVE_WITH_REVIEW) { + acc.temporaryAccessGrants += 1; + } + if (decision.status === STEWARD_REVIEW || decision.status === DENIED) { + acc.blockedOrReviewRequests += 1; + } + return acc; + }, + { + totalRequests: 0, + temporaryAccessGrants: 0, + blockedOrReviewRequests: 0, + byStatus: {}, + } + ); + const result = { + asOf: asOf.toISOString(), + totals, + decisions, + }; + + return { + ...result, + auditDigest: createAuditDigest(result), + }; +} + +function renderMarkdownReport(result) { + const lines = [ + "# Project Break-Glass Access Guard", + "", + `As of: ${result.asOf}`, + `Audit digest: \`${result.auditDigest}\``, + "", + "## Totals", + "", + `- Requests evaluated: ${result.totals.totalRequests}`, + `- Temporary access grants: ${result.totals.temporaryAccessGrants}`, + `- Blocked or review requests: ${result.totals.blockedOrReviewRequests}`, + ]; + + for (const [status, count] of Object.entries(result.totals.byStatus).sort()) { + lines.push(`- ${status}: ${count}`); + } + + lines.push("", "## Request Decisions", ""); + for (const decision of result.decisions) { + lines.push( + `### ${decision.id}`, + "", + `- Project: ${decision.projectId}`, + `- Requester: ${decision.requesterId} (${decision.requesterRole})`, + `- Status: ${decision.status}`, + `- Expires in minutes: ${decision.expiresInMinutes}`, + `- Scopes: ${decision.requestedScopes.join(", ")}`, + `- Reasons: ${decision.reasons.join("; ")}`, + `- Actions: ${decision.actions.join("; ")}`, + "" + ); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvgSummary(result) { + const rows = Object.entries(result.totals.byStatus) + .sort() + .map(([status, count], index) => { + const y = 154 + index * 46; + const width = Math.max(44, count * 92); + return `${escapeXml(status)} + + ${count}`; + }) + .join("\n "); + + return ` + + + + Project Break-Glass Access Guard + Grants ${result.totals.temporaryAccessGrants} | Blocked/review ${result.totals.blockedOrReviewRequests} + ${rows} + audit ${escapeXml(result.auditDigest.slice(0, 48))} + +`; +} + +module.exports = { + APPROVED, + APPROVE_WITH_REVIEW, + STEWARD_REVIEW, + DENIED, + analyzeBreakGlassAccess, + createAuditDigest, + renderMarkdownReport, + renderSvgSummary, +}; diff --git a/project-break-glass-access-guard/package.json b/project-break-glass-access-guard/package.json new file mode 100644 index 00000000..8557ce7f --- /dev/null +++ b/project-break-glass-access-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "project-break-glass-access-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic project break-glass access and post-access review guard for SCIBASE issue #11.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js" + }, + "license": "MIT" +} diff --git a/project-break-glass-access-guard/reports/demo.mp4 b/project-break-glass-access-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7fa1b7e9ac150ca20f7e3c5992a7d4db9558b23c GIT binary patch literal 11623 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 literal 0 HcmV?d00001 diff --git a/project-break-glass-access-guard/reports/reviewer-packet.md b/project-break-glass-access-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..f864c6c8 --- /dev/null +++ b/project-break-glass-access-guard/reports/reviewer-packet.md @@ -0,0 +1,65 @@ +# Project Break-Glass Access Guard + +As of: 2026-05-22T12:00:00.000Z +Audit digest: `e0e335c11209ce7c1812438f76265bb0adffcfbaee4cc16b489e5d2aebefad03` + +## Totals + +- Requests evaluated: 5 +- Temporary access grants: 3 +- Blocked or review requests: 2 +- approve_with_review: 1 +- approved: 2 +- steward_review: 2 + +## Request Decisions + +### bg-001 + +- Project: project-sepsis-lockdown +- Requester: admin-noor (Admin) +- Status: approve_with_review +- Expires in minutes: 90 +- Scopes: manuscript/results:write, discussion/incident:write +- Reasons: after-hours emergency access can proceed with mandatory owner review +- Actions: Grant temporary scoped access; Queue owner review within 24 hours; Auto-expire access and attach audit receipt + +### bg-002 + +- Project: project-sepsis-lockdown +- Requester: viewer-eli (Viewer) +- Status: steward_review +- Expires in minutes: 1440 +- Scopes: dataset/raw:download +- Reasons: requester role is not allowed to initiate break-glass access; reason code is not recognized for emergency access; requested scopes are missing or not least-privilege read/write scopes; MFA verification is stale for emergency access; break-glass expiry must be positive and no longer than 120 minutes; immutable audit receipt is missing; post-access review is missing or due more than 24 hours later; request touches protected project data classes; missing owner sponsor approval or after-hours justification +- Actions: Route to steward review before any access is granted; Notify project owner and institutional admin; Require corrected scope, MFA, expiry, evidence, or sponsorship + +### bg-003 + +- Project: project-climate-preprint +- Requester: owner-mei (Owner) +- Status: approved +- Expires in minutes: 60 +- Scopes: preprint/submission:write +- Reasons: break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates +- Actions: Grant temporary scoped access; Auto-expire access at requested expiry; Preserve immutable audit receipt and post-access review task + +### bg-004 + +- Project: project-climate-preprint +- Requester: admin-ravi (Admin) +- Status: steward_review +- Expires in minutes: 480 +- Scopes: notebooks/final-run:write, results/figures:write +- Reasons: break-glass expiry must be positive and no longer than 120 minutes; post-access review is missing or due more than 24 hours later; request touches protected project data classes +- Actions: Route to steward review before any access is granted; Notify project owner and institutional admin; Require corrected scope, MFA, expiry, evidence, or sponsorship + +### bg-005 + +- Project: project-climate-preprint +- Requester: admin-ravi (Admin) +- Status: approved +- Expires in minutes: 45 +- Scopes: notebooks/final-run:write +- Reasons: break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates +- Actions: Grant temporary scoped access; Auto-expire access at requested expiry; Preserve immutable audit receipt and post-access review task diff --git a/project-break-glass-access-guard/reports/summary.json b/project-break-glass-access-guard/reports/summary.json new file mode 100644 index 00000000..511fb761 --- /dev/null +++ b/project-break-glass-access-guard/reports/summary.json @@ -0,0 +1,143 @@ +{ + "asOf": "2026-05-22T12:00:00.000Z", + "totals": { + "totalRequests": 5, + "temporaryAccessGrants": 3, + "blockedOrReviewRequests": 2, + "byStatus": { + "approve_with_review": 1, + "steward_review": 2, + "approved": 2 + } + }, + "decisions": [ + { + "id": "bg-001", + "projectId": "project-sepsis-lockdown", + "requesterId": "admin-noor", + "requesterRole": "Admin", + "reasonCode": "incident_response", + "requestedScopes": [ + "manuscript/results:write", + "discussion/incident:write" + ], + "requestedDataClasses": [ + "derived_results" + ], + "expiresInMinutes": 90, + "status": "approve_with_review", + "reasons": [ + "after-hours emergency access can proceed with mandatory owner review" + ], + "actions": [ + "Grant temporary scoped access", + "Queue owner review within 24 hours", + "Auto-expire access and attach audit receipt" + ] + }, + { + "id": "bg-002", + "projectId": "project-sepsis-lockdown", + "requesterId": "viewer-eli", + "requesterRole": "Viewer", + "reasonCode": "manual_fix", + "requestedScopes": [ + "dataset/raw:download" + ], + "requestedDataClasses": [ + "human_subjects" + ], + "expiresInMinutes": 1440, + "status": "steward_review", + "reasons": [ + "requester role is not allowed to initiate break-glass access", + "reason code is not recognized for emergency access", + "requested scopes are missing or not least-privilege read/write scopes", + "MFA verification is stale for emergency access", + "break-glass expiry must be positive and no longer than 120 minutes", + "immutable audit receipt is missing", + "post-access review is missing or due more than 24 hours later", + "request touches protected project data classes", + "missing owner sponsor approval or after-hours justification" + ], + "actions": [ + "Route to steward review before any access is granted", + "Notify project owner and institutional admin", + "Require corrected scope, MFA, expiry, evidence, or sponsorship" + ] + }, + { + "id": "bg-003", + "projectId": "project-climate-preprint", + "requesterId": "owner-mei", + "requesterRole": "Owner", + "reasonCode": "submission_deadline", + "requestedScopes": [ + "preprint/submission:write" + ], + "requestedDataClasses": [ + "public_metadata" + ], + "expiresInMinutes": 60, + "status": "approved", + "reasons": [ + "break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates" + ], + "actions": [ + "Grant temporary scoped access", + "Auto-expire access at requested expiry", + "Preserve immutable audit receipt and post-access review task" + ] + }, + { + "id": "bg-004", + "projectId": "project-climate-preprint", + "requesterId": "admin-ravi", + "requesterRole": "Admin", + "reasonCode": "submission_deadline", + "requestedScopes": [ + "notebooks/final-run:write", + "results/figures:write" + ], + "requestedDataClasses": [ + "embargoed_partner_data" + ], + "expiresInMinutes": 480, + "status": "steward_review", + "reasons": [ + "break-glass expiry must be positive and no longer than 120 minutes", + "post-access review is missing or due more than 24 hours later", + "request touches protected project data classes" + ], + "actions": [ + "Route to steward review before any access is granted", + "Notify project owner and institutional admin", + "Require corrected scope, MFA, expiry, evidence, or sponsorship" + ] + }, + { + "id": "bg-005", + "projectId": "project-climate-preprint", + "requesterId": "admin-ravi", + "requesterRole": "Admin", + "reasonCode": "incident_response", + "requestedScopes": [ + "notebooks/final-run:write" + ], + "requestedDataClasses": [ + "derived_results" + ], + "expiresInMinutes": 45, + "status": "approved", + "reasons": [ + "break-glass request satisfies role, MFA, scope, expiry, evidence, and review gates" + ], + "actions": [ + "Grant temporary scoped access", + "Auto-expire access at requested expiry", + "Preserve immutable audit receipt and post-access review task" + ] + } + ], + "auditDigest": "e0e335c11209ce7c1812438f76265bb0adffcfbaee4cc16b489e5d2aebefad03" +} diff --git a/project-break-glass-access-guard/reports/summary.svg b/project-break-glass-access-guard/reports/summary.svg new file mode 100644 index 00000000..03a93769 --- /dev/null +++ b/project-break-glass-access-guard/reports/summary.svg @@ -0,0 +1,26 @@ + + + + + Project Break-Glass Access Guard + Grants 3 | Blocked/review 2 + approve_with_review + + 1 + approved + + 2 + steward_review + + 2 + audit e0e335c11209ce7c1812438f76265bb0adffcfbaee4cc16b + diff --git a/project-break-glass-access-guard/requirements-map.md b/project-break-glass-access-guard/requirements-map.md new file mode 100644 index 00000000..3cbd295c --- /dev/null +++ b/project-break-glass-access-guard/requirements-map.md @@ -0,0 +1,19 @@ +# Requirements Map + +| Issue #11 area | Coverage | +| --- | --- | +| Authentication and identity | Requires fresh MFA before emergency access can proceed. | +| Project spaces | Models locked manuscript, notebook, discussion, and data sections. | +| Permissions and access control | Enforces owner/admin initiation, least-privilege scopes, expiry, and protected data holds. | +| Custom sharing | Prevents broad or long-lived emergency grants from bypassing standard access rules. | +| Project-level audit log | Requires immutable audit receipts and post-access review tasks. | + +## Non-overlap + +This is not a broad workspace/RBAC ledger, privacy access review, member +lifecycle/offboarding flow, institutional recertification module, anonymous +review escrow, identity merge/export, data-room consent ledger, researcher +profile sync, archive handoff, access-audit anomaly monitor, role delegation +guard, invitation-domain/MFA guard, funding-attribution guard, service-token +governance module, or deletion/erasure guard. It focuses on temporary +break-glass emergency access and mandatory post-access review. diff --git a/project-break-glass-access-guard/sample-data.js b/project-break-glass-access-guard/sample-data.js new file mode 100644 index 00000000..a5c6881a --- /dev/null +++ b/project-break-glass-access-guard/sample-data.js @@ -0,0 +1,110 @@ +const sampleBreakGlassPacket = { + asOf: "2026-05-22T12:00:00.000Z", + projects: [ + { + projectId: "project-sepsis-lockdown", + visibility: "institutional_only", + owners: ["owner-ada"], + admins: ["admin-noor"], + protectedDataClasses: ["human_subjects", "restricted_raw_data"], + lockedSections: ["dataset/raw", "manuscript/results"], + }, + { + projectId: "project-climate-preprint", + visibility: "private", + owners: ["owner-mei"], + admins: ["admin-ravi"], + protectedDataClasses: ["embargoed_partner_data"], + lockedSections: ["preprint/submission", "notebooks/final-run"], + }, + ], + requests: [ + { + id: "bg-001", + projectId: "project-sepsis-lockdown", + requesterId: "admin-noor", + requesterRole: "Admin", + reasonCode: "incident_response", + reasonDetail: "Restore locked manuscript section after failed publication export.", + requestedScopes: ["manuscript/results:write", "discussion/incident:write"], + requestedDataClasses: ["derived_results"], + requestedAt: "2026-05-22T03:30:00.000Z", + expiresAt: "2026-05-22T05:00:00.000Z", + mfaVerifiedAt: "2026-05-22T03:20:00.000Z", + sponsorApprovals: [], + afterHoursJustification: "Publication deadline with locked owner timezone.", + auditReceiptIds: ["audit-bg-001"], + postReviewDueAt: "2026-05-23T03:00:00.000Z", + }, + { + id: "bg-002", + projectId: "project-sepsis-lockdown", + requesterId: "viewer-eli", + requesterRole: "Viewer", + reasonCode: "manual_fix", + reasonDetail: "Need raw data download to inspect anomaly.", + requestedScopes: ["dataset/raw:download"], + requestedDataClasses: ["human_subjects"], + requestedAt: "2026-05-22T04:00:00.000Z", + expiresAt: "2026-05-23T04:00:00.000Z", + mfaVerifiedAt: "2026-05-21T01:00:00.000Z", + sponsorApprovals: [], + afterHoursJustification: "", + auditReceiptIds: [], + postReviewDueAt: "", + }, + { + id: "bg-003", + projectId: "project-climate-preprint", + requesterId: "owner-mei", + requesterRole: "Owner", + reasonCode: "submission_deadline", + reasonDetail: "Need final preprint metadata correction before repository deposit.", + requestedScopes: ["preprint/submission:write"], + requestedDataClasses: ["public_metadata"], + requestedAt: "2026-05-22T10:00:00.000Z", + expiresAt: "2026-05-22T11:00:00.000Z", + mfaVerifiedAt: "2026-05-22T09:55:00.000Z", + sponsorApprovals: [], + afterHoursJustification: "", + auditReceiptIds: ["audit-bg-003"], + postReviewDueAt: "2026-05-23T10:00:00.000Z", + }, + { + id: "bg-004", + projectId: "project-climate-preprint", + requesterId: "admin-ravi", + requesterRole: "Admin", + reasonCode: "submission_deadline", + reasonDetail: "Update final notebook output and preprint figures.", + requestedScopes: ["notebooks/final-run:write", "results/figures:write"], + requestedDataClasses: ["embargoed_partner_data"], + requestedAt: "2026-05-22T10:15:00.000Z", + expiresAt: "2026-05-22T18:15:00.000Z", + mfaVerifiedAt: "2026-05-22T10:00:00.000Z", + sponsorApprovals: ["owner-mei"], + afterHoursJustification: "", + auditReceiptIds: ["audit-bg-004"], + postReviewDueAt: "2026-05-25T10:00:00.000Z", + }, + { + id: "bg-005", + projectId: "project-climate-preprint", + requesterId: "admin-ravi", + requesterRole: "Admin", + reasonCode: "incident_response", + reasonDetail: "Emergency rollback after notebook output corruption.", + requestedScopes: ["notebooks/final-run:write"], + requestedDataClasses: ["derived_results"], + requestedAt: "2026-05-22T11:00:00.000Z", + expiresAt: "2026-05-22T11:45:00.000Z", + mfaVerifiedAt: "2026-05-22T10:59:00.000Z", + sponsorApprovals: ["owner-mei"], + afterHoursJustification: "", + auditReceiptIds: ["audit-bg-005"], + postReviewDueAt: "2026-05-23T11:00:00.000Z", + }, + ], +}; + +module.exports = { sampleBreakGlassPacket }; diff --git a/project-break-glass-access-guard/test.js b/project-break-glass-access-guard/test.js new file mode 100644 index 00000000..62701aa4 --- /dev/null +++ b/project-break-glass-access-guard/test.js @@ -0,0 +1,49 @@ +const assert = require("node:assert/strict"); +const { + APPROVED, + APPROVE_WITH_REVIEW, + DENIED, + STEWARD_REVIEW, + analyzeBreakGlassAccess, + createAuditDigest, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { sampleBreakGlassPacket } = require("./sample-data"); + +const result = analyzeBreakGlassAccess(sampleBreakGlassPacket); + +assert.equal(result.totals.totalRequests, 5); +assert.equal(result.totals.temporaryAccessGrants, 3); +assert.equal(result.totals.blockedOrReviewRequests, 2); +assert.equal(result.totals.byStatus[APPROVED], 2); +assert.equal(result.totals.byStatus[APPROVE_WITH_REVIEW], 1); +assert.equal(result.totals.byStatus[DENIED] || 0, 0); +assert.equal(result.totals.byStatus[STEWARD_REVIEW], 2); + +const deniedViewer = result.decisions.find((decision) => decision.id === "bg-002"); +assert.equal(deniedViewer.status, STEWARD_REVIEW); +assert.match(deniedViewer.reasons.join(" "), /not allowed/); +assert.match(deniedViewer.reasons.join(" "), /protected project data/); + +const afterHours = result.decisions.find((decision) => decision.id === "bg-001"); +assert.equal(afterHours.status, APPROVE_WITH_REVIEW); +assert.equal(afterHours.expiresInMinutes, 90); + +const approvedOwner = result.decisions.find((decision) => decision.id === "bg-003"); +assert.equal(approvedOwner.status, APPROVED); + +const longExpiry = result.decisions.find((decision) => decision.id === "bg-004"); +assert.equal(longExpiry.status, STEWARD_REVIEW); +assert.match(longExpiry.reasons.join(" "), /no longer than 120 minutes/); + +assert.equal(createAuditDigest(result), result.auditDigest); +assert.match(renderMarkdownReport(result), /Project Break-Glass Access Guard/); +assert.match(renderSvgSummary(result), /Grants 3/); + +assert.throws( + () => analyzeBreakGlassAccess({ projects: [], requests: [{}] }), + /missing required break-glass request field: id/ +); + +console.log("project break-glass access guard tests passed");