feat: provision GWS accounts for implicit maintainers (SDK teams, WGs)

GWS user accounts were only created for members whose direct roles had
google.provisionUser (lead/core maintainers, moderators, maintainers,
registry-wg). SDK maintainers and working-group members who opted in by
adding firstName/lastName/googleEmailPrefix (e.g. #148, #150, #152)
silently got no account.

- Move provisionUser from GoogleConfig to the Role level, and add it to
  the implicit-maintainer containers (sdk-maintainers, working-groups)
  plus the standalone maintainer roles (docs, inspector, mcpb,
  reference-servers, use-mcp, server-identity-wg, wg/ig facilitators).
- Eligibility now walks github.parent chains (hasProvisionUserRole),
  matching GitHub team-nesting semantics, so e.g. python-sdk members
  qualify via sdk-maintainers.
- validate-config now errors when a googleEmailPrefix would be a silent
  no-op (no eligible role and not an existing GWS user), so opt-in PRs
  like #150 fail CI instead of quietly doing nothing.

Newly provisioned on next apply: marcelo@, alexhancock@, liad@.
ochafik is now eligible but already has an account (existingGWSUser).
This commit is contained in:
David Soria Parra 2026-07-27 10:17:46 +01:00
commit 8eb21f43ff
No known key found for this signature in database
6 changed files with 81 additions and 27 deletions

View file

@ -11,11 +11,11 @@ Infrastructure as Code for managing access to MCP community resources using Pulu
- **GitHub Teams**: Automatically syncs team memberships in the MCP GitHub organization
- **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`
- **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`)
### Opting in to a Google Workspace account (maintainers)
If you're a maintainer and want an `@modelcontextprotocol.io` account, open a PR adding the following fields to your entry in [`src/config/users.ts`](src/config/users.ts):
If you're a maintainer — explicitly or implicitly (SDK maintainers, working group members, etc.) — and want an `@modelcontextprotocol.io` account, open a PR adding the following fields to your entry in [`src/config/users.ts`](src/config/users.ts):
```ts
{

View file

@ -8,6 +8,7 @@
import { ROLES, buildRoleLookup, getRolesForPlatform } from '../src/config/roles';
import { ROLE_IDS, isValidRoleId } from '../src/config/roleIds';
import { MEMBERS } from '../src/config/users';
import { hasProvisionUserRole } from '../src/config/utils';
let passed = 0;
let failed = 0;
@ -84,7 +85,7 @@ test('TYPESCRIPT_SDK_AUTH role exists (GitHub-only)', () => {
// Test Google Workspace user provisioning
test('Roles with provisionUser exist', () => {
const provisionRoles = ROLES.filter((r) => r.google?.provisionUser);
const provisionRoles = ROLES.filter((r) => r.provisionUser);
return provisionRoles.length > 0;
});
@ -100,9 +101,8 @@ test('googleEmailPrefix values are unique', () => {
});
test('skipGoogleUserProvisioning is only used in provisionUser roles and without fields', () => {
const provisionRoleIds = new Set(ROLES.filter((r) => r.google?.provisionUser).map((r) => r.id));
return MEMBERS.every((member) => {
const inProvisionRole = member.memberOf.some((id) => provisionRoleIds.has(id));
const inProvisionRole = hasProvisionUserRole(member.memberOf, roleLookup);
if (!inProvisionRole) return !member.skipGoogleUserProvisioning;
const hasProvisioningFields = !!(
@ -116,10 +116,7 @@ test('skipGoogleUserProvisioning is only used in provisionUser roles and without
test('Some members in provisionUser roles have Google user fields', () => {
const membersInProvisionRoles = MEMBERS.filter((m) =>
m.memberOf.some((id) => {
const role = roleLookup.get(id);
return role?.google?.provisionUser === true;
})
hasProvisionUserRole(m.memberOf, roleLookup)
);
const provisioned = membersInProvisionRoles.filter(
(m) => m.firstName && m.lastName && m.googleEmailPrefix

View file

@ -8,7 +8,7 @@
import { ROLES, buildRoleLookup } from '../src/config/roles';
import { REPOSITORY_ACCESS } from '../src/config/repoAccess';
import { MEMBERS } from '../src/config/users';
import { resolveGoogleMemberEmail } from '../src/config/utils';
import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils';
import type { RoleId } from '../src/config/roleIds';
const roleLookup = buildRoleLookup();
@ -131,10 +131,7 @@ console.log('Validating Google Workspace user provisioning fields...');
}
// Members in provisionUser roles without all three fields won't get a GWS account
const inProvisionUserRole = member.memberOf.some((roleId: RoleId) => {
const role = roleLookup.get(roleId);
return role?.google?.provisionUser === true;
});
const inProvisionUserRole = hasProvisionUserRole(member.memberOf, roleLookup);
const hasProvisioningFields = !!(
member.googleEmailPrefix &&
@ -142,6 +139,20 @@ console.log('Validating Google Workspace user provisioning fields...');
member.lastName
);
// A googleEmailPrefix on a non-eligible member is a silent no-op in
// src/google.ts — no account gets created and nothing reports it.
// Fail loudly instead (members whose account already exists in GWS are
// exempt; those accounts are not managed by Pulumi).
if (member.googleEmailPrefix && !inProvisionUserRole && !member.existingGWSUser) {
console.error(
`ERROR: Member "${memberId}" has googleEmailPrefix but none of their roles ` +
`(or parent roles) has provisionUser, so no GWS account would be created. ` +
`Add provisionUser: true to an appropriate role in src/config/roles.ts or ` +
`remove the provisioning fields.`
);
hasErrors = true;
}
if (member.skipGoogleUserProvisioning && !inProvisionUserRole) {
console.error(
`ERROR: Member "${memberId}" has skipGoogleUserProvisioning=true but is not in a provisionUser role`

View file

@ -26,8 +26,6 @@ export interface GoogleConfig {
group: string;
/** If true, accepts emails from anyone including external users */
isEmailGroup?: boolean;
/** If true, members of this role get a Google Workspace user account */
provisionUser?: boolean;
/**
* Opt-in for groups that intentionally include non-@modelcontextprotocol.io
* members. Defaults to false. Must stay declared on the GroupSettings
@ -47,6 +45,13 @@ export interface Role {
github?: GitHubConfig;
discord?: DiscordConfig;
google?: GoogleConfig;
/**
* If true, members of this role — directly, or via a role that nests under
* it through github.parent (child team members are members of the parent
* team on GitHub) — may opt in to a Google Workspace user account by adding
* firstName/lastName/googleEmailPrefix to their entry in users.ts.
*/
provisionUser?: boolean;
/**
* Roles that are implied for Discord membership.
* If a user has this role, they automatically get the implied roles' Discord roles too.
@ -86,21 +91,24 @@ export const ROLES: readonly Role[] = [
description: 'Lead core maintainers',
github: { team: 'lead-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'lead maintainers (synced)' },
google: { group: 'lead-maintainers', provisionUser: true },
google: { group: 'lead-maintainers' },
provisionUser: true,
},
{
id: ROLE_IDS.CORE_MAINTAINERS,
description: 'Core maintainers',
github: { team: 'core-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'core maintainers (synced)' },
google: { group: 'core-maintainers', provisionUser: true },
google: { group: 'core-maintainers' },
provisionUser: true,
},
{
id: ROLE_IDS.MODERATORS,
description: 'Community moderators',
github: { team: 'moderators', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'community moderators (synced)' },
google: { group: 'moderators', provisionUser: true },
google: { group: 'moderators' },
provisionUser: true,
},
{
id: ROLE_IDS.SECURITY_MANAGERS,
@ -118,38 +126,44 @@ export const ROLES: readonly Role[] = [
discord: { role: 'maintainers (synced)' },
// GWS user accounts are opt-in: maintainers add firstName/lastName/googleEmailPrefix
// to their entry in users.ts via PR to get an @modelcontextprotocol.io account
google: { group: 'maintainers', provisionUser: true, allowExternalMembers: true },
google: { group: 'maintainers', allowExternalMembers: true },
provisionUser: true,
},
{
id: ROLE_IDS.DOCS_MAINTAINERS,
description: 'MCP docs maintainers',
github: { team: 'docs-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
// No discord role for docs maintainers
provisionUser: true,
},
{
id: ROLE_IDS.INSPECTOR_MAINTAINERS,
description: 'MCP Inspector maintainers',
github: { team: 'inspector-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'inspector maintainers (synced)' },
provisionUser: true,
},
{
id: ROLE_IDS.MCPB_MAINTAINERS,
description: 'MCPB (Model Context Protocol Bundle) maintainers',
github: { team: 'mcpb-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
// No discord role
provisionUser: true,
},
{
id: ROLE_IDS.REFERENCE_SERVERS_MAINTAINERS,
description: 'Reference servers maintainers',
github: { team: 'reference-servers-maintainers' },
discord: { role: 'reference servers maintainers (synced)' },
provisionUser: true,
},
{
id: ROLE_IDS.REGISTRY_MAINTAINERS,
description: 'Official registry builders and maintainers',
github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS },
discord: { role: 'registry maintainers (synced)' },
google: { group: 'registry-wg', provisionUser: true, allowExternalMembers: true },
google: { group: 'registry-wg', allowExternalMembers: true },
provisionUser: true,
},
{
id: ROLE_IDS.REGISTRY_COLLABORATORS,
@ -161,6 +175,7 @@ export const ROLES: readonly Role[] = [
description: 'use-mcp maintainers',
discord: { role: 'use-mcp maintainers (synced)' },
// Discord only
provisionUser: true,
},
// ===================
@ -172,6 +187,8 @@ export const ROLES: readonly Role[] = [
github: { team: 'sdk-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'sdk maintainers (synced)' },
discordImplies: [ROLE_IDS.MAINTAINERS], // SDK maintainers are also general maintainers
// Covers all SDK teams via github.parent (e.g. python-sdk, rust-sdk)
provisionUser: true,
},
{
id: ROLE_IDS.CSHARP_SDK,
@ -272,6 +289,8 @@ export const ROLES: readonly Role[] = [
description: 'MCP Working Groups',
github: { team: 'working-groups', parent: ROLE_IDS.STEERING_COMMITTEE },
// No discord - organizational container
// Covers all working groups via github.parent (e.g. transport-wg, agents-wg)
provisionUser: true,
},
{
id: ROLE_IDS.AUTH_MAINTAINERS,
@ -289,7 +308,8 @@ export const ROLES: readonly Role[] = [
id: ROLE_IDS.SERVER_IDENTITY_WG,
description: 'Server Identity Working Group',
discord: { role: 'server identity working group (synced)' },
// Discord only for now
// Discord only for now - no github.parent link to WORKING_GROUPS, so flag directly
provisionUser: true,
},
{
id: ROLE_IDS.TRANSPORT_WG,
@ -399,6 +419,7 @@ export const ROLES: readonly Role[] = [
description: 'Working Group and Interest Group facilitators with calendar access',
discord: { role: 'wg/ig facilitators (synced)' },
// Discord only - grants meet.modelcontextprotocol.io calendar access
provisionUser: true,
},
// ===================

View file

@ -40,6 +40,34 @@ export function resolveGoogleMemberEmail(member: Member): string | undefined {
: member.email;
}
/**
* Whether a member with the given roles is eligible for a Google Workspace
* user account. A member qualifies when any of their roles — or any role
* reached by walking github.parent chains upward (child team members are
* members of the parent team on GitHub) — has provisionUser set.
* Used by src/google.ts to provision accounts and by the validation scripts.
*/
export function hasProvisionUserRole(
roleIds: readonly RoleId[],
roleLookup: Map<RoleId, Role>
): boolean {
const seen = new Set<RoleId>();
const toProcess = [...roleIds];
while (toProcess.length > 0) {
const roleId = toProcess.pop()!;
if (seen.has(roleId)) continue;
seen.add(roleId);
const role = roleLookup.get(roleId);
if (!role) continue;
if (role.provisionUser === true) return true;
if (role.github?.parent) toProcess.push(role.github.parent);
}
return false;
}
/**
* Sort roles by GitHub parent dependency (topological sort).
* Ensures parent teams are created before child teams.

View file

@ -5,7 +5,7 @@ import * as random from '@pulumi/random';
import { ROLES, type Role, buildRoleLookup } from './config/roles';
import { MEMBERS } from './config/users';
import type { RoleId } from './config/roleIds';
import { resolveGoogleMemberEmail } from './config/utils';
import { hasProvisionUserRole, resolveGoogleMemberEmail } from './config/utils';
const roleLookup = buildRoleLookup();
// Groups keyed by Google group name
@ -86,10 +86,7 @@ MEMBERS.forEach((member) => {
)
return;
const needsUser = member.memberOf.some((roleId: RoleId) => {
const role = roleLookup.get(roleId);
return role?.google?.provisionUser === true;
});
const needsUser = hasProvisionUserRole(member.memberOf, roleLookup);
if (!needsUser) return;
const primaryEmail = `${member.googleEmailPrefix}@modelcontextprotocol.io`;