Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
30 changes: 30 additions & 0 deletions scripts/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
48 changes: 48 additions & 0 deletions scripts/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

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) {
Expand Down
7 changes: 5 additions & 2 deletions src/config/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/config/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
},
{
Expand All @@ -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,
Expand Down Expand Up @@ -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],
},
{
Expand Down Expand Up @@ -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],
},
{
Expand Down Expand Up @@ -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,
Expand All @@ -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],
},
{
Expand Down
8 changes: 8 additions & 0 deletions src/config/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
68 changes: 65 additions & 3 deletions src/google.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as pulumi from '@pulumi/pulumi';
import * as gworkspace from '@pulumi/googleworkspace';
import * as random from '@pulumi/random';

Check failure on line 3 in src/google.ts

View workflow job for this annotation

GitHub Actions / Preview Changes

Cannot find module '@pulumi/random' or its corresponding type declarations.

Check failure on line 3 in src/google.ts

View workflow job for this annotation

GitHub Actions / Build and Typecheck

Cannot find module '@pulumi/random' or its corresponding type declarations.
import { ROLES, type Role, buildRoleLookup } from './config/roles';
import { MEMBERS } from './config/users';
import type { RoleId } from './config/roleIds';
Expand Down Expand Up @@ -45,20 +47,80 @@
});
});

// Provision Google Workspace user accounts for members in roles with provisionUser
const provisionedUsers: Record<string, gworkspace.User> = {};
const newUserPasswords: Record<string, pulumi.Output<string>> = {};

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);
Loading