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).
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
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';
|
|
import { hasProvisionUserRole, resolveGoogleMemberEmail } from './config/utils';
|
|
|
|
const roleLookup = buildRoleLookup();
|
|
// Groups keyed by Google group name
|
|
const groups: Record<string, gworkspace.Group> = {};
|
|
|
|
// Create Google groups for roles that have Google config
|
|
ROLES.forEach((role: Role) => {
|
|
if (!role.google) return;
|
|
|
|
groups[role.google.group] = new gworkspace.Group(role.google.group, {
|
|
email: `${role.google.group}@modelcontextprotocol.io`,
|
|
name: role.google.group,
|
|
description: role.description + ' \n(Managed by github.com/modelcontextprotocol/access)',
|
|
});
|
|
|
|
new gworkspace.GroupSettings(
|
|
role.google.group,
|
|
{
|
|
email: groups[role.google.group].email,
|
|
|
|
// Permit external (non-workspace) members only on groups whose role
|
|
// explicitly opts in via allowExternalMembers in config/roles.ts.
|
|
// Who is actually a member is still governed entirely by config/users.ts;
|
|
// validate-config enforces that every external member's role opts in.
|
|
// This field must stay DECLARED: when omitted, the provider defaults it
|
|
// to false on every `pulumi up`, and Google then silently purges
|
|
// external-email members ~1-2 days later (#133 incident).
|
|
allowExternalMembers: role.google.allowExternalMembers ?? false,
|
|
|
|
// Maximise visibility of group. It's visible in GitHub anyway
|
|
whoCanViewMembership: 'ALL_IN_DOMAIN_CAN_VIEW',
|
|
|
|
// This specifies who can add/remove members. We want this to only be via this IaC.
|
|
whoCanModerateMembers: 'NONE',
|
|
whoCanLeaveGroup: 'NONE_CAN_LEAVE',
|
|
whoCanJoin: 'INVITED_CAN_JOIN',
|
|
|
|
// Email groups allow anyone (including externals) to post
|
|
// Non-email groups are not intended as mailing lists, so use the most restrictive settings
|
|
// whoCanViewGroup is badly named, but actually means 'Permissions to view group messages'. See https://developers.google.com/workspace/admin/groups-settings/v1/reference/groups
|
|
...(role.google.isEmailGroup
|
|
? {
|
|
whoCanPostMessage: 'ANYONE_CAN_POST',
|
|
whoCanContactOwner: 'ALL_OWNERS_CAN_CONTACT',
|
|
whoCanViewGroup: 'ALL_MEMBERS_CAN_VIEW',
|
|
}
|
|
: {
|
|
whoCanPostMessage: 'ALL_OWNERS_CAN_POST',
|
|
whoCanContactOwner: 'ALL_OWNERS_CAN_CONTACT',
|
|
whoCanViewGroup: 'ALL_OWNERS_CAN_VIEW',
|
|
}),
|
|
},
|
|
{ ignoreChanges: ['isArchived'] }
|
|
);
|
|
});
|
|
|
|
// Create the organizational unit for MCP users
|
|
const mcpOrgUnit = new gworkspace.OrgUnit(
|
|
'mcp-org-unit',
|
|
{
|
|
name: 'Model Context Protocol',
|
|
description: 'Model Context Protocol',
|
|
parentOrgUnitPath: '/',
|
|
},
|
|
{ ignoreChanges: ['description'] }
|
|
);
|
|
|
|
// 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 = hasProvisionUserRole(member.memberOf, roleLookup);
|
|
if (!needsUser) return;
|
|
|
|
const primaryEmail = `${member.googleEmailPrefix}@modelcontextprotocol.io`;
|
|
|
|
if (member.existingGWSUser) {
|
|
// Existing GWS users are not managed by Pulumi — the GWS provider's import
|
|
// validation rejects empty email types that GWS itself sets on primary/alias
|
|
// emails, and there's no way to fix this at the provider level.
|
|
// Group memberships for these users are created without dependsOn since the
|
|
// user already exists in GWS.
|
|
return;
|
|
} 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: mcpOrgUnit.orgUnitPath,
|
|
},
|
|
{
|
|
dependsOn: [mcpOrgUnit],
|
|
ignoreChanges: [
|
|
'recoveryEmail',
|
|
'recoveryPhone',
|
|
'password',
|
|
'hashFunction',
|
|
'changePasswordAtNextLogin',
|
|
'orgUnitPath',
|
|
'archived',
|
|
'suspended',
|
|
'isAdmin',
|
|
'includeInGlobalAddressList',
|
|
'ipAllowlist',
|
|
'addresses',
|
|
'aliases',
|
|
'customSchemas',
|
|
'emails',
|
|
'externalIds',
|
|
'ims',
|
|
'keywords',
|
|
'languages',
|
|
'locations',
|
|
'organizations',
|
|
'phones',
|
|
'posixAccounts',
|
|
'relations',
|
|
'sshPublicKeys',
|
|
'websites',
|
|
'name',
|
|
],
|
|
}
|
|
);
|
|
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) => {
|
|
// Prefer the provisioned GWS email over the personal email for group memberships
|
|
const memberEmail = resolveGoogleMemberEmail(member);
|
|
if (!memberEmail) return;
|
|
const provisionedUser = member.googleEmailPrefix
|
|
? provisionedUsersByEmail[memberEmail]
|
|
: undefined;
|
|
|
|
member.memberOf.forEach((roleId: RoleId) => {
|
|
const role = roleLookup.get(roleId);
|
|
if (!role?.google) return; // Role doesn't have Google config
|
|
|
|
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);
|