access/scripts/validate-config.ts
claude[bot] 5917af2589
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>
2026-07-03 01:19:16 +00:00

210 lines
6.9 KiB
TypeScript

#!/usr/bin/env npx ts-node
/**
* Validates that all references in the config are valid.
* Run with: npx ts-node scripts/validate-config.ts
*/
import { ROLES, buildRoleLookup } from '../src/config/roles';
import { REPOSITORY_ACCESS } from '../src/config/repoAccess';
import { MEMBERS } from '../src/config/users';
import { resolveGoogleMemberEmail } from '../src/config/utils';
import type { RoleId } from '../src/config/roleIds';
const roleLookup = buildRoleLookup();
// Get all GitHub team names (roles that have GitHub config)
const githubTeamNames = new Set<string>();
// Get all role IDs (for member validation)
const allRoleIds = new Set<RoleId>();
for (const role of ROLES) {
allRoleIds.add(role.id);
if (role.github) {
githubTeamNames.add(role.github.team);
}
}
let hasErrors = false;
// Validate team references in REPOSITORY_ACCESS
console.log('Validating team references in repoAccess.ts...');
for (const repo of REPOSITORY_ACCESS) {
if (!repo.teams) continue;
for (const teamRef of repo.teams) {
if (!githubTeamNames.has(teamRef.team)) {
console.error(
`ERROR: Repository "${repo.repository}" references team "${teamRef.team}" which does not exist in roles.ts`
);
hasErrors = true;
}
}
}
// Validate role references in MEMBERS (memberOf)
console.log('Validating role references in users.ts...');
for (const member of MEMBERS) {
for (const roleId of member.memberOf) {
if (!allRoleIds.has(roleId)) {
console.error(
`ERROR: Member "${member.github || member.email}" references role "${roleId}" which does not exist in roles.ts`
);
hasErrors = true;
}
}
}
// Validate member ordering in users.ts
console.log('Validating member ordering in users.ts...');
{
// Split members into github members and email-only members
const githubMembers: (typeof MEMBERS)[number][] = [];
const emailOnlyMembers: (typeof MEMBERS)[number][] = [];
for (const member of MEMBERS) {
if (member.github) {
githubMembers.push(member);
} else if (member.email) {
emailOnlyMembers.push(member);
}
}
// Check that github members come before email-only members
let foundEmailOnly = false;
for (const member of MEMBERS) {
if (!member.github && member.email) {
foundEmailOnly = true;
} else if (member.github && foundEmailOnly) {
console.error(
`ERROR: Member "${member.github}" appears after email-only members. GitHub members should come before email-only members.`
);
hasErrors = true;
break;
}
}
// Check that github members are sorted alphabetically (case-insensitive)
for (let i = 1; i < githubMembers.length; i++) {
const prev = githubMembers[i - 1].github!.toLowerCase();
const curr = githubMembers[i].github!.toLowerCase();
if (prev > curr) {
console.error(
`ERROR: Members are not sorted alphabetically. "${githubMembers[i - 1].github}" should come after "${githubMembers[i].github}".`
);
hasErrors = true;
break;
}
}
}
// Validate Google Workspace user provisioning fields
console.log('Validating Google Workspace user provisioning fields...');
{
const googleEmailPrefixes = new Map<string, string>();
for (const member of MEMBERS) {
const memberId = member.github || member.email || 'unknown';
// Members with googleEmailPrefix must also have firstName and lastName
if (member.googleEmailPrefix) {
if (!member.firstName) {
console.error(`ERROR: Member "${memberId}" has googleEmailPrefix but is missing firstName`);
hasErrors = true;
}
if (!member.lastName) {
console.error(`ERROR: Member "${memberId}" has googleEmailPrefix but is missing lastName`);
hasErrors = true;
}
// Check uniqueness of googleEmailPrefix
const existing = googleEmailPrefixes.get(member.googleEmailPrefix);
if (existing) {
console.error(
`ERROR: googleEmailPrefix "${member.googleEmailPrefix}" is used by both "${existing}" and "${memberId}"`
);
hasErrors = true;
} else {
googleEmailPrefixes.set(member.googleEmailPrefix, memberId);
}
}
// Members in provisionUser roles without all three fields won't get a GWS account
const inProvisionUserRole = member.memberOf.some((roleId: RoleId) => {
const role = roleLookup.get(roleId);
return role?.google?.provisionUser === true;
});
const hasProvisioningFields = !!(
member.googleEmailPrefix &&
member.firstName &&
member.lastName
);
if (member.skipGoogleUserProvisioning && !inProvisionUserRole) {
console.error(
`ERROR: Member "${memberId}" has skipGoogleUserProvisioning=true but is not in a provisionUser role`
);
hasErrors = true;
}
if (inProvisionUserRole && hasProvisioningFields && member.skipGoogleUserProvisioning) {
console.error(
`ERROR: Member "${memberId}" has provisioning fields and skipGoogleUserProvisioning=true; pick one`
);
hasErrors = true;
}
}
}
// Validate that external group members are explicitly permitted.
// allowExternalMembers is an explicit per-role opt-in (see GoogleConfig in
// roles.ts): if a group's membership resolves to any non-@modelcontextprotocol.io
// email but the role does not opt in, `pulumi up` would set the group's
// ALLOW_EXTERNAL_MEMBERS setting to false and Google would silently purge the
// external members ~1-2 days later (#133 incident). Fail loudly instead.
console.log('Validating external group members are explicitly permitted...');
for (const member of MEMBERS) {
const memberEmail = resolveGoogleMemberEmail(member);
if (!memberEmail || memberEmail.endsWith('@modelcontextprotocol.io')) continue;
for (const roleId of member.memberOf) {
const role = roleLookup.get(roleId);
if (!role?.google || role.google.allowExternalMembers === true) continue;
console.error(
`ERROR: Google group "${role.google.group}" has external member "${memberEmail}" ` +
`but its role does not permit external members. If this member is intentional, ` +
`add allowExternalMembers: true to the '${role.id}' role's google config in src/config/roles.ts`
);
hasErrors = true;
}
}
// Validate parent role references in roles.ts
console.log('Validating parent role references in roles.ts...');
for (const role of ROLES) {
if (!role.github?.parent) continue;
const parentRole = roleLookup.get(role.github.parent);
if (!parentRole) {
console.error(
`ERROR: Role "${role.id}" has parent "${role.github.parent}" which does not exist in roles.ts`
);
hasErrors = true;
} else if (!parentRole.github) {
console.error(
`ERROR: Role "${role.id}" has parent "${role.github.parent}" which does not have GitHub config`
);
hasErrors = true;
}
}
if (hasErrors) {
console.error('\nValidation failed! Please fix the errors above.');
process.exit(1);
} else {
console.log('\nAll references are valid.');
process.exit(0);
}