diff --git a/README.md b/README.md index 5375bcc..85e126d 100644 --- a/README.md +++ b/README.md @@ -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 { diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 108e57e..3ef1a9c 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.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 diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index 51f3736..1e0c6eb 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -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` diff --git a/src/config/roles.ts b/src/config/roles.ts index 1c4fc3d..3dee400 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -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, }, // =================== diff --git a/src/config/utils.ts b/src/config/utils.ts index cb65521..f648596 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -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 +): boolean { + const seen = new Set(); + 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. diff --git a/src/google.ts b/src/google.ts index 9539274..3bee1cc 100644 --- a/src/google.ts +++ b/src/google.ts @@ -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`;