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:
parent
aa88d43df0
commit
8eb21f43ff
6 changed files with 81 additions and 27 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
||||
// ===================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue