From 0a37a1cc67e2b73a28b27ba6a8a73cee74bf1155 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:10:14 +0000 Subject: [PATCH 1/3] Manage the security-room Cloudflare Access policy from config The Cloudflare Zero Trust Access policy protecting securityroom.modelcontextprotocol.io was hand-edited in the Cloudflare dashboard and allowed four GitHub teams: core-maintainers, lead-maintainers, security-managers and sdk-maintainers. Cloudflare matches direct GitHub team membership only, so SDK maintainers, who sit in child teams such as python-sdk, were denied. Declare the policy in src/config/accessPolicies.ts and sync it with @pulumi/cloudflare (src/cloudflare.ts, optional like the Discord module until cloudflare:apiToken is configured). Add a dedicated `security-room` GitHub team (no parent, so it grants no repository permissions) that the policy allows alongside core-maintainers, lead-maintainers and security-managers, and seed it with the per-SDK leads named in the thread. Validation and tests cover the new config; the deploy and preview workflows pass CLOUDFLARE_API_TOKEN when the secret exists. The existing `Maintainers` policy is adopted via the `import` resource option behind cloudflare:importExistingPolicies, since Pulumi requires imported inputs to match the live resource; a follow-up flips the flag off so the include-rule change is applied. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YBarRtzEtwT2HvfcEvRbW7 --- .github/workflows/deploy.yml | 5 +++ .github/workflows/preview.yml | 4 ++ Pulumi.prod.yaml | 7 ++++ README.md | 22 +++++++++++ package-lock.json | 10 +++++ package.json | 1 + scripts/test-config.ts | 44 ++++++++++++++++++++++ scripts/validate-config.ts | 42 +++++++++++++++++++++ src/cloudflare.ts | 70 +++++++++++++++++++++++++++++++++++ src/config/accessPolicies.ts | 70 +++++++++++++++++++++++++++++++++++ src/config/roleIds.ts | 1 + src/config/roles.ts | 11 ++++++ src/config/users.ts | 28 ++++++++++---- src/index.ts | 1 + 14 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 src/cloudflare.ts create mode 100644 src/config/accessPolicies.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e93fe790..f098687a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,6 +66,7 @@ jobs: ORG_BILLING_EMAIL: ${{ secrets.ORG_BILLING_EMAIL }} DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: | echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt export PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt @@ -73,4 +74,8 @@ jobs: pulumi config set discord:guildId "$DISCORD_GUILD_ID" --stack prod pulumi config set discord:botToken "$DISCORD_BOT_TOKEN" --secret --stack prod pulumi config set githubBillingEmail "$ORG_BILLING_EMAIL" --secret --stack prod + # Cloudflare Access is optional until the CLOUDFLARE_API_TOKEN secret exists + if [ -n "$CLOUDFLARE_API_TOKEN" ]; then + pulumi config set cloudflare:apiToken "$CLOUDFLARE_API_TOKEN" --secret --stack prod + fi make up diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 01e21a1a..0978d3c2 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -66,6 +66,7 @@ jobs: ORG_BILLING_EMAIL: ${{ secrets.ORG_BILLING_EMAIL }} DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: | echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt pulumi login gs://mcp-access-prod-pulumi-state @@ -81,6 +82,9 @@ jobs: if [ -n "$ORG_BILLING_EMAIL" ]; then CONFIG_FLAGS="$CONFIG_FLAGS --config githubBillingEmail=$ORG_BILLING_EMAIL" fi + if [ -n "$CLOUDFLARE_API_TOKEN" ]; then + CONFIG_FLAGS="$CONFIG_FLAGS --config cloudflare:apiToken=$CLOUDFLARE_API_TOKEN" + fi # Run preview and capture output set +e diff --git a/Pulumi.prod.yaml b/Pulumi.prod.yaml index eb4ad254..7fab501f 100644 --- a/Pulumi.prod.yaml +++ b/Pulumi.prod.yaml @@ -4,3 +4,10 @@ config: googleworkspace:credentials: secure: v1:Ntr2su0kgwp0oAh3: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 github:owner: modelcontextprotocol + # Cloudflare Access (see README "Cloudflare Access (security-room)"). + # cloudflare:apiToken is set by the deploy workflow from the CLOUDFLARE_API_TOKEN secret. + cloudflare:accountId: c39d6379ccfdb20a3627eeddffa80ec9 + cloudflare:githubIdentityProviderId: 1e53bc11-a504-4572-92d1-4c8d56fc198b + # Adopt the existing dashboard-created policies on the first deploy. Set to "false" + # in a follow-up PR once the deploy that imported them has succeeded. + cloudflare:importExistingPolicies: "true" diff --git a/README.md b/README.md index a8d219fa..b57e22f0 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`) +- **Cloudflare Access (security-room)**: Syncs the Cloudflare Zero Trust Access policy that decides who can sign in to `securityroom.modelcontextprotocol.io` from the roles declared in [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts). See [Cloudflare Access (security-room)](#cloudflare-access-security-room) below. - **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) @@ -31,6 +32,24 @@ 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`). +## Cloudflare Access (security-room) + +[securityroom.modelcontextprotocol.io](https://securityroom.modelcontextprotocol.io) is protected by Cloudflare Zero Trust Access with GitHub as the identity provider. The reusable Access policy `Maintainers` that grants sign-in is managed from this repo by [`src/cloudflare.ts`](src/cloudflare.ts), driven by [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts): + +- Each entry in `ACCESS_POLICIES` lists the roles (from `roles.ts`) whose GitHub team may sign in. Pulumi renders one `github-organization` include rule per team on the policy. Nothing else about the Access application (domain, identity providers, session settings) is managed here. +- **Cloudflare matches direct team membership only.** A member of `python-sdk` does not satisfy a rule for its parent team `sdk-maintainers`. That is why the policy allows the dedicated `security-room` team (the `SECURITY_ROOM` role, which has no parent team and grants no repository permissions) alongside `core-maintainers`, `lead-maintainers` and `security-managers`. +- **To grant someone access**: add `ROLE_IDS.SECURITY_ROOM` to their `memberOf` in [`src/config/users.ts`](src/config/users.ts). **To allow another team**: add its role to the policy's `roles` in `accessPolicies.ts` (validation checks the role exists and has a GitHub team). +- After access is granted, a user who was previously denied must revoke the "Cloudflare Access" OAuth app under their GitHub settings (Applications → Authorized OAuth Apps) and sign in again, otherwise Cloudflare keeps using the cached team list from their earlier login. + +### One-time setup + +1. In the Cloudflare dashboard for the **MCP Domain Account**, create an API token with the permission **Account → Access: Apps and Policies → Edit** (scoped to that account only). +2. Add it as the GitHub Actions secret `CLOUDFLARE_API_TOKEN` in the `production` environment (repository settings → Environments → production). Until the secret exists, the Cloudflare module logs "Cloudflare integration disabled" and creates nothing, so previews stay green. +3. The account ID and GitHub identity-provider ID are non-secret and live in [`Pulumi.prod.yaml`](Pulumi.prod.yaml). +4. **Adopting the existing policy.** Pulumi's `import` resource option only succeeds when the program's inputs match the live resource, so adoption is two deploys: + - With `cloudflare:importExistingPolicies: "true"` in `Pulumi.prod.yaml`, the first deploy imports the existing `Maintainers` policy (by its `cloudflarePolicyId`) as-is, ignoring its rule lists. + - Then set the flag to `"false"` in a follow-up PR; its preview shows exactly the include-rule changes that the next deploy applies. Leave the flag off from then on. + ## 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: @@ -94,6 +113,9 @@ The following secrets must be configured in GitHub Actions for automated deploym - Used to decrypt encrypted values in Pulumi stack configuration - Keep this secure - if lost, you cannot decrypt your Pulumi state +- **`CLOUDFLARE_API_TOKEN`** (optional, `production` environment): Cloudflare API token with **Account → Access: Apps and Policies → Edit** on the MCP Domain Account + - Used to manage the Cloudflare Access policy for `securityroom.modelcontextprotocol.io` (see [Cloudflare Access (security-room)](#cloudflare-access-security-room)) + ## Initial Setup If setting up this infrastructure for the first time: diff --git a/package-lock.json b/package-lock.json index 6808e040..0c3fb00b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@pulumi/cloudflare": "^6.21.0", "@pulumi/github": "^6.11.0", "@pulumi/googleworkspace": "file:sdks/googleworkspace", "@pulumi/pulumi": "^3.218.0", @@ -635,6 +636,15 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@pulumi/cloudflare": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@pulumi/cloudflare/-/cloudflare-6.21.0.tgz", + "integrity": "sha512-IW8oBur+LcdXt2/Gv29P8TNxI7ILL+ZC2LKo8QtDcmCjQL5KFuR1A9rh0OxGEn08zPWxpMfHT6o1wZ5s0q3DIw==", + "license": "Apache-2.0", + "dependencies": { + "@pulumi/pulumi": "^3.142.0" + } + }, "node_modules/@pulumi/github": { "version": "6.12.1", "resolved": "https://registry.npmjs.org/@pulumi/github/-/github-6.12.1.tgz", diff --git a/package.json b/package.json index 7f0c50d9..2f73f4d1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "license": "MIT", "dependencies": { + "@pulumi/cloudflare": "^6.21.0", "@pulumi/github": "^6.11.0", "@pulumi/googleworkspace": "file:sdks/googleworkspace", "@pulumi/pulumi": "^3.218.0", diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 6aa6b99b..25a2a2c2 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -17,6 +17,7 @@ import { getNpmPackageAccess, NPM_DEFAULT_POLICY, } from '../src/config/packageAccess'; +import { ACCESS_POLICIES, getAccessPolicyTeams } from '../src/config/accessPolicies'; let passed = 0; let failed = 0; @@ -172,6 +173,49 @@ test('PyPI project names are unique', () => { return names.length === new Set(names).size; }); +// Test Cloudflare Access policy config +test('SECURITY_ROOM role exists (GitHub-only, no parent team)', () => { + const role = roleLookup.get(ROLE_IDS.SECURITY_ROOM); + return ( + role !== undefined && + role.github?.team === 'security-room' && + role.github.parent === undefined && + role.discord === undefined && + role.google === undefined && + !role.provisionUser + ); +}); +test('ACCESS_POLICIES is not empty with unique ids', () => { + const ids = ACCESS_POLICIES.map((p) => p.id); + return ids.length > 0 && ids.length === new Set(ids).size; +}); +test('All access policy roles exist and have GitHub teams', () => + ACCESS_POLICIES.every((p) => p.roles.every((id) => !!roleLookup.get(id)?.github?.team))); +test('Access policy roles are unique within each policy', () => + ACCESS_POLICIES.every((p) => p.roles.length === new Set(p.roles).size)); +test('security-room policy renders the expected include-rule teams in order', () => { + const policy = ACCESS_POLICIES.find((p) => p.id === 'security-room-maintainers'); + if (!policy) return false; + const teams = getAccessPolicyTeams(policy); + const expected = ['core-maintainers', 'lead-maintainers', 'security-managers', 'security-room']; + return teams.length === expected.length && teams.every((t, i) => t === expected[i]); +}); +test('security-room team has members', () => + MEMBERS.some((m) => m.github && m.memberOf.includes(ROLE_IDS.SECURITY_ROOM))); +test('getAccessPolicyTeams throws for a role without a GitHub team', () => { + try { + getAccessPolicyTeams({ + id: 'bogus', + description: '', + cloudflarePolicyName: 'x', + roles: [ROLE_IDS.ADMINISTRATORS], + }); + return false; + } catch { + return true; + } +}); + // 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 b23f1ad5..4b64fb68 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -14,6 +14,7 @@ import { UNMAPPED_PYPI_USERS, } from '../src/config/packageAccess'; import { MEMBERS } from '../src/config/users'; +import { ACCESS_POLICIES } from '../src/config/accessPolicies'; import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils'; import type { RoleId } from '../src/config/roleIds'; @@ -304,6 +305,47 @@ console.log('Validating package registry references in packageAccess.ts...'); } } +// Validate Cloudflare Access policies in accessPolicies.ts +console.log('Validating Cloudflare Access policies in accessPolicies.ts...'); +{ + const policyIds = new Set(); + for (const policy of ACCESS_POLICIES) { + if (policyIds.has(policy.id)) { + console.error(`ERROR: Access policy "${policy.id}" is declared twice in accessPolicies.ts`); + hasErrors = true; + } + policyIds.add(policy.id); + + if (policy.roles.length === 0) { + console.error(`ERROR: Access policy "${policy.id}" has no roles; nobody could sign in`); + hasErrors = true; + } + + const seenRoles = new Set(); + for (const roleId of policy.roles) { + if (seenRoles.has(roleId)) { + console.error(`ERROR: Access policy "${policy.id}" lists role "${roleId}" more than once`); + hasErrors = true; + } + seenRoles.add(roleId); + + const role = roleLookup.get(roleId); + if (!role) { + console.error( + `ERROR: Access policy "${policy.id}" references role "${roleId}" which does not exist in roles.ts` + ); + hasErrors = true; + } else if (!role.github) { + // Cloudflare Access matches GitHub teams, so a role without one cannot be granted + console.error( + `ERROR: Access policy "${policy.id}" references role "${roleId}" which has no GitHub team` + ); + 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/cloudflare.ts b/src/cloudflare.ts new file mode 100644 index 00000000..10812ee4 --- /dev/null +++ b/src/cloudflare.ts @@ -0,0 +1,70 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as cloudflare from '@pulumi/cloudflare'; +import { ACCESS_POLICIES, GITHUB_ORG, getAccessPolicyTeams } from './config/accessPolicies'; + +const config = new pulumi.Config('cloudflare'); +// Cloudflare integration is optional - only enabled if apiToken is configured, so +// previews stay green before the CLOUDFLARE_API_TOKEN secret exists. +const CLOUDFLARE_API_TOKEN = config.getSecret('apiToken'); +const CLOUDFLARE_ENABLED = CLOUDFLARE_API_TOKEN !== undefined; + +if (!CLOUDFLARE_ENABLED) { + pulumi.log.info('Cloudflare integration disabled: apiToken not configured'); +} + +// Access policies keyed by policy id (accessPolicies.ts) +const accessPolicies: Record = {}; + +if (CLOUDFLARE_ENABLED) { + const accountId = config.require('accountId'); + const githubIdentityProviderId = config.require('githubIdentityProviderId'); + // Pulumi's `import` option requires the program's inputs to match the live + // resource, so adopting an existing policy is a two-step process: + // 1. importExistingPolicies=true: adopt the policy as-is (rule fields ignored) + // 2. importExistingPolicies=false (or unset): manage the rules from config + // See README "Cloudflare Access (security-room)". + const importExisting = config.getBoolean('importExistingPolicies') ?? false; + + const provider = new cloudflare.Provider('cloudflare', { + apiToken: CLOUDFLARE_API_TOKEN, + }); + + ACCESS_POLICIES.forEach((policy) => { + const teams = getAccessPolicyTeams(policy); + const importId = + importExisting && policy.cloudflarePolicyId + ? `${accountId}/${policy.cloudflarePolicyId}` + : undefined; + + accessPolicies[policy.id] = new cloudflare.ZeroTrustAccessPolicy( + `access-policy-${policy.id}`, + { + accountId, + name: policy.cloudflarePolicyName, + decision: 'allow', + includes: teams.map((team) => ({ + githubOrganization: { + identityProviderId: githubIdentityProviderId, + name: GITHUB_ORG, + team, + }, + })), + }, + { + provider, + import: importId, + // connectionRules and sessionDuration are set by the Cloudflare API with + // defaults we do not model. During import, also keep the live rule lists so + // the adoption succeeds; the next deploy without the flag applies the config. + ignoreChanges: importId + ? ['connectionRules', 'sessionDuration', 'includes', 'excludes', 'requires'] + : ['connectionRules', 'sessionDuration'], + } + ); + }); +} + +export const cloudflareAccessPolicyIds = Object.fromEntries( + Object.entries(accessPolicies).map(([id, policy]) => [id, policy.id]) +); +export { accessPolicies as cloudflareAccessPolicies }; diff --git a/src/config/accessPolicies.ts b/src/config/accessPolicies.ts new file mode 100644 index 00000000..0722dcc1 --- /dev/null +++ b/src/config/accessPolicies.ts @@ -0,0 +1,70 @@ +// Cloudflare Zero Trust Access policies. +// Each entry describes who may sign in to a Cloudflare Access-protected site, expressed +// as roles from roles.ts. src/cloudflare.ts renders every role's GitHub team into a +// `github-organization` include rule on the policy, so the roster is managed in +// users.ts like every other GitHub team. +// +// Cloudflare matches DIRECT GitHub team membership only: a member of `python-sdk` +// does not satisfy a rule for its parent team `sdk-maintainers`. Roles listed here +// must therefore be teams people are added to directly (see the `security-room` role). + +import { ROLE_IDS, type RoleId } from './roleIds'; +import { buildRoleLookup } from './roles'; + +/** GitHub organization that Cloudflare Access include rules are matched against. */ +export const GITHUB_ORG = 'modelcontextprotocol'; + +export interface AccessPolicy { + /** Stable identifier, used as the Pulumi resource name */ + id: string; + /** What the policy protects */ + description: string; + /** Name of the reusable Access policy in Cloudflare (Zero Trust > Access > Policies) */ + cloudflarePolicyName: string; + /** + * ID of the reusable policy that already exists in Cloudflare. When set, and the + * `cloudflare:importExistingPolicies` stack config is true, Pulumi adopts this + * policy instead of creating a new one. See the README section + * "Cloudflare Access (security-room)". + */ + cloudflarePolicyId?: string; + /** Roles whose GitHub team may sign in. Order is preserved in the include rules. */ + roles: readonly RoleId[]; +} + +export const ACCESS_POLICIES: readonly AccessPolicy[] = [ + { + id: 'security-room-maintainers', + description: 'Who can sign in to securityroom.modelcontextprotocol.io', + cloudflarePolicyName: 'Maintainers', + cloudflarePolicyId: '11a6814e-bb85-4ea2-aa12-6b081b09aa56', + roles: [ + ROLE_IDS.CORE_MAINTAINERS, + ROLE_IDS.LEAD_MAINTAINERS, + ROLE_IDS.SECURITY_MANAGERS, + // Per-SDK security leads are added to this team directly in users.ts + ROLE_IDS.SECURITY_ROOM, + ], + }, +]; + +/** + * Resolve a policy's roles to the GitHub team slugs Cloudflare matches against, + * in declaration order. Throws if a role is unknown or has no GitHub team, so + * a bad reference fails validation rather than silently dropping a rule. + */ +export function getAccessPolicyTeams(policy: AccessPolicy): string[] { + const roleLookup = buildRoleLookup(); + return policy.roles.map((roleId) => { + const role = roleLookup.get(roleId); + if (!role) { + throw new Error(`Access policy "${policy.id}" references unknown role "${roleId}"`); + } + if (!role.github) { + throw new Error( + `Access policy "${policy.id}" references role "${roleId}" which has no GitHub team` + ); + } + return role.github.team; + }); +} diff --git a/src/config/roleIds.ts b/src/config/roleIds.ts index 07d3aa8a..491c94b1 100644 --- a/src/config/roleIds.ts +++ b/src/config/roleIds.ts @@ -11,6 +11,7 @@ export const ROLE_IDS = { LEAD_MAINTAINERS: 'lead-maintainers', MODERATORS: 'moderators', SECURITY_MANAGERS: 'security-managers', // GitHub only (org security_manager role) + SECURITY_ROOM: 'security-room', // GitHub only (Cloudflare Access to securityroom.modelcontextprotocol.io) ADMINISTRATORS: 'administrators', // Discord only COMMUNITY_MANAGERS: 'community-managers', // Discord only diff --git a/src/config/roles.ts b/src/config/roles.ts index 5a430b15..93696e44 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -116,6 +116,17 @@ export const ROLES: readonly Role[] = [ github: { team: 'security-managers' }, // GitHub only - grants org-wide visibility into security alerts and draft advisories }, + { + id: ROLE_IDS.SECURITY_ROOM, + description: + 'Sign-in access to securityroom.modelcontextprotocol.io (MCP SDK security advisory room)', + // No parent team: must not inherit steering-committee repo permissions. This team + // exists solely to be matched by the Cloudflare Access policy in accessPolicies.ts + // (Cloudflare matches direct team membership only, so SDK maintainers are added + // here explicitly rather than via sdk-maintainers). + github: { team: 'security-room' }, + // GitHub only - grants no repository permissions by itself + }, // =================== // Maintainer Groups diff --git a/src/config/users.ts b/src/config/users.ts index 142c6e11..870ca8d9 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -193,7 +193,7 @@ export const MEMBERS: readonly Member[] = [ firstName: 'Dale', lastName: 'Seo', googleEmailPrefix: 'dale.seo', - memberOf: [ROLE_IDS.RUST_SDK], + memberOf: [ROLE_IDS.RUST_SDK, ROLE_IDS.SECURITY_ROOM], }, { github: 'DanielTemesgen', @@ -349,6 +349,7 @@ export const MEMBERS: readonly Member[] = [ ROLE_IDS.MAINTAINERS, ROLE_IDS.PYTHON_SDK, ROLE_IDS.SECURITY_IG, + ROLE_IDS.SECURITY_ROOM, ROLE_IDS.TYPESCRIPT_SDK, ], }, @@ -360,14 +361,14 @@ export const MEMBERS: readonly Member[] = [ { github: 'guglielmo-san', discord: '1432786987072622613', - memberOf: [ROLE_IDS.GO_SDK], + memberOf: [ROLE_IDS.GO_SDK, ROLE_IDS.SECURITY_ROOM], }, { github: 'halter73', discord: '340718902096953344', firstName: 'Stephen', lastName: 'Halter', - memberOf: [ROLE_IDS.CSHARP_SDK, ROLE_IDS.CSHARP_SDK_ADMIN], + memberOf: [ROLE_IDS.CSHARP_SDK, ROLE_IDS.CSHARP_SDK_ADMIN, ROLE_IDS.SECURITY_ROOM], }, { github: 'herczyn', @@ -491,7 +492,12 @@ export const MEMBERS: readonly Member[] = [ { github: 'KKonstantinov', discord: '390932438903422987', - memberOf: [ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.MAINTAINERS, ROLE_IDS.TYPESCRIPT_SDK], + memberOf: [ + ROLE_IDS.INSPECTOR_MAINTAINERS, + ROLE_IDS.MAINTAINERS, + ROLE_IDS.SECURITY_ROOM, + ROLE_IDS.TYPESCRIPT_SDK, + ], firstName: 'Konstantin', lastName: 'Konstantinov', googleEmailPrefix: 'konstantin', @@ -503,12 +509,12 @@ export const MEMBERS: readonly Member[] = [ firstName: 'Marcelo', lastName: 'Trylesinski', googleEmailPrefix: 'marcelo', - memberOf: [ROLE_IDS.PYTHON_SDK, ROLE_IDS.SDK_MAINTAINERS], + memberOf: [ROLE_IDS.PYTHON_SDK, ROLE_IDS.SDK_MAINTAINERS, ROLE_IDS.SECURITY_ROOM], }, { github: 'koic', discord: '880937364208361483', - memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.RUBY_SDK], + memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.RUBY_SDK, ROLE_IDS.SECURITY_ROOM], }, { github: 'kurtisvg', @@ -592,7 +598,12 @@ export const MEMBERS: readonly Member[] = [ firstName: 'Matt', lastName: 'Carey', googleEmailPrefix: 'matt', - memberOf: [ROLE_IDS.TYPESCRIPT_SDK, ROLE_IDS.TOOL_ANNOTATIONS_IG, ROLE_IDS.WG_IG_FACILITATORS], + memberOf: [ + ROLE_IDS.SECURITY_ROOM, + ROLE_IDS.TYPESCRIPT_SDK, + ROLE_IDS.TOOL_ANNOTATIONS_IG, + ROLE_IDS.WG_IG_FACILITATORS, + ], }, { github: 'maxisbey', @@ -605,6 +616,7 @@ export const MEMBERS: readonly Member[] = [ ROLE_IDS.MAINTAINERS, ROLE_IDS.PYTHON_SDK, ROLE_IDS.SECURITY_MANAGERS, + ROLE_IDS.SECURITY_ROOM, ROLE_IDS.TYPESCRIPT_SDK_COLLABORATORS, ], }, @@ -958,7 +970,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'yarolegovich', discord: '393296640141950977', - memberOf: [ROLE_IDS.GO_SDK], + memberOf: [ROLE_IDS.GO_SDK, ROLE_IDS.SECURITY_ROOM], }, { email: 'adamj@anthropic.com', diff --git a/src/index.ts b/src/index.ts index c72d079b..c059af73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './github'; export * from './google'; export * from './discord'; +export * from './cloudflare'; From 79f0eaa6970a265b5114b72767ca7f9aab4631d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:17:04 +0000 Subject: [PATCH 2/3] Rename security-room team to security-team The team allowed through the Cloudflare Access policy represents the MCP Security Team as a whole (SDK security leads who coordinate advisories across SDKs), not only sign-in to the security room, so name the role and GitHub team `security-team`. It stays parentless so it inherits no repository permissions. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YBarRtzEtwT2HvfcEvRbW7 --- README.md | 4 ++-- scripts/test-config.ts | 12 ++++++------ src/config/accessPolicies.ts | 6 +++--- src/config/roleIds.ts | 2 +- src/config/roles.ts | 15 +++++++-------- src/config/users.ts | 20 ++++++++++---------- 6 files changed, 29 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index b57e22f0..37f3f8f7 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Once merged, Pulumi provisions the account. An admin will share your initial pas [securityroom.modelcontextprotocol.io](https://securityroom.modelcontextprotocol.io) is protected by Cloudflare Zero Trust Access with GitHub as the identity provider. The reusable Access policy `Maintainers` that grants sign-in is managed from this repo by [`src/cloudflare.ts`](src/cloudflare.ts), driven by [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts): - Each entry in `ACCESS_POLICIES` lists the roles (from `roles.ts`) whose GitHub team may sign in. Pulumi renders one `github-organization` include rule per team on the policy. Nothing else about the Access application (domain, identity providers, session settings) is managed here. -- **Cloudflare matches direct team membership only.** A member of `python-sdk` does not satisfy a rule for its parent team `sdk-maintainers`. That is why the policy allows the dedicated `security-room` team (the `SECURITY_ROOM` role, which has no parent team and grants no repository permissions) alongside `core-maintainers`, `lead-maintainers` and `security-managers`. -- **To grant someone access**: add `ROLE_IDS.SECURITY_ROOM` to their `memberOf` in [`src/config/users.ts`](src/config/users.ts). **To allow another team**: add its role to the policy's `roles` in `accessPolicies.ts` (validation checks the role exists and has a GitHub team). +- **Cloudflare matches direct team membership only.** A member of `python-sdk` does not satisfy a rule for its parent team `sdk-maintainers`. That is why the policy allows the `security-team` team (the `SECURITY_TEAM` role: the MCP Security Team of SDK security leads, which has no parent team and grants no repository permissions) alongside `core-maintainers`, `lead-maintainers` and `security-managers`. +- **To add someone to the MCP Security Team** (and grant sign-in): add `ROLE_IDS.SECURITY_TEAM` to their `memberOf` in [`src/config/users.ts`](src/config/users.ts). **To allow another team**: add its role to the policy's `roles` in `accessPolicies.ts` (validation checks the role exists and has a GitHub team). - After access is granted, a user who was previously denied must revoke the "Cloudflare Access" OAuth app under their GitHub settings (Applications → Authorized OAuth Apps) and sign in again, otherwise Cloudflare keeps using the cached team list from their earlier login. ### One-time setup diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 25a2a2c2..c3320e7f 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -174,11 +174,11 @@ test('PyPI project names are unique', () => { }); // Test Cloudflare Access policy config -test('SECURITY_ROOM role exists (GitHub-only, no parent team)', () => { - const role = roleLookup.get(ROLE_IDS.SECURITY_ROOM); +test('SECURITY_TEAM role exists (GitHub-only, no parent team)', () => { + const role = roleLookup.get(ROLE_IDS.SECURITY_TEAM); return ( role !== undefined && - role.github?.team === 'security-room' && + role.github?.team === 'security-team' && role.github.parent === undefined && role.discord === undefined && role.google === undefined && @@ -197,11 +197,11 @@ test('security-room policy renders the expected include-rule teams in order', () const policy = ACCESS_POLICIES.find((p) => p.id === 'security-room-maintainers'); if (!policy) return false; const teams = getAccessPolicyTeams(policy); - const expected = ['core-maintainers', 'lead-maintainers', 'security-managers', 'security-room']; + const expected = ['core-maintainers', 'lead-maintainers', 'security-managers', 'security-team']; return teams.length === expected.length && teams.every((t, i) => t === expected[i]); }); -test('security-room team has members', () => - MEMBERS.some((m) => m.github && m.memberOf.includes(ROLE_IDS.SECURITY_ROOM))); +test('security-team team has members', () => + MEMBERS.some((m) => m.github && m.memberOf.includes(ROLE_IDS.SECURITY_TEAM))); test('getAccessPolicyTeams throws for a role without a GitHub team', () => { try { getAccessPolicyTeams({ diff --git a/src/config/accessPolicies.ts b/src/config/accessPolicies.ts index 0722dcc1..0585a311 100644 --- a/src/config/accessPolicies.ts +++ b/src/config/accessPolicies.ts @@ -6,7 +6,7 @@ // // Cloudflare matches DIRECT GitHub team membership only: a member of `python-sdk` // does not satisfy a rule for its parent team `sdk-maintainers`. Roles listed here -// must therefore be teams people are added to directly (see the `security-room` role). +// must therefore be teams people are added to directly (see the `security-team` role). import { ROLE_IDS, type RoleId } from './roleIds'; import { buildRoleLookup } from './roles'; @@ -42,8 +42,8 @@ export const ACCESS_POLICIES: readonly AccessPolicy[] = [ ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.SECURITY_MANAGERS, - // Per-SDK security leads are added to this team directly in users.ts - ROLE_IDS.SECURITY_ROOM, + // MCP Security Team: per-SDK security leads, added to this team directly in users.ts + ROLE_IDS.SECURITY_TEAM, ], }, ]; diff --git a/src/config/roleIds.ts b/src/config/roleIds.ts index 491c94b1..e21f02e7 100644 --- a/src/config/roleIds.ts +++ b/src/config/roleIds.ts @@ -11,7 +11,7 @@ export const ROLE_IDS = { LEAD_MAINTAINERS: 'lead-maintainers', MODERATORS: 'moderators', SECURITY_MANAGERS: 'security-managers', // GitHub only (org security_manager role) - SECURITY_ROOM: 'security-room', // GitHub only (Cloudflare Access to securityroom.modelcontextprotocol.io) + SECURITY_TEAM: 'security-team', // GitHub only (MCP Security Team; Cloudflare Access to securityroom.modelcontextprotocol.io) ADMINISTRATORS: 'administrators', // Discord only COMMUNITY_MANAGERS: 'community-managers', // Discord only diff --git a/src/config/roles.ts b/src/config/roles.ts index 93696e44..942646fc 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -117,14 +117,13 @@ export const ROLES: readonly Role[] = [ // GitHub only - grants org-wide visibility into security alerts and draft advisories }, { - id: ROLE_IDS.SECURITY_ROOM, - description: - 'Sign-in access to securityroom.modelcontextprotocol.io (MCP SDK security advisory room)', - // No parent team: must not inherit steering-committee repo permissions. This team - // exists solely to be matched by the Cloudflare Access policy in accessPolicies.ts - // (Cloudflare matches direct team membership only, so SDK maintainers are added - // here explicitly rather than via sdk-maintainers). - github: { team: 'security-room' }, + id: ROLE_IDS.SECURITY_TEAM, + description: 'MCP Security Team: SDK security leads who coordinate advisories across SDKs', + // Grants sign-in to securityroom.modelcontextprotocol.io via the Cloudflare Access + // policy in accessPolicies.ts. Cloudflare matches direct team membership only, so + // SDK security leads are added here explicitly rather than via sdk-maintainers. + // No parent team: must not inherit steering-committee repo permissions. + github: { team: 'security-team' }, // GitHub only - grants no repository permissions by itself }, diff --git a/src/config/users.ts b/src/config/users.ts index 870ca8d9..7bb0466c 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -193,7 +193,7 @@ export const MEMBERS: readonly Member[] = [ firstName: 'Dale', lastName: 'Seo', googleEmailPrefix: 'dale.seo', - memberOf: [ROLE_IDS.RUST_SDK, ROLE_IDS.SECURITY_ROOM], + memberOf: [ROLE_IDS.RUST_SDK, ROLE_IDS.SECURITY_TEAM], }, { github: 'DanielTemesgen', @@ -349,7 +349,7 @@ export const MEMBERS: readonly Member[] = [ ROLE_IDS.MAINTAINERS, ROLE_IDS.PYTHON_SDK, ROLE_IDS.SECURITY_IG, - ROLE_IDS.SECURITY_ROOM, + ROLE_IDS.SECURITY_TEAM, ROLE_IDS.TYPESCRIPT_SDK, ], }, @@ -361,14 +361,14 @@ export const MEMBERS: readonly Member[] = [ { github: 'guglielmo-san', discord: '1432786987072622613', - memberOf: [ROLE_IDS.GO_SDK, ROLE_IDS.SECURITY_ROOM], + memberOf: [ROLE_IDS.GO_SDK, ROLE_IDS.SECURITY_TEAM], }, { github: 'halter73', discord: '340718902096953344', firstName: 'Stephen', lastName: 'Halter', - memberOf: [ROLE_IDS.CSHARP_SDK, ROLE_IDS.CSHARP_SDK_ADMIN, ROLE_IDS.SECURITY_ROOM], + memberOf: [ROLE_IDS.CSHARP_SDK, ROLE_IDS.CSHARP_SDK_ADMIN, ROLE_IDS.SECURITY_TEAM], }, { github: 'herczyn', @@ -495,7 +495,7 @@ export const MEMBERS: readonly Member[] = [ memberOf: [ ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.MAINTAINERS, - ROLE_IDS.SECURITY_ROOM, + ROLE_IDS.SECURITY_TEAM, ROLE_IDS.TYPESCRIPT_SDK, ], firstName: 'Konstantin', @@ -509,12 +509,12 @@ export const MEMBERS: readonly Member[] = [ firstName: 'Marcelo', lastName: 'Trylesinski', googleEmailPrefix: 'marcelo', - memberOf: [ROLE_IDS.PYTHON_SDK, ROLE_IDS.SDK_MAINTAINERS, ROLE_IDS.SECURITY_ROOM], + memberOf: [ROLE_IDS.PYTHON_SDK, ROLE_IDS.SDK_MAINTAINERS, ROLE_IDS.SECURITY_TEAM], }, { github: 'koic', discord: '880937364208361483', - memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.RUBY_SDK, ROLE_IDS.SECURITY_ROOM], + memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.RUBY_SDK, ROLE_IDS.SECURITY_TEAM], }, { github: 'kurtisvg', @@ -599,7 +599,7 @@ export const MEMBERS: readonly Member[] = [ lastName: 'Carey', googleEmailPrefix: 'matt', memberOf: [ - ROLE_IDS.SECURITY_ROOM, + ROLE_IDS.SECURITY_TEAM, ROLE_IDS.TYPESCRIPT_SDK, ROLE_IDS.TOOL_ANNOTATIONS_IG, ROLE_IDS.WG_IG_FACILITATORS, @@ -616,7 +616,7 @@ export const MEMBERS: readonly Member[] = [ ROLE_IDS.MAINTAINERS, ROLE_IDS.PYTHON_SDK, ROLE_IDS.SECURITY_MANAGERS, - ROLE_IDS.SECURITY_ROOM, + ROLE_IDS.SECURITY_TEAM, ROLE_IDS.TYPESCRIPT_SDK_COLLABORATORS, ], }, @@ -970,7 +970,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'yarolegovich', discord: '393296640141950977', - memberOf: [ROLE_IDS.GO_SDK, ROLE_IDS.SECURITY_ROOM], + memberOf: [ROLE_IDS.GO_SDK, ROLE_IDS.SECURITY_TEAM], }, { email: 'adamj@anthropic.com', From 73dd1097df9543dd7fabc01f2b052a765dc7be12 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:35:16 +0000 Subject: [PATCH 3/3] Name the Cloudflare token for role management, not as a generic API token The secret and Pulumi config key are dedicated to Access policy role management (Account > Access: Apps and Policies > Edit only), so name them CLOUDFLARE_ROLE_MANAGEMENT_TOKEN / cloudflare:roleManagementToken. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YBarRtzEtwT2HvfcEvRbW7 --- .github/workflows/deploy.yml | 8 ++++---- .github/workflows/preview.yml | 6 +++--- Pulumi.prod.yaml | 2 +- README.md | 6 +++--- src/cloudflare.ts | 15 +++++++++------ 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f098687a..667fee09 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,7 +66,7 @@ jobs: ORG_BILLING_EMAIL: ${{ secrets.ORG_BILLING_EMAIL }} DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ROLE_MANAGEMENT_TOKEN: ${{ secrets.CLOUDFLARE_ROLE_MANAGEMENT_TOKEN }} run: | echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt export PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt @@ -74,8 +74,8 @@ jobs: pulumi config set discord:guildId "$DISCORD_GUILD_ID" --stack prod pulumi config set discord:botToken "$DISCORD_BOT_TOKEN" --secret --stack prod pulumi config set githubBillingEmail "$ORG_BILLING_EMAIL" --secret --stack prod - # Cloudflare Access is optional until the CLOUDFLARE_API_TOKEN secret exists - if [ -n "$CLOUDFLARE_API_TOKEN" ]; then - pulumi config set cloudflare:apiToken "$CLOUDFLARE_API_TOKEN" --secret --stack prod + # Cloudflare Access is optional until the CLOUDFLARE_ROLE_MANAGEMENT_TOKEN secret exists + if [ -n "$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" ]; then + pulumi config set cloudflare:roleManagementToken "$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" --secret --stack prod fi make up diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 0978d3c2..2cb8d640 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -66,7 +66,7 @@ jobs: ORG_BILLING_EMAIL: ${{ secrets.ORG_BILLING_EMAIL }} DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ROLE_MANAGEMENT_TOKEN: ${{ secrets.CLOUDFLARE_ROLE_MANAGEMENT_TOKEN }} run: | echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt pulumi login gs://mcp-access-prod-pulumi-state @@ -82,8 +82,8 @@ jobs: if [ -n "$ORG_BILLING_EMAIL" ]; then CONFIG_FLAGS="$CONFIG_FLAGS --config githubBillingEmail=$ORG_BILLING_EMAIL" fi - if [ -n "$CLOUDFLARE_API_TOKEN" ]; then - CONFIG_FLAGS="$CONFIG_FLAGS --config cloudflare:apiToken=$CLOUDFLARE_API_TOKEN" + if [ -n "$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" ]; then + CONFIG_FLAGS="$CONFIG_FLAGS --config cloudflare:roleManagementToken=$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" fi # Run preview and capture output diff --git a/Pulumi.prod.yaml b/Pulumi.prod.yaml index 7fab501f..89243b59 100644 --- a/Pulumi.prod.yaml +++ b/Pulumi.prod.yaml @@ -5,7 +5,7 @@ config: secure: v1:Ntr2su0kgwp0oAh3:av1h5ir44/HNmtOQBftB6AD/0N7r+nLtIuPNnM47Xgyo71xO0GiV6oG81fT7dDhaxOwbBMWiEiOP+8s3XRiTJoAQHlqybvyHsgiprELsQfAPo2srnSDt289ggi11zPttYb/iNgOvoKwFxCfrapPv3Gnh0akbl3AgsJafs0uNToiNWmV176GaepR2JHEc7dpjIzpS6CFS8lVVl262ROInU85n1mEtaQ9eYZ69LPJn+ZQLSHAdMRROP1hv49Q26iqh4icJGmqbQQ6XGh4e5E0TgHT+/UPbuAzKnearRN+jFdqaJPQneXav9xy5W9zS4834LGa6Otawa12r5DlSIB5LunYCVDENhXcDhouwFHc7OK6bVUe2iY+557YfESZcznuYuHOq0ChPyKKJ7n5nE0Pu34nEJWMR3BARCLcGMuPIQALiecFMKDJjbVJN0ShS0nch4dDJpWH4KHNqyCs2q9h/Mume9pSu4PhZXg28VcBILh4a9B0JF2BzwqFy79OP0gmoz/B7IoIcjN28HnHrYmnK/97xrbTV7YzI2x5iYY9xwW3IlaKIrzr8f6vBAXTZmSFTOAqVWdsGgzalpqljDIqwJbh2YVQAy8NPLhfSxlkMJNPxfO0DR9AcL/kITQPL/f5WkRGU9+aur2K7gp2vkXUoObx8elnogIHpklPHRmb0obqoR8LTCD97Vt/rLL+ZaPgp2eBWwWydz1XAo78PVq53qGGbXk5lrv+i4qUxiUy8639ea4sdRJf2yGNWJb14hrpf5PBn7+bn2mSMdyY2jQIVbE0ltFYUVfPrzUNG51O39EwD9iFf/aR0lyxgO+fF8mMWOOTZleCPBMDY1t6c5u3TlipuSZF79q2LbuTnXGA8XmONiqoGyKDVIaYTNpOllFtuL105scGledqj9S2Q5bbgdRldlFXbFbGXWAYtj4A1z7gBRklb9UMJXbMyECRtfepbLu93XgKZaKjfXdRZkNtVNOjrE9KsGEUp0dkLUToKzj9tdDGxninCEUanhEV5ACTuXxGNqcvYoegYVRnGuLfCZTVMWwIezombduQDh7mEKkBRyw+XqQeRjCGHm+tZaAeo5p82cGX2bcxR2KselYfCVzP3n/yJsqp/D+STYd3bqq5Kno1f2obys2pyGhDri+scaZRZiWF7EysuYEuMC1D6EPkIo9ZEDfhgBU5RbJqC8h8DTTTLzsJa8r25L7qph/E7wVQywSLlL6e6GQ9bbepSikrNxXvftzkmEDNruN+5dVFznOEL5rTRa8Q98Dwet3i5c2PzWCzhz9/24rE0vrEOPO6NNgQ+du53wfG5uwkXEjAC4+w8nbXUrqe64y6o6AnRlo8YBwECV+qNfdpl0+6/JTP83L6aWo+u+QIiq4qpTF95SBNBt//SXk1KkYYgYNTo5Y/thjQ4ycHakHO4Mu/6Gz3+20uIOcSyhvO30Pk65scNcsTPHy1cT61cdK77fipJynVkn6rLKSWvqXMEeP6LwnioLx5WChyNAf+oubFMzlgS8zLW+kH8NxR4BIBpwbuyy+hpXXKZKNYKJtCzSUdBuV3PNZ0b0BaIEr6CyeqPtTPCfAoCxpvXipYJUEWLe6yPdorBQU0AYp3jMblrmqz/eAAPkiI5c7gTF6FAk0ezjzhvY3nVKPIvNi8Zj/5oxlt7p/kGjtUbMaQmGDrZco6TfDf0AtdERIDCUWnplKqLuz6NjIbZ7acAeyrBKyVslKNDn6tRZGk/4XrUFYgIEdWShOLZxAxI7o564n9+TycSIkz+LWYltQtRM1v5lnwNHSWXaXp7NUbhwWA5FlWPKBDOznhM0d6R5CRGfmtxE0KMQ3wac6QiODL3Zh5TJeKyFvXTh9hpSVfCafCMDtPWjzYj0X5G/DFCyNhIOf8Z8jfMBf2kh0AWI8ojZNS3c8ip1+elzLcGLDVfkLGF7rwvVDYXL/2cfamtfwR+CrljXlzT+FPRaMER8plhj6hN27UCWjILXVE7UqInNz/nkTS/YsZtVRaafCEXO1ytyV0gkXbKk00WFN6Qg3cK8HB0ITTYns8+NhstRTXewj9VJv/Kl7GV9Uz/DQX4KZW9rlJNd3XZjPYJRsHeRNXCnezTydyLpjrLYipbpYnuQcqBhE7S3nBiM+fTFrm8XJr7EBf4uIm8/9PSG52PMUiSsLHt+qMg36z+pliavLU5GXUw8RLONP2EnjI14vu8rz8BqlXhUuluWYRKeVNXprIG6pAkahqxhTmqwqYu1VIPDTPfuWJfQ9RCz5BYQEN4flTkuqGj0yYdl9CRxuL+m1BbPcZLufx+gY0gC5buiskzalfek3QhQLu2kH9P4zGuK/03jRVYlPf4IL0rt4l/e5iMV3bKB27Ds6W2An4NQB13WaujyINDiicJcRCyIQwsJt9aiLxf+NKc7N3iCLgjVw85K63WcDcW+zh8YOc89mJj/y3SOfTDAVCUjEzye9TldX8/7biavhsn4Bwdd71VkzxS/eSAJHeTXjw94FveP1d/A86LMBl/PThVEJmQMBZFjiMVw8x8xWG78NfqZDR2QeMU8meBhXrj4gwb3s2H9+H/6HcxoPB4O5kGbY4piQ3yu9MzPojVpQspdT2tgH+CAaNg3qHy3R/HHuWLxuXVGZj+2t3NoGvHrQRiS5e/9IwkQ2EA/3b20ZsBqP9F63oApr+PVnZYMbtRjJLdl02tIsMm4n5xoOI+0yP/93XSwD/rM+YXYu2PLiQRm5IkbDVu8Hg15OzIHSiGkTyPZb2/QBja10O9M/suKF7VZmS74skWZH3ETCNWyA5TpNwltafvou/1WtipHqNs45SXKJMr9iT3yI2jr/KGXlBDIih7mTiY5KN9mioAH1F5ROe0siEzBdaQe9soQsOBfFIZYzMBN/IKjuiqohG2DdDfsZqyYn0zO4GrqQ6Afv0fDY2fJJfbXCbn9i8saEI8CcSI2c2WTIxgQk+q6iOBBr0X/7WGzH7lmAHd4sDoIIi8F1QJyNlFsWj3r+5oe64jQa2XAkgVF3I6JENWpgSz6AJu7zQgnbg3ZIfF0kkwt+jRxEXsuLwdDO0XSxKBqv7Ynk0xDsV+5BkHzLv70fXh6BM8+LcK7kI8dgy8zJMLtctmAd9LmWcs7uWw8Di/EnZHZR5pDju0a+miq15yvHiJVjN6k5SoFRCVOQ== github:owner: modelcontextprotocol # Cloudflare Access (see README "Cloudflare Access (security-room)"). - # cloudflare:apiToken is set by the deploy workflow from the CLOUDFLARE_API_TOKEN secret. + # cloudflare:roleManagementToken is set by the deploy workflow from the CLOUDFLARE_ROLE_MANAGEMENT_TOKEN secret. cloudflare:accountId: c39d6379ccfdb20a3627eeddffa80ec9 cloudflare:githubIdentityProviderId: 1e53bc11-a504-4572-92d1-4c8d56fc198b # Adopt the existing dashboard-created policies on the first deploy. Set to "false" diff --git a/README.md b/README.md index 37f3f8f7..2f584496 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Once merged, Pulumi provisions the account. An admin will share your initial pas ### One-time setup -1. In the Cloudflare dashboard for the **MCP Domain Account**, create an API token with the permission **Account → Access: Apps and Policies → Edit** (scoped to that account only). -2. Add it as the GitHub Actions secret `CLOUDFLARE_API_TOKEN` in the `production` environment (repository settings → Environments → production). Until the secret exists, the Cloudflare module logs "Cloudflare integration disabled" and creates nothing, so previews stay green. +1. In the Cloudflare dashboard for the **MCP Domain Account**, create a token dedicated to Access policy role management. Give it a descriptive name so it does not read as a generic API token, e.g. `mcp-access: Access policy role management`, and scope it to only **Account → Access: Apps and Policies → Edit** on the MCP Domain Account. Do not reuse this token for anything else. +2. Add it as the GitHub Actions secret `CLOUDFLARE_ROLE_MANAGEMENT_TOKEN` in the `production` environment (repository settings → Environments → production). The deploy workflow passes it to Pulumi as `cloudflare:roleManagementToken`. Until the secret exists, the Cloudflare module logs "Cloudflare integration disabled: roleManagementToken not configured" and creates nothing, so previews stay green. 3. The account ID and GitHub identity-provider ID are non-secret and live in [`Pulumi.prod.yaml`](Pulumi.prod.yaml). 4. **Adopting the existing policy.** Pulumi's `import` resource option only succeeds when the program's inputs match the live resource, so adoption is two deploys: - With `cloudflare:importExistingPolicies: "true"` in `Pulumi.prod.yaml`, the first deploy imports the existing `Maintainers` policy (by its `cloudflarePolicyId`) as-is, ignoring its rule lists. @@ -113,7 +113,7 @@ The following secrets must be configured in GitHub Actions for automated deploym - Used to decrypt encrypted values in Pulumi stack configuration - Keep this secure - if lost, you cannot decrypt your Pulumi state -- **`CLOUDFLARE_API_TOKEN`** (optional, `production` environment): Cloudflare API token with **Account → Access: Apps and Policies → Edit** on the MCP Domain Account +- **`CLOUDFLARE_ROLE_MANAGEMENT_TOKEN`** (optional, `production` environment): Cloudflare token dedicated to Access policy role management, scoped only to **Account → Access: Apps and Policies → Edit** on the MCP Domain Account (not a general-purpose API token) - Used to manage the Cloudflare Access policy for `securityroom.modelcontextprotocol.io` (see [Cloudflare Access (security-room)](#cloudflare-access-security-room)) ## Initial Setup diff --git a/src/cloudflare.ts b/src/cloudflare.ts index 10812ee4..08c4c64e 100644 --- a/src/cloudflare.ts +++ b/src/cloudflare.ts @@ -3,13 +3,15 @@ import * as cloudflare from '@pulumi/cloudflare'; import { ACCESS_POLICIES, GITHUB_ORG, getAccessPolicyTeams } from './config/accessPolicies'; const config = new pulumi.Config('cloudflare'); -// Cloudflare integration is optional - only enabled if apiToken is configured, so -// previews stay green before the CLOUDFLARE_API_TOKEN secret exists. -const CLOUDFLARE_API_TOKEN = config.getSecret('apiToken'); -const CLOUDFLARE_ENABLED = CLOUDFLARE_API_TOKEN !== undefined; +// Cloudflare integration is optional - only enabled if roleManagementToken is configured, +// so previews stay green before the CLOUDFLARE_ROLE_MANAGEMENT_TOKEN secret exists. +// The token is scoped to Access policy role management only (Account > Access: Apps and +// Policies > Edit); it is not a general-purpose Cloudflare API token. +const CLOUDFLARE_ROLE_MANAGEMENT_TOKEN = config.getSecret('roleManagementToken'); +const CLOUDFLARE_ENABLED = CLOUDFLARE_ROLE_MANAGEMENT_TOKEN !== undefined; if (!CLOUDFLARE_ENABLED) { - pulumi.log.info('Cloudflare integration disabled: apiToken not configured'); + pulumi.log.info('Cloudflare integration disabled: roleManagementToken not configured'); } // Access policies keyed by policy id (accessPolicies.ts) @@ -26,7 +28,8 @@ if (CLOUDFLARE_ENABLED) { const importExisting = config.getBoolean('importExistingPolicies') ?? false; const provider = new cloudflare.Provider('cloudflare', { - apiToken: CLOUDFLARE_API_TOKEN, + // Provider argument name; the value is the role-management token above. + apiToken: CLOUDFLARE_ROLE_MANAGEMENT_TOKEN, }); ACCESS_POLICIES.forEach((policy) => {