diff --git a/project-role-delegation-guard/README.md b/project-role-delegation-guard/README.md new file mode 100644 index 00000000..973de0d4 --- /dev/null +++ b/project-role-delegation-guard/README.md @@ -0,0 +1,14 @@ +# Project Role Delegation Guard + +This self-contained slice reviews temporary delegated authority before sensitive project actions such as publication, restricted-data export, archive freeze, and role transfer. + +The guard checks sponsor approval, separation of duties, identity posture, object-level scope, and expiry. It produces deterministic reviewer packets so project owners can approve or block delegated actions without relying on ad hoc comments. + +## Run locally + +```bash +node project-role-delegation-guard/test.js +node project-role-delegation-guard/demo.js +``` + +Demo outputs are written to `project-role-delegation-guard/demo-output/`. diff --git a/project-role-delegation-guard/acceptance-notes.md b/project-role-delegation-guard/acceptance-notes.md new file mode 100644 index 00000000..d604c8c6 --- /dev/null +++ b/project-role-delegation-guard/acceptance-notes.md @@ -0,0 +1,6 @@ +# Acceptance Notes + +- The module is dependency-free and uses synthetic data only. +- Tests cover blocked sensitive delegations, deterministic packet ordering, and a ready case with a near-expiry warning. +- Demo artifacts include JSON, Markdown, SVG, and MP4 output. +- The slice is intentionally separate from previous #11 submissions around broad RBAC ledgers, offboarding, access recertification, anonymous review, data-room consent, project archive handoff, profile sync, and audit anomaly monitoring. diff --git a/project-role-delegation-guard/demo-output/approval-checklist.md b/project-role-delegation-guard/demo-output/approval-checklist.md new file mode 100644 index 00000000..48cd7190 --- /dev/null +++ b/project-role-delegation-guard/demo-output/approval-checklist.md @@ -0,0 +1,11 @@ +# Delegation approval checklist + +Workspace: project-neuro-catalyst +Status: hold + +- del-archive-5: Narrow the object-level delegation scope before approving the action. +- del-export-7: Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied. +- del-publish-2: Record sponsor approval or route the action back to the project owner. +- del-transfer-3: Route approval to an independent owner or institutional sponsor. + +Audit digest: eb75b6a2db4222fa1c946655d7ea70920e1685e0ed6e5303d829ea16dc51b0ed diff --git a/project-role-delegation-guard/demo-output/delegation-review-packet.json b/project-role-delegation-guard/demo-output/delegation-review-packet.json new file mode 100644 index 00000000..02f4912c --- /dev/null +++ b/project-role-delegation-guard/demo-output/delegation-review-packet.json @@ -0,0 +1,98 @@ +{ + "packetType": "project-role-delegation-guard", + "workspaceId": "project-neuro-catalyst", + "generatedAt": "2026-05-20T12:00:00Z", + "overallStatus": "hold", + "decisions": [ + { + "requestId": "del-publish-2", + "action": "publish_release", + "delegateId": "delegate-2", + "decision": "needs_sponsor", + "expiresAt": "2026-06-20T00:00:00Z" + }, + { + "requestId": "del-export-7", + "action": "restricted_data_export", + "delegateId": "delegate-7", + "decision": "block", + "expiresAt": "2026-06-10T00:00:00Z" + }, + { + "requestId": "del-transfer-3", + "action": "role_transfer", + "delegateId": "delegate-2", + "decision": "block", + "expiresAt": "2026-06-15T00:00:00Z" + }, + { + "requestId": "del-archive-5", + "action": "archive_freeze", + "delegateId": "delegate-9", + "decision": "block", + "expiresAt": "2026-06-15T00:00:00Z" + } + ], + "holds": [ + { + "requestId": "del-archive-5", + "code": "delegated_scope_exceeds_action", + "owner": "delegate-9", + "severity": "blocker", + "evidence": "del-archive-5 includes excess scope(s): repository:admin.", + "requiredAction": "Narrow the object-level delegation scope before approving the action." + }, + { + "requestId": "del-export-7", + "code": "identity_posture_gap", + "owner": "delegate-7", + "severity": "blocker", + "evidence": "Export Contractor is missing mfa, orcid for restricted_data_export.", + "requiredAction": "Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied." + }, + { + "requestId": "del-publish-2", + "code": "missing_sponsor_approval", + "owner": "owner-1", + "severity": "blocker", + "evidence": "publish_release requires sponsor approval before delegate-2 can act.", + "requiredAction": "Record sponsor approval or route the action back to the project owner." + }, + { + "requestId": "del-transfer-3", + "code": "separation_of_duties_violation", + "owner": "delegate-2", + "severity": "blocker", + "evidence": "delegate-2 cannot both request/delegate and approve role_transfer.", + "requiredAction": "Route approval to an independent owner or institutional sponsor." + } + ], + "warnings": [], + "approvalChecklist": [ + { + "requestId": "del-archive-5", + "owner": "delegate-9", + "requiredAction": "Narrow the object-level delegation scope before approving the action.", + "evidence": "del-archive-5 includes excess scope(s): repository:admin." + }, + { + "requestId": "del-export-7", + "owner": "delegate-7", + "requiredAction": "Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied.", + "evidence": "Export Contractor is missing mfa, orcid for restricted_data_export." + }, + { + "requestId": "del-publish-2", + "owner": "owner-1", + "requiredAction": "Record sponsor approval or route the action back to the project owner.", + "evidence": "publish_release requires sponsor approval before delegate-2 can act." + }, + { + "requestId": "del-transfer-3", + "owner": "delegate-2", + "requiredAction": "Route approval to an independent owner or institutional sponsor.", + "evidence": "delegate-2 cannot both request/delegate and approve role_transfer." + } + ], + "auditDigest": "eb75b6a2db4222fa1c946655d7ea70920e1685e0ed6e5303d829ea16dc51b0ed" +} diff --git a/project-role-delegation-guard/demo-output/demo.mp4 b/project-role-delegation-guard/demo-output/demo.mp4 new file mode 100644 index 00000000..41bb123e Binary files /dev/null and b/project-role-delegation-guard/demo-output/demo.mp4 differ diff --git a/project-role-delegation-guard/demo-output/demo.svg b/project-role-delegation-guard/demo-output/demo.svg new file mode 100644 index 00000000..de0e1e4c --- /dev/null +++ b/project-role-delegation-guard/demo-output/demo.svg @@ -0,0 +1,12 @@ + + + + Project Role Delegation Guard + project-neuro-catalyst + 4 sensitive delegation holds + 1. del-archive-5: delegated_scope_exceeds_action + 2. del-export-7: identity_posture_gap + 3. del-publish-2: missing_sponsor_approval + 4. del-transfer-3: separation_of_duties_violation + audit digest: eb75b6a2db4222fa1c946655... + diff --git a/project-role-delegation-guard/demo.js b/project-role-delegation-guard/demo.js new file mode 100644 index 00000000..ea8eba81 --- /dev/null +++ b/project-role-delegation-guard/demo.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { buildDelegationReviewPacket } = require('./index'); +const { sampleWorkspace } = require('./sample-data'); + +const outputDir = path.join(__dirname, 'demo-output'); +fs.mkdirSync(outputDir, { recursive: true }); + +const packet = buildDelegationReviewPacket(sampleWorkspace, { + asOf: '2026-05-20T12:00:00Z', +}); + +fs.writeFileSync(path.join(outputDir, 'delegation-review-packet.json'), `${JSON.stringify(packet, null, 2)}\n`); + +const rows = packet.holds + .map((hold, index) => `${index + 1}. ${hold.requestId}: ${hold.code}`) + .join('\n '); + +fs.writeFileSync(path.join(outputDir, 'demo.svg'), ` + + + Project Role Delegation Guard + ${packet.workspaceId} + ${packet.holds.length} sensitive delegation holds + ${rows} + audit digest: ${packet.auditDigest.slice(0, 24)}... + +`); + +fs.writeFileSync(path.join(outputDir, 'approval-checklist.md'), [ + '# Delegation approval checklist', + '', + `Workspace: ${packet.workspaceId}`, + `Status: ${packet.overallStatus}`, + '', + ...packet.approvalChecklist.map((item) => `- ${item.requestId}: ${item.requiredAction}`), + '', + `Audit digest: ${packet.auditDigest}`, + '', +].join('\n')); + +function renderMp4() { + const videoPath = path.join(outputDir, 'demo.mp4'); + const font = 'C\\:/Windows/Fonts/arial.ttf'; + const escapeText = (value) => String(value).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'"); + const filters = [ + `drawtext=fontfile='${font}':text='${escapeText('Project Role Delegation Guard')}':x=70:y=80:fontsize=44:fontcolor=white`, + `drawtext=fontfile='${font}':text='${escapeText(`${packet.holds.length} approval blockers found`)}':x=70:y=155:fontsize=34:fontcolor=0xffd166`, + ...packet.holds.map((hold, index) => + `drawtext=fontfile='${font}':text='${escapeText(`${hold.requestId}: ${hold.code}`)}':x=90:y=${235 + index * 58}:fontsize=28:fontcolor=white`, + ), + `drawtext=fontfile='${font}':text='${escapeText(`audit ${packet.auditDigest.slice(0, 20)}...`)}':x=70:y=630:fontsize=24:fontcolor=0x93c5fd`, + ].join(','); + + execFileSync('ffmpeg', [ + '-y', + '-f', + 'lavfi', + '-i', + 'color=c=0x0b1320:s=1280x720:d=7', + '-vf', + filters, + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + videoPath, + ], { stdio: 'inherit' }); +} + +renderMp4(); + +console.log(`Wrote demo artifacts to ${outputDir}`); diff --git a/project-role-delegation-guard/index.js b/project-role-delegation-guard/index.js new file mode 100644 index 00000000..a5f34fba --- /dev/null +++ b/project-role-delegation-guard/index.js @@ -0,0 +1,223 @@ +const crypto = require('crypto'); + +const SENSITIVE_ACTIONS = new Set([ + 'publish_release', + 'restricted_data_export', + 'archive_freeze', + 'role_transfer', +]); + +const ALLOWED_SCOPES_BY_ACTION = { + publish_release: new Set(['manuscript:approve', 'metadata:update', 'release:submit']), + restricted_data_export: new Set(['dataset:read', 'export:restricted']), + archive_freeze: new Set(['archive:freeze', 'citation:snapshot']), + role_transfer: new Set(['role:transfer']), +}; + +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 digest(value) { + return crypto.createHash('sha256').update(stableStringify(value)).digest('hex'); +} + +function toTime(value) { + return new Date(value).getTime(); +} + +function daysUntil(value, asOf) { + return Math.ceil((toTime(value) - toTime(asOf)) / 86_400_000); +} + +function addHold(holds, request, code, owner, evidence, requiredAction) { + holds.push({ + requestId: request.requestId, + code, + owner, + severity: 'blocker', + evidence, + requiredAction, + }); +} + +function addWarning(warnings, request, code, owner, evidence, requiredAction) { + warnings.push({ + requestId: request.requestId, + code, + owner, + severity: 'warning', + evidence, + requiredAction, + }); +} + +function evaluateRequest(request, workspace, options) { + const usersById = new Map(workspace.users.map((user) => [user.userId, user])); + const delegate = usersById.get(request.delegateId); + const holds = []; + const warnings = []; + const sensitive = SENSITIVE_ACTIONS.has(request.action); + + if (toTime(request.expiresAt) < toTime(options.asOf)) { + addHold( + holds, + request, + 'delegation_expired', + request.delegateId, + `${request.requestId} expired on ${request.expiresAt}.`, + 'Create a new delegation request before approving the sensitive action.', + ); + } else if (daysUntil(request.expiresAt, options.asOf) <= workspace.policy.nearExpiryWarningDays) { + addWarning( + warnings, + request, + 'delegation_near_expiry', + request.delegateId, + `${request.requestId} expires in ${daysUntil(request.expiresAt, options.asOf)} days.`, + 'Confirm the delegated authority will still be valid when the action executes.', + ); + } + + if (sensitive && !request.sponsorApproval) { + addHold( + holds, + request, + 'missing_sponsor_approval', + request.sponsorId, + `${request.action} requires sponsor approval before ${request.delegateId} can act.`, + 'Record sponsor approval or route the action back to the project owner.', + ); + } + + if (request.requiresIdentityPosture) { + const missing = request.requiresIdentityPosture.filter((flag) => !delegate.identity[flag]); + if (missing.length > 0) { + addHold( + holds, + request, + 'identity_posture_gap', + request.delegateId, + `${delegate.name} is missing ${missing.join(', ')} for ${request.action}.`, + 'Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied.', + ); + } + } + + if (sensitive && (request.approverId === request.requestorId || request.approverId === request.delegateId)) { + addHold( + holds, + request, + 'separation_of_duties_violation', + request.approverId, + `${request.approverId} cannot both request/delegate and approve ${request.action}.`, + 'Route approval to an independent owner or institutional sponsor.', + ); + } + + const allowedScopes = ALLOWED_SCOPES_BY_ACTION[request.action] || new Set(); + const extraScopes = request.scopes.filter((scope) => !allowedScopes.has(scope)); + if (extraScopes.length > 0) { + addHold( + holds, + request, + 'delegated_scope_exceeds_action', + request.delegateId, + `${request.requestId} includes excess scope(s): ${extraScopes.join(', ')}.`, + 'Narrow the object-level delegation scope before approving the action.', + ); + } + + const holdCodes = new Set(holds.map((hold) => hold.code)); + const decision = holdCodes.has('missing_sponsor_approval') && holds.length === 1 + ? 'needs_sponsor' + : holds.length > 0 + ? 'block' + : 'approve'; + + return { + decision: { + requestId: request.requestId, + action: request.action, + delegateId: request.delegateId, + decision, + expiresAt: request.expiresAt, + }, + holds, + warnings, + }; +} + +function evaluateDelegationRequests(workspace, options = {}) { + const evaluationOptions = { + asOf: options.asOf || new Date().toISOString(), + }; + const decisions = []; + const holds = []; + const warnings = []; + + for (const request of workspace.delegationRequests) { + const result = evaluateRequest(request, workspace, evaluationOptions); + decisions.push(result.decision); + holds.push(...result.holds); + warnings.push(...result.warnings); + } + + const sortedHolds = holds.sort((left, right) => left.code.localeCompare(right.code)); + const sortedWarnings = warnings.sort((left, right) => left.code.localeCompare(right.code)); + + return { + workspaceId: workspace.workspaceId, + overallStatus: sortedHolds.length > 0 ? 'hold' : 'ready', + summary: { + requestsReviewed: workspace.delegationRequests.length, + blockingHolds: sortedHolds.length, + warnings: sortedWarnings.length, + approved: decisions.filter((decision) => decision.decision === 'approve').length, + blocked: decisions.filter((decision) => decision.decision === 'block').length, + }, + decisions, + holds: sortedHolds, + warnings: sortedWarnings, + }; +} + +function buildDelegationReviewPacket(workspace, options = {}) { + const review = evaluateDelegationRequests(workspace, options); + const packet = { + packetType: 'project-role-delegation-guard', + workspaceId: review.workspaceId, + generatedAt: options.asOf || new Date().toISOString(), + overallStatus: review.overallStatus, + decisions: review.decisions, + holds: review.holds, + warnings: review.warnings, + approvalChecklist: review.holds.map((hold) => ({ + requestId: hold.requestId, + owner: hold.owner, + requiredAction: hold.requiredAction, + evidence: hold.evidence, + })), + }; + + return { + ...packet, + auditDigest: digest(packet), + }; +} + +module.exports = { + evaluateDelegationRequests, + buildDelegationReviewPacket, + stableStringify, + digest, +}; diff --git a/project-role-delegation-guard/requirements-map.md b/project-role-delegation-guard/requirements-map.md new file mode 100644 index 00000000..f0611fc7 --- /dev/null +++ b/project-role-delegation-guard/requirements-map.md @@ -0,0 +1,11 @@ +# Requirements Map + +Issue #11 asks for user and project management: profiles, roles, access controls, project visibility, invitation workflows, audit trails, and administrative safety. + +| Requirement area | Coverage in this slice | +| --- | --- | +| User identity | Checks MFA, SAML, and ORCID posture before delegated actions. | +| Project roles | Evaluates delegated authority for publication, export, archive, and role transfer actions. | +| Access control | Enforces action-specific object scopes and blocks over-broad delegated scopes. | +| Auditability | Produces deterministic review packets, approval checklists, and SHA-256 audit digests. | +| Project governance | Requires sponsor approval and separation of duties for sensitive project workflows. | diff --git a/project-role-delegation-guard/sample-data.js b/project-role-delegation-guard/sample-data.js new file mode 100644 index 00000000..29599439 --- /dev/null +++ b/project-role-delegation-guard/sample-data.js @@ -0,0 +1,126 @@ +const sampleWorkspace = { + workspaceId: 'project-neuro-catalyst', + policy: { + nearExpiryWarningDays: 7, + }, + users: [ + { + userId: 'owner-1', + name: 'Dr. Lina Owner', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'delegate-2', + name: 'Postdoc Delegate', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'delegate-7', + name: 'Export Contractor', + identity: { mfa: false, saml: true, orcid: false }, + }, + { + userId: 'delegate-9', + name: 'Archive Assistant', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'approver-3', + name: 'Independent Project Approver', + identity: { mfa: true, saml: true, orcid: true }, + }, + ], + delegationRequests: [ + { + requestId: 'del-publish-2', + action: 'publish_release', + requestorId: 'owner-1', + delegateId: 'delegate-2', + approverId: 'approver-3', + sponsorId: 'owner-1', + sponsorApproval: false, + scopes: ['manuscript:approve', 'metadata:update', 'release:submit'], + expiresAt: '2026-06-20T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml', 'orcid'], + }, + { + requestId: 'del-export-7', + action: 'restricted_data_export', + requestorId: 'owner-1', + delegateId: 'delegate-7', + approverId: 'approver-3', + sponsorId: 'owner-1', + sponsorApproval: true, + scopes: ['dataset:read', 'export:restricted'], + expiresAt: '2026-06-10T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml', 'orcid'], + }, + { + requestId: 'del-transfer-3', + action: 'role_transfer', + requestorId: 'delegate-2', + delegateId: 'delegate-2', + approverId: 'delegate-2', + sponsorId: 'owner-1', + sponsorApproval: true, + scopes: ['role:transfer'], + expiresAt: '2026-06-15T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml'], + }, + { + requestId: 'del-archive-5', + action: 'archive_freeze', + requestorId: 'owner-1', + delegateId: 'delegate-9', + approverId: 'approver-3', + sponsorId: 'owner-1', + sponsorApproval: true, + scopes: ['archive:freeze', 'citation:snapshot', 'repository:admin'], + expiresAt: '2026-06-15T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml'], + }, + ], +}; + +const compliantWorkspace = { + workspaceId: 'project-clean-delegation', + policy: { + nearExpiryWarningDays: 7, + }, + users: [ + { + userId: 'owner-5', + name: 'Dr. Maya Sponsor', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'delegate-5', + name: 'Release Delegate', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'approver-8', + name: 'Independent Approver', + identity: { mfa: true, saml: true, orcid: true }, + }, + ], + delegationRequests: [ + { + requestId: 'del-clean-1', + action: 'publish_release', + requestorId: 'owner-5', + delegateId: 'delegate-5', + approverId: 'approver-8', + sponsorId: 'owner-5', + sponsorApproval: true, + scopes: ['manuscript:approve', 'metadata:update', 'release:submit'], + expiresAt: '2026-05-26T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml', 'orcid'], + }, + ], +}; + +module.exports = { + sampleWorkspace, + compliantWorkspace, +}; diff --git a/project-role-delegation-guard/test.js b/project-role-delegation-guard/test.js new file mode 100644 index 00000000..00d7ab40 --- /dev/null +++ b/project-role-delegation-guard/test.js @@ -0,0 +1,57 @@ +const assert = require('assert'); +const { + evaluateDelegationRequests, + buildDelegationReviewPacket, +} = require('./index'); +const { sampleWorkspace, compliantWorkspace } = require('./sample-data'); + +function testSensitiveDelegationsAreHeld() { + const review = evaluateDelegationRequests(sampleWorkspace, { + asOf: '2026-05-20T12:00:00Z', + }); + + assert.equal(review.overallStatus, 'hold'); + assert.equal(review.summary.blockingHolds, 4); + assert.ok(review.holds.some((hold) => hold.code === 'missing_sponsor_approval')); + assert.ok(review.holds.some((hold) => hold.code === 'identity_posture_gap')); + assert.ok(review.holds.some((hold) => hold.code === 'separation_of_duties_violation')); + assert.ok(review.holds.some((hold) => hold.code === 'delegated_scope_exceeds_action')); + assert.equal(review.decisions.find((decision) => decision.requestId === 'del-export-7').decision, 'block'); + assert.equal(review.decisions.find((decision) => decision.requestId === 'del-publish-2').decision, 'needs_sponsor'); +} + +function testReviewPacketIsDeterministic() { + const packet = buildDelegationReviewPacket(sampleWorkspace, { + asOf: '2026-05-20T12:00:00Z', + }); + + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); + assert.deepEqual( + packet.holds.map((hold) => hold.code), + [ + 'delegated_scope_exceeds_action', + 'identity_posture_gap', + 'missing_sponsor_approval', + 'separation_of_duties_violation', + ], + ); + assert.equal(packet.approvalChecklist.length, 4); + assert.ok(packet.approvalChecklist.every((item) => item.requestId && item.requiredAction)); +} + +function testCompliantDelegationsPassWithExpiryWarning() { + const review = evaluateDelegationRequests(compliantWorkspace, { + asOf: '2026-05-20T12:00:00Z', + }); + + assert.equal(review.overallStatus, 'ready'); + assert.equal(review.summary.blockingHolds, 0); + assert.equal(review.warnings.length, 1); + assert.equal(review.warnings[0].code, 'delegation_near_expiry'); +} + +testSensitiveDelegationsAreHeld(); +testReviewPacketIsDeterministic(); +testCompliantDelegationsPassWithExpiryWarning(); + +console.log('project-role-delegation-guard tests passed');