diff --git a/workspace-access-ledger/README.md b/workspace-access-ledger/README.md new file mode 100644 index 0000000..54f4028 --- /dev/null +++ b/workspace-access-ledger/README.md @@ -0,0 +1,70 @@ +# Workspace Access Ledger + +Self-contained user and project management milestone for [SCIBASE.AI issue #11](https://github.com/SCIBASE-AI/SCIBASE.AI/issues/11). + +The issue asks for identity, researcher profiles, scientific workspaces, permissions, invitations, and audit history. This module provides a deterministic governance slice that reviewers can run locally without external auth providers. + +## What It Adds + +- Unified identity summaries for email, ORCID, GitHub/OAuth, SAML, MFA, and anonymous mode. +- Identity security review that flags privileged roles without MFA and anonymous users with write-capable access. +- Researcher profiles with institution, field, keywords, ORCID sync, activity, citation-style metrics, and reputation score. +- Project-space access evaluation with visibility, RBAC, and object-level rules. +- Project lifecycle report with workspace components, citation/funding/institution metadata, archive approval, retention dates, and invitation expiry review. +- External collaborator invitations with role, read-only mode, expiry, and invitation hash. +- Collaborator onboarding plan with required identity providers, MFA gates, invite-acceptance route contracts, blocker reasons, and audit events. +- Hashed audit events for project access history. +- Workspace dashboard with identity coverage, profile metrics, project summary, lifecycle status, pending invitations, and access decision counts. +- Sample workspace fixture, tests, requirement map, CLI demo, and short demo GIF. + +## Run + +```bash +cd workspace-access-ledger +npm run check +npm test +npm run demo +``` + +Expected demo shape: + +```json +{ + "workspace": "Microbiome Atlas Lab", + "identitySummary": { + "users": 3, + "orcidLinked": 2, + "samlLinked": 1 + }, + "identitySecurity": { + "status": "ready" + }, + "lifecycle": { + "activeProjects": 1, + "archivedProjects": 1, + "incompleteProjects": 0 + }, + "onboarding": { + "readyCount": 0, + "blockedCount": 2 + }, + "allowedCount": 2, + "deniedCount": 1 +} +``` + +## Demo Artifact + +See [docs/demo.gif](docs/demo.gif) for a short visual walkthrough. The SVG source is included at [docs/demo.svg](docs/demo.svg). + +## Files + +- `src/access-ledger.js` - identity, profiles, access policy, invitations, collaborator onboarding, project lifecycle, audit, dashboard. +- `data/sample-workspace.json` - reviewable workspace/project fixture. +- `test/access-ledger.test.js` - dependency-free Node tests. +- `scripts/demo.js` - CLI demo. +- `docs/issue-11-requirement-map.md` - maps implementation to bounty requirements. + +## AI-Assisted Disclosure + +This contribution was produced with AI assistance and manually verified with the local commands above. diff --git a/workspace-access-ledger/data/sample-workspace.json b/workspace-access-ledger/data/sample-workspace.json new file mode 100644 index 0000000..2da181f --- /dev/null +++ b/workspace-access-ledger/data/sample-workspace.json @@ -0,0 +1,154 @@ +{ + "workspace": { + "id": "workspace-atlas", + "name": "Microbiome Atlas Lab", + "asOf": "2026-05-14T00:00:00Z", + "users": [ + { + "id": "u-owner", + "email": "owner@example.edu", + "name": "Principal Investigator", + "institution": "Northstar University", + "institutionId": "northstar", + "field": "environmental health", + "keywords": ["microbiome", "public health"], + "profileVisibility": "public", + "mfaEnabled": true, + "identities": [ + { "provider": "email", "subject": "owner@example.edu" }, + { "provider": "orcid", "subject": "0000-0002-1111-2222" }, + { "provider": "saml", "subject": "northstar:owner" } + ] + }, + { + "id": "u-reviewer", + "email": "reviewer@example.edu", + "name": "External Reviewer", + "institution": "Open Review Lab", + "institutionId": "open-review", + "field": "statistics", + "keywords": ["reproducibility", "methods"], + "profileVisibility": "public", + "mfaEnabled": true, + "identities": [ + { "provider": "email", "subject": "reviewer@example.edu" }, + { "provider": "github", "subject": "reviewer-gh" }, + { "provider": "orcid", "subject": "0000-0003-3333-4444" } + ] + }, + { + "id": "u-anon", + "email": "anon@example.org", + "name": "Anonymous Commenter", + "profileVisibility": "private", + "anonymousMode": true, + "mfaEnabled": false, + "identities": [{ "provider": "email", "subject": "anon@example.org" }] + } + ], + "projects": [ + { + "id": "project-private", + "title": "Coastal flooding microbiome study", + "visibility": "private", + "institutionId": "northstar", + "requireOrcid": true, + "requireMfa": true, + "members": [ + { "userId": "u-owner", "role": "owner" }, + { "userId": "u-reviewer", "role": "reviewer" } + ], + "policy": { + "manuscript": { "read": "viewer", "write": "contributor" }, + "code": { "read": "reviewer", "write": "contributor" }, + "dataset": { "read": "admin", "download": "admin" } + }, + "documents": ["manuscript.md", "lab-notes.md"], + "code": ["analysis/normalize.py"], + "datasets": ["data/samples.csv"], + "discussionThreads": ["thread-methods", "thread-batch-effects"], + "citations": ["doi:10.5555/flood.microbiome"], + "fundingSources": ["NSF-OPEN-2026"], + "institutions": ["northstar"], + "objectRules": [ + { + "objectType": "dataset", + "action": "read", + "effect": "allow", + "userIds": ["u-reviewer"] + } + ] + }, + { + "id": "project-public", + "title": "Open protocol notes", + "visibility": "public", + "members": [{ "userId": "u-owner", "role": "owner" }], + "policy": { + "manuscript": { "read": "viewer", "write": "contributor" } + }, + "documents": ["protocol.md"], + "code": [], + "datasets": [], + "discussionThreads": [], + "citations": ["doi:10.5555/open.protocol"], + "fundingSources": [], + "institutions": ["northstar"], + "archiveRequestedAt": "2026-05-01T00:00:00Z", + "archivedAt": "2026-05-08T00:00:00Z", + "retentionDays": 365, + "objectRules": [] + } + ], + "invitations": [ + { + "id": "invite-1", + "email": "collaborator@example.edu", + "projectId": "project-private", + "role": "contributor", + "status": "pending", + "readOnly": false, + "expiresAt": "2026-05-21T00:00:00Z" + }, + { + "id": "invite-2", + "email": "expired@example.edu", + "projectId": "project-private", + "role": "viewer", + "status": "expired", + "readOnly": true, + "expiresAt": "2026-05-01T00:00:00Z" + } + ], + "auditLog": [ + { "id": "audit-1", "actorId": "u-owner", "projectId": "project-private", "action": "project.created" }, + { "id": "audit-2", "actorId": "u-owner", "projectId": "project-private", "action": "reviewer.invited" }, + { "id": "audit-3", "actorId": "u-owner", "projectId": "project-public", "action": "project.archive.approved" } + ] + }, + "activityByUser": { + "u-owner": { + "publications": ["paper-1", "paper-2"], + "reviews": ["review-1"], + "projects": ["project-private", "project-public"], + "downloads": 240, + "forks": 8, + "endorsements": 12, + "reproducibilityScore": 0.91 + }, + "u-reviewer": { + "publications": ["paper-3"], + "reviews": ["review-2", "review-3", "review-4"], + "projects": ["project-private"], + "downloads": 90, + "forks": 3, + "endorsements": 7, + "reproducibilityScore": 0.87 + } + }, + "accessRequests": [ + { "userId": "u-reviewer", "projectId": "project-private", "objectType": "dataset", "action": "read" }, + { "userId": "u-reviewer", "projectId": "project-private", "objectType": "dataset", "action": "download" }, + { "userId": "u-anon", "projectId": "project-public", "objectType": "manuscript", "action": "read" } + ] +} diff --git a/workspace-access-ledger/docs/demo.gif b/workspace-access-ledger/docs/demo.gif new file mode 100644 index 0000000..324a621 Binary files /dev/null and b/workspace-access-ledger/docs/demo.gif differ diff --git a/workspace-access-ledger/docs/demo.mp4 b/workspace-access-ledger/docs/demo.mp4 new file mode 100644 index 0000000..ee17d30 Binary files /dev/null and b/workspace-access-ledger/docs/demo.mp4 differ diff --git a/workspace-access-ledger/docs/demo.svg b/workspace-access-ledger/docs/demo.svg new file mode 100644 index 0000000..ff40525 --- /dev/null +++ b/workspace-access-ledger/docs/demo.svg @@ -0,0 +1,34 @@ + + Workspace Access Ledger Demo + Visual demo for identity, project access decisions, invitations, and audit governance. + + + Workspace Access Ledger + Identity · profiles · project permissions · audit trail + + Identity + 3 users + ORCID + SAML metadata + + Access decisions + 2 / 1 + allowed / denied + + Governance + 1 invite + hashed audit events + + Policy example + Reviewer can read a dataset by object rule, but cannot download it. + Public manuscript read is allowed through visibility policy. + diff --git a/workspace-access-ledger/docs/issue-11-requirement-map.md b/workspace-access-ledger/docs/issue-11-requirement-map.md new file mode 100644 index 0000000..7abba0b --- /dev/null +++ b/workspace-access-ledger/docs/issue-11-requirement-map.md @@ -0,0 +1,29 @@ +# Issue #11 Requirement Map + +This module is a deterministic milestone for SCIBASE issue #11, User & Project Management. It focuses on identity, researcher profiles, project-space access policies, invitations, and audit governance. + +| Issue requirement | Implementation | +| --- | --- | +| Email/OAuth/ORCID/SAML identity | `buildUnifiedIdentity()` models linked provider identities, ORCID links, SAML links, MFA, and anonymous mode. | +| 2FA and anonymous-mode safeguards | `buildIdentitySecurityReview()` flags privileged project roles without MFA and anonymous users with write-capable membership. | +| Researcher profiles | `buildResearcherProfile()` summarizes institution, field, keywords, ORCID sync, activity, citations-style metrics, and reputation. | +| Project spaces | Sample projects include manuscripts, code, datasets, discussion threads, citations, funding sources, institutions, visibility, members, and object rules. | +| Create, manage, and archive projects | `buildProjectLifecycleReport()` reports project component completeness, lifecycle state, archive approval, archived date, retention date, and lifecycle hash. | +| Visibility settings | `evaluateAccess()` supports public, private, institutional-only, and role/object-rule paths. | +| Role-based access | Role ranks cover Owner, Admin, Contributor, Reviewer, and Viewer. | +| Object-level control | Project policies and `objectRules` can allow or deny actions such as dataset read/download independently of role defaults. | +| External collaborator invitations | `createInvitation()` creates time-limited invitation records with role and read-only state, while `buildProjectLifecycleReport()` reviews active/expired invitation status. | +| Invitation acceptance and onboarding | `buildCollaboratorOnboardingPlan()` reports required identity providers, MFA gates, invite-acceptance route contracts, blocker reasons, and audit events before a collaborator can join a project. | +| Audit log | `appendAuditEvent()` appends hashed audit events. | +| Reputation metrics | Dashboard profiles include downloads, forks, endorsements, peer reviews, collaborations, and reproducibility score. | +| Reviewer demo | `npm run demo` prints identity summary, access decisions, and dashboard hash for `data/sample-workspace.json`. | + +## Verification + +```bash +npm run check +npm test +npm run demo +``` + +The module is dependency-free and isolated under `workspace-access-ledger/`. diff --git a/workspace-access-ledger/package.json b/workspace-access-ledger/package.json new file mode 100644 index 0000000..b35ba90 --- /dev/null +++ b/workspace-access-ledger/package.json @@ -0,0 +1,12 @@ +{ + "name": "scibase-workspace-access-ledger", + "version": "0.1.0", + "private": true, + "description": "Identity and project-space access governance module for SCIBASE issue #11.", + "type": "commonjs", + "scripts": { + "check": "node --check src/access-ledger.js && node --check scripts/demo.js && node --check test/access-ledger.test.js", + "demo": "node scripts/demo.js", + "test": "node test/access-ledger.test.js" + } +} diff --git a/workspace-access-ledger/scripts/demo.js b/workspace-access-ledger/scripts/demo.js new file mode 100644 index 0000000..87e0180 --- /dev/null +++ b/workspace-access-ledger/scripts/demo.js @@ -0,0 +1,48 @@ +"use strict"; + +const sample = require("../data/sample-workspace.json"); +const { buildWorkspaceAccessPacket } = require("../src/access-ledger"); + +const packet = buildWorkspaceAccessPacket( + sample.workspace, + sample.activityByUser, + sample.accessRequests, +); + +console.log( + JSON.stringify( + { + workspace: packet.dashboard.workspace.name, + identitySummary: packet.dashboard.identitySummary, + identitySecurity: packet.dashboard.identitySecurity, + lifecycle: { + activeProjects: packet.dashboard.lifecycle.activeProjects, + archivedProjects: packet.dashboard.lifecycle.archivedProjects, + incompleteProjects: packet.dashboard.lifecycle.incompleteProjects, + invitationStatuses: packet.dashboard.lifecycle.invitationReview.map((invitation) => ({ + invitationId: invitation.invitationId, + status: invitation.status, + risk: invitation.risk, + })), + }, + onboarding: { + readyCount: packet.dashboard.onboarding.readyCount, + blockedCount: packet.dashboard.onboarding.blockedCount, + plans: packet.dashboard.onboarding.plans.map((plan) => ({ + invitationId: plan.invitationId, + status: plan.status, + requiredProviders: plan.requiredProviders, + mfaRequired: plan.mfaRequired, + blockers: plan.blockers, + acceptanceRoute: plan.acceptanceRoute, + })), + }, + allowedCount: packet.allowedCount, + deniedCount: packet.deniedCount, + decisions: packet.decisions, + dashboardHash: packet.dashboard.dashboardHash, + }, + null, + 2, + ), +); diff --git a/workspace-access-ledger/src/access-ledger.js b/workspace-access-ledger/src/access-ledger.js new file mode 100644 index 0000000..0a359f3 --- /dev/null +++ b/workspace-access-ledger/src/access-ledger.js @@ -0,0 +1,436 @@ +"use strict"; + +const crypto = require("crypto"); + +const ROLE_RANK = { + viewer: 1, + reviewer: 2, + contributor: 3, + admin: 4, + owner: 5, +}; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function hashRecord(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 18); +} + +function normalizeWorkspace(workspace) { + if (!workspace || typeof workspace !== "object") throw new TypeError("workspace must be an object"); + return { + id: workspace.id || "workspace-unknown", + name: workspace.name || "Unnamed workspace", + users: asArray(workspace.users), + projects: asArray(workspace.projects), + invitations: asArray(workspace.invitations), + auditLog: asArray(workspace.auditLog), + asOf: workspace.asOf || null, + }; +} + +function buildUnifiedIdentity(user) { + const identities = asArray(user.identities); + const providers = identities.map((identity) => identity.provider); + return { + userId: user.id, + email: user.email, + displayName: user.name || user.email, + mfaEnabled: Boolean(user.mfaEnabled), + anonymousMode: Boolean(user.anonymousMode), + providers, + hasOrcid: providers.includes("orcid"), + hasInstitutionalSaml: providers.includes("saml"), + identityHash: hashRecord({ userId: user.id, identities }), + }; +} + +function buildResearcherProfile(user, activity) { + const publications = asArray(activity.publications); + const reviews = asArray(activity.reviews); + const projects = asArray(activity.projects); + const reproducibilityScore = Number(activity.reproducibilityScore || 0); + const reputation = + publications.length * 4 + + reviews.length * 3 + + projects.length * 2 + + Math.round(reproducibilityScore * 20); + + return { + userId: user.id, + name: user.name, + institution: user.institution || null, + field: user.field || null, + keywords: asArray(user.keywords), + publicProfile: user.profileVisibility !== "private", + syncedFromOrcid: asArray(user.identities).some((identity) => identity.provider === "orcid"), + metrics: { + publications: publications.length, + peerReviews: reviews.length, + collaborations: projects.length, + downloads: Number(activity.downloads || 0), + forks: Number(activity.forks || 0), + endorsements: Number(activity.endorsements || 0), + reproducibilityScore, + reputation, + }, + }; +} + +function buildIdentitySecurityReview(workspaceInput) { + const workspace = normalizeWorkspace(workspaceInput); + const findings = []; + + for (const user of workspace.users) { + const identity = buildUnifiedIdentity(user); + if (!identity.providers.includes("email")) { + findings.push({ + userId: user.id, + severity: "medium", + type: "missing-email-identity", + message: "User should have a verified email identity for account recovery.", + }); + } + if (identity.anonymousMode && identity.providers.some((provider) => provider !== "email")) { + findings.push({ + userId: user.id, + severity: "medium", + type: "anonymous-linked-identity", + message: "Anonymous mode should not expose linked external identities.", + }); + } + } + + for (const project of workspace.projects) { + for (const member of asArray(project.members)) { + const user = workspace.users.find((candidate) => candidate.id === member.userId); + if (!user) continue; + const roleRank = ROLE_RANK[member.role] || 0; + if (roleRank >= ROLE_RANK.admin && !user.mfaEnabled) { + findings.push({ + userId: user.id, + projectId: project.id, + severity: "high", + type: "privileged-user-without-mfa", + message: "Owner/admin project roles require MFA.", + }); + } + if (user.anonymousMode && roleRank >= ROLE_RANK.contributor) { + findings.push({ + userId: user.id, + projectId: project.id, + severity: "high", + type: "anonymous-write-access", + message: "Anonymous users should not have write-capable project roles.", + }); + } + } + } + + return { + status: findings.some((finding) => finding.severity === "high") ? "needs-action" : "ready", + findings, + reviewHash: hashRecord(findings), + }; +} + +function membershipFor(project, userId) { + return asArray(project.members).find((member) => member.userId === userId) || null; +} + +function visibilityAllows(project, user, action) { + if (project.visibility === "public" && action === "read") return true; + if (project.visibility === "institutional-only") { + return ( + action === "read" && + Boolean(user.institution && project.institutionId && user.institutionId === project.institutionId) + ); + } + return false; +} + +function evaluateAccess(workspaceInput, request) { + const workspace = normalizeWorkspace(workspaceInput); + const user = workspace.users.find((candidate) => candidate.id === request.userId); + const project = workspace.projects.find((candidate) => candidate.id === request.projectId); + if (!user || !project) { + return { allowed: false, reason: "unknown-user-or-project" }; + } + + const member = membershipFor(project, user.id); + const roleRank = member ? ROLE_RANK[member.role] || 0 : 0; + const objectPolicy = project.policy && project.policy[request.objectType]; + const requiredRole = objectPolicy && objectPolicy[request.action]; + const requiredRank = ROLE_RANK[requiredRole || "viewer"] || 1; + const explicitObjectRule = asArray(project.objectRules).find( + (rule) => + rule.objectType === request.objectType && + rule.action === request.action && + asArray(rule.userIds).includes(user.id), + ); + + if (explicitObjectRule) { + return { + allowed: explicitObjectRule.effect === "allow", + reason: `object-rule-${explicitObjectRule.effect}`, + role: member ? member.role : null, + }; + } + + if (requiredRole && roleRank >= requiredRank) { + return { allowed: true, reason: "role-policy", role: member.role }; + } + + if (visibilityAllows(project, user, request.action)) { + return { allowed: true, reason: "visibility-policy", role: member ? member.role : null }; + } + + return { + allowed: false, + reason: "insufficient-role", + role: member ? member.role : null, + requiredRole: requiredRole || "viewer", + }; +} + +function createInvitation(workspaceInput, invitation) { + const workspace = normalizeWorkspace(workspaceInput); + const expiresAt = invitation.expiresAt || new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(); + const record = { + id: invitation.id || `invite-${workspace.invitations.length + 1}`, + email: invitation.email, + projectId: invitation.projectId, + role: invitation.role || "viewer", + invitedBy: invitation.invitedBy, + expiresAt, + readOnly: Boolean(invitation.readOnly), + status: "pending", + invitationHash: hashRecord(invitation), + }; + return { + ...workspace, + invitations: [...workspace.invitations, record], + }; +} + +function appendAuditEvent(workspaceInput, event) { + const workspace = normalizeWorkspace(workspaceInput); + const record = { + id: event.id || `audit-${workspace.auditLog.length + 1}`, + actorId: event.actorId, + projectId: event.projectId || null, + action: event.action, + target: event.target || null, + createdAt: event.createdAt || new Date().toISOString(), + eventHash: hashRecord(event), + }; + return { + ...workspace, + auditLog: [...workspace.auditLog, record], + }; +} + +function addDays(timestamp, days) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return null; + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString(); +} + +function isBefore(left, right) { + const leftDate = new Date(left); + const rightDate = new Date(right); + if (Number.isNaN(leftDate.getTime()) || Number.isNaN(rightDate.getTime())) return false; + return leftDate.getTime() < rightDate.getTime(); +} + +function buildProjectLifecycleReport(workspaceInput) { + const workspace = normalizeWorkspace(workspaceInput); + const asOf = workspace.asOf || new Date().toISOString(); + const projectReports = workspace.projects.map((project) => { + const components = { + documents: asArray(project.documents).length, + code: asArray(project.code).length, + datasets: asArray(project.datasets).length, + discussions: asArray(project.discussionThreads).length, + citations: asArray(project.citations).length, + fundingSources: asArray(project.fundingSources).length, + institutions: asArray(project.institutions).length, + }; + const requiredComponents = ["documents", "code", "datasets", "discussions", "citations"]; + const missingComponents = requiredComponents.filter((component) => components[component] === 0); + const archiveApproved = workspace.auditLog.some( + (event) => event.projectId === project.id && event.action === "project.archive.approved", + ); + const archived = Boolean(project.archivedAt); + + return { + projectId: project.id, + title: project.title, + visibility: project.visibility, + lifecycleState: archived ? "archived" : missingComponents.length ? "setup-incomplete" : "active", + missingComponents, + components, + archive: { + requestedAt: project.archiveRequestedAt || null, + approved: archiveApproved, + archivedAt: project.archivedAt || null, + retentionUntil: project.archivedAt ? addDays(project.archivedAt, Number(project.retentionDays || 2555)) : null, + }, + managementHash: hashRecord({ projectId: project.id, components, missingComponents, archiveApproved }), + }; + }); + + const invitationReview = workspace.invitations.map((invitation) => { + const expired = invitation.expiresAt ? isBefore(invitation.expiresAt, asOf) : false; + return { + invitationId: invitation.id, + projectId: invitation.projectId, + role: invitation.role || "viewer", + status: expired && invitation.status === "pending" ? "expired" : invitation.status || "pending", + readOnly: Boolean(invitation.readOnly), + expiresAt: invitation.expiresAt || null, + risk: expired && invitation.status === "pending" ? "expired-pending-invitation" : null, + invitationHash: hashRecord(invitation), + }; + }); + + return { + asOf, + projects: projectReports, + invitationReview, + activeProjects: projectReports.filter((project) => project.lifecycleState === "active").length, + archivedProjects: projectReports.filter((project) => project.lifecycleState === "archived").length, + incompleteProjects: projectReports.filter((project) => project.lifecycleState === "setup-incomplete").length, + lifecycleHash: hashRecord({ projectReports, invitationReview, asOf }), + }; +} + +function buildCollaboratorOnboardingPlan(workspaceInput) { + const workspace = normalizeWorkspace(workspaceInput); + const asOf = workspace.asOf || new Date().toISOString(); + const plans = workspace.invitations.map((invitation) => { + const project = workspace.projects.find((candidate) => candidate.id === invitation.projectId) || {}; + const invitedUser = workspace.users.find((user) => user.email === invitation.email) || null; + const role = invitation.role || "viewer"; + const roleRank = ROLE_RANK[role] || ROLE_RANK.viewer; + const expired = invitation.expiresAt ? isBefore(invitation.expiresAt, asOf) : false; + const requiredProviders = ["email"]; + if (roleRank >= ROLE_RANK.reviewer || project.requireOrcid) requiredProviders.push("orcid"); + if (project.visibility === "institutional-only" || project.requireInstitutionalSaml) { + requiredProviders.push("saml"); + } + + const identity = invitedUser ? buildUnifiedIdentity(invitedUser) : null; + const missingProviders = identity + ? requiredProviders.filter((provider) => !identity.providers.includes(provider)) + : requiredProviders; + const mfaRequired = roleRank >= ROLE_RANK.contributor || Boolean(project.requireMfa); + const blockers = []; + if (invitation.status === "expired" || (expired && invitation.status === "pending")) { + blockers.push("invitation-expired"); + } + if (missingProviders.length) blockers.push("missing-identity-provider"); + if (mfaRequired && (!identity || !identity.mfaEnabled)) blockers.push("mfa-required"); + if (invitation.status === "revoked") blockers.push("invitation-revoked"); + + return { + invitationId: invitation.id, + projectId: invitation.projectId, + email: invitation.email, + role, + readOnly: Boolean(invitation.readOnly), + status: blockers.length ? "blocked" : "ready-to-accept", + requiredProviders, + missingProviders, + mfaRequired, + acceptanceRoute: `/api/workspaces/${workspace.id}/projects/${invitation.projectId}/invitations/${invitation.id}/accept`, + auditEvents: [ + "identity.linked", + ...(mfaRequired ? ["mfa.enabled"] : []), + "invitation.accepted", + "project.member.added", + ], + blockers, + onboardingHash: hashRecord({ invitation, requiredProviders, blockers, asOf }), + }; + }); + + return { + asOf, + plans, + readyCount: plans.filter((plan) => plan.status === "ready-to-accept").length, + blockedCount: plans.filter((plan) => plan.status === "blocked").length, + onboardingHash: hashRecord({ plans, asOf }), + }; +} + +function buildAccessDashboard(workspaceInput, activityByUser) { + const workspace = normalizeWorkspace(workspaceInput); + const identities = workspace.users.map(buildUnifiedIdentity); + const profiles = workspace.users.map((user) => buildResearcherProfile(user, (activityByUser || {})[user.id] || {})); + const lifecycle = buildProjectLifecycleReport(workspace); + const onboarding = buildCollaboratorOnboardingPlan(workspace); + const mfaCoverage = identities.length + ? Number((identities.filter((identity) => identity.mfaEnabled).length / identities.length).toFixed(4)) + : 0; + const projectSummary = workspace.projects.map((project) => ({ + projectId: project.id, + title: project.title, + visibility: project.visibility, + members: asArray(project.members).length, + objectRules: asArray(project.objectRules).length, + })); + + return { + workspace: { id: workspace.id, name: workspace.name }, + identitySummary: { + users: identities.length, + mfaCoverage, + orcidLinked: identities.filter((identity) => identity.hasOrcid).length, + samlLinked: identities.filter((identity) => identity.hasInstitutionalSaml).length, + anonymousEnabled: identities.filter((identity) => identity.anonymousMode).length, + }, + profiles, + projectSummary, + lifecycle, + onboarding, + identitySecurity: buildIdentitySecurityReview(workspace), + pendingInvitations: workspace.invitations.filter((invitation) => invitation.status === "pending"), + auditEvents: workspace.auditLog.length, + dashboardHash: hashRecord({ identities, profiles, projectSummary, lifecycle, onboarding, auditLog: workspace.auditLog }), + }; +} + +function buildWorkspaceAccessPacket(workspaceInput, activityByUser, accessRequests) { + const workspace = normalizeWorkspace(workspaceInput); + const dashboard = buildAccessDashboard(workspace, activityByUser); + const decisions = asArray(accessRequests).map((request) => ({ + request, + decision: evaluateAccess(workspace, request), + })); + return { + dashboard, + decisions, + allowedCount: decisions.filter((item) => item.decision.allowed).length, + deniedCount: decisions.filter((item) => !item.decision.allowed).length, + }; +} + +module.exports = { + ROLE_RANK, + appendAuditEvent, + buildAccessDashboard, + buildCollaboratorOnboardingPlan, + buildIdentitySecurityReview, + buildProjectLifecycleReport, + buildResearcherProfile, + buildUnifiedIdentity, + buildWorkspaceAccessPacket, + createInvitation, + evaluateAccess, + hashRecord, + normalizeWorkspace, +}; diff --git a/workspace-access-ledger/test/access-ledger.test.js b/workspace-access-ledger/test/access-ledger.test.js new file mode 100644 index 0000000..9b1d4c1 --- /dev/null +++ b/workspace-access-ledger/test/access-ledger.test.js @@ -0,0 +1,191 @@ +"use strict"; + +const assert = require("assert"); +const sample = require("../data/sample-workspace.json"); +const { + appendAuditEvent, + buildAccessDashboard, + buildCollaboratorOnboardingPlan, + buildIdentitySecurityReview, + buildProjectLifecycleReport, + buildResearcherProfile, + buildUnifiedIdentity, + buildWorkspaceAccessPacket, + createInvitation, + evaluateAccess, +} = require("../src/access-ledger"); + +function testUnifiedIdentity() { + const identity = buildUnifiedIdentity(sample.workspace.users[0]); + + assert.strictEqual(identity.hasOrcid, true); + assert.strictEqual(identity.hasInstitutionalSaml, true); + assert.strictEqual(identity.mfaEnabled, true); +} + +function testResearcherProfile() { + const profile = buildResearcherProfile( + sample.workspace.users[0], + sample.activityByUser["u-owner"], + ); + + assert.strictEqual(profile.publicProfile, true); + assert.ok(profile.metrics.reputation > 0); + assert.strictEqual(profile.syncedFromOrcid, true); +} + +function testAccessDecisions() { + const allowed = evaluateAccess(sample.workspace, sample.accessRequests[0]); + const denied = evaluateAccess(sample.workspace, sample.accessRequests[1]); + const publicRead = evaluateAccess(sample.workspace, sample.accessRequests[2]); + const unknownPolicy = evaluateAccess(sample.workspace, { + userId: "u-owner", + projectId: "project-private", + objectType: "unregistered-object", + action: "write", + }); + const institutionalWrite = evaluateAccess( + { + ...sample.workspace, + projects: [ + { + id: "institutional-project", + visibility: "institutional-only", + institutionId: "northstar", + members: [], + policy: {}, + objectRules: [], + }, + ], + }, + { + userId: "u-owner", + projectId: "institutional-project", + objectType: "manuscript", + action: "write", + }, + ); + + assert.strictEqual(allowed.allowed, true); + assert.strictEqual(allowed.reason, "object-rule-allow"); + assert.strictEqual(denied.allowed, false); + assert.strictEqual(publicRead.allowed, true); + assert.strictEqual(publicRead.reason, "visibility-policy"); + assert.strictEqual(unknownPolicy.allowed, false); + assert.strictEqual(institutionalWrite.allowed, false); +} + +function testInvitationAndAudit() { + const invited = createInvitation(sample.workspace, { + email: "new@example.edu", + projectId: "project-private", + role: "viewer", + invitedBy: "u-owner", + readOnly: true, + }); + const audited = appendAuditEvent(invited, { + actorId: "u-owner", + projectId: "project-private", + action: "invitation.created", + target: "new@example.edu", + }); + + assert.strictEqual(invited.invitations.length, 3); + assert.strictEqual(audited.auditLog.length, 4); + assert.ok(audited.auditLog[3].eventHash); +} + +function testIdentitySecurityReview() { + const ready = buildIdentitySecurityReview(sample.workspace); + const risky = buildIdentitySecurityReview({ + ...sample.workspace, + projects: [ + ...sample.workspace.projects, + { + id: "risky-project", + visibility: "private", + members: [ + { userId: "u-anon", role: "contributor" }, + { userId: "u-reviewer", role: "admin" }, + ], + }, + ], + users: sample.workspace.users.map((user) => + user.id === "u-reviewer" ? { ...user, mfaEnabled: false } : user, + ), + }); + + assert.strictEqual(ready.status, "ready"); + assert.strictEqual(risky.status, "needs-action"); + assert.ok(risky.findings.some((finding) => finding.type === "anonymous-write-access")); + assert.ok(risky.findings.some((finding) => finding.type === "privileged-user-without-mfa")); +} + +function testProjectLifecycleReport() { + const lifecycle = buildProjectLifecycleReport(sample.workspace); + const privateProject = lifecycle.projects.find((project) => project.projectId === "project-private"); + const publicProject = lifecycle.projects.find((project) => project.projectId === "project-public"); + const expiredInvite = lifecycle.invitationReview.find((invitation) => invitation.invitationId === "invite-2"); + + assert.strictEqual(lifecycle.activeProjects, 1); + assert.strictEqual(lifecycle.archivedProjects, 1); + assert.strictEqual(privateProject.lifecycleState, "active"); + assert.deepStrictEqual(privateProject.missingComponents, []); + assert.strictEqual(publicProject.lifecycleState, "archived"); + assert.strictEqual(publicProject.archive.approved, true); + assert.strictEqual(publicProject.archive.retentionUntil, "2027-05-08T00:00:00.000Z"); + assert.strictEqual(expiredInvite.status, "expired"); + assert.ok(lifecycle.lifecycleHash.length >= 12); +} + +function testCollaboratorOnboardingPlan() { + const onboarding = buildCollaboratorOnboardingPlan(sample.workspace); + const pendingContributor = onboarding.plans.find((plan) => plan.invitationId === "invite-1"); + const expiredViewer = onboarding.plans.find((plan) => plan.invitationId === "invite-2"); + + assert.strictEqual(onboarding.readyCount, 0); + assert.strictEqual(onboarding.blockedCount, 2); + assert.deepStrictEqual(pendingContributor.requiredProviders, ["email", "orcid"]); + assert.strictEqual(pendingContributor.mfaRequired, true); + assert.ok(pendingContributor.blockers.includes("missing-identity-provider")); + assert.ok(pendingContributor.blockers.includes("mfa-required")); + assert.ok(pendingContributor.acceptanceRoute.includes("/invitations/invite-1/accept")); + assert.ok(expiredViewer.blockers.includes("invitation-expired")); + assert.ok(onboarding.onboardingHash.length >= 12); +} + +function testDashboard() { + const dashboard = buildAccessDashboard(sample.workspace, sample.activityByUser); + + assert.strictEqual(dashboard.identitySummary.users, 3); + assert.strictEqual(dashboard.identitySummary.orcidLinked, 2); + assert.strictEqual(dashboard.identitySecurity.status, "ready"); + assert.strictEqual(dashboard.pendingInvitations.length, 1); + assert.strictEqual(dashboard.lifecycle.activeProjects, 1); + assert.strictEqual(dashboard.onboarding.blockedCount, 2); + assert.ok(dashboard.dashboardHash.length >= 12); +} + +function testFullPacket() { + const packet = buildWorkspaceAccessPacket( + sample.workspace, + sample.activityByUser, + sample.accessRequests, + ); + + assert.strictEqual(packet.allowedCount, 2); + assert.strictEqual(packet.deniedCount, 1); + assert.strictEqual(packet.decisions.length, 3); +} + +testUnifiedIdentity(); +testResearcherProfile(); +testAccessDecisions(); +testInvitationAndAudit(); +testIdentitySecurityReview(); +testProjectLifecycleReport(); +testCollaboratorOnboardingPlan(); +testDashboard(); +testFullPacket(); + +console.log("workspace-access-ledger tests passed");