Skip to content

Commit 5917af2

Browse files
claude[bot]claude
andauthored
Declare allowExternalMembers on managed groups so deploys stop re-arming the external-member purge (#140)
* 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>
1 parent 89cb9de commit 5917af2

4 files changed

Lines changed: 63 additions & 9 deletions

File tree

scripts/validate-config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { ROLES, buildRoleLookup } from '../src/config/roles';
99
import { REPOSITORY_ACCESS } from '../src/config/repoAccess';
1010
import { MEMBERS } from '../src/config/users';
11+
import { resolveGoogleMemberEmail } from '../src/config/utils';
1112
import type { RoleId } from '../src/config/roleIds';
1213

1314
const roleLookup = buildRoleLookup();
@@ -157,6 +158,30 @@ console.log('Validating Google Workspace user provisioning fields...');
157158
}
158159
}
159160

161+
// Validate that external group members are explicitly permitted.
162+
// allowExternalMembers is an explicit per-role opt-in (see GoogleConfig in
163+
// roles.ts): if a group's membership resolves to any non-@modelcontextprotocol.io
164+
// email but the role does not opt in, `pulumi up` would set the group's
165+
// ALLOW_EXTERNAL_MEMBERS setting to false and Google would silently purge the
166+
// external members ~1-2 days later (#133 incident). Fail loudly instead.
167+
console.log('Validating external group members are explicitly permitted...');
168+
for (const member of MEMBERS) {
169+
const memberEmail = resolveGoogleMemberEmail(member);
170+
if (!memberEmail || memberEmail.endsWith('@modelcontextprotocol.io')) continue;
171+
172+
for (const roleId of member.memberOf) {
173+
const role = roleLookup.get(roleId);
174+
if (!role?.google || role.google.allowExternalMembers === true) continue;
175+
176+
console.error(
177+
`ERROR: Google group "${role.google.group}" has external member "${memberEmail}" ` +
178+
`but its role does not permit external members. If this member is intentional, ` +
179+
`add allowExternalMembers: true to the '${role.id}' role's google config in src/config/roles.ts`
180+
);
181+
hasErrors = true;
182+
}
183+
}
184+
160185
// Validate parent role references in roles.ts
161186
console.log('Validating parent role references in roles.ts...');
162187
for (const role of ROLES) {

src/config/roles.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export interface GoogleConfig {
2828
isEmailGroup?: boolean;
2929
/** If true, members of this role get a Google Workspace user account */
3030
provisionUser?: boolean;
31+
/**
32+
* Opt-in for groups that intentionally include non-@modelcontextprotocol.io
33+
* members. Defaults to false. Must stay declared on the GroupSettings
34+
* resource: the provider resets an omitted field to false on every apply,
35+
* after which Google silently purges external members (#133 incident).
36+
*/
37+
allowExternalMembers?: boolean;
3138
}
3239

3340
/**
@@ -105,7 +112,7 @@ export const ROLES: readonly Role[] = [
105112
discord: { role: 'maintainers (synced)' },
106113
// GWS user accounts are opt-in: maintainers add firstName/lastName/googleEmailPrefix
107114
// to their entry in users.ts via PR to get an @modelcontextprotocol.io account
108-
google: { group: 'maintainers', provisionUser: true },
115+
google: { group: 'maintainers', provisionUser: true, allowExternalMembers: true },
109116
},
110117
{
111118
id: ROLE_IDS.DOCS_MAINTAINERS,
@@ -136,7 +143,7 @@ export const ROLES: readonly Role[] = [
136143
description: 'Official registry builders and maintainers',
137144
github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS },
138145
discord: { role: 'registry maintainers (synced)' },
139-
google: { group: 'registry-wg', provisionUser: true },
146+
google: { group: 'registry-wg', provisionUser: true, allowExternalMembers: true },
140147
},
141148
{
142149
id: ROLE_IDS.REGISTRY_COLLABORATORS,
@@ -394,7 +401,7 @@ export const ROLES: readonly Role[] = [
394401
{
395402
id: ROLE_IDS.ANTITRUST,
396403
description: 'Antitrust compliance contacts',
397-
google: { group: 'antitrust', isEmailGroup: true },
404+
google: { group: 'antitrust', isEmailGroup: true, allowExternalMembers: true },
398405
// Google only
399406
},
400407
{
@@ -406,7 +413,7 @@ export const ROLES: readonly Role[] = [
406413
{
407414
id: ROLE_IDS.CATCH_ALL,
408415
description: 'Catch-all email group',
409-
google: { group: 'catch-all', isEmailGroup: true },
416+
google: { group: 'catch-all', isEmailGroup: true, allowExternalMembers: true },
410417
// Google only
411418
},
412419
] as const;

src/config/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ export interface Member {
2727
skipGoogleUserProvisioning?: boolean;
2828
}
2929

30+
/**
31+
* Resolve the email a member joins Google groups with.
32+
* Prefers the provisioned GWS email over the personal email. This is the
33+
* single source of truth for group-membership emails — src/google.ts uses it
34+
* to create memberships, and scripts/validate-config.ts uses it to check that
35+
* external members only appear in roles that opt into allowExternalMembers.
36+
*/
37+
export function resolveGoogleMemberEmail(member: Member): string | undefined {
38+
return member.googleEmailPrefix
39+
? `${member.googleEmailPrefix}@modelcontextprotocol.io`
40+
: member.email;
41+
}
42+
3043
/**
3144
* Sort roles by GitHub parent dependency (topological sort).
3245
* Ensures parent teams are created before child teams.

src/google.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as random from '@pulumi/random';
55
import { ROLES, type Role, buildRoleLookup } from './config/roles';
66
import { MEMBERS } from './config/users';
77
import type { RoleId } from './config/roleIds';
8+
import { resolveGoogleMemberEmail } from './config/utils';
89

910
const roleLookup = buildRoleLookup();
1011
// Groups keyed by Google group name
@@ -25,6 +26,15 @@ ROLES.forEach((role: Role) => {
2526
{
2627
email: groups[role.google.group].email,
2728

29+
// Permit external (non-workspace) members only on groups whose role
30+
// explicitly opts in via allowExternalMembers in config/roles.ts.
31+
// Who is actually a member is still governed entirely by config/users.ts;
32+
// validate-config enforces that every external member's role opts in.
33+
// This field must stay DECLARED: when omitted, the provider defaults it
34+
// to false on every `pulumi up`, and Google then silently purges
35+
// external-email members ~1-2 days later (#133 incident).
36+
allowExternalMembers: role.google.allowExternalMembers ?? false,
37+
2838
// Maximise visibility of group. It's visible in GitHub anyway
2939
whoCanViewMembership: 'ALL_IN_DOMAIN_CAN_VIEW',
3040

@@ -154,12 +164,11 @@ MEMBERS.forEach((member) => {
154164
// Create group memberships for users
155165
MEMBERS.forEach((member) => {
156166
// Prefer the provisioned GWS email over the personal email for group memberships
157-
const gwsEmail = member.googleEmailPrefix
158-
? `${member.googleEmailPrefix}@modelcontextprotocol.io`
159-
: undefined;
160-
const memberEmail = gwsEmail || member.email;
167+
const memberEmail = resolveGoogleMemberEmail(member);
161168
if (!memberEmail) return;
162-
const provisionedUser = gwsEmail ? provisionedUsersByEmail[gwsEmail] : undefined;
169+
const provisionedUser = member.googleEmailPrefix
170+
? provisionedUsersByEmail[memberEmail]
171+
: undefined;
163172

164173
member.memberOf.forEach((roleId: RoleId) => {
165174
const role = roleLookup.get(roleId);

0 commit comments

Comments
 (0)