Repository navigation
Conversation
…gam whatis`, failed with "There are no scopes authorized" when the only Cloud Identity scope authorized was User Invitations. GAM-team#1934
VerificationAdversarial re-verification at head Superseded checkUpstream issue GAM-team#1934 is still OPEN ( Re-ran the Hunter's evidence myself (all matched)$ sh rv-ab.sh # ab.sh with HEAD pointed at my own worktree /agent-workspace/oss/GAM-wt-verifyC1–C7 (see C8 (send userinvitation, readonly-only scope) re-run directly: base 15, head 12 ( Mutants (
New adversarial checks (not in the original body, both hold)
Rejected-alternatives and control audit
Lint / compile
Verification method
Not verified here (as the body already discloses): a call against a real Workspace tenant — needs an admin with a User-Invitations-only client, the reporter's setup in GAM-team#1934. Rules: mutate-the-rejected-alternatives=covered (M1/M2/M3 above) | no-control-cases-in-the-suite=covered (C4/C5/C7 re-run on base) | ledger-row-needs-its-fixture=covered (B9 measured with its own cfg, not argued) | prior-art-recheck-at-gate=covered (git log re-run at this gate) | static-row-vs-alias-stub=unreachable (no static rows in this body; upstream has no unit-test infra to alias into) |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the gating lane (gating review).
Verdict: approve. Ready for the operator to submit upstream. Reviewed at head e10a630884614f01d0e180a3ac5cecdaf2762a8e against base dac28c15f557a9a7b92546acd11b1f53d431a688.
What I checked
Bug is real on the base. I fetched src/gam/__init__.py at the base sha and read buildGAPIObject (src/gam/__init__.py:5756-5768):
discovery_scopes = list(service._rootDesc.get('auth', {}).get('oauth2', {}).get('scopes', {}).keys())
extra_scopes = API.EXTRA_SCOPES.get(api, [])
API_Scopes = set(discovery_scopes + extra_scopes)
...
GM.Globals[GM.CURRENT_CLIENT_API_SCOPES] = API_Scopes.intersection(GM.Globals[GM.CREDENTIALS_SCOPES])
if api not in API.SCOPELESS_APIS and not GM.Globals[GM.CURRENT_CLIENT_API_SCOPES]:
systemErrorExit(NO_SCOPES_FOR_API_RC, Msg.NO_SCOPES_FOR_API.format(API.getAPIName(api)))I downloaded the live Cloud Identity v1 discovery document myself (revision 20260923, newer than the body's 20260920 snapshot). Its auth.oauth2.scopes block lists 12 scopes (allowlisteddomains, devices, groups, inboundsso, policies, their .readonly variants, devices.lookup, cloud-platform) and contains zero occurrences of cloud-identity.userinvitations; the isInvitableUser method carries no scopes list either. On the base, EXTRA_SCOPES (src/gam/gamlib/glapi.py:144-153) has only CLOUDRESOURCEMANAGER and VAULT. So for a client whose Cloud Identity scopes are exactly {cloud-identity.userinvitations} the intersection is empty, CLOUDIDENTITY_USERINVITATIONS is not in SCOPELESS_APIS, and buildGAPIObject exits 15 before _getIsInvitableUser (:49256) ever issues the isInvitableUser call. That is the issue's symptom exactly.
The fix. The two added lines put both User Invitations scopes into the set the intersection is taken over. _CLIENT_SCOPES offers this API with 'subscopes': READONLY (glapi.py:422-425), and the oauth flow adds f'{scope["scope"]}.readonly' for such entries (__init__.py:11428), so the .readonly variant is a real credential scope and needs to be listed too. The entry sits in alphabetical position and matches the neighbouring entry's indentation. A client with neither scope still has an empty intersection and still gets the 15 (the body's C7 control, which also kills the SCOPELESS_APIS alternative M3). set() over the concatenation means a future discovery revision that adds the scopes changes nothing.
Test evidence. Upstream has no unit-test suite (CI runs live gam against a tenant), so the evidence is the in-process A/B harness with the HTTP transport faked. The body carries verbatim base and head output for C1 (exit 15 / exit 59, with the isInvitableUser call visible only on head), a five-discriminating-case plus three-control matrix, and three mutants each killed by a named case. The Breaker's verification comment at this head re-ran the matrix and mutants and matched, measured the DASA row (B9) with a real gam.cfg instead of arguing it, confirmed by grep that EXTRA_SCOPES has a single reader, and drove a fourth command (gam <user> check isinvitable) through the same boundary.
Boundaries. The diff changes one dict lookup feeding one truthiness check. Ledger rows B1 (full scope only), B2 (readonly only), B3 (neither, falsy), B4 (plus another CI scope), B5 (noinvitablecheck), B6 (readonly-only write now reaches the API and gets GAM's own 403 message, exit 12), B7 (invitable true/false), B8 (other API keys untouched), B9 (DASA skips the block), B10 (future discovery listing), B11 (service-account path) cover every input I could construct for that check.
Prior art, re-run. gh search prs --repo GAM-team/GAM for userinvitations, no scopes authorized, EXTRA_SCOPES, 1934, whatis: only merged 2023 PRs (GAM-team#1329, GAM-team#1339, GAM-team#1355, GAM-team#1368, GAM-team#1481, GAM-team#214). Open upstream PRs are GAM-team#1978 (MCP server) and GAM-team#1877 (homebrew workflow). compare dac28c15...main shows 3 commits, all touching only .github/workflows/build.yml; EXTRA_SCOPES at main is unchanged. Issue GAM-team#1934 is open (state_reason: reopened). No competing fix.
Policy. I confirmed CONTRIBUTING.md, AGENTS.md, .github/PULL_REQUEST_TEMPLATE.md and .github/CONTRIBUTING.md all 404 at main. No AI policy, no DCO, no linter. The single commit message ("Fixed bug where Cloud Identity User Invitations commands, including gam whatis, failed with ... GAM-team#1934") matches the maintainers' plain-sentence "Fixed bug in ..." style. No AI attribution in the commit, branch or title.
Hygiene and generated-text pass. One bug, +2/-0, no unrelated changes, no comments added. No em dashes or filler in the diff, commit message, title or body.
Fork CI. gh pr checks reports no checks (Actions not enabled on the fork); upstream CI needs tenant credentials a fork cannot have, so this is absence, not failure.
Notes for the operator
- Not verified against a real Workspace tenant; the body says so. If you have a client authorized only for "Cloud Identity API - User Invitations",
gam whatis nosuchentity@yourdomain noinfoshould exit 59 after this change instead of 15. - Maintainers usually commit directly and record user-visible fixes in
src/GamUpdate.txtthemselves; outside PRs do not touch it, so leaving it alone is right.
|
Submitted upstream for review. |
Summary
gam whatis <email>on an address that does not exist exits 15 (NO_SCOPES_FOR_API_RC) and printsERROR: There are no scopes authorized for the API(s): Cloud Identity API - User Invitations, even though the admin has authorized the User Invitations scope. Expected: exit 59 (ENTITY_IS_UKNOWN_RC). Issue 'gam whatis' exit status is incorrect for non-existent entries GAM-team/GAM#1934.buildGAPIObject(src/gam/__init__.py:5756-5768) accepts a client's scopes for an API only if they appear in the discovery document'sauth.oauth2.scopesor inAPI.EXTRA_SCOPES[api]. The Cloud Identity v1 discovery document (checked at revisions 20260826 and 20260920) does not listcloud-identity.userinvitationsorcloud-identity.userinvitations.readonly, andEXTRA_SCOPEShas no entry forCLOUDIDENTITY_USERINVITATIONS. So the scope GAM's owngam oauth createmenu offers for this API (glapi.py:422-425) never counts, and GAM exits before calling the API.EXTRA_SCOPES, the dict whose comment reads "Scopes not in the discovery doc that are still valid for the API". Google's REST reference forcustomers.userinvitations.isInvitableUserlists exactly these two scopes. The diff is 2 lines insrc/gam/gamlib/glapi.py.gam print|show|info userinvitation(s)andgam send|cancel userinvitation, which build the same API object and failed the same way (case C6 below).Upstream
maindac28c15f557a9a7b92546acd11b1f53d431a688("chore: upgrade PyPi deps (Upgrade PyPi deps GAM-team/GAM#1990)"), version 7.48.12. Re-fetched at hand-off and still the tip oforigin/main.e10a630884614f01d0e180a3ac5cecdaf2762a8eonfix/userinvitations-extra-scopessrc/gam/gamlib/glapi.py,EXTRA_SCOPES(line 144)doWhatIs(src/gam/__init__.py:13427) →_getIsInvitableUser(:49256) →_getCIUserInvitationsEntity(:49242) →buildGAPIObject(API.CLOUDIDENTITY_USERINVITATIONS)(:5756), which exits at:5768Bug
The trigger is an OAuth client whose only Cloud Identity scope is
cloud-identity.userinvitations(or.readonly), which is what an admin gets when they select only "Cloud Identity API - User Invitations" ingam oauth create.gam whatison an address that is not a user, alias or group reaches the invitable-user check, callsbuildGAPIObject(API.CLOUDIDENTITY_USERINVITATIONS)and exits 15 with the misleading "no scopes authorized" error. The scope is authorized, so thegam oauth updateadvice in the issue thread does not help, and the reporter's comment on 7.46.11 confirms this. The same thing happens for every User Invitations command (print|show|info|send|cancel userinvitation(s)). Scripts that branch ongam whatisexit codes (56/59 for "does not exist") get 15 instead. Anyone who authorized the default scope set is not affected, because Cloud Identity Groups is on by default (glapi.py:400-403, nooffByDefault), which putscloud-identity.groupsinto the intersection. That is why the bug looks "obscure", in the maintainer's word.Repro
This repo has no unit-test suite; CI runs live
gamcommands against a real tenant. So the repro runs the real command in-process against a fake Google backend:httplib2.Http.requestis replaced so that discovery documents come from the live snapshots and API calls get canned responses. Nothing in GAM is patched. The script and discovery snapshots are in/agent-output/oss/GAM/(repro_whatis.py, sha256abe6a674…;discovery/cloudidentity-v1.jsonrevision 20260920,discovery/admin-directory_v1.json).Verbatim output on base (dac28c1):
This matches the issue text exactly (message and exit status 15).
Fix
# Scopes not in the discovery doc that are still valid for the API. EXTRA_SCOPES = { + CLOUDIDENTITY_USERINVITATIONS: ['https://www.googleapis.com/auth/cloud-identity.userinvitations', + 'https://www.googleapis.com/auth/cloud-identity.userinvitations.readonly'], CLOUDRESOURCEMANAGER: ['https://www.googleapis.com/auth/cloudplatformfolders',EXTRA_SCOPESis the mechanism upstream already uses for this exact situation (Cloud Resource Manager and Vault scopes missing from discovery; Business Account Management used it until ddda059). Both scopes are needed:_CLIENT_SCOPESoffers the scope with'subscopes': READONLY, so the read-only variant is a real client choice.Alternatives rejected:
CLOUDIDENTITY_USERINVITATIONStoSCOPELESS_APIS. This removes the check altogether: a client with no User Invitations scope at all would skip the clear error and go on to a 403 from the API. Mutant M3 below shows it passing C7 (no scope → 59 instead of 15).buildGAPIObject. That is a larger change to shared code, when the data table is the documented extension point.doWhatIsto catch the exit.systemErrorExitcallssys.exit. Catching it in one command would hide the problem forwhatisonly and leaveprint|send|cancel userinvitation(s)broken.Test evidence
Upstream has no test suite to extend (the old
*_test.pymodules were deleted in 03917fb, and the recent outside PRs GAM-team#1912 and GAM-team#1764 touch no tests). So the regression evidence is the executable A/B matrix below. Each case runs the unmodified command path on BASE (dac28c1) and HEAD (e10a630). The script is/agent-output/oss/GAM/ab.shand the full transcript is/agent-output/oss/GAM/ab-matrix.txt.whatis x noinfo, scopeuserinvitations, not invitableuserinvitations.readonlyuserinvitations, isInvitableUser → trueENTITY_IS_AN_UNMANAGED_ACCOUNT_RC), "User Invitation: …"print userinvitations, scopeuserinvitationssend userinvitation x, scopeuserinvitations.readonlyonlyAPI_ACCESS_DENIED_RC), API 403 → "authorized for … scopes: cloud-identity.userinvitations"cloud-identity.groups.readonly(issue workaround)whatis x noinfo noinvitablecheckwhatis x noinfo, no User Invitations scope at allVerbatim HEAD output for C1:
Mutants of the fixed
glapi.py(/agent-output/oss/GAM/mutants.sh, transcriptmutants.txt), run on C1/C2/C7:.readonly).readonly(drop full scope)EXTRA_SCOPESentry, API added toSCOPELESS_APISLint/format: the upstream has no linter config or lint CI step (
pyproject.tomland.github/workflows/build.ymlname none).python3 -m py_compile src/gam/gamlib/glapi.pypasses, and the entry follows the 2-space indent and continuation alignment of the neighbouringCLOUDRESOURCEMANAGERentry.Verification method
executed. The runtime was Linux container, Python 3.14.7, venv holding thepyproject.tomlpins (google-api-python-client 2.200.0, google-auth 2.58.0, httplib2 0.32.0, …). The realgam.ProcessGAMCommandpath ran with only the HTTP transport faked. The discovery documents are live snapshots fromcloudidentity.googleapis.com/admin.googleapis.comtaken 2026-09-24, and the scope list was checked against Google's published reference forisInvitableUser.Not verified here: a call against a real Workspace tenant. That needs an admin with a User-Invitations-only client, which is the reporter's setup in GAM-team#1934. Fork CI has not run: Actions is not yet enabled on
sprayberry-code/GAM, and upstream CI needs tenant credentials that fork runs do not have.Prior art
gh pr list --repo GAM-team/GAM --search "1934 in:body" --state all→ none (Scout)gh search prs --repo GAM-team/GAMforuserinvitations,whatis,EXTRA_SCOPES,no scopes authorized,1934→ only merged 2023-era PRs UserInvitations clean up GAM-team/GAM#1329, Update policies and user invitations GAM-team/GAM#1339, Have whatis check for unmanaged accounts GAM-team/GAM#1355 (which added the invitable check), Add convertalias to delegate commands to convert aliases to primary GAM-team/GAM#1368, Add verifytarget to update alias to address Issue #1479 GAM-team/GAM#1481, Multiple fixes and enhancements GAM-team/GAM#214; none touch scope handlinggh search issueson the same terms → 'gam whatis' exit status is incorrect for non-existent entries GAM-team/GAM#1934 (this issue), plus closed unauthorized_client error when running whatis for non-existent account GAM-team/GAM#1544 (2022,whatis+ service-account scope, a different cause) and Authorization Error Error 400: invalid_scope GAM-team/GAM#1358 (invalid_scope)git log origin/main --grep "#1934\|userinvit"→ only 2023 commits f212022, b333816, 0eee697, f252f75git log -S EXTRA_SCOPES -- src/gam/gamlib/glapi.py→ ddda059 (removed the Business Account Management entry), 0fdcab4 (introduced the dict)Policy
Checked at
main@ dac28c1 viagh api repos/GAM-team/GAM/contents/<path>:CONTRIBUTING.md,AGENTS.md,.github/PULL_REQUEST_TEMPLATE.md,.github/CONTRIBUTING.md,CODE_OF_CONDUCT.md,AI_POLICY.md,.github/AI_POLICY.md,AI.md,AGENT_POLICY.mdandCLAUDE.mdare all absent (404) at that ref..github/holds onlyISSUE_TEMPLATE*,actions,stale.ymlandworkflows. Upstream has no written contribution policy, no AI policy (silent), no CLA, no DCO, no required formatter/linter and no test command.Observed convention: maintainer commits are plain sentences ("Fixed bug in
gam print orgs allfieldsthat caused a trap."), and the commit here follows that. Maintainers record user-visible changes insrc/GamUpdate.txtunder a version heading; outside PRs (GAM-team#1912, GAM-team#1764) do not touch it, so this diff leaves it alone for the maintainer's release note.Disclosure facts for the operator
EXTRA_SCOPESmissing the User Invitations scopes that discovery omits).repro_whatis.py,ab.sh,mutants.sh), ran the base/head matrix and mutants, and wrote this facts sheet.Boundaries
The diff adds one dict entry, which feeds one expression:
API_Scopes = set(discovery_scopes + EXTRA_SCOPES.get(api, []))intersected with the credential scopes, then thenot ...CURRENT_CLIENT_API_SCOPEStruthiness check at__init__.py:5767.userinvitationsonly (among CI scopes)userinvitations.readonlyonlynoinvitablecheckgivenbuildGAPIObjectis never called, unchangedsend)ClientAPIAccessDeniedExitnames the missing full scope (exit 12)isInvitableUsertrue vs falseCLOUDIDENTITY_GROUPS,_DEVICES,_POLICY, the CRM/Vault entries)EXTRA_SCOPES.getunchanged for every other key (printed inmutants.txt)enable_dasa)if not GC.Values[GC.ENABLE_DASA]block is skipped, soEXTRA_SCOPESis never read__init__.py:5761-5763)set(...)dedups, so no behaviour changeset()over the concatenation)getSvcAcctCredentials)EXTRA_SCOPES(single reader at:5763)Suggested upstream PR title
Fixed
gam whatisand User Invitations commands failing with "no scopes authorized" when only the User Invitations scope is authorized (GAM-team#1934)