diff --git a/repository-access-review-gate/README.md b/repository-access-review-gate/README.md new file mode 100644 index 0000000..838c558 --- /dev/null +++ b/repository-access-review-gate/README.md @@ -0,0 +1,23 @@ +# Repository Access Review Gate + +This self-contained slice adds a release-time access review for scientific project repositories. +It checks whether collaborators, reviewer links, restricted datasets, and artifact licenses are safe before a tagged repository export is made public. + +The module is intentionally dependency-free and uses synthetic data only. + +## What it produces + +- `releaseStatus`: `ready` or `hold`. +- `releaseHolds`: deterministic blockers for stale external reviewers, expired data-use approvals, privileged role drift, secret-link exposure, and license/access mismatch. +- `accessMatrix`: per-collaborator keep/revoke/downgrade decisions. +- `reviewerChecklist`: action-ready remediation items for repository owners and data stewards. +- `auditDigest`: SHA-256 digest over the packet for reviewer evidence. + +## Run locally + +```bash +node repository-access-review-gate/test.js +node repository-access-review-gate/demo.js +``` + +The demo writes JSON, Markdown, SVG, and MP4 artifacts to `repository-access-review-gate/demo-output/`. diff --git a/repository-access-review-gate/acceptance-notes.md b/repository-access-review-gate/acceptance-notes.md new file mode 100644 index 0000000..9297657 --- /dev/null +++ b/repository-access-review-gate/acceptance-notes.md @@ -0,0 +1,7 @@ +# Acceptance Notes + +- The module stays self-contained under `repository-access-review-gate/`. +- No credentials, network calls, or live user data are used. +- Tests cover both a blocked release and a ready release with a warning-only temporary access case. +- Demo artifacts include reviewer-friendly JSON, Markdown, SVG, and a short MP4. +- The implementation is intentionally separate from prior issue #10 submissions such as repository ledgers, release engines, schema migration, merge queue governance, and environment drift gates. diff --git a/repository-access-review-gate/demo-output/demo.mp4 b/repository-access-review-gate/demo-output/demo.mp4 new file mode 100644 index 0000000..e6fcdf3 Binary files /dev/null and b/repository-access-review-gate/demo-output/demo.mp4 differ diff --git a/repository-access-review-gate/demo-output/demo.svg b/repository-access-review-gate/demo-output/demo.svg new file mode 100644 index 0000000..c9a517d --- /dev/null +++ b/repository-access-review-gate/demo-output/demo.svg @@ -0,0 +1,13 @@ + + + + Repository Access Review Gate + repo-sci-artery-flow-042 / v2.1.0 + 5 release holds before export + 1. external_reviewer_expired -> u-ext-17 + 2. license_access_mismatch -> data-steward + 3. privileged_role_without_release_scope -> u-admin-2 + 4. restricted_dataset_approval_expired -> u-ext-17 + 5. secret_link_exposure -> security-review + audit digest: 8f8ae3250c816f296ba59f67... + diff --git a/repository-access-review-gate/demo-output/release-hold-packet.json b/repository-access-review-gate/demo-output/release-hold-packet.json new file mode 100644 index 0000000..87c815f --- /dev/null +++ b/repository-access-review-gate/demo-output/release-hold-packet.json @@ -0,0 +1,107 @@ +{ + "packetType": "repository-access-review-gate", + "repositoryId": "repo-sci-artery-flow-042", + "releaseTag": "v2.1.0", + "releaseStatus": "hold", + "generatedAt": "2026-05-20T12:00:00Z", + "releaseHolds": [ + { + "code": "external_reviewer_expired", + "severity": "blocker", + "owner": "u-ext-17", + "evidence": "External Reviewer 17 review access expired on 2026-05-01T00:00:00Z", + "requiredAction": "Revoke reviewer access or issue a fresh time-boxed invite before release." + }, + { + "code": "license_access_mismatch", + "severity": "blocker", + "owner": "data-steward", + "evidence": "data/restricted-waveforms.parquet is restricted-dua but v2.1.0 targets public export.", + "requiredAction": "Block public export or replace the artifact with a license-compatible derivative." + }, + { + "code": "privileged_role_without_release_scope", + "severity": "blocker", + "owner": "u-admin-2", + "evidence": "Samir Dev is admin but is not approved for v2.1.0.", + "requiredAction": "Downgrade the role or record release-scope approval from a repository owner." + }, + { + "code": "restricted_dataset_approval_expired", + "severity": "blocker", + "owner": "u-ext-17", + "evidence": "External Reviewer 17 has restricted-data download access with an expired DUA approval.", + "requiredAction": "Remove restricted-data access until the data-use approval is renewed." + }, + { + "code": "secret_link_exposure", + "severity": "blocker", + "owner": "security-review", + "evidence": "1 artifact link(s) expose secret-bearing URLs in the release packet.", + "requiredAction": "Replace secret links with scoped repository object grants before export." + } + ], + "reviewerChecklist": [ + { + "owner": "u-ext-17", + "requiredAction": "Revoke reviewer access or issue a fresh time-boxed invite before release.", + "evidence": "External Reviewer 17 review access expired on 2026-05-01T00:00:00Z" + }, + { + "owner": "data-steward", + "requiredAction": "Block public export or replace the artifact with a license-compatible derivative.", + "evidence": "data/restricted-waveforms.parquet is restricted-dua but v2.1.0 targets public export." + }, + { + "owner": "u-admin-2", + "requiredAction": "Downgrade the role or record release-scope approval from a repository owner.", + "evidence": "Samir Dev is admin but is not approved for v2.1.0." + }, + { + "owner": "u-ext-17", + "requiredAction": "Remove restricted-data access until the data-use approval is renewed.", + "evidence": "External Reviewer 17 has restricted-data download access with an expired DUA approval." + }, + { + "owner": "security-review", + "requiredAction": "Replace secret links with scoped repository object grants before export.", + "evidence": "1 artifact link(s) expose secret-bearing URLs in the release packet." + } + ], + "accessMatrix": [ + { + "userId": "u-owner-1", + "name": "Dr. Amina Park", + "role": "owner", + "scope": "repository-admin", + "releaseApproved": true, + "decision": "keep" + }, + { + "userId": "u-admin-2", + "name": "Samir Dev", + "role": "admin", + "scope": "all-components", + "releaseApproved": false, + "decision": "downgrade_before_release" + }, + { + "userId": "u-ext-17", + "name": "External Reviewer 17", + "role": "reviewer", + "scope": "manuscript,data", + "releaseApproved": true, + "decision": "revoke_before_release" + }, + { + "userId": "u-analyst-4", + "name": "Mina Analyst", + "role": "maintainer", + "scope": "code,results", + "releaseApproved": true, + "decision": "keep" + } + ], + "warnings": [], + "auditDigest": "8f8ae3250c816f296ba59f67236e29f9ccd8ae375092da4c7dc8dd59c5da9ff3" +} diff --git a/repository-access-review-gate/demo-output/reviewer-packet.md b/repository-access-review-gate/demo-output/reviewer-packet.md new file mode 100644 index 0000000..785de8e --- /dev/null +++ b/repository-access-review-gate/demo-output/reviewer-packet.md @@ -0,0 +1,15 @@ +# Repository access review gate demo + +Repository: repo-sci-artery-flow-042 +Release: v2.1.0 +Status: hold +Blocking holds: 5 + +## Required reviewer actions +- u-ext-17: Revoke reviewer access or issue a fresh time-boxed invite before release. +- data-steward: Block public export or replace the artifact with a license-compatible derivative. +- u-admin-2: Downgrade the role or record release-scope approval from a repository owner. +- u-ext-17: Remove restricted-data access until the data-use approval is renewed. +- security-review: Replace secret links with scoped repository object grants before export. + +Audit digest: 8f8ae3250c816f296ba59f67236e29f9ccd8ae375092da4c7dc8dd59c5da9ff3 diff --git a/repository-access-review-gate/demo.js b/repository-access-review-gate/demo.js new file mode 100644 index 0000000..7c10f8d --- /dev/null +++ b/repository-access-review-gate/demo.js @@ -0,0 +1,81 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { buildReleaseHoldPacket } = require('./index'); +const { sampleRepository } = require('./sample-data'); + +const outputDir = path.join(__dirname, 'demo-output'); +fs.mkdirSync(outputDir, { recursive: true }); + +const packet = buildReleaseHoldPacket(sampleRepository, { + asOf: '2026-05-20T12:00:00Z', + releaseTag: 'v2.1.0', +}); + +fs.writeFileSync(path.join(outputDir, 'release-hold-packet.json'), `${JSON.stringify(packet, null, 2)}\n`); + +const svg = ` + + + Repository Access Review Gate + ${packet.repositoryId} / ${packet.releaseTag} + ${packet.releaseHolds.length} release holds before export + ${packet.releaseHolds + .map( + (hold, index) => + `${index + 1}. ${hold.code} -> ${hold.owner}`, + ) + .join('\n ')} + audit digest: ${packet.auditDigest.slice(0, 24)}... + +`; +fs.writeFileSync(path.join(outputDir, 'demo.svg'), svg); + +const markdown = [ + '# Repository access review gate demo', + '', + `Repository: ${packet.repositoryId}`, + `Release: ${packet.releaseTag}`, + `Status: ${packet.releaseStatus}`, + `Blocking holds: ${packet.releaseHolds.length}`, + '', + '## Required reviewer actions', + ...packet.reviewerChecklist.map((item) => `- ${item.owner}: ${item.requiredAction}`), + '', + `Audit digest: ${packet.auditDigest}`, + '', +].join('\n'); +fs.writeFileSync(path.join(outputDir, 'reviewer-packet.md'), markdown); + +function renderMp4() { + const videoPath = path.join(outputDir, 'demo.mp4'); + const font = 'C\\:/Windows/Fonts/arial.ttf'; + const text = (value) => String(value).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'"); + const filters = [ + `drawtext=fontfile='${font}':text='${text('Repository Access Review Gate')}':x=70:y=80:fontsize=44:fontcolor=white`, + `drawtext=fontfile='${font}':text='${text(`${packet.releaseHolds.length} blocking release holds detected`)}':x=70:y=160:fontsize=34:fontcolor=0xffd166`, + ...packet.releaseHolds.slice(0, 5).map((hold, index) => + `drawtext=fontfile='${font}':text='${text(`${index + 1}. ${hold.code} -> ${hold.owner}`)}':x=90:y=${240 + index * 54}:fontsize=27:fontcolor=white`, + ), + `drawtext=fontfile='${font}':text='${text(`audit ${packet.auditDigest.slice(0, 20)}...`)}':x=70:y=630:fontsize=24:fontcolor=0x93c5fd`, + ].join(','); + + execFileSync('ffmpeg', [ + '-y', + '-f', + 'lavfi', + '-i', + 'color=c=0x111827: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/repository-access-review-gate/index.js b/repository-access-review-gate/index.js new file mode 100644 index 0000000..2c26fb0 --- /dev/null +++ b/repository-access-review-gate/index.js @@ -0,0 +1,212 @@ +const crypto = require('crypto'); + +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, code, owner, evidence, requiredAction) { + holds.push({ + code, + severity: 'blocker', + owner, + evidence, + requiredAction, + }); +} + +function addWarning(warnings, code, owner, evidence, requiredAction) { + warnings.push({ + code, + severity: 'warning', + owner, + evidence, + requiredAction, + }); +} + +function evaluateCollaborator(collaborator, repository, options) { + const holds = []; + const warnings = []; + const decisions = new Set(); + const asOf = options.asOf; + + const isExternalReviewer = collaborator.affiliation === 'external' && collaborator.role === 'reviewer'; + if (isExternalReviewer && collaborator.access.expiresAt && toTime(collaborator.access.expiresAt) < toTime(asOf)) { + addHold( + holds, + 'external_reviewer_expired', + collaborator.userId, + `${collaborator.name} review access expired on ${collaborator.access.expiresAt}`, + 'Revoke reviewer access or issue a fresh time-boxed invite before release.', + ); + decisions.add('revoke_before_release'); + } + + if ( + collaborator.access.canDownloadRestrictedData && + collaborator.access.dataUseApprovalExpiresAt && + toTime(collaborator.access.dataUseApprovalExpiresAt) < toTime(asOf) + ) { + addHold( + holds, + 'restricted_dataset_approval_expired', + collaborator.userId, + `${collaborator.name} has restricted-data download access with an expired DUA approval.`, + 'Remove restricted-data access until the data-use approval is renewed.', + ); + decisions.add('revoke_before_release'); + } + + const privileged = ['owner', 'admin', 'maintainer'].includes(collaborator.role); + if (privileged && !collaborator.access.releaseApproved) { + addHold( + holds, + 'privileged_role_without_release_scope', + collaborator.userId, + `${collaborator.name} is ${collaborator.role} but is not approved for ${options.releaseTag}.`, + 'Downgrade the role or record release-scope approval from a repository owner.', + ); + decisions.add('downgrade_before_release'); + } + + if ( + collaborator.access.expiresAt && + toTime(collaborator.access.expiresAt) >= toTime(asOf) && + daysUntil(collaborator.access.expiresAt, asOf) <= repository.policy.nearExpiryWarningDays + ) { + addWarning( + warnings, + 'temporary_access_near_expiry', + collaborator.userId, + `${collaborator.name} access expires in ${daysUntil(collaborator.access.expiresAt, asOf)} days.`, + 'Confirm whether temporary access should survive the release review window.', + ); + } + + return { + holds, + warnings, + accessDecision: { + userId: collaborator.userId, + name: collaborator.name, + role: collaborator.role, + scope: collaborator.access.scope, + releaseApproved: Boolean(collaborator.access.releaseApproved), + decision: decisions.has('revoke_before_release') + ? 'revoke_before_release' + : decisions.has('downgrade_before_release') + ? 'downgrade_before_release' + : 'keep', + }, + }; +} + +function evaluateRepositoryAccessReview(repository, options = {}) { + const reviewOptions = { + asOf: options.asOf || new Date().toISOString(), + releaseTag: options.releaseTag || repository.release.tag, + }; + const holds = []; + const warnings = []; + const accessMatrix = []; + + for (const collaborator of repository.collaborators) { + const collaboratorReview = evaluateCollaborator(collaborator, repository, reviewOptions); + holds.push(...collaboratorReview.holds); + warnings.push(...collaboratorReview.warnings); + accessMatrix.push(collaboratorReview.accessDecision); + } + + const exposedLinks = repository.artifacts.filter((artifact) => artifact.secretLink); + if (exposedLinks.length > 0) { + addHold( + holds, + 'secret_link_exposure', + 'security-review', + `${exposedLinks.length} artifact link(s) expose secret-bearing URLs in the release packet.`, + 'Replace secret links with scoped repository object grants before export.', + ); + } + + const licenseMismatch = repository.artifacts.find( + (artifact) => artifact.license === 'restricted-dua' && repository.release.visibility === 'public', + ); + if (licenseMismatch) { + addHold( + holds, + 'license_access_mismatch', + licenseMismatch.owner, + `${licenseMismatch.path} is restricted-dua but ${repository.release.tag} targets public export.`, + 'Block public export or replace the artifact with a license-compatible derivative.', + ); + } + + const sortedHolds = holds.sort((left, right) => left.code.localeCompare(right.code)); + const sortedWarnings = warnings.sort((left, right) => left.code.localeCompare(right.code)); + + return { + repositoryId: repository.repositoryId, + releaseTag: reviewOptions.releaseTag, + releaseStatus: sortedHolds.length > 0 ? 'hold' : 'ready', + summary: { + collaboratorsReviewed: repository.collaborators.length, + artifactsReviewed: repository.artifacts.length, + blockingHolds: sortedHolds.length, + warningCount: sortedWarnings.length, + }, + holds: sortedHolds, + warnings: sortedWarnings, + accessMatrix, + }; +} + +function buildReleaseHoldPacket(repository, options = {}) { + const review = evaluateRepositoryAccessReview(repository, options); + const packet = { + packetType: 'repository-access-review-gate', + repositoryId: review.repositoryId, + releaseTag: review.releaseTag, + releaseStatus: review.releaseStatus, + generatedAt: options.asOf || new Date().toISOString(), + releaseHolds: review.holds, + reviewerChecklist: review.holds.map((hold) => ({ + owner: hold.owner, + requiredAction: hold.requiredAction, + evidence: hold.evidence, + })), + accessMatrix: review.accessMatrix, + warnings: review.warnings, + }; + return { + ...packet, + auditDigest: digest(packet), + }; +} + +module.exports = { + evaluateRepositoryAccessReview, + buildReleaseHoldPacket, + stableStringify, + digest, +}; diff --git a/repository-access-review-gate/requirements-map.md b/repository-access-review-gate/requirements-map.md new file mode 100644 index 0000000..32477b7 --- /dev/null +++ b/repository-access-review-gate/requirements-map.md @@ -0,0 +1,12 @@ +# Requirements Map + +Issue #10 asks for project repositories that combine collaboration, publication, reproducibility, versioning, provenance, access, and export workflows. + +| Requirement area | Coverage in this slice | +| --- | --- | +| Collaboration and review | Builds a collaborator access matrix for owners, admins, maintainers, and external reviewers. | +| Provenance and contribution trust | Records release-scope approval, owner attribution, and deterministic audit digests. | +| Data and artifact governance | Blocks expired restricted-data approvals and secret-bearing artifact links. | +| Versioned release workflow | Evaluates a named release tag before public export. | +| Programmatic access/export safety | Produces a release hold packet that can gate API/export flows. | +| Reproducibility and archival trust | Preserves license/access evidence so restricted datasets are not accidentally exported as public release assets. | diff --git a/repository-access-review-gate/sample-data.js b/repository-access-review-gate/sample-data.js new file mode 100644 index 0000000..1deee1b --- /dev/null +++ b/repository-access-review-gate/sample-data.js @@ -0,0 +1,128 @@ +const sampleRepository = { + repositoryId: 'repo-sci-artery-flow-042', + title: 'Artery Flow Digital Twin Release', + policy: { + nearExpiryWarningDays: 14, + }, + release: { + tag: 'v2.1.0', + visibility: 'public', + }, + collaborators: [ + { + userId: 'u-owner-1', + name: 'Dr. Amina Park', + role: 'owner', + affiliation: 'internal', + access: { + scope: 'repository-admin', + releaseApproved: true, + }, + }, + { + userId: 'u-admin-2', + name: 'Samir Dev', + role: 'admin', + affiliation: 'internal', + access: { + scope: 'all-components', + releaseApproved: false, + }, + }, + { + userId: 'u-ext-17', + name: 'External Reviewer 17', + role: 'reviewer', + affiliation: 'external', + access: { + scope: 'manuscript,data', + expiresAt: '2026-05-01T00:00:00Z', + releaseApproved: true, + canDownloadRestrictedData: true, + dataUseApprovalExpiresAt: '2026-05-05T00:00:00Z', + }, + }, + { + userId: 'u-analyst-4', + name: 'Mina Analyst', + role: 'maintainer', + affiliation: 'internal', + access: { + scope: 'code,results', + releaseApproved: true, + }, + }, + ], + artifacts: [ + { + artifactId: 'data-waveforms', + path: 'data/restricted-waveforms.parquet', + owner: 'data-steward', + license: 'restricted-dua', + secretLink: false, + }, + { + artifactId: 'supplement-secret', + path: 'results/supplemental-viewer.url', + owner: 'security-review', + license: 'internal-review', + secretLink: true, + }, + { + artifactId: 'manuscript', + path: 'manuscript/preprint.md', + owner: 'u-owner-1', + license: 'cc-by-4.0', + secretLink: false, + }, + ], +}; + +const readyRepository = { + repositoryId: 'repo-sci-hydrogel-ready', + title: 'Hydrogel Stress Test Release', + policy: { + nearExpiryWarningDays: 14, + }, + release: { + tag: 'v1.4.2', + visibility: 'institutional', + }, + collaborators: [ + { + userId: 'u-owner-8', + name: 'Dr. Tessa Rowan', + role: 'owner', + affiliation: 'internal', + access: { + scope: 'repository-admin', + releaseApproved: true, + }, + }, + { + userId: 'u-ext-22', + name: 'External Reviewer 22', + role: 'reviewer', + affiliation: 'external', + access: { + scope: 'manuscript', + expiresAt: '2026-05-29T00:00:00Z', + releaseApproved: true, + }, + }, + ], + artifacts: [ + { + artifactId: 'preprint', + path: 'manuscript/preprint.md', + owner: 'u-owner-8', + license: 'cc-by-4.0', + secretLink: false, + }, + ], +}; + +module.exports = { + sampleRepository, + readyRepository, +}; diff --git a/repository-access-review-gate/test.js b/repository-access-review-gate/test.js new file mode 100644 index 0000000..f7a52db --- /dev/null +++ b/repository-access-review-gate/test.js @@ -0,0 +1,62 @@ +const assert = require('assert'); +const { + evaluateRepositoryAccessReview, + buildReleaseHoldPacket, +} = require('./index'); +const { sampleRepository, readyRepository } = require('./sample-data'); + +function testReleaseHoldSignals() { + const review = evaluateRepositoryAccessReview(sampleRepository, { + asOf: '2026-05-20T12:00:00Z', + releaseTag: 'v2.1.0', + }); + + assert.equal(review.releaseStatus, 'hold'); + assert.equal(review.summary.blockingHolds, 5); + assert.ok(review.holds.some((hold) => hold.code === 'external_reviewer_expired')); + assert.ok(review.holds.some((hold) => hold.code === 'restricted_dataset_approval_expired')); + assert.ok(review.holds.some((hold) => hold.code === 'privileged_role_without_release_scope')); + assert.ok(review.holds.some((hold) => hold.code === 'secret_link_exposure')); + assert.ok(review.holds.some((hold) => hold.code === 'license_access_mismatch')); + assert.equal(review.accessMatrix.find((entry) => entry.userId === 'u-ext-17').decision, 'revoke_before_release'); + assert.equal(review.accessMatrix.find((entry) => entry.userId === 'u-admin-2').decision, 'downgrade_before_release'); +} + +function testReleasePacketIsDeterministicAndReviewReady() { + const packet = buildReleaseHoldPacket(sampleRepository, { + asOf: '2026-05-20T12:00:00Z', + releaseTag: 'v2.1.0', + }); + + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); + assert.deepEqual( + packet.releaseHolds.map((hold) => hold.code), + [ + 'external_reviewer_expired', + 'license_access_mismatch', + 'privileged_role_without_release_scope', + 'restricted_dataset_approval_expired', + 'secret_link_exposure', + ], + ); + assert.equal(packet.reviewerChecklist.length, 5); + assert.ok(packet.reviewerChecklist.every((item) => item.owner && item.requiredAction)); +} + +function testReadyRepositoryPassesWithAuditableWarnings() { + const review = evaluateRepositoryAccessReview(readyRepository, { + asOf: '2026-05-20T12:00:00Z', + releaseTag: 'v1.4.2', + }); + + assert.equal(review.releaseStatus, 'ready'); + assert.equal(review.summary.blockingHolds, 0); + assert.equal(review.summary.warningCount, 1); + assert.equal(review.warnings[0].code, 'temporary_access_near_expiry'); +} + +testReleaseHoldSignals(); +testReleasePacketIsDeterministicAndReviewReady(); +testReadyRepositoryPassesWithAuditableWarnings(); + +console.log('repository-access-review-gate tests passed');