Add Google Workspace user account provisioning (#66)

* Add Google Workspace user account provisioning

* 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
This commit is contained in:
David Soria Parra 2026-02-19 22:17:25 +00:00 • committed by GitHub
commit 2de77b8684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 502 additions and 23 deletions

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
node_modules/ node_modules/
bin/ bin/
*.log *.log
.devenv/
.devenv.flake.nix
devenv.lock
# Pulumi # Pulumi
.pulumi/ .pulumi/

8
devenv.nix Normal file
View file

@ -0,0 +1,8 @@
{ pkgs, ... }:
{
packages = [
pkgs.nodejs_22
pkgs.pulumi-bin
pkgs.pulumiPackages.pulumi-nodejs
];
}

246
package-lock.json generated
View file

@ -11,14 +11,29 @@
"dependencies": { "dependencies": {
"@pulumi/github": "^6.7.3", "@pulumi/github": "^6.7.3",
"@pulumi/googleworkspace": "file:sdks/googleworkspace", "@pulumi/googleworkspace": "file:sdks/googleworkspace",
"@pulumi/pulumi": "^3.197.0" "@pulumi/pulumi": "^3.218.0",
"@pulumi/random": "^4.14.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.18.6", "@types/node": "^22.18.6",
"prettier": "^3.7.2", "prettier": "^3.7.2",
"ts-node": "^10.9.2",
"typescript": "^5.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": { "node_modules/@grpc/grpc-js": {
"version": "1.14.3", "version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "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==", "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==",
"license": "ISC" "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": { "node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "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", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "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": { "node_modules/@sigstore/bundle": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz",
@ -774,6 +825,34 @@
"node": ">=10" "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": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -896,6 +974,19 @@
"acorn": "^8" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@ -905,6 +996,13 @@
"node": ">= 14" "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": { "node_modules/argparse": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@ -914,6 +1012,15 @@
"sprintf-js": "~1.0.2" "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": { "node_modules/bin-links": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
@ -1150,6 +1257,13 @@
"node": ">= 18" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1250,6 +1364,16 @@
"node": ">=10" "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": { "node_modules/encoding": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
@ -1744,6 +1868,13 @@
"node": "20 || >=22" "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": { "node_modules/make-fetch-happen": {
"version": "15.0.3", "version": "15.0.3",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", "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", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
"integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -2767,6 +2897,56 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "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": { "node_modules/tuf-js": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz",
@ -2787,7 +2967,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -2842,6 +3021,13 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "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": { "node_modules/validate-npm-package-license": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@ -3002,6 +3188,54 @@
"node": ">=8" "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"
}
} }
} }

View file

@ -15,11 +15,13 @@
"dependencies": { "dependencies": {
"@pulumi/github": "^6.11.0", "@pulumi/github": "^6.11.0",
"@pulumi/googleworkspace": "file:sdks/googleworkspace", "@pulumi/googleworkspace": "file:sdks/googleworkspace",
"@pulumi/pulumi": "^3.218.0" "@pulumi/pulumi": "^3.218.0",
"@pulumi/random": "^4.14.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.18.6", "@types/node": "^22.18.6",
"prettier": "^3.7.2", "prettier": "^3.7.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.2" "typescript": "^5.9.2"
} }
} }

View file

@ -82,6 +82,51 @@ test('TYPESCRIPT_SDK_AUTH role exists (GitHub-only)', () => {
return role !== undefined && role.github !== undefined && role.discord === undefined; 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 // Summary
console.log(`\n${passed} passed, ${failed} failed`); console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);

View file

@ -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<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;
}
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 // Validate parent role references in roles.ts
console.log('Validating parent role references in roles.ts...'); console.log('Validating parent role references in roles.ts...');
for (const role of ROLES) { for (const role of ROLES) {

View file

@ -26,6 +26,8 @@ export interface GoogleConfig {
group: string; group: string;
/** If true, accepts emails from anyone including external users */ /** If true, accepts emails from anyone including external users */
isEmailGroup?: boolean; 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', description: 'Lead core maintainers',
github: { team: 'lead-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, github: { team: 'lead-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'lead maintainers (synced)' }, 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, id: ROLE_IDS.CORE_MAINTAINERS,
description: 'Core maintainers', description: 'Core maintainers',
github: { team: 'core-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE }, github: { team: 'core-maintainers', parent: ROLE_IDS.STEERING_COMMITTEE },
discord: { role: 'core maintainers (synced)' }, discord: { role: 'core maintainers (synced)' },
google: { group: 'core-maintainers', provisionUser: true },
}, },
{ {
id: ROLE_IDS.MODERATORS, id: ROLE_IDS.MODERATORS,
@ -130,7 +133,7 @@ export const ROLES: readonly Role[] = [
description: 'Official registry builders and maintainers', description: 'Official registry builders and maintainers',
github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS }, github: { team: 'registry-wg', parent: ROLE_IDS.WORKING_GROUPS },
discord: { role: 'registry maintainers (synced)' }, discord: { role: 'registry maintainers (synced)' },
google: { group: 'registry-wg' }, google: { group: 'registry-wg', provisionUser: true },
}, },
{ {
id: ROLE_IDS.USE_MCP_MAINTAINERS, id: ROLE_IDS.USE_MCP_MAINTAINERS,

View file

@ -5,6 +5,7 @@ export const MEMBERS: readonly Member[] = [
{ {
github: '000-000-000-000-000', github: '000-000-000-000-000',
discord: '1360717264051241071', discord: '1360717264051241071',
skipGoogleUserProvisioning: true,
memberOf: [ROLE_IDS.CORE_MAINTAINERS], memberOf: [ROLE_IDS.CORE_MAINTAINERS],
}, },
{ {
@ -60,6 +61,7 @@ export const MEMBERS: readonly Member[] = [
github: 'BobDickinson', github: 'BobDickinson',
email: 'bob.dickinson@gmail.com', email: 'bob.dickinson@gmail.com',
discord: '1175893001202045139', discord: '1175893001202045139',
skipGoogleUserProvisioning: true,
memberOf: [ memberOf: [
ROLE_IDS.MAINTAINERS, ROLE_IDS.MAINTAINERS,
ROLE_IDS.INSPECTOR_MAINTAINERS, ROLE_IDS.INSPECTOR_MAINTAINERS,
@ -75,6 +77,7 @@ export const MEMBERS: readonly Member[] = [
github: 'caitiem20', github: 'caitiem20',
email: 'caitie.mccaffrey@microsoft.com', email: 'caitie.mccaffrey@microsoft.com',
discord: '1425586366288494722', discord: '1425586366288494722',
skipGoogleUserProvisioning: true,
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG], memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG],
}, },
{ {
@ -147,10 +150,15 @@ export const MEMBERS: readonly Member[] = [
github: 'domdomegg', github: 'domdomegg',
email: 'adam@modelcontextprotocol.io', email: 'adam@modelcontextprotocol.io',
discord: '102128241715716096', discord: '102128241715716096',
firstName: 'Adam',
lastName: 'Jones',
googleEmailPrefix: 'adam',
existingGWSUser: true,
memberOf: [ROLE_IDS.MCPB_MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS], memberOf: [ROLE_IDS.MCPB_MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS],
}, },
{ {
github: 'dsp', github: 'dsp',
skipGoogleUserProvisioning: true,
memberOf: [ memberOf: [
ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.AUTH_MAINTAINERS,
ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.LEAD_MAINTAINERS,
@ -170,19 +178,16 @@ export const MEMBERS: readonly Member[] = [
github: 'dsp-ant', github: 'dsp-ant',
email: 'david@modelcontextprotocol.io', email: 'david@modelcontextprotocol.io',
discord: '166107790262272000', discord: '166107790262272000',
firstName: 'David',
lastName: 'Soria Parra',
googleEmailPrefix: 'david',
existingGWSUser: true,
memberOf: [ memberOf: [
ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.AUTH_MAINTAINERS,
ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.LEAD_MAINTAINERS,
ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS,
ROLE_IDS.DOCS_MAINTAINERS, ROLE_IDS.DOCS_MAINTAINERS,
ROLE_IDS.GO_SDK,
ROLE_IDS.FINANCIAL_SERVICES_IG,
ROLE_IDS.MODERATORS, 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', github: 'jspahrsummers',
email: 'justin@modelcontextprotocol.io', email: 'justin@modelcontextprotocol.io',
firstName: 'Justin',
lastName: 'Spahr-Summers',
googleEmailPrefix: 'justin',
existingGWSUser: true,
memberOf: [ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS], memberOf: [ROLE_IDS.LEAD_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS],
}, },
{ {
@ -335,6 +344,7 @@ export const MEMBERS: readonly Member[] = [
{ {
github: 'kurtisvg', github: 'kurtisvg',
discord: '1158458388917780590', discord: '1158458388917780590',
skipGoogleUserProvisioning: true,
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG], memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.TRANSPORT_WG],
}, },
{ {
@ -345,6 +355,9 @@ export const MEMBERS: readonly Member[] = [
{ {
github: 'localden', github: 'localden',
discord: '1351224014143754260', discord: '1351224014143754260',
firstName: 'Den',
lastName: 'Delimarsky',
googleEmailPrefix: 'den',
memberOf: [ memberOf: [
ROLE_IDS.AUTH_MAINTAINERS, ROLE_IDS.AUTH_MAINTAINERS,
ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS,
@ -422,6 +435,7 @@ export const MEMBERS: readonly Member[] = [
{ {
github: 'nickcoai', github: 'nickcoai',
discord: '1153783469860732968', discord: '1153783469860732968',
skipGoogleUserProvisioning: true,
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.SERVER_IDENTITY_WG], memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.SERVER_IDENTITY_WG],
}, },
{ {
@ -469,6 +483,10 @@ export const MEMBERS: readonly Member[] = [
{ {
github: 'pcarleton', github: 'pcarleton',
discord: '1354465170969067852', discord: '1354465170969067852',
firstName: 'Paul',
lastName: 'Carleton',
googleEmailPrefix: 'paul',
existingGWSUser: true,
memberOf: [ memberOf: [
ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.CORE_MAINTAINERS,
ROLE_IDS.PYTHON_SDK, ROLE_IDS.PYTHON_SDK,
@ -496,6 +514,9 @@ export const MEMBERS: readonly Member[] = [
{ {
github: 'pja-ant', github: 'pja-ant',
discord: '328628782497923072', discord: '328628782497923072',
firstName: 'Peter',
lastName: 'Alexander',
googleEmailPrefix: 'pja',
memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.MAINTAINERS, ROLE_IDS.TRANSPORT_WG], memberOf: [ROLE_IDS.CORE_MAINTAINERS, ROLE_IDS.MAINTAINERS, ROLE_IDS.TRANSPORT_WG],
}, },
{ {
@ -505,12 +526,17 @@ export const MEMBERS: readonly Member[] = [
{ {
github: 'pwwpche', github: 'pwwpche',
discord: '1226238847013228604', discord: '1226238847013228604',
skipGoogleUserProvisioning: true,
memberOf: [ROLE_IDS.CORE_MAINTAINERS], memberOf: [ROLE_IDS.CORE_MAINTAINERS],
}, },
{ {
github: 'rdimitrov', github: 'rdimitrov',
email: 'radoslav@modelcontextprotocol.io', email: 'radoslav@modelcontextprotocol.io',
discord: '1088231882979815424', discord: '1088231882979815424',
firstName: 'Radoslav',
lastName: 'Dimitrov',
googleEmailPrefix: 'radoslav',
existingGWSUser: true,
memberOf: [ROLE_IDS.MAINTAINERS, ROLE_IDS.REGISTRY_MAINTAINERS, ROLE_IDS.SKILLS_OVER_MCP_IG], 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', github: 'tadasant',
email: 'tadas@modelcontextprotocol.io', email: 'tadas@modelcontextprotocol.io',
discord: '400092503677599754', discord: '400092503677599754',
firstName: 'Tadas',
lastName: 'Antanavicius',
googleEmailPrefix: 'tadas',
existingGWSUser: true,
memberOf: [ memberOf: [
ROLE_IDS.COMMUNITY_MANAGERS, ROLE_IDS.COMMUNITY_MANAGERS,
ROLE_IDS.MODERATORS, ROLE_IDS.MODERATORS,
@ -564,7 +594,11 @@ export const MEMBERS: readonly Member[] = [
github: 'toby', github: 'toby',
email: 'toby@modelcontextprotocol.io', email: 'toby@modelcontextprotocol.io',
discord: '560155411777323048', 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', github: 'topherbullock',

View file

@ -15,6 +15,16 @@ export interface Member {
discord?: string; discord?: string;
/** Roles this member belongs to */ /** Roles this member belongs to */
memberOf: readonly RoleId[]; 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;
} }
/** /**

View file

@ -1,4 +1,7 @@
import * as crypto from 'crypto';
import * as pulumi from '@pulumi/pulumi';
import * as gworkspace from '@pulumi/googleworkspace'; import * as gworkspace from '@pulumi/googleworkspace';
import * as random from '@pulumi/random';
import { ROLES, type Role, buildRoleLookup } from './config/roles'; import { ROLES, type Role, buildRoleLookup } from './config/roles';
import { MEMBERS } from './config/users'; import { MEMBERS } from './config/users';
import type { RoleId } from './config/roleIds'; 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<string, gworkspace.User> = {};
const newUserPasswords: Record<string, pulumi.Output<string>> = {};
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 // Create group memberships for users
MEMBERS.forEach((member) => { 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) => { member.memberOf.forEach((roleId: RoleId) => {
const role = roleLookup.get(roleId); const role = roleLookup.get(roleId);
if (!role?.google) return; // Role doesn't have Google config if (!role?.google) return; // Role doesn't have Google config
new gworkspace.GroupMember(`${member.email}-${role.google.group}`, { new gworkspace.GroupMember(
groupId: groups[role.google.group].id, `${memberEmail}-${role.google.group}`,
email: member.email!, {
role: 'MEMBER', groupId: groups[role.google.group].id,
}); email: memberEmail,
role: 'MEMBER',
},
provisionedUser ? { dependsOn: [provisionedUser] } : undefined
);
}); });
}); });
export { groups as googleGroups }; 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);