Skip to content

Commit a078d8d

Browse files
authored
Merge branch 'main' into chore/remove-133-temp-repair
2 parents 35e1cef + 5917af2 commit a078d8d

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)