Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions researcher-affiliation-attestation-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions researcher-affiliation-attestation-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="920" height="430" viewBox="0 0 920 430">
<rect width="920" height="430" fill="#111827"/>
<text x="42" y="66" fill="#f8fafc" font-family="Arial" font-size="32">Researcher Affiliation Attestation Guard</text>
<text x="42" y="112" fill="#cbd5e1" font-family="Arial" font-size="20">${report.displayName} / ${report.profileMode}</text>
<rect x="42" y="150" width="210" height="80" rx="8" fill="${color}"/>
<text x="68" y="201" fill="#fff" font-family="Arial" font-size="30">${report.decision.toUpperCase()}</text>
<text x="42" y="280" fill="#e5e7eb" font-family="Arial" font-size="22">Public affiliations: ${report.summary.publicAffiliations}</text>
<text x="42" y="320" fill="#fecaca" font-family="Arial" font-size="20">High: ${report.summary.high}</text>
<text x="172" y="320" fill="#fed7aa" font-family="Arial" font-size="20">Medium: ${report.summary.medium}</text>
<text x="342" y="320" fill="#bfdbfe" font-family="Arial" font-size="20">Low: ${report.summary.low}</text>
<text x="42" y="374" fill="#94a3b8" font-family="Arial" font-size="18">Synthetic profile packets only. No real ORCID, SSO, grant, or email calls.</text>
</svg>`;
}

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)`);
}
46 changes: 46 additions & 0 deletions researcher-affiliation-attestation-guard/demo_video.py
Original file line number Diff line number Diff line change
@@ -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}")
264 changes: 264 additions & 0 deletions researcher-affiliation-attestation-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading