From ebb6b3d196d2db294359a34072ff7e4e74b6caea Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 17 Feb 2026 20:48:08 +0000 Subject: [PATCH] Add Google Workspace user account provisioning - Add provisionUser flag to GoogleConfig; set on LEAD_MAINTAINERS, CORE_MAINTAINERS, and REGISTRY_MAINTAINERS roles - Add firstName, lastName, googleEmailPrefix, and existingGWSUser fields to Member interface - Provision GWS user accounts with random passwords, orgUnitPath for auto-licensing, and changePasswordAtNextLogin - Import existing users into Pulumi state via existingGWSUser flag to avoid recreating accounts that already exist - Export initial passwords as Pulumi secret stack output (pulumi stack output --show-secrets newGWSUserPasswords) - Update group membership logic to prefer GWS email over personal email - Add @pulumi/random dependency for password generation - Add validation for googleEmailPrefix uniqueness and completeness - Add tests for provisionUser roles and Google user fields --- package.json | 1 + scripts/test-config.ts | 30 +++++++++++++++++ scripts/validate-config.ts | 48 +++++++++++++++++++++++++++ src/config/roles.ts | 7 ++-- src/config/users.ts | 24 ++++++++++++++ src/config/utils.ts | 8 +++++ src/google.ts | 68 ++++++++++++++++++++++++++++++++++++-- 7 files changed, 181 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index bb4186b..9e9548b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@pulumi/github": "^6.7.3", "@pulumi/googleworkspace": "file:sdks/googleworkspace", + "@pulumi/random": "file:sdks/random", "@pulumi/pulumi": "^3.197.0" }, "devDependencies": { diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 471bbf4..99d8562 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -82,6 +82,36 @@ test('TYPESCRIPT_SDK_AUTH role exists (GitHub-only)', () => { return role !== undefined && role.github !== undefined && role.discord === undefined; }); +// Test Google Workspace user provisioning +test('Roles with provisionUser exist', () => { + const provisionRoles = ROLES.filter((r) => r.google?.provisionUser); + return provisionRoles.length > 0; +}); + +test('Members with googleEmailPrefix have firstName and lastName', () => + MEMBERS.every((m) => { + if (!m.googleEmailPrefix) return true; + return !!m.firstName && !!m.lastName; + })); + +test('googleEmailPrefix values are unique', () => { + const prefixes = MEMBERS.filter((m) => m.googleEmailPrefix).map((m) => m.googleEmailPrefix); + return prefixes.length === new Set(prefixes).size; +}); + +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; + }) + ); + const provisioned = membersInProvisionRoles.filter( + (m) => m.firstName && m.lastName && m.googleEmailPrefix + ); + return membersInProvisionRoles.length > 0 && provisioned.length > 0; +}); + // 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 b824b3c..bf754cc 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -98,6 +98,54 @@ console.log('Validating member ordering in users.ts...'); } } +// Validate Google Workspace user provisioning fields +console.log('Validating Google Workspace user provisioning fields...'); +{ + const googleEmailPrefixes = new Map(); + + for (const member of MEMBERS) { + const memberId = member.github || member.email || 'unknown'; + + // Members with googleEmailPrefix must also have firstName and lastName + if (member.googleEmailPrefix) { + if (!member.firstName) { + console.error(`ERROR: Member "${memberId}" has googleEmailPrefix but is missing firstName`); + hasErrors = true; + } + if (!member.lastName) { + console.error(`ERROR: Member "${memberId}" has googleEmailPrefix but is missing lastName`); + hasErrors = true; + } + + // Check uniqueness of googleEmailPrefix + const existing = googleEmailPrefixes.get(member.googleEmailPrefix); + if (existing) { + console.error( + `ERROR: googleEmailPrefix "${member.googleEmailPrefix}" is used by both "${existing}" and "${memberId}"` + ); + hasErrors = true; + } else { + googleEmailPrefixes.set(member.googleEmailPrefix, memberId); + } + } + + // 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; + }); + + if ( + inProvisionUserRole && + (!member.googleEmailPrefix || !member.firstName || !member.lastName) + ) { + console.warn( + `WARNING: Member "${memberId}" is in a provisionUser role but is missing Google user fields (will not be provisioned)` + ); + } + } +} + // 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/config/roles.ts b/src/config/roles.ts index 9629bdd..318bb36 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -26,6 +26,8 @@ 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; } /** @@ -77,13 +79,14 @@ export const ROLES: readonly Role[] = [ description: 'Lead core maintainers', github: { team: 'lead-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, discord: { role: 'lead maintainers (synced)' }, - // Discord only for now - could add GitHub if needed + 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 }, }, { id: ROLE_IDS.MODERATORS, @@ -130,7 +133,7 @@ export const ROLES: readonly Role[] = [ description: 'Official registry builders and maintainers', github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS }, discord: { role: 'registry maintainers (synced)' }, - google: { group: 'registry-wg' }, + google: { group: 'registry-wg', provisionUser: true }, }, { id: ROLE_IDS.USE_MCP_MAINTAINERS, diff --git a/src/config/users.ts b/src/config/users.ts index fc18c0e..a849d2d 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -137,6 +137,10 @@ export const MEMBERS: readonly Member[] = [ github: 'domdomegg', email: 'adam@modelcontextprotocol.io', discord: '102128241715716096', + firstName: 'Adam', + lastName: 'Jones', + googleEmailPrefix: 'adam', + existingGWSUser: true, memberOf: [ROLE_IDS.MCPB_MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS], }, { @@ -160,6 +164,10 @@ export const MEMBERS: readonly Member[] = [ github: 'dsp-ant', email: 'david@modelcontextprotocol.io', discord: '166107790262272000', + firstName: 'David', + lastName: 'Soria Parra', + googleEmailPrefix: 'david', + existingGWSUser: true, memberOf: [ ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.LEAD_MAINTAINERS, @@ -288,6 +296,10 @@ export const MEMBERS: readonly Member[] = [ { github: 'jspahrsummers', email: 'justin@modelcontextprotocol.io', + firstName: 'Justin', + lastName: 'Spahr-Summers', + googleEmailPrefix: 'justin', + existingGWSUser: true, memberOf: [ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS], }, { @@ -501,6 +513,10 @@ export const MEMBERS: readonly Member[] = [ github: 'rdimitrov', email: 'radoslav@modelcontextprotocol.io', discord: '1088231882979815424', + firstName: 'Radoslav', + lastName: 'Dimitrov', + googleEmailPrefix: 'radoslav', + existingGWSUser: true, memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS, ROLE_IDS.SKILLS_OVER_MCP_IG], }, { @@ -536,6 +552,10 @@ export const MEMBERS: readonly Member[] = [ github: 'tadasant', email: 'tadas@modelcontextprotocol.io', discord: '400092503677599754', + firstName: 'Tadas', + lastName: 'Antanavicius', + googleEmailPrefix: 'tadas', + existingGWSUser: true, memberOf: [ ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.MODERATORS, @@ -554,6 +574,10 @@ export const MEMBERS: readonly Member[] = [ github: 'toby', email: 'toby@modelcontextprotocol.io', discord: '560155411777323048', + firstName: 'Toby', + lastName: 'Padilla', + googleEmailPrefix: 'toby', + existingGWSUser: true, memberOf: [ROLE_IDS.REGISTRY_MAINTAINERS], }, { diff --git a/src/config/utils.ts b/src/config/utils.ts index 782c3e9..cd2d39d 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -15,6 +15,14 @@ export interface Member { discord?: string; /** Roles this member belongs to */ memberOf: readonly RoleId[]; + /** First name (required for Google Workspace user provisioning) */ + firstName?: string; + /** Last name (required for Google Workspace user provisioning) */ + lastName?: string; + /** Google Workspace email prefix (e.g., 'david' -> david@modelcontextprotocol.io) */ + googleEmailPrefix?: string; + /** If true, this user already exists in Google Workspace and should be imported into Pulumi state */ + existingGWSUser?: boolean; } /** diff --git a/src/google.ts b/src/google.ts index f52499b..5941b0a 100644 --- a/src/google.ts +++ b/src/google.ts @@ -1,4 +1,6 @@ +import * as pulumi from '@pulumi/pulumi'; import * as gworkspace from '@pulumi/googleworkspace'; +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'; @@ -45,20 +47,80 @@ ROLES.forEach((role: Role) => { }); }); +// Provision Google Workspace user accounts for members in roles with provisionUser +const provisionedUsers: Record = {}; +const newUserPasswords: Record> = {}; + +MEMBERS.forEach((member) => { + if (!member.firstName || !member.lastName || !member.googleEmailPrefix) return; + + const needsUser = member.memberOf.some((roleId: RoleId) => { + const role = roleLookup.get(roleId); + return role?.google?.provisionUser === true; + }); + if (!needsUser) return; + + const primaryEmail = `${member.googleEmailPrefix}@modelcontextprotocol.io`; + + if (member.existingGWSUser) { + // Import existing user into Pulumi state without recreating + provisionedUsers[member.googleEmailPrefix] = new gworkspace.User( + `gws-user-${member.googleEmailPrefix}`, + { + primaryEmail, + name: { familyName: member.lastName!, givenName: member.firstName! }, + password: '', + changePasswordAtNextLogin: false, + orgUnitPath: '/Members', + }, + { import: primaryEmail } + ); + } else { + // Create new user with random password + const password = new random.RandomPassword(`gws-pwd-${member.googleEmailPrefix}`, { + length: 24, + special: true, + }); + + provisionedUsers[member.googleEmailPrefix] = new gworkspace.User( + `gws-user-${member.googleEmailPrefix}`, + { + primaryEmail, + name: { familyName: member.lastName!, givenName: member.firstName! }, + password: password.result, + hashFunction: 'SHA-1', + changePasswordAtNextLogin: true, + orgUnitPath: '/Members', + } + ); + + // Track password for export so an admin can retrieve it + newUserPasswords[primaryEmail] = password.result; + } +}); + // Create group memberships for users MEMBERS.forEach((member) => { - if (!member.email) return; + // Prefer the provisioned GWS email over the personal email for group memberships + const gwsEmail = member.googleEmailPrefix + ? `${member.googleEmailPrefix}@modelcontextprotocol.io` + : undefined; + const memberEmail = gwsEmail || member.email; + if (!memberEmail) return; member.memberOf.forEach((roleId: RoleId) => { const role = roleLookup.get(roleId); if (!role?.google) return; // Role doesn't have Google config - new gworkspace.GroupMember(`${member.email}-${role.google.group}`, { + new gworkspace.GroupMember(`${memberEmail}-${role.google.group}`, { groupId: groups[role.google.group].id, - email: member.email!, + email: memberEmail, role: 'MEMBER', }); }); }); export { groups as googleGroups }; +// Export initial passwords as secrets so an admin can retrieve them with: +// pulumi stack output --show-secrets newGWSUserPasswords +export const newGWSUserPasswords = pulumi.secret(newUserPasswords);