Skip to content

Commit 0425173

Browse files
authored
Merge branch 'main' into declare-org-website
2 parents 64df141 + 16701aa commit 0425173

7 files changed

Lines changed: 76 additions & 13 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,6 @@ jobs:
7070
echo "$PULUMI_PASSPHRASE" > passphrase.prod.txt
7171
export PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt
7272
pulumi login gs://mcp-access-prod-pulumi-state
73-
# TEMP: drop stale state for renamed repo (experimental-ext-tasks -> ext-tasks, #125).
74-
# Delete-on-up 404s because the old repo name is gone. Remove after one successful deploy.
75-
pulumi state delete 'urn:pulumi:prod::mcp-access::github:index/repositoryCollaborators:RepositoryCollaborators::repo-experimental-ext-tasks' --stack prod --yes || true
7673
pulumi config set discord:guildId "$DISCORD_GUILD_ID" --stack prod
7774
pulumi config set discord:botToken "$DISCORD_BOT_TOKEN" --secret --stack prod
7875
pulumi config set githubBillingEmail "$ORG_BILLING_EMAIL" --secret --stack prod

Pulumi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ packages:
77
version: 0.14.0
88
parameters:
99
- SamuZad/googleworkspace
10-
- 0.11.1
10+
- 0.11.2

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/users.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export const MEMBERS: readonly Member[] = [
2020
discord: '324624369428987905',
2121
memberOf: [ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.MAINTAINERS],
2222
},
23+
{
24+
github: 'ajribeiro',
25+
firstName: 'AJ',
26+
lastName: 'Ribeiro',
27+
googleEmailPrefix: 'ajr',
28+
existingGWSUser: true,
29+
memberOf: [],
30+
},
2331
{
2432
github: 'alexhancock',
2533
discord: '1325885093343924316',
@@ -566,6 +574,10 @@ export const MEMBERS: readonly Member[] = [
566574
{
567575
github: 'ochafik',
568576
discord: '1004897332069925024',
577+
firstName: 'Olivier',
578+
lastName: 'Chafik',
579+
googleEmailPrefix: 'ochafik',
580+
existingGWSUser: true,
569581
memberOf: [
570582
ROLE_IDS.FILE_UPLOADS_WG,
571583
ROLE_IDS.MCP_APPS_SDK,

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)