diff --git a/researcher-affiliation-attestation-guard/README.md b/researcher-affiliation-attestation-guard/README.md new file mode 100644 index 00000000..33718c85 --- /dev/null +++ b/researcher-affiliation-attestation-guard/README.md @@ -0,0 +1,17 @@ +# Researcher Affiliation Attestation Guard + +Self-contained SCIBASE User & Project Management slice for issue #11. The guard checks whether public researcher profile affiliation and grant claims are sufficiently evidenced before they influence project trust, invitations, grants, or reputation signals. + +## Why this slice is distinct + +Existing #11 submissions cover RBAC/workspace ledgers, privacy access review, member lifecycle/offboarding, institutional recertification, anonymous-review escrow, 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 access, visibility transition, provisioning baseline, object-permission inheritance, session step-up, collaborator COI, data residency, authoring artifact integrity, ORCID publication disambiguation, and access denial appeals. This module focuses only on public profile affiliation and grant attestation: ORCID/SSO/admin evidence, expired appointments, institutional email-domain matching, grant consent, evidence receipts, and blocking unverified profile claims from trust/reputation decisions. + +## Run + +```bash +npm test +npm run demo +npm run demo:video +``` + +Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files. diff --git a/researcher-affiliation-attestation-guard/demo.js b/researcher-affiliation-attestation-guard/demo.js new file mode 100644 index 00000000..94bac2fe --- /dev/null +++ b/researcher-affiliation-attestation-guard/demo.js @@ -0,0 +1,61 @@ +const fs = require("fs"); +const path = require("path"); + +const { assessAffiliationAttestation } = require("./index"); +const { trustedProfile, riskyProfile } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +function markdownReport(name, report) { + const findings = report.findings.length + ? report.findings + .map((item) => `- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`) + .join("\n") + : "- No affiliation or grant attestation findings."; + return `# Researcher Affiliation Attestation Guard + +Scenario: ${name} + +Researcher: ${report.displayName} +Profile mode: ${report.profileMode} +Decision: ${report.decision.toUpperCase()} + +Reviewed ${report.summary.affiliationsReviewed} affiliations and ${report.summary.grantsReviewed} grants. + +## Findings + +${findings} + +## Release Criteria + +${report.releaseCriteria.map((item) => `- ${item}`).join("\n")} +`; +} + +function svgReport(report) { + const color = report.decision === "hold" ? "#b91c1c" : report.decision === "revise" ? "#c2410c" : "#15803d"; + return ` + + Researcher Affiliation Attestation Guard + ${report.displayName} / ${report.profileMode} + + ${report.decision.toUpperCase()} + Public affiliations: ${report.summary.publicAffiliations} + High: ${report.summary.high} + Medium: ${report.summary.medium} + Low: ${report.summary.low} + Synthetic profile packets only. No real ORCID, SSO, grant, or email calls. +`; +} + +for (const [name, profile] of [ + ["trusted-profile", trustedProfile], + ["risky-profile", riskyProfile], +]) { + const report = assessAffiliationAttestation(profile, { asOf: "2026-06-01" }); + fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2)); + fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report)); + fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report)); + console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`); +} diff --git a/researcher-affiliation-attestation-guard/demo_video.py b/researcher-affiliation-attestation-guard/demo_video.py new file mode 100644 index 00000000..5babd526 --- /dev/null +++ b/researcher-affiliation-attestation-guard/demo_video.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import imageio.v3 as iio +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) + + +def font(size): + for name in ("arial.ttf", "segoeui.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + pass + return ImageFont.load_default() + + +slides = [ + ("Affiliation Attestation Guard", "User & Project Management #11"), + ("Checks", "ORCID, institutional SSO, admin attestations"), + ("Checks", "expired appointments, grant consent, email domains"), + ("Decision", "hold trust features until public claims are evidenced"), +] + +frames = [] +for index, (title, subtitle) in enumerate(slides, start=1): + image = Image.new("RGB", (960, 544), "#111827") + draw = ImageDraw.Draw(image) + draw.rectangle((44, 52, 916, 492), outline="#60a5fa", width=3) + draw.text((80, 124), title, fill="#f8fafc", font=font(40)) + draw.text((80, 206), subtitle, fill="#dbeafe", font=font(25)) + draw.rectangle((80, 326, 818, 382), fill="#1d4ed8") + draw.text((104, 342), "profile claims must be verified before reputation or invitations", fill="#eff6ff", font=font(21)) + draw.text((80, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#cbd5e1", font=font(20)) + frames.extend([image] * 14) + +gif_path = REPORTS / "demo.gif" +mp4_path = REPORTS / "demo.mp4" +frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0) +iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264") +print(f"wrote {gif_path}") +print(f"wrote {mp4_path}") diff --git a/researcher-affiliation-attestation-guard/index.js b/researcher-affiliation-attestation-guard/index.js new file mode 100644 index 00000000..1b9b2eed --- /dev/null +++ b/researcher-affiliation-attestation-guard/index.js @@ -0,0 +1,264 @@ +const HIGH = "high"; +const MEDIUM = "medium"; +const LOW = "low"; + +function requiredString(value, field) { + if (typeof value !== "string" || value.trim() === "") { + throw new TypeError(`${field} must be a non-empty string`); + } + return value.trim(); +} + +function array(value, field) { + if (!Array.isArray(value)) { + throw new TypeError(`${field} must be an array`); + } + return value; +} + +function unique(values) { + return [...new Set(values.map(String))]; +} + +function normalizeDate(value, field) { + const text = requiredString(value, field); + const time = Date.parse(text); + if (Number.isNaN(time)) { + throw new TypeError(`${field} must be an ISO date`); + } + return text; +} + +function normalizeAffiliation(raw, index) { + return { + id: requiredString(raw.id, `affiliations[${index}].id`), + institution: requiredString(raw.institution, `affiliations[${index}].institution`), + role: requiredString(raw.role, `affiliations[${index}].role`), + source: raw.source ? String(raw.source) : "", + verifiedDomain: raw.verifiedDomain ? String(raw.verifiedDomain).toLowerCase() : "", + validFrom: normalizeDate(raw.validFrom || "1970-01-01", `affiliations[${index}].validFrom`), + validUntil: raw.validUntil ? normalizeDate(raw.validUntil, `affiliations[${index}].validUntil`) : "", + publicClaim: Boolean(raw.publicClaim), + evidenceRefs: unique(raw.evidenceRefs || []), + }; +} + +function normalizeGrant(raw, index) { + return { + id: requiredString(raw.id, `grants[${index}].id`), + funder: requiredString(raw.funder, `grants[${index}].funder`), + projectId: requiredString(raw.projectId, `grants[${index}].projectId`), + publicClaim: Boolean(raw.publicClaim), + consent: raw.consent ? String(raw.consent) : "", + evidenceRefs: unique(raw.evidenceRefs || []), + }; +} + +function normalizeProfile(raw) { + return { + researcherId: requiredString(raw.researcherId, "researcherId"), + displayName: requiredString(raw.displayName, "displayName"), + profileMode: requiredString(raw.profileMode || "private", "profileMode"), + orcid: raw.orcid ? String(raw.orcid) : "", + verifiedEmails: unique(raw.verifiedEmails || []).map((item) => item.toLowerCase()), + affiliations: array(raw.affiliations || [], "affiliations").map(normalizeAffiliation), + grants: array(raw.grants || [], "grants").map(normalizeGrant), + trustInputs: { + useAffiliationsForInvites: Boolean(raw.trustInputs && raw.trustInputs.useAffiliationsForInvites), + useGrantsForReputation: Boolean(raw.trustInputs && raw.trustInputs.useGrantsForReputation), + publicReputationBadge: Boolean(raw.trustInputs && raw.trustInputs.publicReputationBadge), + }, + }; +} + +function finding(code, severity, sourceId, message, remediation) { + return { code, severity, sourceId, message, remediation }; +} + +function emailDomains(emails) { + return unique(emails.map((email) => email.split("@")[1]).filter(Boolean)); +} + +function isExpired(dateText, asOf) { + return dateText && Date.parse(dateText) < Date.parse(asOf); +} + +function assessAffiliationAttestation(rawProfile, options = {}) { + const profile = normalizeProfile(rawProfile); + const asOf = normalizeDate(options.asOf || "2026-06-01", "asOf"); + const findings = []; + const domains = emailDomains(profile.verifiedEmails); + const publicAffiliations = profile.affiliations.filter((item) => item.publicClaim); + const publicGrants = profile.grants.filter((item) => item.publicClaim); + + if (profile.profileMode === "public" && !profile.orcid) { + findings.push( + finding( + "PUBLIC_PROFILE_WITHOUT_ORCID", + MEDIUM, + profile.researcherId, + `${profile.displayName} has a public profile without an ORCID identifier.`, + "Link ORCID or keep profile credentials private until identity evidence is complete." + ) + ); + } + + for (const affiliation of publicAffiliations) { + if (!["orcid", "institutional-sso", "admin-attestation"].includes(affiliation.source)) { + findings.push( + finding( + "UNTRUSTED_AFFILIATION_SOURCE", + HIGH, + affiliation.id, + `${affiliation.institution} affiliation uses untrusted source ${affiliation.source || "none"}.`, + "Require ORCID, institutional SSO, or admin attestation before publishing affiliation claims." + ) + ); + } + + if (isExpired(affiliation.validUntil, asOf)) { + findings.push( + finding( + "EXPIRED_PUBLIC_AFFILIATION", + HIGH, + affiliation.id, + `${affiliation.institution} affiliation expired on ${affiliation.validUntil}.`, + "Hide or refresh expired appointments before they influence invitations, grants, or reputation." + ) + ); + } + + if (affiliation.verifiedDomain && !domains.includes(affiliation.verifiedDomain)) { + findings.push( + finding( + "AFFILIATION_DOMAIN_MISMATCH", + HIGH, + affiliation.id, + `${affiliation.institution} requires ${affiliation.verifiedDomain}, but no verified email matches that domain.`, + "Verify an institutional email domain or remove the public affiliation claim." + ) + ); + } + + if (affiliation.evidenceRefs.length === 0) { + findings.push( + finding( + "AFFILIATION_WITHOUT_EVIDENCE", + MEDIUM, + affiliation.id, + `${affiliation.institution} public affiliation lacks evidence references.`, + "Attach ORCID records, SSO assertions, appointment receipts, or admin attestations." + ) + ); + } + } + + const activeInstitutions = publicAffiliations + .filter((item) => !isExpired(item.validUntil, asOf)) + .map((item) => item.institution); + if (unique(activeInstitutions).length > 1) { + findings.push( + finding( + "MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS", + MEDIUM, + "affiliations", + `Profile has multiple active public institutions: ${unique(activeInstitutions).join(", ")}.`, + "Require primary affiliation selection and conflict disclosure before institutional trust is applied." + ) + ); + } + + for (const grant of publicGrants) { + if (grant.consent !== "public") { + findings.push( + finding( + "GRANT_WITHOUT_PUBLIC_CONSENT", + HIGH, + grant.id, + `${grant.funder} grant is public but consent is ${grant.consent || "missing"}.`, + "Collect funder/PI consent before exposing grant claims or using them in reputation scoring." + ) + ); + } + + if (grant.evidenceRefs.length === 0) { + findings.push( + finding( + "GRANT_WITHOUT_EVIDENCE", + MEDIUM, + grant.id, + `${grant.funder} grant lacks evidence references.`, + "Attach award IDs, public grant pages, or institutional award receipts." + ) + ); + } + } + + if (profile.trustInputs.useAffiliationsForInvites && findings.some((item) => item.code.includes("AFFILIATION"))) { + findings.push( + finding( + "UNVERIFIED_AFFILIATION_USED_FOR_INVITES", + HIGH, + "trustInputs.useAffiliationsForInvites", + "Affiliation claims have blockers but are enabled for project invitation trust.", + "Disable affiliation-derived invitations until all public affiliation findings are resolved." + ) + ); + } + + if (profile.trustInputs.useGrantsForReputation && findings.some((item) => item.code.includes("GRANT"))) { + findings.push( + finding( + "UNVERIFIED_GRANT_USED_FOR_REPUTATION", + HIGH, + "trustInputs.useGrantsForReputation", + "Grant claims have blockers but are enabled for reputation scoring.", + "Disable grant-derived reputation inputs until grant consent and evidence are verified." + ) + ); + } + + if (profile.trustInputs.publicReputationBadge && profile.profileMode !== "public") { + findings.push( + finding( + "REPUTATION_BADGE_ON_PRIVATE_PROFILE", + LOW, + "trustInputs.publicReputationBadge", + "A public reputation badge is enabled while the profile is private.", + "Align badge visibility with the profile mode or provide explicit public-badge consent." + ) + ); + } + + const high = findings.filter((item) => item.severity === HIGH).length; + const medium = findings.filter((item) => item.severity === MEDIUM).length; + return { + researcherId: profile.researcherId, + displayName: profile.displayName, + profileMode: profile.profileMode, + decision: high > 0 ? "hold" : medium > 0 ? "revise" : "release", + summary: { + affiliationsReviewed: profile.affiliations.length, + publicAffiliations: publicAffiliations.length, + grantsReviewed: profile.grants.length, + publicGrants: publicGrants.length, + findings: findings.length, + high, + medium, + low: findings.filter((item) => item.severity === LOW).length, + }, + findings, + releaseCriteria: [ + "Public affiliations come from ORCID, institutional SSO, or admin attestation.", + "Expired appointments are hidden or refreshed before trust decisions.", + "Verified email domains match claimed institutional affiliations.", + "Public grant claims have consent and evidence references.", + "Unverified affiliations and grants do not feed invitations, trust, or reputation scoring.", + ], + }; +} + +module.exports = { + assessAffiliationAttestation, + normalizeProfile, +}; diff --git a/researcher-affiliation-attestation-guard/package.json b/researcher-affiliation-attestation-guard/package.json new file mode 100644 index 00000000..1acd89b9 --- /dev/null +++ b/researcher-affiliation-attestation-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "researcher-affiliation-attestation-guard", + "version": "1.0.0", + "description": "Researcher affiliation and grant attestation guard for SCIBASE user/project management", + "main": "index.js", + "type": "commonjs", + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "python demo_video.py" + }, + "license": "MIT" +} diff --git a/researcher-affiliation-attestation-guard/reports/demo.gif b/researcher-affiliation-attestation-guard/reports/demo.gif new file mode 100644 index 00000000..9c04ed69 Binary files /dev/null and b/researcher-affiliation-attestation-guard/reports/demo.gif differ diff --git a/researcher-affiliation-attestation-guard/reports/demo.mp4 b/researcher-affiliation-attestation-guard/reports/demo.mp4 new file mode 100644 index 00000000..277035b2 Binary files /dev/null and b/researcher-affiliation-attestation-guard/reports/demo.mp4 differ diff --git a/researcher-affiliation-attestation-guard/reports/risky-profile.json b/researcher-affiliation-attestation-guard/reports/risky-profile.json new file mode 100644 index 00000000..bcdfa82b --- /dev/null +++ b/researcher-affiliation-attestation-guard/reports/risky-profile.json @@ -0,0 +1,109 @@ +{ + "researcherId": "researcher-claim-risk-002", + "displayName": "R. Example", + "profileMode": "public", + "decision": "hold", + "summary": { + "affiliationsReviewed": 3, + "publicAffiliations": 3, + "grantsReviewed": 1, + "publicGrants": 1, + "findings": 12, + "high": 8, + "medium": 4, + "low": 0 + }, + "findings": [ + { + "code": "PUBLIC_PROFILE_WITHOUT_ORCID", + "severity": "medium", + "sourceId": "researcher-claim-risk-002", + "message": "R. Example has a public profile without an ORCID identifier.", + "remediation": "Link ORCID or keep profile credentials private until identity evidence is complete." + }, + { + "code": "UNTRUSTED_AFFILIATION_SOURCE", + "severity": "high", + "sourceId": "aff-expired", + "message": "Northbridge Institute affiliation uses untrusted source manual.", + "remediation": "Require ORCID, institutional SSO, or admin attestation before publishing affiliation claims." + }, + { + "code": "EXPIRED_PUBLIC_AFFILIATION", + "severity": "high", + "sourceId": "aff-expired", + "message": "Northbridge Institute affiliation expired on 2024-12-31.", + "remediation": "Hide or refresh expired appointments before they influence invitations, grants, or reputation." + }, + { + "code": "AFFILIATION_DOMAIN_MISMATCH", + "severity": "high", + "sourceId": "aff-expired", + "message": "Northbridge Institute requires northbridge.edu, but no verified email matches that domain.", + "remediation": "Verify an institutional email domain or remove the public affiliation claim." + }, + { + "code": "AFFILIATION_WITHOUT_EVIDENCE", + "severity": "medium", + "sourceId": "aff-expired", + "message": "Northbridge Institute public affiliation lacks evidence references.", + "remediation": "Attach ORCID records, SSO assertions, appointment receipts, or admin attestations." + }, + { + "code": "AFFILIATION_DOMAIN_MISMATCH", + "severity": "high", + "sourceId": "aff-active-conflict", + "message": "West River Labs requires westriver.example, but no verified email matches that domain.", + "remediation": "Verify an institutional email domain or remove the public affiliation claim." + }, + { + "code": "AFFILIATION_DOMAIN_MISMATCH", + "severity": "high", + "sourceId": "aff-second-active", + "message": "East Valley University requires evu.example, but no verified email matches that domain.", + "remediation": "Verify an institutional email domain or remove the public affiliation claim." + }, + { + "code": "MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS", + "severity": "medium", + "sourceId": "affiliations", + "message": "Profile has multiple active public institutions: West River Labs, East Valley University.", + "remediation": "Require primary affiliation selection and conflict disclosure before institutional trust is applied." + }, + { + "code": "GRANT_WITHOUT_PUBLIC_CONSENT", + "severity": "high", + "sourceId": "grant-private-77", + "message": "Private Foundation grant is public but consent is private.", + "remediation": "Collect funder/PI consent before exposing grant claims or using them in reputation scoring." + }, + { + "code": "GRANT_WITHOUT_EVIDENCE", + "severity": "medium", + "sourceId": "grant-private-77", + "message": "Private Foundation grant lacks evidence references.", + "remediation": "Attach award IDs, public grant pages, or institutional award receipts." + }, + { + "code": "UNVERIFIED_AFFILIATION_USED_FOR_INVITES", + "severity": "high", + "sourceId": "trustInputs.useAffiliationsForInvites", + "message": "Affiliation claims have blockers but are enabled for project invitation trust.", + "remediation": "Disable affiliation-derived invitations until all public affiliation findings are resolved." + }, + { + "code": "UNVERIFIED_GRANT_USED_FOR_REPUTATION", + "severity": "high", + "sourceId": "trustInputs.useGrantsForReputation", + "message": "Grant claims have blockers but are enabled for reputation scoring.", + "remediation": "Disable grant-derived reputation inputs until grant consent and evidence are verified." + } + ], + "releaseCriteria": [ + "Public affiliations come from ORCID, institutional SSO, or admin attestation.", + "Expired appointments are hidden or refreshed before trust decisions.", + "Verified email domains match claimed institutional affiliations.", + "Public grant claims have consent and evidence references.", + "Unverified affiliations and grants do not feed invitations, trust, or reputation scoring." + ] +} \ No newline at end of file diff --git a/researcher-affiliation-attestation-guard/reports/risky-profile.md b/researcher-affiliation-attestation-guard/reports/risky-profile.md new file mode 100644 index 00000000..66ac86e9 --- /dev/null +++ b/researcher-affiliation-attestation-guard/reports/risky-profile.md @@ -0,0 +1,32 @@ +# Researcher Affiliation Attestation Guard + +Scenario: risky-profile + +Researcher: R. Example +Profile mode: public +Decision: HOLD + +Reviewed 3 affiliations and 1 grants. + +## Findings + +- MEDIUM PUBLIC_PROFILE_WITHOUT_ORCID: R. Example has a public profile without an ORCID identifier. +- HIGH UNTRUSTED_AFFILIATION_SOURCE: Northbridge Institute affiliation uses untrusted source manual. +- HIGH EXPIRED_PUBLIC_AFFILIATION: Northbridge Institute affiliation expired on 2024-12-31. +- HIGH AFFILIATION_DOMAIN_MISMATCH: Northbridge Institute requires northbridge.edu, but no verified email matches that domain. +- MEDIUM AFFILIATION_WITHOUT_EVIDENCE: Northbridge Institute public affiliation lacks evidence references. +- HIGH AFFILIATION_DOMAIN_MISMATCH: West River Labs requires westriver.example, but no verified email matches that domain. +- HIGH AFFILIATION_DOMAIN_MISMATCH: East Valley University requires evu.example, but no verified email matches that domain. +- MEDIUM MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS: Profile has multiple active public institutions: West River Labs, East Valley University. +- HIGH GRANT_WITHOUT_PUBLIC_CONSENT: Private Foundation grant is public but consent is private. +- MEDIUM GRANT_WITHOUT_EVIDENCE: Private Foundation grant lacks evidence references. +- HIGH UNVERIFIED_AFFILIATION_USED_FOR_INVITES: Affiliation claims have blockers but are enabled for project invitation trust. +- HIGH UNVERIFIED_GRANT_USED_FOR_REPUTATION: Grant claims have blockers but are enabled for reputation scoring. + +## Release Criteria + +- Public affiliations come from ORCID, institutional SSO, or admin attestation. +- Expired appointments are hidden or refreshed before trust decisions. +- Verified email domains match claimed institutional affiliations. +- Public grant claims have consent and evidence references. +- Unverified affiliations and grants do not feed invitations, trust, or reputation scoring. diff --git a/researcher-affiliation-attestation-guard/reports/risky-profile.svg b/researcher-affiliation-attestation-guard/reports/risky-profile.svg new file mode 100644 index 00000000..eac4e97a --- /dev/null +++ b/researcher-affiliation-attestation-guard/reports/risky-profile.svg @@ -0,0 +1,12 @@ + + + Researcher Affiliation Attestation Guard + R. Example / public + + HOLD + Public affiliations: 3 + High: 8 + Medium: 4 + Low: 0 + Synthetic profile packets only. No real ORCID, SSO, grant, or email calls. + \ No newline at end of file diff --git a/researcher-affiliation-attestation-guard/reports/trusted-profile.json b/researcher-affiliation-attestation-guard/reports/trusted-profile.json new file mode 100644 index 00000000..1985a038 --- /dev/null +++ b/researcher-affiliation-attestation-guard/reports/trusted-profile.json @@ -0,0 +1,24 @@ +{ + "researcherId": "researcher-ada-001", + "displayName": "Ada Rivera", + "profileMode": "public", + "decision": "release", + "summary": { + "affiliationsReviewed": 1, + "publicAffiliations": 1, + "grantsReviewed": 1, + "publicGrants": 1, + "findings": 0, + "high": 0, + "medium": 0, + "low": 0 + }, + "findings": [], + "releaseCriteria": [ + "Public affiliations come from ORCID, institutional SSO, or admin attestation.", + "Expired appointments are hidden or refreshed before trust decisions.", + "Verified email domains match claimed institutional affiliations.", + "Public grant claims have consent and evidence references.", + "Unverified affiliations and grants do not feed invitations, trust, or reputation scoring." + ] +} \ No newline at end of file diff --git a/researcher-affiliation-attestation-guard/reports/trusted-profile.md b/researcher-affiliation-attestation-guard/reports/trusted-profile.md new file mode 100644 index 00000000..2937f6f3 --- /dev/null +++ b/researcher-affiliation-attestation-guard/reports/trusted-profile.md @@ -0,0 +1,21 @@ +# Researcher Affiliation Attestation Guard + +Scenario: trusted-profile + +Researcher: Ada Rivera +Profile mode: public +Decision: RELEASE + +Reviewed 1 affiliations and 1 grants. + +## Findings + +- No affiliation or grant attestation findings. + +## Release Criteria + +- Public affiliations come from ORCID, institutional SSO, or admin attestation. +- Expired appointments are hidden or refreshed before trust decisions. +- Verified email domains match claimed institutional affiliations. +- Public grant claims have consent and evidence references. +- Unverified affiliations and grants do not feed invitations, trust, or reputation scoring. diff --git a/researcher-affiliation-attestation-guard/reports/trusted-profile.svg b/researcher-affiliation-attestation-guard/reports/trusted-profile.svg new file mode 100644 index 00000000..96393710 --- /dev/null +++ b/researcher-affiliation-attestation-guard/reports/trusted-profile.svg @@ -0,0 +1,12 @@ + + + Researcher Affiliation Attestation Guard + Ada Rivera / public + + RELEASE + Public affiliations: 1 + High: 0 + Medium: 0 + Low: 0 + Synthetic profile packets only. No real ORCID, SSO, grant, or email calls. + \ No newline at end of file diff --git a/researcher-affiliation-attestation-guard/requirements-map.md b/researcher-affiliation-attestation-guard/requirements-map.md new file mode 100644 index 00000000..aeec9a73 --- /dev/null +++ b/researcher-affiliation-attestation-guard/requirements-map.md @@ -0,0 +1,14 @@ +# Requirements Map + +Issue #11 asks for user and project management: authentication and identity, researcher profiles, project spaces, permissions, activity/reputation metrics, public/private profile modes, institutional login, ORCID sync, funding sources, and collaboration governance. + +This slice covers a focused profile-trust gate: + +- Authentication and identity: validates that public affiliation claims come from ORCID, institutional SSO, or admin attestation. +- Researcher profiles: checks public affiliation and grant claims before they appear on a public profile. +- Institutional login: verifies institutional email domains match claimed public affiliations. +- Funding sources: requires public grant claims to carry consent and evidence references. +- Reputation metrics: blocks unverified affiliations or grants from feeding invitation trust or reputation scoring. +- Public/private modes: flags visibility mismatches between private profiles and public reputation badges. + +Out of scope by design: broad RBAC, project visibility transitions, object-level permissions, invitation MFA, lifecycle offboarding, data residency, profile publication disambiguation, and access denial appeals, because those are already covered by separate same-issue slices. diff --git a/researcher-affiliation-attestation-guard/sample-data.js b/researcher-affiliation-attestation-guard/sample-data.js new file mode 100644 index 00000000..83b2941c --- /dev/null +++ b/researcher-affiliation-attestation-guard/sample-data.js @@ -0,0 +1,98 @@ +const trustedProfile = { + researcherId: "researcher-ada-001", + displayName: "Ada Rivera", + profileMode: "public", + orcid: "0000-0002-1825-0097", + verifiedEmails: ["ada.rivera@unam.mx"], + affiliations: [ + { + id: "aff-unam", + institution: "UNAM Climate Lab", + role: "Principal Investigator", + source: "institutional-sso", + verifiedDomain: "unam.mx", + validFrom: "2024-01-01", + validUntil: "2027-12-31", + publicClaim: true, + evidenceRefs: ["sso:unam:ada.rivera", "orcid:employment:001"], + }, + ], + grants: [ + { + id: "grant-conacyt-42", + funder: "CONACYT", + projectId: "project-climate-yield", + publicClaim: true, + consent: "public", + evidenceRefs: ["award:conacyt-42"], + }, + ], + trustInputs: { + useAffiliationsForInvites: true, + useGrantsForReputation: true, + publicReputationBadge: true, + }, +}; + +const riskyProfile = { + researcherId: "researcher-claim-risk-002", + displayName: "R. Example", + profileMode: "public", + orcid: "", + verifiedEmails: ["researcher@gmail.com"], + affiliations: [ + { + id: "aff-expired", + institution: "Northbridge Institute", + role: "Visiting Fellow", + source: "manual", + verifiedDomain: "northbridge.edu", + validFrom: "2021-01-01", + validUntil: "2024-12-31", + publicClaim: true, + evidenceRefs: [], + }, + { + id: "aff-active-conflict", + institution: "West River Labs", + role: "Advisor", + source: "admin-attestation", + verifiedDomain: "westriver.example", + validFrom: "2025-01-01", + validUntil: "2027-12-31", + publicClaim: true, + evidenceRefs: ["attestation:wr-2026"], + }, + { + id: "aff-second-active", + institution: "East Valley University", + role: "Faculty", + source: "orcid", + verifiedDomain: "evu.example", + validFrom: "2025-01-01", + validUntil: "2027-12-31", + publicClaim: true, + evidenceRefs: ["orcid:employment:evu"], + }, + ], + grants: [ + { + id: "grant-private-77", + funder: "Private Foundation", + projectId: "project-sensitive-review", + publicClaim: true, + consent: "private", + evidenceRefs: [], + }, + ], + trustInputs: { + useAffiliationsForInvites: true, + useGrantsForReputation: true, + publicReputationBadge: true, + }, +}; + +module.exports = { + trustedProfile, + riskyProfile, +}; diff --git a/researcher-affiliation-attestation-guard/test.js b/researcher-affiliation-attestation-guard/test.js new file mode 100644 index 00000000..5ccacb56 --- /dev/null +++ b/researcher-affiliation-attestation-guard/test.js @@ -0,0 +1,41 @@ +const assert = require("assert"); + +const { assessAffiliationAttestation, normalizeProfile } = require("./index"); +const { trustedProfile, riskyProfile } = require("./sample-data"); + +const clean = assessAffiliationAttestation(trustedProfile, { asOf: "2026-06-01" }); +assert.strictEqual(clean.decision, "release"); +assert.strictEqual(clean.summary.findings, 0); +assert.strictEqual(clean.summary.publicAffiliations, 1); + +const risky = assessAffiliationAttestation(riskyProfile, { asOf: "2026-06-01" }); +assert.strictEqual(risky.decision, "hold"); +for (const code of [ + "PUBLIC_PROFILE_WITHOUT_ORCID", + "UNTRUSTED_AFFILIATION_SOURCE", + "EXPIRED_PUBLIC_AFFILIATION", + "AFFILIATION_DOMAIN_MISMATCH", + "AFFILIATION_WITHOUT_EVIDENCE", + "MULTIPLE_ACTIVE_PUBLIC_INSTITUTIONS", + "GRANT_WITHOUT_PUBLIC_CONSENT", + "GRANT_WITHOUT_EVIDENCE", + "UNVERIFIED_AFFILIATION_USED_FOR_INVITES", + "UNVERIFIED_GRANT_USED_FOR_REPUTATION", +]) { + assert(risky.findings.some((finding) => finding.code === code), `missing ${code}`); +} + +const reviseOnly = assessAffiliationAttestation({ + ...trustedProfile, + affiliations: trustedProfile.affiliations.map((affiliation) => ({ ...affiliation, evidenceRefs: [] })), + trustInputs: { ...trustedProfile.trustInputs, useAffiliationsForInvites: false }, +}); +assert.strictEqual(reviseOnly.decision, "revise"); +assert(reviseOnly.findings.some((finding) => finding.code === "AFFILIATION_WITHOUT_EVIDENCE")); + +assert.throws( + () => normalizeProfile({ ...trustedProfile, researcherId: "" }), + /researcherId must be a non-empty string/ +); + +console.log("researcher affiliation attestation guard tests passed");