Add Google Workspace user account provisioning (#66)
* Add Google Workspace user account provisioning * Fix validation: remove stale skipGoogleUserProvisioning from a-akimov, add it for BobDickinson - a-akimov: had skipGoogleUserProvisioning but is not in a provisionUser role (DOCS_MAINTAINERS) - BobDickinson: is in REGISTRY_MAINTAINERS (provisionUser role) but missing Google user fields
This commit is contained in:
parent
d365a398cf
commit
2de77b8684
4 changed files with 502 additions and 23 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: '000-000-000-000-000',
|
||||
discord: '1360717264051241071',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [ROLE_IDS.CORE_MAINTAINERS],
|
||||
},
|
||||
{
|
||||
|
|
@ -60,6 +61,7 @@ export const MEMBERS: readonly Member[] = [
|
|||
github: 'BobDickinson',
|
||||
email: 'bob.dickinson@gmail.com',
|
||||
discord: '1175893001202045139',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [
|
||||
ROLE_IDS.MAINTAINERS,
|
||||
ROLE_IDS.INSPECTOR_MAINTAINERS,
|
||||
|
|
@ -75,6 +77,7 @@ export const MEMBERS: readonly Member[] = [
|
|||
github: 'caitiem20',
|
||||
email: 'caitie.mccaffrey@microsoft.com',
|
||||
discord: '1425586366288494722',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG],
|
||||
},
|
||||
{
|
||||
|
|
@ -147,10 +150,15 @@ 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],
|
||||
},
|
||||
{
|
||||
github: 'dsp',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [
|
||||
ROLE_IDS.AUTH_MAINTAINERS,
|
||||
ROLE_IDS.LEAD_MAINTAINERS,
|
||||
|
|
@ -170,19 +178,16 @@ 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,
|
||||
ROLE_IDS.CORE_MAINTAINERS,
|
||||
ROLE_IDS.DOCS_MAINTAINERS,
|
||||
ROLE_IDS.GO_SDK,
|
||||
ROLE_IDS.FINANCIAL_SERVICES_IG,
|
||||
ROLE_IDS.MODERATORS,
|
||||
ROLE_IDS.PHP_SDK,
|
||||
ROLE_IDS.PYTHON_SDK,
|
||||
ROLE_IDS.SECURITY_WG,
|
||||
ROLE_IDS.TRANSPORT_WG,
|
||||
ROLE_IDS.TYPESCRIPT_SDK,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -298,6 +303,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],
|
||||
},
|
||||
{
|
||||
|
|
@ -335,6 +344,7 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: 'kurtisvg',
|
||||
discord: '1158458388917780590',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG],
|
||||
},
|
||||
{
|
||||
|
|
@ -345,6 +355,9 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: 'localden',
|
||||
discord: '1351224014143754260',
|
||||
firstName: 'Den',
|
||||
lastName: 'Delimarsky',
|
||||
googleEmailPrefix: 'den',
|
||||
memberOf: [
|
||||
ROLE_IDS.AUTH_MAINTAINERS,
|
||||
ROLE_IDS.CORE_MAINTAINERS,
|
||||
|
|
@ -422,6 +435,7 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: 'nickcoai',
|
||||
discord: '1153783469860732968',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.SERVER_IDENTITY_WG],
|
||||
},
|
||||
{
|
||||
|
|
@ -469,6 +483,10 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: 'pcarleton',
|
||||
discord: '1354465170969067852',
|
||||
firstName: 'Paul',
|
||||
lastName: 'Carleton',
|
||||
googleEmailPrefix: 'paul',
|
||||
existingGWSUser: true,
|
||||
memberOf: [
|
||||
ROLE_IDS.CORE_MAINTAINERS,
|
||||
ROLE_IDS.PYTHON_SDK,
|
||||
|
|
@ -496,6 +514,9 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: 'pja-ant',
|
||||
discord: '328628782497923072',
|
||||
firstName: 'Peter',
|
||||
lastName: 'Alexander',
|
||||
googleEmailPrefix: 'pja',
|
||||
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.MAINTAINERS, ROLE_IDS.TRANSPORT_WG],
|
||||
},
|
||||
{
|
||||
|
|
@ -505,12 +526,17 @@ export const MEMBERS: readonly Member[] = [
|
|||
{
|
||||
github: 'pwwpche',
|
||||
discord: '1226238847013228604',
|
||||
skipGoogleUserProvisioning: true,
|
||||
memberOf: [ROLE_IDS.CORE_MAINTAINERS],
|
||||
},
|
||||
{
|
||||
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],
|
||||
},
|
||||
{
|
||||
|
|
@ -546,6 +572,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,
|
||||
|
|
@ -564,7 +594,11 @@ export const MEMBERS: readonly Member[] = [
|
|||
github: 'toby',
|
||||
email: 'toby@modelcontextprotocol.io',
|
||||
discord: '560155411777323048',
|
||||
memberOf: [ROLE_IDS.REGISTRY_MAINTAINERS],
|
||||
firstName: 'Toby',
|
||||
lastName: 'Padilla',
|
||||
googleEmailPrefix: 'toby',
|
||||
existingGWSUser: true,
|
||||
memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS],
|
||||
},
|
||||
{
|
||||
github: 'topherbullock',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ 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;
|
||||
/** Explicitly skip automatic GWS user provisioning for provisionUser roles */
|
||||
skipGoogleUserProvisioning?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import * as crypto from 'crypto';
|
||||
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 +48,91 @@ ROLES.forEach((role: Role) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Provision Google Workspace user accounts for members in roles with provisionUser
|
||||
const provisionedUsersByEmail: Record<string, gworkspace.User> = {};
|
||||
const newUserPasswords: Record<string, pulumi.Output<string>> = {};
|
||||
|
||||
MEMBERS.forEach((member) => {
|
||||
if (
|
||||
!member.firstName ||
|
||||
!member.lastName ||
|
||||
!member.googleEmailPrefix ||
|
||||
member.skipGoogleUserProvisioning
|
||||
)
|
||||
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
|
||||
const user = new gworkspace.User(
|
||||
`gws-user-${member.googleEmailPrefix}`,
|
||||
{
|
||||
primaryEmail,
|
||||
name: { familyName: member.lastName!, givenName: member.firstName! },
|
||||
orgUnitPath: '/Model Context Protocol',
|
||||
},
|
||||
{ import: primaryEmail }
|
||||
);
|
||||
provisionedUsersByEmail[primaryEmail] = user;
|
||||
} else {
|
||||
// Create new user with random password
|
||||
const password = new random.RandomPassword(`gws-pwd-${member.googleEmailPrefix}`, {
|
||||
length: 24,
|
||||
special: true,
|
||||
});
|
||||
const hashedPassword = password.result.apply((plaintext: string) =>
|
||||
crypto.createHash('sha1').update(plaintext).digest('hex')
|
||||
);
|
||||
|
||||
const user = new gworkspace.User(`gws-user-${member.googleEmailPrefix}`, {
|
||||
primaryEmail,
|
||||
name: { familyName: member.lastName!, givenName: member.firstName! },
|
||||
password: hashedPassword,
|
||||
hashFunction: 'SHA-1',
|
||||
changePasswordAtNextLogin: true,
|
||||
orgUnitPath: '/Model Context Protocol',
|
||||
});
|
||||
provisionedUsersByEmail[primaryEmail] = user;
|
||||
|
||||
// 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;
|
||||
const provisionedUser = gwsEmail ? provisionedUsersByEmail[gwsEmail] : undefined;
|
||||
|
||||
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}`, {
|
||||
groupId: groups[role.google.group].id,
|
||||
email: member.email!,
|
||||
role: 'MEMBER',
|
||||
});
|
||||
new gworkspace.GroupMember(
|
||||
`${memberEmail}-${role.google.group}`,
|
||||
{
|
||||
groupId: groups[role.google.group].id,
|
||||
email: memberEmail,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
provisionedUser ? { dependsOn: [provisionedUser] } : undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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…
Reference in a new issue