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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu
## What This Manages

- **GitHub Teams**: Automatically syncs team memberships in the MCP GitHub organization
- **GitHub Repositories**: Creates and owns repositories declared with `settings` in [`src/config/repoAccess.ts`](src/config/repoAccess.ts), and syncs team and user access for every listed repository. See [Creating a new repository](#creating-a-new-repository-working-group-leads) below.
- **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`)
Expand All @@ -32,6 +33,34 @@ If you're a maintainer — explicitly or implicitly (SDK maintainers, working gr

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

### Creating a new repository (working group leads)

Org members cannot create repositories directly. Instead, open a PR adding an entry to [`src/config/repoAccess.ts`](src/config/repoAccess.ts) with a `settings` block plus the usual `teams`:

```ts
{
repository: 'ext-example',
settings: {
description: 'MCP Extension for Example. Maintained by the Example Working Group.',
// visibility: 'public' (default), homepage, topics, template are optional
},
teams: [
{ team: 'core-maintainers', permission: 'admin' },
{ team: 'moderators', permission: 'maintain' },
{ team: 'example-wg', permission: 'admin' },
],
},
```

The PR's `pulumi preview` comment shows the repository create. Once merged, the deploy creates the repository (with the baseline in `REPOSITORY_DEFAULTS`) and then grants the listed access, in one apply. Notes:

- Repository names are lowercase kebab-case (`ext-*` for extensions, `experimental-ext-*` while experimental).
- At least one team or user must have `admin` permission (validated), so a managed repository is never ownerless.
- Removing the entry **archives** the repository rather than deleting it; deletion stays a manual org-owner action.
- Entries without `settings` are access-only: the repository pre-dates this config and Pulumi manages only its collaborators. Adding `settings` to such an entry does not adopt the repository — the deploy fails with a name-already-exists error. Adopt it with `pulumi import` first; that is out of scope for the PR flow above.
- The `repository` key of a managed entry is also the Pulumi resource name. Renaming it in place archives the old repository and creates a new one: rename on GitHub first, then move the state (`pulumi state mv`) before changing the key.
- A repository archived by hand in GitHub stays archived (`archived` is ignored on refresh); un-archiving is a manual org-owner action.

## 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):
Expand Down
17 changes: 17 additions & 0 deletions scripts/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
NPM_DEFAULT_POLICY,
} from '../src/config/packageAccess';
import { ACCESS_POLICIES, getAccessPolicyTeams } from '../src/config/accessPolicies';
import { REPOSITORY_ACCESS, REPOSITORY_DEFAULTS } from '../src/config/repoAccess';

let passed = 0;
let failed = 0;
Expand Down Expand Up @@ -133,6 +134,22 @@ test('Some members in provisionUser roles have Google user fields', () => {
return membersInProvisionRoles.length > 0 && provisioned.length > 0;
});

// Test repository config
test('REPOSITORY_ACCESS has no duplicate repositories', () => {
const names = REPOSITORY_ACCESS.map((r) => r.repository);
return names.length > 0 && names.length === new Set(names).size;
});
test('All managed repositories (with settings) have an admin grant', () =>
REPOSITORY_ACCESS.filter((r) => r.settings).every(
(r) =>
r.teams?.some((t) => t.permission === 'admin') ||
r.users?.some((u) => u.permission === 'admin')
));
test('All managed repositories have a non-empty description', () =>
REPOSITORY_ACCESS.filter((r) => r.settings).every((r) => !!r.settings!.description.trim()));
test('REPOSITORY_DEFAULTS archives instead of deleting on destroy', () =>
REPOSITORY_DEFAULTS.archiveOnDestroy === true);

// Test package registry access config
test('NPM_ORG is modelcontextprotocol', () => NPM_ORG === 'modelcontextprotocol');
test('NPM_PACKAGES is not empty and all packages are org-scoped', () =>
Expand Down
50 changes: 50 additions & 0 deletions scripts/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,56 @@ for (const repo of REPOSITORY_ACCESS) {
}
}

// Validate repository entries in REPOSITORY_ACCESS
console.log('Validating repository entries in repoAccess.ts...');
{
const repositoryNames = new Set<string>();
for (const repo of REPOSITORY_ACCESS) {
if (repositoryNames.has(repo.repository)) {
console.error(`ERROR: Repository "${repo.repository}" is declared twice in repoAccess.ts`);
hasErrors = true;
}
repositoryNames.add(repo.repository);
}

// Managed repositories (entries with `settings`) are created by Pulumi, so
// their inputs must be valid before the deploy tries to create them.
for (const repo of REPOSITORY_ACCESS) {
if (!repo.settings) continue;

if (!/^[a-z0-9][a-z0-9._-]*$/.test(repo.repository)) {
console.error(
`ERROR: Managed repository "${repo.repository}" has an invalid name; use lowercase letters, digits, '.', '_' and '-'`
);
hasErrors = true;
}

if (!repo.settings.description.trim()) {
console.error(`ERROR: Managed repository "${repo.repository}" has an empty description`);
hasErrors = true;
}

const hasAdmin =
(repo.teams?.some((t) => t.permission === 'admin') ?? false) ||
(repo.users?.some((u) => u.permission === 'admin') ?? false);
if (!hasAdmin) {
console.error(
`ERROR: Managed repository "${repo.repository}" grants no team or user admin permission; ` +
`a managed repository must have an admin so it is never ownerless`
);
hasErrors = true;
}

if (repo.settings.template && !repositoryNames.has(repo.settings.template)) {
console.error(
`ERROR: Managed repository "${repo.repository}" uses template "${repo.settings.template}" ` +
`which is not declared in repoAccess.ts; templates must be known org repositories`
);
hasErrors = true;
}
}
}

// Validate role references in MEMBERS (memberOf)
console.log('Validating role references in users.ts...');
for (const member of MEMBERS) {
Expand Down
75 changes: 71 additions & 4 deletions src/config/repoAccess.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,85 @@
// Repository access configuration
// Each repository lists all teams and users that should have access and their permission level
// Repository configuration
// Each entry lists all teams and users that should have access to a repository
// and their permission level. There are two kinds of entries:
//
// - Access-only: the repository pre-dates this config. Pulumi manages only its
// collaborators; the repository itself is untouched.
// - Managed: the entry has `settings`. Pulumi creates the repository (if it does
// not exist yet), keeps those settings in sync, and then manages its
// collaborators — so a new repository and its access land in one deploy.
//
// To create a new repository (e.g. for a working group), add a managed entry.
// See README.md "Creating a new repository".

export type RepositoryPermission = 'pull' | 'triage' | 'push' | 'maintain' | 'admin';

/**
* Settings for a repository that Pulumi creates and owns. Declaring this on an
* entry makes the deploy create the repository (if it does not exist yet) and
* keep these fields in sync afterwards. Removing the entry ARCHIVES the
* repository rather than deleting it (archiveOnDestroy); actual deletion stays
* a manual org-owner action.
*/
export interface RepositorySettings {
/** Shown on the repository page and in the org listing. Required. */
description: string;
/** Defaults to 'public'. */
visibility?: 'public' | 'private';
homepage?: string;
topics?: readonly string[];
/** Create from a template repository in the org (applied at creation only). */
template?: string;
}

export interface RepositoryAccess {
repository: string;
/**
* Declare to have Pulumi create and own the repository. Omit for repositories
* that pre-date this config (access-only). Adding `settings` to a repository
* that already exists does not adopt it: the deploy fails with a
* name-already-exists error. Adopt it with `pulumi import` first. The
* `repository` key of a managed entry is also the Pulumi resource name, so
* renaming it in place archives the old repository and creates a new one:
* rename on GitHub first, then move the state (`pulumi state mv`) before
* changing the key.
*/
settings?: RepositorySettings;
teams?: Array<{
team: string; // Team slug
permission: 'pull' | 'triage' | 'push' | 'maintain' | 'admin';
permission: RepositoryPermission;
}>;
users?: Array<{
username: string; // GitHub username
permission: 'pull' | 'triage' | 'push' | 'maintain' | 'admin';
permission: RepositoryPermission;
}>;
}

/**
* Baseline applied to every repository Pulumi creates. Mirrors how the org's
* existing extension repositories (ext-*, experimental-ext-*) are configured;
* per-repo fields come from RepositorySettings. Security features (Dependabot,
* secret scanning, ...) come from orgSettings.ts "*EnabledForNewRepositories"
* and are not repeated here.
*/
export const REPOSITORY_DEFAULTS = {
hasIssues: true,
hasProjects: false,
hasWiki: false,
hasDiscussions: false,
allowSquashMerge: true,
allowMergeCommit: false,
allowRebaseMerge: false,
allowUpdateBranch: true,
deleteBranchOnMerge: true,
squashMergeCommitTitle: 'PR_TITLE',
squashMergeCommitMessage: 'PR_BODY',
vulnerabilityAlerts: true,
autoInit: true,
licenseTemplate: 'apache-2.0',
// Removing a managed entry archives the repository instead of deleting it.
archiveOnDestroy: true,
} as const;

export const REPOSITORY_ACCESS: RepositoryAccess[] = [
{
repository: 'docs',
Expand Down
40 changes: 36 additions & 4 deletions src/github.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as pulumi from '@pulumi/pulumi';
import * as github from '@pulumi/github';
import { ROLES, type Role, buildRoleLookup } from './config/roles';
import { REPOSITORY_ACCESS } from './config/repoAccess';
import { REPOSITORY_ACCESS, REPOSITORY_DEFAULTS } from './config/repoAccess';
import { GITHUB_ORG } from './config/accessPolicies';
import { ORG_ROLE_ASSIGNMENTS } from './config/orgRoles';
import { ORG_SETTINGS } from './config/orgSettings';
import { MEMBERS } from './config/users';
Expand Down Expand Up @@ -97,11 +98,42 @@ ORG_ROLE_ASSIGNMENTS.forEach((assignment) => {
// @pulumi/github release that includes that fix.
const orgRoleTeamNames = [...new Set(ORG_ROLE_ASSIGNMENTS.map((a) => a.team))];

// Configure repository access
// Repositories. An entry with `settings` is created and owned by Pulumi; the
// collaborators resource then depends on it through the name output, so a new
// repository and its access land in the same apply. Entries without `settings`
// pre-date this config: Pulumi manages only their collaborators.
const repositories: Record<string, github.Repository> = {};
REPOSITORY_ACCESS.forEach((repo) => {
let repositoryName: pulumi.Input<string> = repo.repository;
if (repo.settings) {
const repository = new github.Repository(
`repository-${repo.repository}`,
{
...REPOSITORY_DEFAULTS,
name: repo.repository,
description: repo.settings.description,
visibility: repo.settings.visibility ?? 'public',
homepageUrl: repo.settings.homepage,
topics: repo.settings.topics ? [...repo.settings.topics] : undefined,
template: repo.settings.template
? { owner: GITHUB_ORG, repository: repo.settings.template }
: undefined,
},
{
// A repository archived by hand in GitHub must stay archived: without
// this, the deploy's `pulumi up --refresh` would plan archived: true ->
// false and un-archive it. Archiving through this config still works
// (remove the entry; archiveOnDestroy archives instead of deleting).
ignoreChanges: ['archived'],
}
);
repositories[repo.repository] = repository;
repositoryName = repository.name;
}

const grantedTeams = new Set(repo.teams?.map((t) => t.team));
new github.RepositoryCollaborators(`repo-${repo.repository}`, {
repository: repo.repository,
repository: repositoryName,
// Ignore org-role teams, except where repoAccess.ts grants them directly on
// this repository (e.g. lead-maintainers on maintainer-docs) — those grants
// must stay managed by Pulumi.
Expand All @@ -119,4 +151,4 @@ REPOSITORY_ACCESS.forEach((repo) => {
});
});

export { teams as githubTeams };
export { teams as githubTeams, repositories as githubRepositories };
Loading