diff --git a/.github/workflows/package-drift.yml b/.github/workflows/package-drift.yml new file mode 100644 index 00000000..d732b318 --- /dev/null +++ b/.github/workflows/package-drift.yml @@ -0,0 +1,41 @@ +name: Package Drift + +# Read-only drift detection for npm package publishing access, comparing the +# live registry against src/config/packageAccess.ts. Mutations are applied by +# a human via the generated remediation plan (npm requires an interactive 2FA +# challenge for all governance writes); PyPI has no API and is not checked. +# +# Requires the NPM_READ_TOKEN secret: a read-only npm granular access token +# with organization read access to "modelcontextprotocol". When the secret is +# absent the check prints a skip notice and passes (same optional-credential +# pattern as the Discord integration). + +on: + schedule: + - cron: '0 14 * * 1' # Mondays 14:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + npm-drift: + name: npm package access drift + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check npm package access drift + env: + NPM_TOKEN: ${{ secrets.NPM_READ_TOKEN }} + run: npm run check-package-drift diff --git a/README.md b/README.md index 85e126d7..030c7ee3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu - **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts - **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security. - **Google Workspace User Accounts**: Provisions @modelcontextprotocol.io accounts for members of roles with `provisionUser: true` (directly, or via a role nested under one through `github.parent` — e.g. SDK teams under `sdk-maintainers`, working groups under `working-groups`) +- **npm & PyPI Package Publishing Access** (declared, not applied): Expected registry access is declared in [`src/config/packageAccess.ts`](src/config/packageAccess.ts) and drift against the live npm registry is detected by CI — but changes are applied manually by a maintainer. See [npm & PyPI Package Publishing Access](#npm--pypi-package-publishing-access) below for why and how. ### Opting in to a Google Workspace account (maintainers) @@ -30,6 +31,34 @@ If you're a maintainer — explicitly or implicitly (SDK maintainers, working gr Once merged, Pulumi provisions the account. An admin will share your initial password (retrievable via `pulumi stack output --show-secrets newGWSUserPasswords`). +## npm & PyPI Package Publishing Access + +Publishing access to the `modelcontextprotocol` npm organization and to the MCP PyPI projects is **config-as-code with human-applied changes** — deliberately outside the Pulumi resource graph: + +- **npm** has an official management API ([api-docs.npmjs.com](https://api-docs.npmjs.com/)), but since August 2026 every governance mutation (org/team membership, maintainer add/remove, trusted-publisher config, token management) requires an **interactive 2FA challenge** — tokens, even with "bypass 2FA", get `403`. Reads still work headless with a granular access token, so drift is detected automatically and remediated manually. +- **PyPI** has **no management API at all**: collaborators, trusted publishers, and organizations are web-UI only, and maintainer invites must be accepted by email. The PyPI section of the config is declared state for audit purposes plus the manual procedures below. + +What lives where: + +- [`src/config/packageAccess.ts`](src/config/packageAccess.ts) — expected npm org membership (derived from members' `npm` field in `users.ts`), per-package maintainers and trusted publishers for the key packages, a default policy for the rest of the org's packages, and declared PyPI project rosters. +- [`scripts/check-package-drift.ts`](scripts/check-package-drift.ts) — read-only npm drift check: `NPM_TOKEN= npm run check-package-drift`. Prints a drift report and a remediation plan of `npm` CLI commands; exits nonzero on drift, and skips gracefully when `NPM_TOKEN` is unset. +- [`.github/workflows/package-drift.yml`](.github/workflows/package-drift.yml) — runs the check weekly and on demand, using the optional `NPM_READ_TOKEN` secret (a read-only npm granular access token with organization read access; note write-capable npm tokens expire after at most 90 days, so keep this one read-only). + +### Applying npm changes (runbook) + +1. Edit `src/config/packageAccess.ts` / `users.ts` to the desired state and merge the PR. +2. Run the drift check locally with a read-only token: `NPM_TOKEN= npm run check-package-drift`. +3. Review the printed remediation plan — especially any removal commands. +4. As an npm org owner, execute the plan in **one interactive session**: log in with `npm login`, trigger a 2FA prompt (e.g. run the first command), and choose **"Don't ask again for 5 minutes"** on the npmjs.com challenge. npm's own bulk guidance is to script the commands with a `sleep 2` between calls — roughly 80 operations fit in one approval window. +5. Re-run the drift check to confirm it exits clean. + +### PyPI procedures (manual, web UI only) + +- **Add a maintainer**: Manage project → Collaborators → invite by PyPI username with the Maintainer (upload only) or Owner role. The invitee must accept the invitation email before the role takes effect. Afterwards, record the account in `packageAccess.ts` and on the member's `pypi` field. +- **Trusted publisher**: Manage project → Publishing → add the GitHub repository + workflow (multiple publishers per project are allowed). Prefer trusted publishing over project-scoped API tokens. +- **Recommended follow-up**: apply for a free [PyPI community organization](https://docs.pypi.org/organization-accounts/) so projects are org-owned and access is managed via teams rather than per-project role edits. +- **Naming constraint**: the PyPI project name `modelcontextprotocol` is registered by an unrelated third party, so MCP's Python packages live under `mcp*` names. Any future consolidation under that name would require a [PEP 541](https://peps.python.org/pep-0541/) name-transfer request or simply keeping the `mcp*` naming. + ## Deployment ### Production Deployment (Automated) diff --git a/package.json b/package.json index 47aabb9d..7f0c50d9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "tsc", "validate": "npx ts-node scripts/validate-config.ts", "test": "npx ts-node scripts/test-config.ts", + "check-package-drift": "npx ts-node scripts/check-package-drift.ts", "check": "npm run format:check && npm run validate && npm run test", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/scripts/check-package-drift.ts b/scripts/check-package-drift.ts new file mode 100644 index 00000000..ddead76a --- /dev/null +++ b/scripts/check-package-drift.ts @@ -0,0 +1,313 @@ +#!/usr/bin/env npx ts-node + +/** + * Compares live npm registry state against the expected state declared in + * src/config/packageAccess.ts and prints a drift report plus a remediation + * plan of npm CLI commands for a maintainer to run in a single interactive + * 2FA-authenticated session (see "npm & PyPI Package Publishing Access" in + * the README). + * + * Read-only: this script never mutates anything. All npm write operations + * require an interactive 2FA challenge (since August 2026), which is why the + * remediation is a human-executed plan instead of automation. PyPI has no + * management API at all and is not checked here. + * + * Run with: NPM_TOKEN= npx ts-node scripts/check-package-drift.ts + * + * - Without NPM_TOKEN: prints a skip notice and exits 0 (so CI without the + * secret is a graceful no-op, mirroring the optional Discord credentials). + * - With NPM_TOKEN: exits 1 when drift is found, 0 when clean. + */ + +import { + NPM_ORG, + NPM_PACKAGES, + NPM_DEFAULT_POLICY, + getNpmPackageAccess, + getExpectedNpmOrgMembers, +} from '../src/config/packageAccess'; + +const REGISTRY = 'https://registry.npmjs.org'; +const REQUEST_DELAY_MS = 150; + +const token = process.env.NPM_TOKEN; + +interface Drift { + description: string; + /** Remediation lines; lines starting with '#' are guidance, not commands */ + commands: readonly string[]; +} + +const drifts: Drift[] = []; +const warnings: string[] = []; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function registryGet(path: string): Promise<{ status: number; body: unknown }> { + const url = `${REGISTRY}${path}`; + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) await sleep(2000 * attempt); + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + }); + if (response.status >= 500) { + lastError = new Error(`HTTP ${response.status} from ${url}`); + continue; + } + let body: unknown = null; + try { + body = await response.json(); + } catch { + // Non-JSON body (e.g. empty 404) — leave as null + } + return { status: response.status, body }; + } catch (e) { + lastError = e; + } + } + throw lastError; +} + +/** + * Escape a package name for use as a registry URL path segment. Follows the + * npm CLI's convention for scoped packages: keep the leading '@' literal and + * percent-encode every '/'. + */ +function escapePackageName(packageName: string): string { + return packageName.replace(/\//g, '%2F'); +} + +function diffSets( + expected: readonly string[], + actual: readonly string[] +): { missing: string[]; unexpected: string[] } { + const expectedSet = new Set(expected); + const actualSet = new Set(actual); + return { + missing: expected.filter((e) => !actualSet.has(e)).sort(), + unexpected: actual.filter((a) => !expectedSet.has(a)).sort(), + }; +} + +async function checkOrgMembership(): Promise { + const { status, body } = await registryGet(`/-/org/${NPM_ORG}/user`); + if (status !== 200 || typeof body !== 'object' || body === null) { + warnings.push( + `Could not read org roster (GET /-/org/${NPM_ORG}/user returned ${status}). ` + + `The token likely lacks organization read access — org membership was NOT checked.` + ); + return; + } + + const roster = body as Record; + const actual = Object.keys(roster); + if (actual.length === 0) { + warnings.push( + `Org roster came back empty — unauthenticated requests see an empty roster, ` + + `so the token likely lacks organization access. Org membership was NOT checked.` + ); + return; + } + + const { missing, unexpected } = diffSets(getExpectedNpmOrgMembers(), actual); + for (const user of missing) { + drifts.push({ + description: `Org member missing: "${user}" is expected in the "${NPM_ORG}" org but is not a member`, + commands: [`npm org set ${NPM_ORG} ${user} developer`], + }); + } + for (const user of unexpected) { + drifts.push({ + description: + `Unexpected org member: "${user}" (role: ${roster[user]}) is in the "${NPM_ORG}" org ` + + `but not declared in packageAccess.ts/users.ts`, + commands: [ + `# Either add npm: '${user}' to the right member in src/config/users.ts (or UNMAPPED_NPM_USERS), or remove them:`, + `npm org rm ${NPM_ORG} ${user}`, + ], + }); + } +} + +async function listOrgPackages(): Promise { + const { status, body } = await registryGet(`/-/org/${NPM_ORG}/package`); + if (status === 200 && typeof body === 'object' && body !== null) { + return Object.keys(body as Record).sort(); + } + warnings.push( + `Could not enumerate org packages (GET /-/org/${NPM_ORG}/package returned ${status}); ` + + `falling back to the ${NPM_PACKAGES.length} explicitly declared packages.` + ); + return NPM_PACKAGES.map((p) => p.package); +} + +/** Extract candidate trusted-publisher configs from a /trust response, defensively. */ +function extractTrustConfigs(body: unknown): Array> { + if (body === null || typeof body !== 'object') return []; + if (Array.isArray(body)) return body.filter((c) => typeof c === 'object' && c !== null); + const obj = body as Record; + for (const key of ['objects', 'configurations', 'trustedPublishers', 'trust']) { + if (Array.isArray(obj[key])) { + return (obj[key] as unknown[]).filter( + (c): c is Record => typeof c === 'object' && c !== null + ); + } + } + return [obj]; +} + +async function checkPackage(packageName: string): Promise { + const expected = getNpmPackageAccess(packageName); + const isExplicit = NPM_PACKAGES.some((p) => p.package === packageName); + + // 1) Maintainers, from the public package document + const { status, body } = await registryGet(`/${escapePackageName(packageName)}`); + if (status !== 200 || typeof body !== 'object' || body === null) { + warnings.push(`Could not read package document for ${packageName} (HTTP ${status}).`); + return; + } + const packument = body as { + maintainers?: Array<{ name?: string }>; + 'dist-tags'?: Record; + versions?: Record; + }; + + const actualMaintainers = (packument.maintainers ?? []) + .map((m) => m.name) + .filter((name): name is string => typeof name === 'string'); + const { missing, unexpected } = diffSets(expected.maintainers, actualMaintainers); + for (const user of missing) { + drifts.push({ + description: `Maintainer missing on ${packageName}: "${user}"`, + commands: [`npm owner add ${user} ${packageName}`], + }); + } + for (const user of unexpected) { + drifts.push({ + description: + `Unexpected maintainer on ${packageName}: "${user}" ` + + `(not in its ${isExplicit ? 'declared maintainers' : 'default-policy maintainers'})`, + commands: [ + `# Either declare "${user}" for this package in src/config/packageAccess.ts, or remove them:`, + `npm owner rm ${user} ${packageName}`, + ], + }); + } + + // 2) Trusted publishing of the latest release (default policy) + const latestVersion = packument['dist-tags']?.latest; + const latest = latestVersion ? packument.versions?.[latestVersion] : undefined; + if (NPM_DEFAULT_POLICY.requireTrustedPublishing && latest && !latest._npmUser?.trustedPublisher) { + drifts.push({ + description: + `${packageName}@${latestVersion} was not published via trusted publishing ` + + `(policy: all org packages publish via OIDC)`, + commands: [ + `# Configure a trusted publisher for ${packageName} (web UI: package Settings -> Trusted publishing,`, + `# or \`npm trust\` on npm >= 11.15.0), then stop using publish tokens for it.`, + ], + }); + } + + // 3) Declared trusted-publisher configuration (explicit packages only) + if (expected.trustedPublisher) { + const want = expected.trustedPublisher; + const trust = await registryGet(`/-/package/${escapePackageName(packageName)}/trust`); + if (trust.status === 401 || trust.status === 403) { + warnings.push( + `Cannot read trusted-publisher config for ${packageName} (HTTP ${trust.status}); ` + + `token lacks permission — declared publisher (${want.repository} ${want.workflow}) was NOT verified.` + ); + } else if (trust.status === 404 || extractTrustConfigs(trust.body).length === 0) { + drifts.push({ + description: `No trusted publisher configured on ${packageName} (expected ${want.repository} via ${want.workflow})`, + commands: [ + `# Configure trusted publishing for ${packageName}: GitHub Actions, repository ${want.repository},`, + `# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`, + ], + }); + } else if (trust.status === 200) { + const configs = extractTrustConfigs(trust.body); + const matches = configs.some((c) => { + const text = JSON.stringify(c); + const repoOk = + text.includes(want.repository) || + (text.includes(want.repository.split('/')[0]) && + text.includes(want.repository.split('/')[1])); + const workflowFile = want.workflow.split('/').pop() ?? want.workflow; + return repoOk && text.includes(workflowFile); + }); + if (!matches) { + drifts.push({ + description: + `Trusted publisher mismatch on ${packageName}: expected ${want.repository} via ${want.workflow}, ` + + `live config differs: ${JSON.stringify(configs)}`, + commands: [ + `# Update trusted publishing for ${packageName} to repository ${want.repository},`, + `# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`, + ], + }); + } + } + } +} + +async function main(): Promise { + if (!token) { + console.log('NPM_TOKEN is not set — skipping npm package access drift check.'); + console.log( + 'Provide a read-only granular access token with organization read access to enable it.' + ); + process.exit(0); + } + + console.log(`Checking npm package access drift for org "${NPM_ORG}"...\n`); + + await checkOrgMembership(); + + const packages = await listOrgPackages(); + console.log(`Checking ${packages.length} packages...`); + const declared = new Set(NPM_PACKAGES.map((p) => p.package)); + const notInOrg = [...declared].filter((p) => !packages.includes(p)); + for (const pkg of notInOrg) { + warnings.push(`Declared package ${pkg} was not found in the org package list.`); + } + for (const pkg of packages) { + await checkPackage(pkg); + await sleep(REQUEST_DELAY_MS); + } + + // Report + if (warnings.length > 0) { + console.log('\n--- Warnings (not drift) ---'); + for (const warning of warnings) console.log(` ! ${warning}`); + } + + if (drifts.length === 0) { + console.log('\nNo drift detected. Live npm state matches src/config/packageAccess.ts.'); + process.exit(0); + } + + console.log(`\n--- Drift report (${drifts.length} finding${drifts.length === 1 ? '' : 's'}) ---`); + for (const drift of drifts) console.log(` ✗ ${drift.description}`); + + console.log('\n--- Remediation plan ---'); + console.log('# Review, then run in ONE interactive npm session (logged in as an org owner).'); + console.log('# Approve a 2FA challenge on npmjs.com and choose "Don\'t ask again for 5 minutes"'); + console.log('# to batch these; add `sleep 2` between commands in longer batches.'); + console.log('# See "npm & PyPI Package Publishing Access" in the README.\n'); + for (const drift of drifts) { + for (const command of drift.commands) console.log(command); + } + + process.exit(1); +} + +main().catch((e) => { + console.error(`Drift check failed: ${e}`); + process.exit(1); +}); diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 3ef1a9c3..6aa6b99b 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -9,6 +9,14 @@ import { ROLES, buildRoleLookup, getRolesForPlatform } from '../src/config/roles import { ROLE_IDS, isValidRoleId } from '../src/config/roleIds'; import { MEMBERS } from '../src/config/users'; import { hasProvisionUserRole } from '../src/config/utils'; +import { + NPM_ORG, + NPM_PACKAGES, + PYPI_PROJECTS, + getExpectedNpmOrgMembers, + getNpmPackageAccess, + NPM_DEFAULT_POLICY, +} from '../src/config/packageAccess'; let passed = 0; let failed = 0; @@ -124,6 +132,46 @@ test('Some members in provisionUser roles have Google user fields', () => { return membersInProvisionRoles.length > 0 && provisioned.length > 0; }); +// Test package registry access config +test('NPM_ORG is modelcontextprotocol', () => NPM_ORG === 'modelcontextprotocol'); +test('NPM_PACKAGES is not empty and all packages are org-scoped', () => + NPM_PACKAGES.length > 0 && NPM_PACKAGES.every((p) => p.package.startsWith(`@${NPM_ORG}/`))); +test('All NPM_PACKAGES have at least one maintainer', () => + NPM_PACKAGES.every((p) => p.maintainers.length > 0)); +test('npm usernames on members are unique', () => { + const usernames = MEMBERS.filter((m) => m.npm).map((m) => m.npm); + return usernames.length === new Set(usernames).size; +}); +test('pypi usernames on members are unique', () => { + const usernames = MEMBERS.filter((m) => m.pypi).map((m) => m.pypi); + return usernames.length === new Set(usernames).size; +}); +test('Expected npm org membership is non-empty, sorted, and unique', () => { + const orgMembers = getExpectedNpmOrgMembers(); + const sorted = [...orgMembers].sort((a, b) => a.localeCompare(b)); + return ( + orgMembers.length > 0 && + orgMembers.length === new Set(orgMembers).size && + orgMembers.every((username, i) => username === sorted[i]) + ); +}); +test('getNpmPackageAccess falls back to the default policy', () => { + const access = getNpmPackageAccess(`@${NPM_ORG}/some-undeclared-package`); + return access.maintainers === NPM_DEFAULT_POLICY.maintainers && !access.trustedPublisher; +}); +test('getNpmPackageAccess returns explicit entries', () => { + const access = getNpmPackageAccess(`@${NPM_ORG}/sdk`); + return !!access.trustedPublisher; +}); +test('PYPI_PROJECTS includes the mcp project with accounts', () => { + const mcp = PYPI_PROJECTS.find((p) => p.project === 'mcp'); + return !!mcp && mcp.accounts.length > 0; +}); +test('PyPI project names are unique', () => { + const names = PYPI_PROJECTS.map((p) => p.project); + return names.length === new Set(names).size; +}); + // Summary console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index 1e0c6eb1..b23f1ad5 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -7,6 +7,12 @@ import { ROLES, buildRoleLookup } from '../src/config/roles'; import { REPOSITORY_ACCESS } from '../src/config/repoAccess'; +import { + NPM_PACKAGES, + PYPI_PROJECTS, + UNMAPPED_NPM_USERS, + UNMAPPED_PYPI_USERS, +} from '../src/config/packageAccess'; import { MEMBERS } from '../src/config/users'; import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils'; import type { RoleId } from '../src/config/roleIds'; @@ -193,6 +199,111 @@ for (const member of MEMBERS) { } } +// Validate npm/PyPI identities and packageAccess.ts references +console.log('Validating package registry references in packageAccess.ts...'); +{ + // npm/pypi usernames must be unique across members + const npmUsers = new Map(); + const pypiUsers = new Map(); + for (const member of MEMBERS) { + const memberId = member.github || member.email || 'unknown'; + for (const [field, seen] of [ + ['npm', npmUsers], + ['pypi', pypiUsers], + ] as const) { + const username = member[field]; + if (!username) continue; + const existing = seen.get(username); + if (existing) { + console.error( + `ERROR: ${field} username "${username}" is used by both "${existing}" and "${memberId}"` + ); + hasErrors = true; + } else { + seen.set(username, memberId); + } + } + } + + // Unmapped lists must not overlap with mapped members (stale entries) + for (const username of UNMAPPED_NPM_USERS) { + if (npmUsers.has(username)) { + console.error( + `ERROR: npm username "${username}" is in UNMAPPED_NPM_USERS but is already mapped ` + + `to member "${npmUsers.get(username)}" — remove it from the unmapped list` + ); + hasErrors = true; + } + } + for (const username of UNMAPPED_PYPI_USERS) { + if (pypiUsers.has(username)) { + console.error( + `ERROR: PyPI username "${username}" is in UNMAPPED_PYPI_USERS but is already mapped ` + + `to member "${pypiUsers.get(username)}" — remove it from the unmapped list` + ); + hasErrors = true; + } + } + + // Every npm maintainer referenced by a package must be a known account + const knownNpm = new Set([...npmUsers.keys(), ...UNMAPPED_NPM_USERS]); + const npmPackageNames = new Set(); + for (const pkg of NPM_PACKAGES) { + if (npmPackageNames.has(pkg.package)) { + console.error(`ERROR: Package "${pkg.package}" is declared twice in packageAccess.ts`); + hasErrors = true; + } + npmPackageNames.add(pkg.package); + + if (!pkg.package.startsWith('@modelcontextprotocol/')) { + console.error(`ERROR: Package "${pkg.package}" is not in the @modelcontextprotocol scope`); + hasErrors = true; + } + for (const username of pkg.maintainers) { + if (!knownNpm.has(username)) { + console.error( + `ERROR: Package "${pkg.package}" references npm user "${username}" which is not ` + + `declared on any member in users.ts (npm field) or in UNMAPPED_NPM_USERS` + ); + hasErrors = true; + } + } + if ( + pkg.trustedPublisher && + !pkg.trustedPublisher.repository.startsWith('modelcontextprotocol/') + ) { + console.error( + `ERROR: Package "${pkg.package}" declares trusted publisher repository ` + + `"${pkg.trustedPublisher.repository}" outside the modelcontextprotocol GitHub org` + ); + hasErrors = true; + } + } + + // Every PyPI account referenced by a project must be a known account + const knownPypi = new Set([...pypiUsers.keys(), ...UNMAPPED_PYPI_USERS]); + const pypiProjectNames = new Set(); + for (const project of PYPI_PROJECTS) { + if (pypiProjectNames.has(project.project)) { + console.error( + `ERROR: PyPI project "${project.project}" is declared twice in packageAccess.ts` + ); + hasErrors = true; + } + pypiProjectNames.add(project.project); + + for (const username of project.accounts) { + if (!knownPypi.has(username)) { + console.error( + `ERROR: PyPI project "${project.project}" references PyPI user "${username}" which is not ` + + `declared on any member in users.ts (pypi field) or in UNMAPPED_PYPI_USERS` + ); + hasErrors = true; + } + } + } +} + // Validate parent role references in roles.ts console.log('Validating parent role references in roles.ts...'); for (const role of ROLES) { diff --git a/src/config/packageAccess.ts b/src/config/packageAccess.ts new file mode 100644 index 00000000..3912d3fc --- /dev/null +++ b/src/config/packageAccess.ts @@ -0,0 +1,199 @@ +// npm & PyPI package publishing access configuration +// +// This file declares the *expected* state of package-registry access so it can +// be reviewed and audited in one place. Unlike GitHub/Google/Discord, none of +// it is applied by Pulumi: +// +// - npm: since August 2026 every mutating org/team/package-access/trust +// operation requires an interactive 2FA challenge (tokens — even with +// "bypass 2FA" — get 403), so unattended reconciliation is impossible. +// Read endpoints still work with a granular access token, so drift is +// *detected* by scripts/check-package-drift.ts (run weekly by +// .github/workflows/package-drift.yml) and *applied* by a human following +// the runbook in the README ("npm & PyPI Package Publishing Access"). +// - PyPI: there is no management API at all (collaborators, trusted +// publishers and organizations are web-UI only), so the PyPI section below +// is declared state for audit plus a manual runbook — nothing is automated. +// +// This module must stay import-safe for scripts/validate-config.ts and +// scripts/test-config.ts: pure data and helpers, no network, no Pulumi. + +import { MEMBERS } from './users'; + +/** The npm organization (scope) that owns @modelcontextprotocol/* packages. */ +export const NPM_ORG = 'modelcontextprotocol'; + +/** + * A package's npm trusted publishing configuration (OIDC from GitHub Actions). + * npm allows exactly one trusted publisher per package. + */ +export interface NpmTrustedPublisher { + /** GitHub repository allowed to publish, as "owner/repo" */ + repository: string; + /** Workflow file path within the repository, e.g. ".github/workflows/main.yml" */ + workflow: string; +} + +/** Expected access for a single npm package. */ +export interface NpmPackageAccess { + /** Full package name, e.g. "@modelcontextprotocol/sdk" */ + package: string; + /** + * npm usernames expected to have publish rights (the registry + * "maintainers" list). Every entry must be the `npm` field of a member in + * users.ts or listed in UNMAPPED_NPM_USERS (enforced by validate-config). + */ + maintainers: readonly string[]; + /** Expected trusted publishing configuration, if declared */ + trustedPublisher?: NpmTrustedPublisher; +} + +/** + * npm usernames that currently hold access but have not been verifiably + * mapped to a member in users.ts yet. Keep this list shrinking: when a + * mapping is confirmed, set `npm` on the member and remove the entry here. + */ +export const UNMAPPED_NPM_USERS: readonly string[] = [ + // Registry email ashwin@anthropic.com; no matching entry in users.ts. + 'ashwin-ant', +]; + +/** + * The baseline maintainer set for org packages: the npm accounts of the core + * publishing group (see users.ts: jspahrsummers, pcarleton, felixweinberger, + * dsp-ant, ochafik, plus the unmapped ashwin-ant). + */ +export const NPM_BASE_MAINTAINERS: readonly string[] = [ + 'ashwin-ant', + 'fweinberger', + 'jspahrsummers', + 'ochafik-ant', + 'pcarleton', + 'thedsp', +]; + +/** + * Packages with explicitly declared access. Packages in the org that are not + * listed here fall under NPM_DEFAULT_POLICY. + */ +export const NPM_PACKAGES: readonly NpmPackageAccess[] = [ + { + package: '@modelcontextprotocol/sdk', + maintainers: NPM_BASE_MAINTAINERS, + trustedPublisher: { + repository: 'modelcontextprotocol/typescript-sdk', + workflow: '.github/workflows/main.yml', + }, + }, + { + package: '@modelcontextprotocol/inspector', + maintainers: [...NPM_BASE_MAINTAINERS, 'cliffhall'], + trustedPublisher: { + repository: 'modelcontextprotocol/inspector', + workflow: '.github/workflows/main.yml', + }, + }, + { + package: '@modelcontextprotocol/server-everything', + maintainers: [...NPM_BASE_MAINTAINERS, 'cliffhall'], + trustedPublisher: { + repository: 'modelcontextprotocol/servers', + workflow: '.github/workflows/release.yml', + }, + }, +]; + +/** + * Policy applied to every org package without an explicit NPM_PACKAGES entry + * (the org has ~54 packages; the drift checker enumerates them live). + */ +export const NPM_DEFAULT_POLICY = { + /** Expected maintainers for packages without an explicit entry */ + maintainers: NPM_BASE_MAINTAINERS, + /** + * All org packages should publish via trusted publishing (OIDC), not + * tokens. The drift checker reports packages whose latest release was not + * trusted-published; the exact repository/workflow is only pinned for + * packages with an explicit NPM_PACKAGES entry. + */ + requireTrustedPublishing: true, +} as const; + +/** Expected access for the given package: its explicit entry or the default policy. */ +export function getNpmPackageAccess(packageName: string): NpmPackageAccess { + return ( + NPM_PACKAGES.find((p) => p.package === packageName) ?? { + package: packageName, + maintainers: NPM_DEFAULT_POLICY.maintainers, + } + ); +} + +/** + * Expected npm org membership: every npm username declared on a member in + * users.ts, plus the not-yet-mapped accounts. Sorted, de-duplicated. + */ +export function getExpectedNpmOrgMembers(): string[] { + const usernames = new Set(UNMAPPED_NPM_USERS); + for (const member of MEMBERS) { + if (member.npm) usernames.add(member.npm); + } + return [...usernames].sort((a, b) => a.localeCompare(b)); +} + +// --------------------------------------------------------------------------- +// PyPI (declared state only — no API exists, nothing is automated) +// --------------------------------------------------------------------------- + +/** Expected access for a single PyPI project. */ +export interface PyPiProjectAccess { + /** Project name on pypi.org */ + project: string; + /** + * PyPI accounts with a role on the project. PyPI does not publicly expose + * whether an account is Owner or Maintainer, so this is a single list. + * Every entry must be the `pypi` field of a member in users.ts or listed + * in UNMAPPED_PYPI_USERS (enforced by validate-config). + */ + accounts: readonly string[]; + /** Source repository expected to publish via trusted publishing */ + repository?: string; + /** Caveats about this entry (e.g. unverified rosters) */ + notes?: string; +} + +/** PyPI usernames with access that are not yet mapped to a member in users.ts. */ +export const UNMAPPED_PYPI_USERS: readonly string[] = []; + +export const PYPI_PROJECTS: readonly PyPiProjectAccess[] = [ + { + project: 'mcp', + accounts: ['Kludex', 'dsp', 'jspahrsummers', 'maxisbey'], + repository: 'modelcontextprotocol/python-sdk', + }, + { + project: 'mcp-server-git', + accounts: [], + repository: 'modelcontextprotocol/servers', + notes: + 'Account roster not yet audited — PyPI exposes rosters only in the ' + + 'web UI, and this page could not be verified. A project owner should ' + + 'fill this in from pypi.org/manage/project/mcp-server-git/collaboration/.', + }, + { + project: 'mcp-server-fetch', + accounts: [], + repository: 'modelcontextprotocol/servers', + notes: + 'Account roster not yet audited — see mcp-server-git note; source: ' + + 'pypi.org/manage/project/mcp-server-fetch/collaboration/.', + }, + { + project: 'mcp-server-time', + accounts: [], + repository: 'modelcontextprotocol/servers', + notes: + 'Account roster not yet audited — see mcp-server-git note; source: ' + + 'pypi.org/manage/project/mcp-server-time/collaboration/.', + }, +]; diff --git a/src/config/users.ts b/src/config/users.ts index c25e743d..ebd8be4e 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -150,6 +150,7 @@ export const MEMBERS: readonly Member[] = [ github: 'cliffhall', email: 'cliff@futurescale.com', discord: '501498061965754380', + npm: 'cliffhall', firstName: 'Cliff', lastName: 'Hall', googleEmailPrefix: 'cliff', @@ -240,6 +241,7 @@ export const MEMBERS: readonly Member[] = [ }, { github: 'dsp', + pypi: 'dsp', skipGoogleUserProvisioning: true, memberOf: [ ROLE_IDS.AUTH_MAINTAINERS, @@ -260,6 +262,7 @@ export const MEMBERS: readonly Member[] = [ github: 'dsp-ant', email: 'david@modelcontextprotocol.io', discord: '166107790262272000', + npm: 'thedsp', firstName: 'David', lastName: 'Soria Parra', googleEmailPrefix: 'david', @@ -321,6 +324,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'felixweinberger', discord: '1377138523492057212', + npm: 'fweinberger', firstName: 'Felix', lastName: 'Weinberger', googleEmailPrefix: 'felix', @@ -434,6 +438,8 @@ export const MEMBERS: readonly Member[] = [ { github: 'jspahrsummers', email: 'justin@modelcontextprotocol.io', + npm: 'jspahrsummers', + pypi: 'jspahrsummers', firstName: 'Justin', lastName: 'Spahr-Summers', googleEmailPrefix: 'justin', @@ -469,6 +475,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'Kludex', discord: '247021664624312322', + pypi: 'Kludex', firstName: 'Marcelo', lastName: 'Trylesinski', googleEmailPrefix: 'marcelo', @@ -569,6 +576,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'maxisbey', discord: '1404871241738748058', + pypi: 'maxisbey', firstName: 'Max', lastName: 'Isbey', googleEmailPrefix: 'max', @@ -618,6 +626,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'ochafik', discord: '1004897332069925024', + npm: 'ochafik-ant', firstName: 'Olivier', lastName: 'Chafik', googleEmailPrefix: 'ochafik', @@ -661,6 +670,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'pcarleton', discord: '1354465170969067852', + npm: 'pcarleton', firstName: 'Paul', lastName: 'Carleton', googleEmailPrefix: 'paul', diff --git a/src/config/utils.ts b/src/config/utils.ts index f6485967..41784273 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -13,6 +13,20 @@ export interface Member { email?: string; /** Discord user ID (snowflake) */ discord?: string; + /** + * npm username (npmjs.com). Only set when the mapping to this member has + * been verified (e.g. the npm account's registry email matches this member, + * or the username is identical and the person is a known maintainer). + * Referenced by src/config/packageAccess.ts and + * scripts/check-package-drift.ts; not managed by Pulumi. + */ + npm?: string; + /** + * PyPI username (pypi.org). Only set when the mapping to this member has + * been verified. Declared for audit purposes only — PyPI has no management + * API, so this is never automated (see packageAccess.ts). + */ + pypi?: string; /** Roles this member belongs to */ memberOf: readonly RoleId[]; /** First name (required for Google Workspace user provisioning) */