diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e93fe79..667fee0 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_ROLE_MANAGEMENT_TOKEN: ${{ secrets.CLOUDFLARE_ROLE_MANAGEMENT_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_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 01e21a1..2cb8d64 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_ROLE_MANAGEMENT_TOKEN: ${{ secrets.CLOUDFLARE_ROLE_MANAGEMENT_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_ROLE_MANAGEMENT_TOKEN" ]; then + CONFIG_FLAGS="$CONFIG_FLAGS --config cloudflare:roleManagementToken=$CLOUDFLARE_ROLE_MANAGEMENT_TOKEN" + fi # Run preview and capture output set +e diff --git a/Pulumi.prod.yaml b/Pulumi.prod.yaml index eb4ad25..89243b5 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: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" + # 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 a8d219f..2f58449 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 `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 + +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. + - 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_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 If setting up this infrastructure for the first time: diff --git a/package-lock.json b/package-lock.json index 6808e04..0c3fb00 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 7f0c50d..2f73f4d 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 6aa6b99..c3320e7 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_TEAM role exists (GitHub-only, no parent team)', () => { + const role = roleLookup.get(ROLE_IDS.SECURITY_TEAM); + return ( + role !== undefined && + role.github?.team === 'security-team' && + 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-team']; + return teams.length === expected.length && teams.every((t, i) => t === expected[i]); +}); +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({ + 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 b23f1ad..4b64fb6 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 0000000..08c4c64 --- /dev/null +++ b/src/cloudflare.ts @@ -0,0 +1,73 @@ +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 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: roleManagementToken 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', { + // Provider argument name; the value is the role-management token above. + apiToken: CLOUDFLARE_ROLE_MANAGEMENT_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 0000000..0585a31 --- /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-team` 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, + // MCP Security Team: per-SDK security leads, added to this team directly in users.ts + ROLE_IDS.SECURITY_TEAM, + ], + }, +]; + +/** + * 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 07d3aa8..e21f02e 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_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 5a430b1..942646f 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -116,6 +116,16 @@ 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_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 + }, // =================== // Maintainer Groups diff --git a/src/config/users.ts b/src/config/users.ts index 142c6e1..7bb0466 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_TEAM], }, { 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_TEAM, 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_TEAM], }, { 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_TEAM], }, { 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_TEAM, + 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_TEAM], }, { github: 'koic', discord: '880937364208361483', - memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.RUBY_SDK], + memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.RUBY_SDK, ROLE_IDS.SECURITY_TEAM], }, { 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_TEAM, + 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_TEAM, 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_TEAM], }, { email: 'adamj@anthropic.com', diff --git a/src/index.ts b/src/index.ts index c72d079..c059af7 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';