access/src/google.ts
David Soria Parra 8eb21f43ff
feat: provision GWS accounts for implicit maintainers (SDK teams, WGs)
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).
2026-07-27 10:17:46 +01:00

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