* Declare allowExternalMembers on managed group settings Every pulumi up was resetting the Google Workspace per-group setting ALLOW_EXTERNAL_MEMBERS to false on all managed groups, because the GroupSettings resources never declared allowExternalMembers and the provider (SamuZad/googleworkspace 0.11.1) defaults the field to false. Google then silently purges external-email members from affected groups roughly 1-2 days after each apply, with no audit events. Pin the setting to true for all managed groups. External membership itself remains governed exclusively by config/users.ts entries; this setting only permits it. Part of the #133 incident. * Scope allowExternalMembers per group, derived from membership config Instead of permitting external members on every managed group, derive allowExternalMembers per group: true only for groups whose membership config (config/users.ts) resolves at least one member to a non-@modelcontextprotocol.io email. Email resolution is factored into resolveGoogleMemberEmail and shared with GroupMember creation so the two cannot drift. The field stays declared on every group because the provider defaults it to false on each apply, after which Google silently purges external members (#133 incident). With current config, exactly these groups derive true: maintainers, registry-wg, antitrust, catch-all. A config test pins this set so changes to it are always deliberate. * Make allowExternalMembers an explicit per-role opt-in with config validation Replace the derived per-group allowExternalMembers (computed from membership config) with an explicit opt-in: group owners set allowExternalMembers: true on the role's google config in src/config/roles.ts, and src/google.ts passes it straight through to GroupSettings (defaulting to false). The field stays declared on every group because the provider resets an omitted field to false on each apply, after which Google silently purges external members (#133). Roles opting in, matching current membership: maintainers, registry-maintainers (registry-wg), antitrust, catch-all. A validate-config check replaces the old pinned-set test: it fails with an actionable error whenever a group has a member resolving to a non-@modelcontextprotocol.io email but its role does not opt in, naming the group, the member email, and the exact config line to add. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
192 lines
6.5 KiB
TypeScript
192 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 { 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 = 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) {
|
|
// 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);
|