Skip to content

Commit 542e95c

Browse files
claude[bot]claude
andauthored
Manage the security-room Cloudflare Access policy from config via a security-team GitHub team (#195)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBarRtzEtwT2HvfcEvRbW7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBarRtzEtwT2HvfcEvRbW7 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBarRtzEtwT2HvfcEvRbW7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8736ec commit 542e95c

14 files changed

Lines changed: 310 additions & 8 deletions

‎.github/workflows/deploy.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,16 @@ jobs:
6666
ORG_BILLING_EMAIL: ${{ secrets.ORG_BILLING_EMAIL }}
6767
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
6868
DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }}
69+
CLOUDFLARE_ROLE_MANAGEMENT_TOKEN: ${{ secrets.CLOUDFLARE_ROLE_MANAGEMENT_TOKEN }}
6970
run: |
7071
echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt
7172
export PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt
7273
pulumi login gs://mcp-access-prod-pulumi-state
7374
pulumi config set discord:guildId "$DISCORD_GUILD_ID" --stack prod
7475
pulumi config set discord:botToken "$DISCORD_BOT_TOKEN" --secret --stack prod
7576
pulumi config set githubBillingEmail "$ORG_BILLING_EMAIL" --secret --stack prod
77+
# Cloudflare Access is optional until the CLOUDFLARE_ROLE_MANAGEMENT_TOKEN secret exists
78+
if [ -n "$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" ]; then
79+
pulumi config set cloudflare:roleManagementToken "$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" --secret --stack prod
80+
fi
7681
make up

‎.github/workflows/preview.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jobs:
6666
ORG_BILLING_EMAIL: ${{ secrets.ORG_BILLING_EMAIL }}
6767
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
6868
DISCORD_GUILD_ID: ${{ secrets.DISCORD_GUILD_ID }}
69+
CLOUDFLARE_ROLE_MANAGEMENT_TOKEN: ${{ secrets.CLOUDFLARE_ROLE_MANAGEMENT_TOKEN }}
6970
run: |
7071
echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt
7172
pulumi login gs://mcp-access-prod-pulumi-state
@@ -81,6 +82,9 @@ jobs:
8182
if [ -n "$ORG_BILLING_EMAIL" ]; then
8283
CONFIG_FLAGS="$CONFIG_FLAGS --config githubBillingEmail=$ORG_BILLING_EMAIL"
8384
fi
85+
if [ -n "$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" ]; then
86+
CONFIG_FLAGS="$CONFIG_FLAGS --config cloudflare:roleManagementToken=$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN"
87+
fi
8488
8589
# Run preview and capture output
8690
set +e

‎Pulumi.prod.yaml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@ config:
44
googleworkspace:credentials:
55
secure: v1:Ntr2su0kgwp0oAh3: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
66
github:owner: modelcontextprotocol
7+
# Cloudflare Access (see README "Cloudflare Access (security-room)").
8+
# cloudflare:roleManagementToken is set by the deploy workflow from the CLOUDFLARE_ROLE_MANAGEMENT_TOKEN secret.
9+
cloudflare:accountId: c39d6379ccfdb20a3627eeddffa80ec9
10+
cloudflare:githubIdentityProviderId: 1e53bc11-a504-4572-92d1-4c8d56fc198b
11+
# Adopt the existing dashboard-created policies on the first deploy. Set to "false"
12+
# in a follow-up PR once the deploy that imported them has succeeded.
13+
cloudflare:importExistingPolicies: "true"

‎README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu
1212
- **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts
1313
- **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security.
1414
- **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`)
15+
- **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.
1516
- **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.
1617

1718
### Opting in to a Google Workspace account (maintainers)
@@ -31,6 +32,24 @@ If you're a maintainer — explicitly or implicitly (SDK maintainers, working gr
3132

3233
Once merged, Pulumi provisions the account. An admin will share your initial password (retrievable via `pulumi stack output --show-secrets newGWSUserPasswords`).
3334

35+
## Cloudflare Access (security-room)
36+
37+
[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):
38+
39+
- 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.
40+
- **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`.
41+
- **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).
42+
- 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.
43+
44+
### One-time setup
45+
46+
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.
47+
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.
48+
3. The account ID and GitHub identity-provider ID are non-secret and live in [`Pulumi.prod.yaml`](Pulumi.prod.yaml).
49+
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:
50+
- 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.
51+
- 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.
52+
3453
## npm & PyPI Package Publishing Access
3554

3655
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
94113
- Used to decrypt encrypted values in Pulumi stack configuration
95114
- Keep this secure - if lost, you cannot decrypt your Pulumi state
96115

116+
- **`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)
117+
- Used to manage the Cloudflare Access policy for `securityroom.modelcontextprotocol.io` (see [Cloudflare Access (security-room)](#cloudflare-access-security-room))
118+
97119
## Initial Setup
98120

99121
If setting up this infrastructure for the first time:

‎package-lock.json‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"license": "MIT",
1616
"dependencies": {
17+
"@pulumi/cloudflare": "^6.21.0",
1718
"@pulumi/github": "^6.11.0",
1819
"@pulumi/googleworkspace": "file:sdks/googleworkspace",
1920
"@pulumi/pulumi": "^3.218.0",

‎scripts/test-config.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getNpmPackageAccess,
1818
NPM_DEFAULT_POLICY,
1919
} from '../src/config/packageAccess';
20+
import { ACCESS_POLICIES, getAccessPolicyTeams } from '../src/config/accessPolicies';
2021

2122
let passed = 0;
2223
let failed = 0;
@@ -172,6 +173,49 @@ test('PyPI project names are unique', () => {
172173
return names.length === new Set(names).size;
173174
});
174175

176+
// Test Cloudflare Access policy config
177+
test('SECURITY_TEAM role exists (GitHub-only, no parent team)', () => {
178+
const role = roleLookup.get(ROLE_IDS.SECURITY_TEAM);
179+
return (
180+
role !== undefined &&
181+
role.github?.team === 'security-team' &&
182+
role.github.parent === undefined &&
183+
role.discord === undefined &&
184+
role.google === undefined &&
185+
!role.provisionUser
186+
);
187+
});
188+
test('ACCESS_POLICIES is not empty with unique ids', () => {
189+
const ids = ACCESS_POLICIES.map((p) => p.id);
190+
return ids.length > 0 && ids.length === new Set(ids).size;
191+
});
192+
test('All access policy roles exist and have GitHub teams', () =>
193+
ACCESS_POLICIES.every((p) => p.roles.every((id) => !!roleLookup.get(id)?.github?.team)));
194+
test('Access policy roles are unique within each policy', () =>
195+
ACCESS_POLICIES.every((p) => p.roles.length === new Set(p.roles).size));
196+
test('security-room policy renders the expected include-rule teams in order', () => {
197+
const policy = ACCESS_POLICIES.find((p) => p.id === 'security-room-maintainers');
198+
if (!policy) return false;
199+
const teams = getAccessPolicyTeams(policy);
200+
const expected = ['core-maintainers', 'lead-maintainers', 'security-managers', 'security-team'];
201+
return teams.length === expected.length && teams.every((t, i) => t === expected[i]);
202+
});
203+
test('security-team team has members', () =>
204+
MEMBERS.some((m) => m.github && m.memberOf.includes(ROLE_IDS.SECURITY_TEAM)));
205+
test('getAccessPolicyTeams throws for a role without a GitHub team', () => {
206+
try {
207+
getAccessPolicyTeams({
208+
id: 'bogus',
209+
description: '',
210+
cloudflarePolicyName: 'x',
211+
roles: [ROLE_IDS.ADMINISTRATORS],
212+
});
213+
return false;
214+
} catch {
215+
return true;
216+
}
217+
});
218+
175219
// Summary
176220
console.log(`\n${passed} passed, ${failed} failed`);
177221
process.exit(failed > 0 ? 1 : 0);

‎scripts/validate-config.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
UNMAPPED_PYPI_USERS,
1515
} from '../src/config/packageAccess';
1616
import { MEMBERS } from '../src/config/users';
17+
import { ACCESS_POLICIES } from '../src/config/accessPolicies';
1718
import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils';
1819
import type { RoleId } from '../src/config/roleIds';
1920

@@ -304,6 +305,47 @@ console.log('Validating package registry references in packageAccess.ts...');
304305
}
305306
}
306307

308+
// Validate Cloudflare Access policies in accessPolicies.ts
309+
console.log('Validating Cloudflare Access policies in accessPolicies.ts...');
310+
{
311+
const policyIds = new Set<string>();
312+
for (const policy of ACCESS_POLICIES) {
313+
if (policyIds.has(policy.id)) {
314+
console.error(`ERROR: Access policy "${policy.id}" is declared twice in accessPolicies.ts`);
315+
hasErrors = true;
316+
}
317+
policyIds.add(policy.id);
318+
319+
if (policy.roles.length === 0) {
320+
console.error(`ERROR: Access policy "${policy.id}" has no roles; nobody could sign in`);
321+
hasErrors = true;
322+
}
323+
324+
const seenRoles = new Set<RoleId>();
325+
for (const roleId of policy.roles) {
326+
if (seenRoles.has(roleId)) {
327+
console.error(`ERROR: Access policy "${policy.id}" lists role "${roleId}" more than once`);
328+
hasErrors = true;
329+
}
330+
seenRoles.add(roleId);
331+
332+
const role = roleLookup.get(roleId);
333+
if (!role) {
334+
console.error(
335+
`ERROR: Access policy "${policy.id}" references role "${roleId}" which does not exist in roles.ts`
336+
);
337+
hasErrors = true;
338+
} else if (!role.github) {
339+
// Cloudflare Access matches GitHub teams, so a role without one cannot be granted
340+
console.error(
341+
`ERROR: Access policy "${policy.id}" references role "${roleId}" which has no GitHub team`
342+
);
343+
hasErrors = true;
344+
}
345+
}
346+
}
347+
}
348+
307349
// Validate parent role references in roles.ts
308350
console.log('Validating parent role references in roles.ts...');
309351
for (const role of ROLES) {

‎src/cloudflare.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as pulumi from '@pulumi/pulumi';
2+
import * as cloudflare from '@pulumi/cloudflare';
3+
import { ACCESS_POLICIES, GITHUB_ORG, getAccessPolicyTeams } from './config/accessPolicies';
4+
5+
const config = new pulumi.Config('cloudflare');
6+
// Cloudflare integration is optional - only enabled if roleManagementToken is configured,
7+
// so previews stay green before the CLOUDFLARE_ROLE_MANAGEMENT_TOKEN secret exists.
8+
// The token is scoped to Access policy role management only (Account > Access: Apps and
9+
// Policies > Edit); it is not a general-purpose Cloudflare API token.
10+
const CLOUDFLARE_ROLE_MANAGEMENT_TOKEN = config.getSecret('roleManagementToken');
11+
const CLOUDFLARE_ENABLED = CLOUDFLARE_ROLE_MANAGEMENT_TOKEN !== undefined;
12+
13+
if (!CLOUDFLARE_ENABLED) {
14+
pulumi.log.info('Cloudflare integration disabled: roleManagementToken not configured');
15+
}
16+
17+
// Access policies keyed by policy id (accessPolicies.ts)
18+
const accessPolicies: Record<string, cloudflare.ZeroTrustAccessPolicy> = {};
19+
20+
if (CLOUDFLARE_ENABLED) {
21+
const accountId = config.require('accountId');
22+
const githubIdentityProviderId = config.require('githubIdentityProviderId');
23+
// Pulumi's `import` option requires the program's inputs to match the live
24+
// resource, so adopting an existing policy is a two-step process:
25+
// 1. importExistingPolicies=true: adopt the policy as-is (rule fields ignored)
26+
// 2. importExistingPolicies=false (or unset): manage the rules from config
27+
// See README "Cloudflare Access (security-room)".
28+
const importExisting = config.getBoolean('importExistingPolicies') ?? false;
29+
30+
const provider = new cloudflare.Provider('cloudflare', {
31+
// Provider argument name; the value is the role-management token above.
32+
apiToken: CLOUDFLARE_ROLE_MANAGEMENT_TOKEN,
33+
});
34+
35+
ACCESS_POLICIES.forEach((policy) => {
36+
const teams = getAccessPolicyTeams(policy);
37+
const importId =
38+
importExisting && policy.cloudflarePolicyId
39+
? `${accountId}/${policy.cloudflarePolicyId}`
40+
: undefined;
41+
42+
accessPolicies[policy.id] = new cloudflare.ZeroTrustAccessPolicy(
43+
`access-policy-${policy.id}`,
44+
{
45+
accountId,
46+
name: policy.cloudflarePolicyName,
47+
decision: 'allow',
48+
includes: teams.map((team) => ({
49+
githubOrganization: {
50+
identityProviderId: githubIdentityProviderId,
51+
name: GITHUB_ORG,
52+
team,
53+
},
54+
})),
55+
},
56+
{
57+
provider,
58+
import: importId,
59+
// connectionRules and sessionDuration are set by the Cloudflare API with
60+
// defaults we do not model. During import, also keep the live rule lists so
61+
// the adoption succeeds; the next deploy without the flag applies the config.
62+
ignoreChanges: importId
63+
? ['connectionRules', 'sessionDuration', 'includes', 'excludes', 'requires']
64+
: ['connectionRules', 'sessionDuration'],
65+
}
66+
);
67+
});
68+
}
69+
70+
export const cloudflareAccessPolicyIds = Object.fromEntries(
71+
Object.entries(accessPolicies).map(([id, policy]) => [id, policy.id])
72+
);
73+
export { accessPolicies as cloudflareAccessPolicies };

0 commit comments

Comments
 (0)