From 1dbf51155eefc81ef56c236769e179ef78a0745d Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Feb 2026 22:02:30 +0000 Subject: [PATCH 1/2] Add Google Workspace user account provisioning --- .gitignore | 3 + devenv.nix | 8 ++ package-lock.json | 246 ++++++++++++++++++++++++++++++++++++- package.json | 4 +- scripts/test-config.ts | 45 +++++++ scripts/validate-config.ts | 66 ++++++++++ src/config/roles.ts | 7 +- src/config/users.ts | 50 ++++++-- src/config/utils.ts | 10 ++ src/google.ts | 86 ++++++++++++- 10 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 devenv.nix diff --git a/.gitignore b/.gitignore index 84a9b0d..4e71df0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ bin/ *.log +.devenv/ +.devenv.flake.nix +devenv.lock # Pulumi .pulumi/ diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..a9ec9e4 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,8 @@ +{ pkgs, ... }: +{ + packages = [ + pkgs.nodejs_22 + pkgs.pulumi-bin + pkgs.pulumiPackages.pulumi-nodejs + ]; +} diff --git a/package-lock.json b/package-lock.json index fbe8d2c..e7602a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,29 @@ "dependencies": { "@pulumi/github": "^6.7.3", "@pulumi/googleworkspace": "file:sdks/googleworkspace", - "@pulumi/pulumi": "^3.197.0" + "@pulumi/pulumi": "^3.218.0", + "@pulumi/random": "^4.14.0" }, "devDependencies": { "@types/node": "^22.18.6", "prettier": "^3.7.2", + "ts-node": "^10.9.2", "typescript": "^5.9.2" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -89,6 +104,34 @@ "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", "license": "ISC" }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -349,7 +392,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -676,6 +718,15 @@ } } }, + "node_modules/@pulumi/random": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/@pulumi/random/-/random-4.19.1.tgz", + "integrity": "sha512-gDotQyxtl+pcJb4oaGfbi6PsK8OtzjBmF1D8PYLvr13kCup98KTIIv/8wF2xbgbFf7ljJn3EblADxZ0u8AC/Dg==", + "license": "Apache-2.0", + "dependencies": { + "@pulumi/pulumi": "^3.142.0" + } + }, "node_modules/@sigstore/bundle": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", @@ -774,6 +825,34 @@ "node": ">=10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -879,7 +958,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -896,6 +974,19 @@ "acorn": "^8" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -905,6 +996,13 @@ "node": ">= 14" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -914,6 +1012,15 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/bin-links": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", @@ -1150,6 +1257,13 @@ "node": ">= 18" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1250,6 +1364,16 @@ "node": ">=10" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -1744,6 +1868,13 @@ "node": "20 || >=22" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, "node_modules/make-fetch-happen": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", @@ -2288,7 +2419,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2767,6 +2897,56 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tuf-js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", @@ -2787,7 +2967,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2842,6 +3021,13 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -3002,6 +3188,54 @@ "node": ">=8" } }, - "sdks/googleworkspace": {} + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "sdks/googleworkspace": { + "name": "@pulumi/googleworkspace", + "version": "0.7.0", + "hasInstallScript": true, + "dependencies": { + "@pulumi/pulumi": "^3.142.0", + "@types/node": "^18", + "async-mutex": "^0.5.0", + "typescript": "^4.3.5" + } + }, + "sdks/googleworkspace/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "sdks/googleworkspace/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "sdks/googleworkspace/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + } } } diff --git a/package.json b/package.json index bc00b33..47aabb9 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,13 @@ "dependencies": { "@pulumi/github": "^6.11.0", "@pulumi/googleworkspace": "file:sdks/googleworkspace", - "@pulumi/pulumi": "^3.218.0" + "@pulumi/pulumi": "^3.218.0", + "@pulumi/random": "^4.14.0" }, "devDependencies": { "@types/node": "^22.18.6", "prettier": "^3.7.2", + "ts-node": "^10.9.2", "typescript": "^5.9.2" } } diff --git a/scripts/test-config.ts b/scripts/test-config.ts index 471bbf4..edffdca 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -82,6 +82,51 @@ test('TYPESCRIPT_SDK_AUTH role exists (GitHub-only)', () => { return role !== undefined && role.github !== undefined && role.discord === undefined; }); +// Test Google Workspace user provisioning +test('Roles with provisionUser exist', () => { + const provisionRoles = ROLES.filter((r) => r.google?.provisionUser); + return provisionRoles.length > 0; +}); + +test('Members with googleEmailPrefix have firstName and lastName', () => + MEMBERS.every((m) => { + if (!m.googleEmailPrefix) return true; + return !!m.firstName && !!m.lastName; + })); + +test('googleEmailPrefix values are unique', () => { + const prefixes = MEMBERS.filter((m) => m.googleEmailPrefix).map((m) => m.googleEmailPrefix); + return prefixes.length === new Set(prefixes).size; +}); + +test('ProvisionUser members are explicit: fields present or skip flag set', () => { + const provisionRoleIds = new Set(ROLES.filter((r) => r.google?.provisionUser).map((r) => r.id)); + return MEMBERS.every((member) => { + const inProvisionRole = member.memberOf.some((id) => provisionRoleIds.has(id)); + if (!inProvisionRole) return !member.skipGoogleUserProvisioning; + + const hasProvisioningFields = !!( + member.firstName && + member.lastName && + member.googleEmailPrefix + ); + return hasProvisioningFields !== !!member.skipGoogleUserProvisioning; + }); +}); + +test('Some members in provisionUser roles have Google user fields', () => { + const membersInProvisionRoles = MEMBERS.filter((m) => + m.memberOf.some((id) => { + const role = roleLookup.get(id); + return role?.google?.provisionUser === true; + }) + ); + const provisioned = membersInProvisionRoles.filter( + (m) => m.firstName && m.lastName && m.googleEmailPrefix + ); + return membersInProvisionRoles.length > 0 && provisioned.length > 0; +}); + // Summary console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index b824b3c..4f8c748 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -98,6 +98,72 @@ console.log('Validating member ordering in users.ts...'); } } +// Validate Google Workspace user provisioning fields +console.log('Validating Google Workspace user provisioning fields...'); +{ + const googleEmailPrefixes = new Map(); + + 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; + } + + if (inProvisionUserRole && !hasProvisioningFields && !member.skipGoogleUserProvisioning) { + console.error( + `ERROR: Member "${memberId}" is in a provisionUser role but is missing Google user fields. Add fields or set skipGoogleUserProvisioning: true` + ); + hasErrors = true; + } + } +} + // Validate parent role references in roles.ts console.log('Validating parent role references in roles.ts...'); for (const role of ROLES) { diff --git a/src/config/roles.ts b/src/config/roles.ts index 9629bdd..318bb36 100644 --- a/src/config/roles.ts +++ b/src/config/roles.ts @@ -26,6 +26,8 @@ export interface GoogleConfig { group: string; /** If true, accepts emails from anyone including external users */ isEmailGroup?: boolean; + /** If true, members of this role get a Google Workspace user account */ + provisionUser?: boolean; } /** @@ -77,13 +79,14 @@ export const ROLES: readonly Role[] = [ description: 'Lead core maintainers', github: { team: 'lead-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, discord: { role: 'lead maintainers (synced)' }, - // Discord only for now - could add GitHub if needed + google: { group: 'lead-maintainers', provisionUser: true }, }, { id: ROLE_IDS.CORE_MAINTAINERS, description: 'Core maintainers', github: { team: 'core-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, discord: { role: 'core maintainers (synced)' }, + google: { group: 'core-maintainers', provisionUser: true }, }, { id: ROLE_IDS.MODERATORS, @@ -130,7 +133,7 @@ export const ROLES: readonly Role[] = [ description: 'Official registry builders and maintainers', github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS }, discord: { role: 'registry maintainers (synced)' }, - google: { group: 'registry-wg' }, + google: { group: 'registry-wg', provisionUser: true }, }, { id: ROLE_IDS.USE_MCP_MAINTAINERS, diff --git a/src/config/users.ts b/src/config/users.ts index bbc4895..154537a 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -5,11 +5,13 @@ export const MEMBERS: readonly Member[] = [ { github: '000-000-000-000-000', discord: '1360717264051241071', + skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, { github: 'a-akimov', discord: '1365254196621738116', + skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.DOCS_MAINTAINERS], }, { @@ -75,6 +77,7 @@ export const MEMBERS: readonly Member[] = [ github: 'caitiem20', email: 'caitie.mccaffrey@microsoft.com', discord: '1425586366288494722', + skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG], }, { @@ -147,10 +150,15 @@ export const MEMBERS: readonly Member[] = [ github: 'domdomegg', email: 'adam@modelcontextprotocol.io', discord: '102128241715716096', + firstName: 'Adam', + lastName: 'Jones', + googleEmailPrefix: 'adam', + existingGWSUser: true, memberOf: [ROLE_IDS.MCPB_MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS], }, { github: 'dsp', + skipGoogleUserProvisioning: true, memberOf: [ ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.LEAD_MAINTAINERS, @@ -170,19 +178,16 @@ export const MEMBERS: readonly Member[] = [ github: 'dsp-ant', email: 'david@modelcontextprotocol.io', discord: '166107790262272000', + firstName: 'David', + lastName: 'Soria Parra', + googleEmailPrefix: 'david', + existingGWSUser: true, memberOf: [ ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.DOCS_MAINTAINERS, - ROLE_IDS.GO_SDK, - ROLE_IDS.FINANCIAL_SERVICES_IG, ROLE_IDS.MODERATORS, - ROLE_IDS.PHP_SDK, - ROLE_IDS.PYTHON_SDK, - ROLE_IDS.SECURITY_WG, - ROLE_IDS.TRANSPORT_WG, - ROLE_IDS.TYPESCRIPT_SDK, ], }, { @@ -298,6 +303,10 @@ export const MEMBERS: readonly Member[] = [ { github: 'jspahrsummers', email: 'justin@modelcontextprotocol.io', + firstName: 'Justin', + lastName: 'Spahr-Summers', + googleEmailPrefix: 'justin', + existingGWSUser: true, memberOf: [ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS], }, { @@ -335,6 +344,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'kurtisvg', discord: '1158458388917780590', + skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG], }, { @@ -345,6 +355,9 @@ export const MEMBERS: readonly Member[] = [ { github: 'localden', discord: '1351224014143754260', + firstName: 'Den', + lastName: 'Delimarsky', + googleEmailPrefix: 'den', memberOf: [ ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS, @@ -422,6 +435,7 @@ export const MEMBERS: readonly Member[] = [ { github: 'nickcoai', discord: '1153783469860732968', + skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.SERVER_IDENTITY_WG], }, { @@ -469,6 +483,10 @@ export const MEMBERS: readonly Member[] = [ { github: 'pcarleton', discord: '1354465170969067852', + firstName: 'Paul', + lastName: 'Carleton', + googleEmailPrefix: 'paul', + existingGWSUser: true, memberOf: [ ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.PYTHON_SDK, @@ -496,6 +514,9 @@ export const MEMBERS: readonly Member[] = [ { github: 'pja-ant', discord: '328628782497923072', + firstName: 'Peter', + lastName: 'Alexander', + googleEmailPrefix: 'pja', memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.MAINTAINERS, ROLE_IDS.TRANSPORT_WG], }, { @@ -505,12 +526,17 @@ export const MEMBERS: readonly Member[] = [ { github: 'pwwpche', discord: '1226238847013228604', + skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.CORE_MAINTAINERS], }, { github: 'rdimitrov', email: 'radoslav@modelcontextprotocol.io', discord: '1088231882979815424', + firstName: 'Radoslav', + lastName: 'Dimitrov', + googleEmailPrefix: 'radoslav', + existingGWSUser: true, memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS, ROLE_IDS.SKILLS_OVER_MCP_IG], }, { @@ -546,6 +572,10 @@ export const MEMBERS: readonly Member[] = [ github: 'tadasant', email: 'tadas@modelcontextprotocol.io', discord: '400092503677599754', + firstName: 'Tadas', + lastName: 'Antanavicius', + googleEmailPrefix: 'tadas', + existingGWSUser: true, memberOf: [ ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.MODERATORS, @@ -564,7 +594,11 @@ export const MEMBERS: readonly Member[] = [ github: 'toby', email: 'toby@modelcontextprotocol.io', discord: '560155411777323048', - memberOf: [ROLE_IDS.REGISTRY_MAINTAINERS], + firstName: 'Toby', + lastName: 'Padilla', + googleEmailPrefix: 'toby', + existingGWSUser: true, + memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS], }, { github: 'topherbullock', diff --git a/src/config/utils.ts b/src/config/utils.ts index 782c3e9..6c84daf 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -15,6 +15,16 @@ export interface Member { discord?: string; /** Roles this member belongs to */ memberOf: readonly RoleId[]; + /** First name (required for Google Workspace user provisioning) */ + firstName?: string; + /** Last name (required for Google Workspace user provisioning) */ + lastName?: string; + /** Google Workspace email prefix (e.g., 'david' -> david@modelcontextprotocol.io) */ + googleEmailPrefix?: string; + /** If true, this user already exists in Google Workspace and should be imported into Pulumi state */ + existingGWSUser?: boolean; + /** Explicitly skip automatic GWS user provisioning for provisionUser roles */ + skipGoogleUserProvisioning?: boolean; } /** diff --git a/src/google.ts b/src/google.ts index f52499b..43cce9a 100644 --- a/src/google.ts +++ b/src/google.ts @@ -1,4 +1,7 @@ +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'; @@ -45,20 +48,91 @@ ROLES.forEach((role: Role) => { }); }); +// Provision Google Workspace user accounts for members in roles with provisionUser +const provisionedUsersByEmail: Record = {}; +const newUserPasswords: Record> = {}; + +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) { + // Import existing user into Pulumi state without recreating + const user = new gworkspace.User( + `gws-user-${member.googleEmailPrefix}`, + { + primaryEmail, + name: { familyName: member.lastName!, givenName: member.firstName! }, + orgUnitPath: '/Model Context Protocol', + }, + { import: primaryEmail } + ); + provisionedUsersByEmail[primaryEmail] = user; + } 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: '/Model Context Protocol', + }); + 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) => { - if (!member.email) return; + // Prefer the provisioned GWS email over the personal email for group memberships + const gwsEmail = member.googleEmailPrefix + ? `${member.googleEmailPrefix}@modelcontextprotocol.io` + : undefined; + const memberEmail = gwsEmail || member.email; + if (!memberEmail) return; + const provisionedUser = gwsEmail ? provisionedUsersByEmail[gwsEmail] : undefined; member.memberOf.forEach((roleId: RoleId) => { const role = roleLookup.get(roleId); if (!role?.google) return; // Role doesn't have Google config - new gworkspace.GroupMember(`${member.email}-${role.google.group}`, { - groupId: groups[role.google.group].id, - email: member.email!, - role: 'MEMBER', - }); + 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); From fbc29dcd1be92398e17a4aba3d64bc75a4fe5e82 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Feb 2026 22:13:16 +0000 Subject: [PATCH 2/2] Fix validation: remove stale skipGoogleUserProvisioning from a-akimov, add it for BobDickinson - a-akimov: had skipGoogleUserProvisioning but is not in a provisionUser role (DOCS_MAINTAINERS) - BobDickinson: is in REGISTRY_MAINTAINERS (provisionUser role) but missing Google user fields --- src/config/users.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/users.ts b/src/config/users.ts index 154537a..570521c 100644 --- a/src/config/users.ts +++ b/src/config/users.ts @@ -11,7 +11,6 @@ export const MEMBERS: readonly Member[] = [ { github: 'a-akimov', discord: '1365254196621738116', - skipGoogleUserProvisioning: true, memberOf: [ROLE_IDS.DOCS_MAINTAINERS], }, { @@ -62,6 +61,7 @@ export const MEMBERS: readonly Member[] = [ github: 'BobDickinson', email: 'bob.dickinson@gmail.com', discord: '1175893001202045139', + skipGoogleUserProvisioning: true, memberOf: [ ROLE_IDS.MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS,