diff --git a/project-provisioning-baseline-guard/README.md b/project-provisioning-baseline-guard/README.md new file mode 100644 index 00000000..1f399367 --- /dev/null +++ b/project-provisioning-baseline-guard/README.md @@ -0,0 +1,19 @@ +# Project Provisioning Baseline Guard + +This module adds a focused User & Project Management guard for creating new research workspaces safely. + +It evaluates whether a project can be provisioned by checking requester authority, verified institution/profile evidence, fresh MFA, required launch metadata, template controls, visibility rules by data classification, initial project roles, object-level grants, external collaborator constraints, and immutable audit evidence. + +## Run + +```sh +node project-provisioning-baseline-guard/test.js +node project-provisioning-baseline-guard/demo.js +node project-provisioning-baseline-guard/render-video.js +``` + +The demo writes JSON, Markdown, and SVG reviewer artifacts to `project-provisioning-baseline-guard/reports/`. + +## Review Surface + +The implementation is dependency-free, uses synthetic data only, and does not call external APIs or read credentials. diff --git a/project-provisioning-baseline-guard/acceptance-notes.md b/project-provisioning-baseline-guard/acceptance-notes.md new file mode 100644 index 00000000..db2e2385 --- /dev/null +++ b/project-provisioning-baseline-guard/acceptance-notes.md @@ -0,0 +1,21 @@ +# Acceptance Notes + +## Validation + +- `node project-provisioning-baseline-guard/test.js` +- `node project-provisioning-baseline-guard/demo.js` +- `node project-provisioning-baseline-guard/render-video.js` +- `node --check project-provisioning-baseline-guard/index.js` +- `node --check project-provisioning-baseline-guard/test.js` +- `node --check project-provisioning-baseline-guard/demo.js` +- `node --check project-provisioning-baseline-guard/render-video.js` +- `ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 project-provisioning-baseline-guard/demo.mp4` + +## Acceptance Coverage + +- A controlled institutional project with fresh requester MFA, verified affiliation, required metadata, template controls, owner and data-steward roles, scoped external collaboration, and audit events can provision cleanly. +- Restricted human-subject projects cannot start with public visibility. +- Missing requester authority and stale MFA block provisioning. +- External restricted-data grants require data-use agreement evidence. +- Missing template controls block workspace creation. +- The output audit digest is deterministic for replay and reviewer comparison. diff --git a/project-provisioning-baseline-guard/demo.js b/project-provisioning-baseline-guard/demo.js new file mode 100644 index 00000000..f6b5b27a --- /dev/null +++ b/project-provisioning-baseline-guard/demo.js @@ -0,0 +1,171 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateProjectProvisioning } = require("./index"); + +const outputDir = path.join(__dirname, "reports"); +fs.mkdirSync(outputDir, { recursive: true }); + +function packetForDemo() { + return { + now: "2026-06-01T12:00:00Z", + policy: { + maxMfaAgeHours: 12, + requiredMetadataFields: ["projectName", "projectPurpose", "discipline", "institutionId", "dataClassification"], + requiredProjectRoles: ["owner"], + allowedExternalDomains: ["partner-lab.org"], + visibilityByClassification: { + open: ["public", "institutional", "private"], + controlled: ["institutional", "private"], + "restricted-human-subjects": ["private"], + }, + templates: { + "controlled-study": { + id: "controlled-study", + allowedClassifications: ["controlled", "restricted-human-subjects"], + requiredMetadataFields: ["retentionPlan"], + requiredProjectRoles: ["data-steward"], + requiredControls: ["auditTrail", "objectGrantReview"], + }, + }, + }, + users: [ + { + id: "user-pi-ada", + type: "internal", + projectCreator: true, + email: "ada@northbridge.edu", + mfaAt: "2026-06-01T08:00:00Z", + verifiedAffiliations: [{ institutionId: "northbridge", status: "verified" }], + identifiers: [{ type: "orcid", value: "0000-0002-1825-0097", status: "verified" }], + }, + { + id: "user-steward-lin", + type: "internal", + email: "lin@northbridge.edu", + verifiedAffiliations: [{ institutionId: "northbridge", status: "verified" }], + identifiers: [{ type: "orcid", value: "0000-0001-5555-1212", status: "verified" }], + }, + { + id: "user-ext-ren", + type: "external", + email: "ren@partner-lab.org", + training: { restrictedData: "current" }, + verifiedAffiliations: [{ institutionId: "partner-lab", status: "verified" }], + identifiers: [{ type: "orcid", value: "0000-0003-9999-8888", status: "verified" }], + }, + ], + request: { + requestId: "provision-1", + projectId: "project-neuro-qc", + name: "Neuro QC Consortium", + requesterId: "user-pi-ada", + institutionId: "northbridge", + templateId: "controlled-study", + dataClassification: "controlled", + visibility: "institutional", + metadata: { + projectName: "Neuro QC Consortium", + projectPurpose: "Coordinate reproducible quality-control notebooks for multi-site neuroimaging studies.", + discipline: "neuroscience", + institutionId: "northbridge", + dataClassification: "controlled", + retentionPlan: "retain-seven-years", + }, + controls: { auditTrail: true, objectGrantReview: true }, + roleAssignments: [ + { userId: "user-pi-ada", role: "owner", scope: "project" }, + { userId: "user-steward-lin", role: "data-steward", scope: "project" }, + { userId: "user-ext-ren", role: "viewer", scope: "project" }, + ], + objectGrants: [ + { principalId: "user-pi-ada", objectType: "workspace", permissions: ["admin"] }, + { principalId: "user-steward-lin", objectType: "controlled-dataset", permissions: ["read", "review"] }, + { principalId: "user-ext-ren", objectType: "manuscript", permissions: ["read"] }, + ], + externalCollaborators: ["user-ext-ren"], + approvals: [{ type: "sponsor-review", status: "approved", subjectId: "project-neuro-qc" }], + auditEvents: [ + { type: "provision-requested", actorId: "user-pi-ada", at: "2026-06-01T08:03:00Z" }, + { type: "baseline-evaluated", actorId: "system", at: "2026-06-01T08:04:00Z" }, + ], + }, + }; +} + +function markdownReport(report) { + return [ + "# Project Provisioning Baseline Guard Demo", + "", + `Decision: ${report.decision}`, + `Audit digest: ${report.auditDigest}`, + "", + "## Public Summary", + "", + `- Project: ${report.publicSummary.projectName} (${report.projectId})`, + `- Template: ${report.publicSummary.templateId}`, + `- Visibility: ${report.publicSummary.visibility}`, + `- Data classification: ${report.publicSummary.dataClassification}`, + "", + "## Finding Counts", + "", + `- Blockers: ${report.counts.blocker}`, + `- Warnings: ${report.counts.warning}`, + `- Info: ${report.counts.info}`, + "", + "## Action Queue", + "", + ...(report.actionQueue.length + ? report.actionQueue.map((action) => `- ${action.severity} ${action.code} (${action.subject}): ${action.remediation}`) + : ["- No remediation actions required."]), + "", + ].join("\n"); +} + +function svgReport(report) { + const blocked = report.counts.blocker; + const warnings = report.counts.warning; + const readyColor = report.decision === "provision-ready" ? "#68d391" : "#f6ad55"; + + return ` + Project Provisioning Baseline Guard Demo + A user and project management demo showing project launch controls, identity evidence, visibility rules, role grants, and audit evidence. + + + SCIBASE bounty demo artifact + Project Provisioning Baseline Guard + Issue #11: user and project management launch governance + + + PROVISIONING BASELINE + 1. Verify requester authority and MFA + 2. Check metadata and template controls + 3. Enforce visibility by data classification + 4. Validate initial roles and object grants + 5. Emit audit digest and action queue + $ node project-provisioning-baseline-guard/test.js + decision: ${report.decision} + blockers: ${blocked} | warnings: ${warnings} + Reviewer artifacts + reports/provisioning-baseline-packet.json + reports/provisioning-baseline-report.md + + + + Synthetic project launch packet + deterministic audit evidence + +`; +} + +const report = evaluateProjectProvisioning(packetForDemo()); +const jsonPath = path.join(outputDir, "provisioning-baseline-packet.json"); +const markdownPath = path.join(outputDir, "provisioning-baseline-report.md"); +const svgPath = path.join(outputDir, "summary.svg"); + +fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)); +fs.writeFileSync(markdownPath, markdownReport(report)); +fs.writeFileSync(svgPath, svgReport(report)); + +console.log(`Wrote ${jsonPath}`); +console.log(`Wrote ${markdownPath}`); +console.log(`Wrote ${svgPath}`); +console.log(`${report.decision}: ${report.counts.blocker} blocker(s), ${report.counts.warning} warning(s), ${report.auditDigest}`); diff --git a/project-provisioning-baseline-guard/demo.mp4 b/project-provisioning-baseline-guard/demo.mp4 new file mode 100644 index 00000000..39fd8b1e Binary files /dev/null and b/project-provisioning-baseline-guard/demo.mp4 differ diff --git a/project-provisioning-baseline-guard/demo.svg b/project-provisioning-baseline-guard/demo.svg new file mode 100644 index 00000000..15ce3242 --- /dev/null +++ b/project-provisioning-baseline-guard/demo.svg @@ -0,0 +1,27 @@ + + Project Provisioning Baseline Guard Demo + A user and project management demo showing project launch controls, identity evidence, visibility rules, role grants, and audit evidence. + + + SCIBASE bounty demo artifact + Project Provisioning Baseline Guard + Issue #11: user and project management launch governance + + + PROVISIONING BASELINE + 1. Verify requester authority and MFA + 2. Check metadata and template controls + 3. Enforce visibility by data classification + 4. Validate initial roles and object grants + 5. Emit audit digest and action queue + $ node project-provisioning-baseline-guard/test.js + tests passed: identity, metadata, visibility, + roles, external grants, deterministic digest + Reviewer artifacts + reports/provisioning-baseline-packet.json + reports/provisioning-baseline-report.md + + + + Committed video demo + focused tests + synthetic evidence + diff --git a/project-provisioning-baseline-guard/index.js b/project-provisioning-baseline-guard/index.js new file mode 100644 index 00000000..e1328286 --- /dev/null +++ b/project-provisioning-baseline-guard/index.js @@ -0,0 +1,484 @@ +const crypto = require("crypto"); + +const HOUR_MS = 60 * 60 * 1000; + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function normalize(value) { + return String(value || "").trim().toLowerCase(); +} + +function unique(values) { + return [...new Set(asArray(values).map(normalize).filter(Boolean))]; +} + +function uniqueFields(values) { + const seen = new Set(); + const fields = []; + for (const value of asArray(values).filter(Boolean)) { + const key = normalize(value); + if (seen.has(key)) continue; + seen.add(key); + fields.push(value); + } + return fields; +} + +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 parseDate(value) { + const time = Date.parse(value || ""); + return Number.isNaN(time) ? 0 : time; +} + +function hoursSince(value, now) { + const time = parseDate(value); + if (!time || !now) return Infinity; + return Math.floor((now - time) / HOUR_MS); +} + +function addFinding(findings, severity, code, subject, message, remediation) { + findings.push({ severity, code, subject, message, remediation }); +} + +function byId(records) { + return asArray(records).reduce((acc, record) => { + if (record && record.id) acc[record.id] = record; + return acc; + }, {}); +} + +function hasVerifiedAffiliation(user, institutionId) { + const target = normalize(institutionId); + return asArray(user.verifiedAffiliations).some((affiliation) => { + if (normalize(affiliation.institutionId || affiliation.id) !== target) return false; + return normalize(affiliation.status || "verified") === "verified"; + }); +} + +function hasVerifiedIdentifier(user, type) { + return asArray(user.identifiers).some( + (identifier) => normalize(identifier.type) === normalize(type) && normalize(identifier.status || "verified") === "verified", + ); +} + +function roleAssignmentsFor(assignments, userId) { + const target = normalize(userId); + return asArray(assignments).filter((assignment) => normalize(assignment.userId) === target); +} + +function hasAnyProjectRole(assignments, roles) { + const wanted = unique(roles); + return asArray(assignments).some((assignment) => wanted.includes(normalize(assignment.role))); +} + +function approvalExists(approvals, type, subjectId) { + const targetType = normalize(type); + const targetSubject = normalize(subjectId); + return asArray(approvals).some((approval) => { + if (normalize(approval.type) !== targetType) return false; + if (targetSubject && normalize(approval.subjectId || approval.userId || approval.projectId) !== targetSubject) return false; + return normalize(approval.status || "approved") === "approved"; + }); +} + +function expectedMetadataFields(policy, request, template) { + const required = [ + ...asArray(policy.requiredMetadataFields), + ...asArray(template.requiredMetadataFields), + ]; + + if (normalize(request.dataClassification).includes("restricted")) { + required.push("irbProtocolId", "dataUseAgreementId", "retentionPlan"); + } + + return uniqueFields(required); +} + +function readField(record, field) { + const target = normalize(field); + for (const [key, value] of Object.entries(record || {})) { + if (normalize(key) === target) return value; + } + return undefined; +} + +function visibilityAllowed(policy, request) { + const classification = normalize(request.dataClassification); + const visibility = normalize(request.visibility); + const allowedByClass = policy.visibilityByClassification || {}; + const allowed = unique(allowedByClass[classification] || policy.allowedVisibilities || []); + + if (allowed.length === 0) return true; + return allowed.includes(visibility); +} + +function grantTouchesRestrictedData(grant) { + const objectType = normalize(grant.objectType || grant.resourceType); + const permissions = unique(grant.permissions || grant.permission); + return objectType.includes("restricted") || objectType.includes("human-subject") || permissions.some((permission) => ["download", "export", "share"].includes(permission)); +} + +function publicProvisioningSummary(request, decision, counts) { + return { + projectId: request.projectId, + projectName: request.name, + templateId: request.templateId, + visibility: request.visibility, + dataClassification: request.dataClassification, + decision, + findingCounts: counts, + }; +} + +function evaluateRequester(request, usersById, policy, findings, now) { + const requester = usersById[request.requesterId] || {}; + const requesterAssignments = roleAssignmentsFor(request.roleAssignments, request.requesterId); + const authorityRoles = policy.creatorRoles || ["principal-investigator", "project-admin", "institution-admin"]; + const maxMfaAgeHours = Number(policy.maxMfaAgeHours || 24); + + if (!requester.id) { + addFinding( + findings, + "blocker", + "REQUESTER_PROFILE_MISSING", + request.requesterId || "requester", + "Project provisioning requires a resolvable requester profile.", + "Load the requester profile before creating the workspace.", + ); + return; + } + + if (!requester.projectCreator && !hasAnyProjectRole(requesterAssignments, authorityRoles)) { + addFinding( + findings, + "blocker", + "REQUESTER_AUTHORITY_MISSING", + requester.id, + "Requester lacks a creator flag or approved project creation role.", + "Require a principal investigator, project admin, institution admin, or explicit projectCreator grant.", + ); + } + + if (!hasVerifiedAffiliation(requester, request.institutionId)) { + addFinding( + findings, + "blocker", + "REQUESTER_AFFILIATION_UNVERIFIED", + requester.id, + "Requester does not have a verified affiliation for the target institution.", + "Verify institutional SAML, domain, or administrator evidence before provisioning.", + ); + } + + if (!hasVerifiedIdentifier(requester, "orcid")) { + addFinding( + findings, + "warning", + "REQUESTER_ORCID_UNVERIFIED", + requester.id, + "Requester profile lacks a verified ORCID identifier.", + "Allow steward review or require ORCID linkage before public researcher profile exposure.", + ); + } + + if (hoursSince(requester.mfaAt, now) > maxMfaAgeHours) { + addFinding( + findings, + "blocker", + "REQUESTER_MFA_STALE", + requester.id, + `Requester MFA is older than ${maxMfaAgeHours} hour(s).`, + "Require a fresh MFA challenge before opening the project space.", + ); + } +} + +function evaluateMetadata(request, policy, template, findings) { + const metadata = request.metadata || {}; + for (const field of expectedMetadataFields(policy, request, template)) { + const value = readField(metadata, field) || readField(request, field); + if (!value) { + addFinding( + findings, + "blocker", + "REQUIRED_METADATA_MISSING", + field, + `Required project provisioning metadata is missing: ${field}.`, + "Collect the missing metadata before the workspace is created.", + ); + } + } + + const projectName = String(request.name || metadata.projectName || ""); + if (projectName.trim().length < Number(policy.minimumProjectNameLength || 6)) { + addFinding( + findings, + "warning", + "PROJECT_NAME_TOO_SHORT", + request.projectId || "project", + "Project name is too short for reviewer and audit records.", + "Require a descriptive title before showing the workspace in project lists.", + ); + } +} + +function evaluateVisibility(request, policy, findings) { + if (!visibilityAllowed(policy, request)) { + addFinding( + findings, + "blocker", + "VISIBILITY_CLASSIFICATION_CONFLICT", + request.visibility, + `Visibility ${request.visibility} is not allowed for ${request.dataClassification} projects.`, + "Lower visibility or reclassify the project before provisioning.", + ); + } + + if (normalize(request.visibility) === "public" && asArray(request.externalCollaborators).length > 0) { + addFinding( + findings, + "info", + "PUBLIC_PROJECT_EXTERNAL_COLLABORATORS", + request.projectId || "project", + "Public project includes external collaborators at creation time.", + "Keep object-level grants narrow and retain collaborator audit evidence.", + ); + } +} + +function evaluateTemplate(request, policy, findings) { + const template = (policy.templates || {})[request.templateId] || {}; + if (!template.id && request.templateId) { + addFinding( + findings, + "warning", + "UNKNOWN_TEMPLATE_ID", + request.templateId, + "Project references a template that is not registered in the provisioning policy.", + "Route to steward review or register the template baseline before launch.", + ); + } + + const allowedClassifications = unique(template.allowedClassifications); + if (allowedClassifications.length > 0 && !allowedClassifications.includes(normalize(request.dataClassification))) { + addFinding( + findings, + "blocker", + "TEMPLATE_CLASSIFICATION_MISMATCH", + request.templateId, + "Selected project template is not approved for this data classification.", + "Choose an approved template or add steward approval for the classification.", + ); + } + + for (const control of asArray(template.requiredControls)) { + if (!request.controls || request.controls[control] !== true) { + addFinding( + findings, + "blocker", + "TEMPLATE_CONTROL_MISSING", + control, + `Template control ${control} has not been enabled.`, + "Enable the required template control before creating the workspace.", + ); + } + } + + return template; +} + +function evaluateInitialRoles(request, policy, template, usersById, findings) { + const roleAssignments = asArray(request.roleAssignments); + const requiredRoles = unique([...(policy.requiredProjectRoles || ["owner"]), ...asArray(template.requiredProjectRoles)]); + + for (const role of requiredRoles) { + if (!hasAnyProjectRole(roleAssignments, [role])) { + addFinding( + findings, + "blocker", + "REQUIRED_PROJECT_ROLE_MISSING", + role, + `Project is missing required initial role: ${role}.`, + "Add the required role assignment before provisioning.", + ); + } + } + + for (const assignment of roleAssignments) { + const user = usersById[assignment.userId] || {}; + if (!user.id) { + addFinding( + findings, + "blocker", + "ROLE_ASSIGNEE_MISSING", + assignment.userId || "unknown", + "Initial role assignment references a missing user profile.", + "Resolve every role assignee before committing the role matrix.", + ); + continue; + } + + if (normalize(user.type) === "external" && ["owner", "project-admin", "data-steward"].includes(normalize(assignment.role))) { + addFinding( + findings, + "blocker", + "EXTERNAL_COLLABORATOR_PRIVILEGED_ROLE", + user.id, + "External collaborator is assigned a privileged launch role.", + "Use a viewer, contributor, or scoped collaborator role until sponsor approval is recorded.", + ); + } + } +} + +function evaluateExternalCollaborators(request, policy, usersById, findings) { + const approvals = asArray(request.approvals); + const objectGrants = asArray(request.objectGrants); + const allowedDomains = unique(policy.allowedExternalDomains); + + for (const collaboratorRef of asArray(request.externalCollaborators)) { + const collaboratorId = typeof collaboratorRef === "string" ? collaboratorRef : collaboratorRef.userId || collaboratorRef.id; + const collaborator = usersById[collaboratorId] || {}; + if (!collaborator.id) { + addFinding( + findings, + "blocker", + "EXTERNAL_COLLABORATOR_PROFILE_MISSING", + collaboratorId, + "External collaborator profile is missing.", + "Invite or verify the collaborator profile before provisioning.", + ); + continue; + } + + const emailDomain = normalize(collaborator.email || "").split("@")[1] || normalize(collaborator.domain); + if (allowedDomains.length > 0 && !allowedDomains.includes(emailDomain)) { + addFinding( + findings, + "warning", + "EXTERNAL_DOMAIN_NOT_ALLOWLISTED", + collaborator.id, + `External collaborator domain ${emailDomain || "unknown"} is not allowlisted.`, + "Require sponsor review before retaining this collaborator at project launch.", + ); + } + + const restrictedGrant = objectGrants.some( + (grant) => normalize(grant.principalId || grant.userId) === normalize(collaborator.id) && grantTouchesRestrictedData(grant), + ); + if (restrictedGrant && !approvalExists(approvals, "data-use-agreement", collaborator.id)) { + addFinding( + findings, + "blocker", + "EXTERNAL_RESTRICTED_DATA_APPROVAL_MISSING", + collaborator.id, + "External collaborator has a restricted data grant without data-use agreement approval.", + "Add data-use agreement evidence or remove the restricted grant.", + ); + } + + const hasCurrentRestrictedTraining = collaborator.training && normalize(collaborator.training.restrictedData) === "current"; + if (restrictedGrant && !hasCurrentRestrictedTraining) { + addFinding( + findings, + "warning", + "EXTERNAL_RESTRICTED_DATA_TRAINING_GAP", + collaborator.id, + "External collaborator restricted-data training is missing or stale.", + "Require current training evidence before enabling restricted data access.", + ); + } + } +} + +function evaluateAuditEvidence(request, findings) { + const eventTypes = unique(asArray(request.auditEvents).map((event) => event.type)); + for (const expected of ["provision-requested", "baseline-evaluated"]) { + if (!eventTypes.includes(expected)) { + addFinding( + findings, + "warning", + "AUDIT_EVENT_MISSING", + expected, + `Audit event ${expected} is missing from the provisioning packet.`, + "Record immutable launch evidence before approving the workspace.", + ); + } + } +} + +function findingCounts(findings) { + return findings.reduce( + (acc, finding) => { + acc[finding.severity] = (acc[finding.severity] || 0) + 1; + return acc; + }, + { blocker: 0, warning: 0, info: 0 }, + ); +} + +function evaluateProjectProvisioning(packet = {}) { + const policy = packet.policy || {}; + const request = packet.request || {}; + const usersById = byId(packet.users); + const now = parseDate(packet.now || new Date().toISOString()); + const findings = []; + const template = evaluateTemplate(request, policy, findings); + + evaluateRequester(request, usersById, policy, findings, now); + evaluateMetadata(request, policy, template, findings); + evaluateVisibility(request, policy, findings); + evaluateInitialRoles(request, policy, template, usersById, findings); + evaluateExternalCollaborators(request, policy, usersById, findings); + evaluateAuditEvidence(request, findings); + + const counts = findingCounts(findings); + const decision = + counts.blocker > 0 + ? "hold-provisioning" + : counts.warning > 0 + ? "provision-with-steward-review" + : "provision-ready"; + const actionQueue = findings.map((finding) => ({ + severity: finding.severity, + code: finding.code, + subject: finding.subject, + remediation: finding.remediation, + })); + const result = { + generatedAt: packet.now || new Date().toISOString(), + decision, + projectId: request.projectId, + counts, + findings, + actionQueue, + publicSummary: publicProvisioningSummary(request, decision, counts), + }; + + return { + ...result, + auditDigest: digest(result), + }; +} + +module.exports = { + evaluateProjectProvisioning, + stableStringify, +}; diff --git a/project-provisioning-baseline-guard/render-video.js b/project-provisioning-baseline-guard/render-video.js new file mode 100644 index 00000000..824091d8 --- /dev/null +++ b/project-provisioning-baseline-guard/render-video.js @@ -0,0 +1,151 @@ +const childProcess = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const frames = [ + ["Problem", "New research workspaces can start with weak requester evidence, unsafe visibility, missing metadata, and overbroad initial grants."], + ["Implementation", "The guard validates identity posture, template controls, metadata, visibility policy, initial roles, object grants, and audit events."], + ["Acceptance", "Unsafe launch packets are held when restricted data, external collaborators, missing controls, or stale MFA create project risk."], + ["Demo output", "decision: provision-ready | blockers: 0 | actionQueue: empty | auditDigest: deterministic"], +]; + +function ffmpegCandidates() { + const localAppData = process.env.LOCALAPPDATA; + const candidates = [process.env.FFMPEG_PATH, "ffmpeg.exe", "ffmpeg"].filter(Boolean); + + if (localAppData) { + candidates.unshift( + path.join( + localAppData, + "Microsoft", + "WinGet", + "Packages", + "Gyan.FFmpeg.Essentials_Microsoft.Winget.Source_8wekyb3d8bbwe", + "ffmpeg-8.1.1-essentials_build", + "bin", + "ffmpeg.exe", + ), + ); + } + + return candidates; +} + +function findFfmpeg() { + for (const candidate of ffmpegCandidates()) { + if (candidate.includes(path.sep) && fs.existsSync(candidate)) return candidate; + try { + childProcess.execFileSync(candidate, ["-version"], { stdio: "ignore" }); + return candidate; + } catch { + // Try the next candidate. + } + } + + throw new Error("FFmpeg was not found. Install it or set FFMPEG_PATH."); +} + +function quoteText(text) { + return String(text) + .replace(/\\/g, "\\\\") + .replace(/:/g, "\\:") + .replace(/'/g, "\\'") + .replace(/%/g, "\\%"); +} + +function drawText(text, x, y, size, color, weight = "") { + const style = weight ? `:font=${weight}` : ""; + return `drawtext=text='${quoteText(text)}'${style}:x=${x}:y=${y}:fontsize=${size}:fontcolor=${color}`; +} + +function wrapText(text, maxChars) { + const words = String(text).split(/\s+/); + const lines = []; + let line = ""; + + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > maxChars && line) { + lines.push(line); + line = word; + } else { + line = next; + } + } + + if (line) lines.push(line); + return lines; +} + +function textBlock(text, x, y, size, color, maxChars, lineHeight) { + return wrapText(text, maxChars).map((line, index) => drawText(line, x, y + index * lineHeight, size, color)); +} + +function slideFilter(frame, slideIndex, inputLabel) { + const filters = []; + const label = frame[0].toUpperCase(); + const body = frame[1]; + + filters.push("drawbox=x=0:y=0:w=iw:h=ih:color=0d1726@1:t=fill"); + filters.push("drawbox=x=0:y=0:w=iw:h=130:color=10243a@1:t=fill"); + filters.push("drawbox=x=64:y=214:w=540:h=318:color=ffffff@0.93:t=fill"); + filters.push("drawbox=x=676:y=214:w=540:h=318:color=07101c@0.84:t=fill"); + filters.push(drawText("SCIBASE bounty demo artifact", 64, 44, 22, "d8e8f7")); + filters.push(drawText("Project Provisioning Baseline Guard", 64, 92, 42, "ffffff")); + filters.push(drawText("Issue #11: user and project management", 66, 150, 24, "b8cbe1")); + filters.push(drawText(label, 96, 250, 18, "16405f")); + filters.push(...textBlock(body, 96, 302, 23, "152036", 34, 34)); + filters.push(drawText("$ node project-provisioning-baseline-guard/test.js", 708, 254, 18, "68d391")); + filters.push(...textBlock("tests passed: requester evidence, metadata, visibility, external grants, template controls, deterministic digest", 708, 304, 19, "e9f7ef", 40, 28)); + filters.push(drawText("Validation path", 708, 404, 18, "a8bdd1")); + filters.push(drawText("1. Focused tests", 708, 438, 21, "ffffff")); + filters.push(drawText("2. Demo JSON and Markdown", 708, 468, 21, "ffffff")); + filters.push(drawText("3. Requirement map and acceptance notes", 708, 498, 21, "ffffff")); + + for (let index = 0; index < frames.length; index += 1) { + const color = index === slideIndex ? "68d391@1" : "ffffff@0.30"; + filters.push(`drawbox=x=${64 + index * 54}:y=590:w=38:h=8:color=${color}:t=fill`); + } + + filters.push(drawText("Committed video demo + focused tests + synthetic evidence", 64, 650, 21, "d8e8f7")); + filters.push(drawText(`Slide ${slideIndex + 1} / ${frames.length}`, 1096, 650, 18, "b8cbe1")); + + return `${inputLabel}${filters.join(",")}[v${slideIndex}]`; +} + +function renderDemo(ffmpeg, outputPath) { + const args = ["-y"]; + const slideDuration = "2.1"; + + for (let index = 0; index < frames.length; index += 1) { + args.push("-f", "lavfi", "-t", slideDuration, "-i", "color=c=0d1726:s=1280x720:r=30"); + } + + const slideFilters = frames.map((frame, index) => slideFilter(frame, index, `[${index}:v]`)); + const concatInputs = frames.map((_, index) => `[v${index}]`).join(""); + const filterComplex = `${slideFilters.join(";")};${concatInputs}concat=n=${frames.length}:v=1:a=0[outv]`; + + args.push( + "-filter_complex", + filterComplex, + "-map", + "[outv]", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + outputPath, + ); + + childProcess.execFileSync(ffmpeg, args, { stdio: "inherit" }); +} + +const outputPath = path.join(__dirname, "demo.mp4"); +renderDemo(findFfmpeg(), outputPath); +console.log(`Wrote ${outputPath} (${fs.statSync(outputPath).size} bytes)`); diff --git a/project-provisioning-baseline-guard/reports/provisioning-baseline-packet.json b/project-provisioning-baseline-guard/reports/provisioning-baseline-packet.json new file mode 100644 index 00000000..71a72be0 --- /dev/null +++ b/project-provisioning-baseline-guard/reports/provisioning-baseline-packet.json @@ -0,0 +1,26 @@ +{ + "generatedAt": "2026-06-01T12:00:00Z", + "decision": "provision-ready", + "projectId": "project-neuro-qc", + "counts": { + "blocker": 0, + "warning": 0, + "info": 0 + }, + "findings": [], + "actionQueue": [], + "publicSummary": { + "projectId": "project-neuro-qc", + "projectName": "Neuro QC Consortium", + "templateId": "controlled-study", + "visibility": "institutional", + "dataClassification": "controlled", + "decision": "provision-ready", + "findingCounts": { + "blocker": 0, + "warning": 0, + "info": 0 + } + }, + "auditDigest": "4d892fa7ef881f2261ac7b2c22774bc59439d85b74ed1a3688af60be7c04b083" +} \ No newline at end of file diff --git a/project-provisioning-baseline-guard/reports/provisioning-baseline-report.md b/project-provisioning-baseline-guard/reports/provisioning-baseline-report.md new file mode 100644 index 00000000..169d2262 --- /dev/null +++ b/project-provisioning-baseline-guard/reports/provisioning-baseline-report.md @@ -0,0 +1,21 @@ +# Project Provisioning Baseline Guard Demo + +Decision: provision-ready +Audit digest: 4d892fa7ef881f2261ac7b2c22774bc59439d85b74ed1a3688af60be7c04b083 + +## Public Summary + +- Project: Neuro QC Consortium (project-neuro-qc) +- Template: controlled-study +- Visibility: institutional +- Data classification: controlled + +## Finding Counts + +- Blockers: 0 +- Warnings: 0 +- Info: 0 + +## Action Queue + +- No remediation actions required. diff --git a/project-provisioning-baseline-guard/reports/summary.svg b/project-provisioning-baseline-guard/reports/summary.svg new file mode 100644 index 00000000..b37ea547 --- /dev/null +++ b/project-provisioning-baseline-guard/reports/summary.svg @@ -0,0 +1,27 @@ + + Project Provisioning Baseline Guard Demo + A user and project management demo showing project launch controls, identity evidence, visibility rules, role grants, and audit evidence. + + + SCIBASE bounty demo artifact + Project Provisioning Baseline Guard + Issue #11: user and project management launch governance + + + PROVISIONING BASELINE + 1. Verify requester authority and MFA + 2. Check metadata and template controls + 3. Enforce visibility by data classification + 4. Validate initial roles and object grants + 5. Emit audit digest and action queue + $ node project-provisioning-baseline-guard/test.js + decision: provision-ready + blockers: 0 | warnings: 0 + Reviewer artifacts + reports/provisioning-baseline-packet.json + reports/provisioning-baseline-report.md + + + + Synthetic project launch packet + deterministic audit evidence + diff --git a/project-provisioning-baseline-guard/requirements-map.md b/project-provisioning-baseline-guard/requirements-map.md new file mode 100644 index 00000000..cb44ca6c --- /dev/null +++ b/project-provisioning-baseline-guard/requirements-map.md @@ -0,0 +1,17 @@ +# Requirements Map + +Issue #11 asks for User & Project Management features, including secure authentication, user profiles, project workspaces, role-based access, collaboration, and audit trails. + +This submission focuses on a distinct project provisioning baseline lane: + +- Verifies that the project requester has creation authority, institutional affiliation evidence, ORCID linkage, and fresh MFA. +- Checks required project metadata before a new workspace appears in user project lists. +- Enforces visibility constraints by data classification so restricted projects cannot start public. +- Validates registered project templates and their required launch controls. +- Requires owner and data-steward role coverage for controlled research projects. +- Blocks privileged launch roles for external collaborators. +- Requires data-use agreement evidence before external collaborators can receive restricted data export grants. +- Preserves immutable provisioning audit events and emits deterministic audit digests. +- Produces public reviewer summaries without exposing private collaborator evidence. + +The scope intentionally avoids the existing broad RBAC/workspace ledger, privacy access review, member lifecycle/offboarding, institutional recertification, anonymous-review escrow, identity merge/export, data-room consent, researcher profile sync, archive handoff, access-audit anomaly, role delegation, invitation-domain/MFA, funding-attribution, service-token governance, deletion/erasure, break-glass, and visibility-transition submissions. diff --git a/project-provisioning-baseline-guard/test.js b/project-provisioning-baseline-guard/test.js new file mode 100644 index 00000000..79b85adc --- /dev/null +++ b/project-provisioning-baseline-guard/test.js @@ -0,0 +1,197 @@ +const assert = require("assert"); +const { evaluateProjectProvisioning } = require("./index"); + +function basePacket(overrides = {}) { + const packet = { + now: "2026-06-01T12:00:00Z", + policy: { + maxMfaAgeHours: 12, + minimumProjectNameLength: 6, + requiredMetadataFields: ["projectName", "projectPurpose", "discipline", "institutionId", "dataClassification"], + requiredProjectRoles: ["owner"], + creatorRoles: ["principal-investigator", "project-admin", "institution-admin"], + allowedExternalDomains: ["partner-lab.org"], + visibilityByClassification: { + open: ["public", "institutional", "private"], + controlled: ["institutional", "private"], + "restricted-human-subjects": ["private"], + }, + templates: { + "controlled-study": { + id: "controlled-study", + allowedClassifications: ["controlled", "restricted-human-subjects"], + requiredMetadataFields: ["retentionPlan"], + requiredProjectRoles: ["data-steward"], + requiredControls: ["auditTrail", "objectGrantReview"], + }, + }, + }, + users: [ + { + id: "user-pi-ada", + type: "internal", + projectCreator: true, + email: "ada@northbridge.edu", + mfaAt: "2026-06-01T08:00:00Z", + verifiedAffiliations: [{ institutionId: "northbridge", status: "verified" }], + identifiers: [{ type: "orcid", value: "0000-0002-1825-0097", status: "verified" }], + }, + { + id: "user-steward-lin", + type: "internal", + email: "lin@northbridge.edu", + verifiedAffiliations: [{ institutionId: "northbridge", status: "verified" }], + identifiers: [{ type: "orcid", value: "0000-0001-5555-1212", status: "verified" }], + }, + { + id: "user-ext-ren", + type: "external", + email: "ren@partner-lab.org", + training: { restrictedData: "current" }, + verifiedAffiliations: [{ institutionId: "partner-lab", status: "verified" }], + identifiers: [{ type: "orcid", value: "0000-0003-9999-8888", status: "verified" }], + }, + ], + request: { + requestId: "provision-1", + projectId: "project-neuro-qc", + name: "Neuro QC Consortium", + requesterId: "user-pi-ada", + institutionId: "northbridge", + templateId: "controlled-study", + dataClassification: "controlled", + visibility: "institutional", + metadata: { + projectName: "Neuro QC Consortium", + projectPurpose: "Coordinate reproducible quality-control notebooks for multi-site neuroimaging studies.", + discipline: "neuroscience", + institutionId: "northbridge", + dataClassification: "controlled", + retentionPlan: "retain-seven-years", + }, + controls: { + auditTrail: true, + objectGrantReview: true, + }, + roleAssignments: [ + { userId: "user-pi-ada", role: "owner", scope: "project" }, + { userId: "user-steward-lin", role: "data-steward", scope: "project" }, + { userId: "user-ext-ren", role: "viewer", scope: "project" }, + ], + objectGrants: [ + { principalId: "user-pi-ada", objectType: "workspace", permissions: ["admin"] }, + { principalId: "user-steward-lin", objectType: "controlled-dataset", permissions: ["read", "review"] }, + { principalId: "user-ext-ren", objectType: "manuscript", permissions: ["read"] }, + ], + externalCollaborators: ["user-ext-ren"], + approvals: [{ type: "sponsor-review", status: "approved", subjectId: "project-neuro-qc" }], + auditEvents: [ + { type: "provision-requested", actorId: "user-pi-ada", at: "2026-06-01T08:03:00Z" }, + { type: "baseline-evaluated", actorId: "system", at: "2026-06-01T08:04:00Z" }, + ], + }, + }; + + return { + ...packet, + ...overrides, + policy: { ...packet.policy, ...(overrides.policy || {}) }, + request: { ...packet.request, ...(overrides.request || {}) }, + }; +} + +function testReadyControlledProject() { + const result = evaluateProjectProvisioning(basePacket()); + + assert.equal(result.decision, "provision-ready"); + assert.equal(result.counts.blocker, 0); + assert.equal(result.publicSummary.visibility, "institutional"); +} + +function testRestrictedProjectCannotStartPublic() { + const packet = basePacket({ + request: { + dataClassification: "restricted-human-subjects", + visibility: "public", + metadata: { + projectName: "Neuro QC Consortium", + projectPurpose: "Coordinate reproducible quality-control notebooks for multi-site neuroimaging studies.", + discipline: "neuroscience", + institutionId: "northbridge", + dataClassification: "restricted-human-subjects", + retentionPlan: "retain-seven-years", + irbProtocolId: "IRB-2026-17", + dataUseAgreementId: "DUA-99", + }, + }, + }); + const result = evaluateProjectProvisioning(packet); + + assert.equal(result.decision, "hold-provisioning"); + assert.ok(result.findings.some((finding) => finding.code === "VISIBILITY_CLASSIFICATION_CONFLICT")); +} + +function testRequesterAuthorityAndMfaAreRequired() { + const packet = basePacket(); + packet.users[0].projectCreator = false; + packet.users[0].mfaAt = "2026-05-01T08:00:00Z"; + packet.request.roleAssignments = packet.request.roleAssignments.filter((assignment) => assignment.userId !== "user-pi-ada"); + + const result = evaluateProjectProvisioning(packet); + + assert.equal(result.decision, "hold-provisioning"); + assert.ok(result.findings.some((finding) => finding.code === "REQUESTER_AUTHORITY_MISSING")); + assert.ok(result.findings.some((finding) => finding.code === "REQUESTER_MFA_STALE")); +} + +function testExternalRestrictedGrantNeedsAgreement() { + const packet = basePacket({ + request: { + dataClassification: "restricted-human-subjects", + visibility: "private", + metadata: { + projectName: "Neuro QC Consortium", + projectPurpose: "Coordinate reproducible quality-control notebooks for multi-site neuroimaging studies.", + discipline: "neuroscience", + institutionId: "northbridge", + dataClassification: "restricted-human-subjects", + retentionPlan: "retain-seven-years", + irbProtocolId: "IRB-2026-17", + dataUseAgreementId: "DUA-99", + }, + objectGrants: [ + { principalId: "user-ext-ren", objectType: "restricted-dataset", permissions: ["read", "export"] }, + ], + approvals: [], + }, + }); + const result = evaluateProjectProvisioning(packet); + + assert.equal(result.decision, "hold-provisioning"); + assert.ok(result.findings.some((finding) => finding.code === "EXTERNAL_RESTRICTED_DATA_APPROVAL_MISSING")); +} + +function testMissingTemplateControlBlocksProvisioning() { + const packet = basePacket(); + packet.request.controls.objectGrantReview = false; + const result = evaluateProjectProvisioning(packet); + + assert.equal(result.decision, "hold-provisioning"); + assert.ok(result.findings.some((finding) => finding.code === "TEMPLATE_CONTROL_MISSING")); +} + +function testDeterministicDigest() { + const first = evaluateProjectProvisioning(basePacket()); + const second = evaluateProjectProvisioning(basePacket()); + + assert.equal(first.auditDigest, second.auditDigest); +} + +testReadyControlledProject(); +testRestrictedProjectCannotStartPublic(); +testRequesterAuthorityAndMfaAreRequired(); +testExternalRestrictedGrantNeedsAgreement(); +testMissingTemplateControlBlocksProvisioning(); +testDeterministicDigest(); + +console.log("project-provisioning-baseline-guard tests passed");