We built a web front end for GAM so our team can administer every client’s Google Workspace from one place — one install, no credentials on anyone’s machine.
If you administer Google Workspace for a living, you know GAM. It’s the tool that does the things the admin console won’t — bulk changes, real reporting, anything involving a few thousand users or a delegation you need to fix at 6pm.
You also know its shape. GAM is a command line tool you install on a laptop and authorise against one Workspace tenant. That’s fine when you have one tenant. We don’t. We look after a good number of them, and the maths gets ugly fast: every technician who needs GAM installs it, and then authorises it separately for every client. Ten clients and four people is forty authorisations, forty sets of credentials, and forty copies of an oauth2service.json sitting in someone’s home directory.
That’s not a scaling problem so much as a security problem wearing a scaling problem’s clothes. Client credentials that grant domain-wide delegation should not be spread across laptops. And when someone leaves, “which machines had which client’s keys on them” is not a question you want to be answering from memory.
So we built the obvious thing: GAM Web UI for MSPs. One GAM install on one server, every client tenant configured on it, and a web interface our team signs into with their Microsoft account.
How it works
GAM keeps its entire configuration in one directory, named by the GAMCFGDIR environment variable. That single fact is the hinge the whole thing turns on.
Every client tenant gets its own directory: its own gam.cfg, its own service account key, its own token cache, its own output folder. When a command runs, it runs with GAMCFGDIR pointed at exactly one of those. A command against one client is not prevented from reaching another client’s credentials by a permission check that might have a bug in it — it’s physically incapable of it, because it never learns the other directory exists.
The rest follows from there. You pick a client and type a command, or fill in a form from the command library. The request goes onto a queue, a worker runs GAM directly — as an argv array, never through a shell — and the output streams back into the page while it runs. Every run is recorded: who, which client, the full command, the full output, and any CSV it produced.
Sign-in is Microsoft Entra ID and nothing else. No password field, no reset flow, no local credential to steal. Access is per person and per client, so a technician who looks after three clients sees three clients.
The step Google won’t let you automate
Adding a client is the part we most wanted to make painless, and mostly we did. A super admin of the client signs in with Google once and consents. From that single consent we create their Cloud project, enable the APIs GAM needs, create the service account, generate and store its key, and store the token GAM will act with.
Then there’s domain-wide delegation, and it stops.
We went looking for an API for it, because it seemed like the sort of thing that ought to have one. It doesn’t. In GAM’s own source code the string domainwidedelegation appears exactly once — and it’s a URL, pre-filled, that GAM prints for you to open in the Admin console. If the tool everyone uses can’t do better than handing you a link, that’s your answer.
So we hand you a link too, pre-filled with the service account’s client ID and every scope already listed. Open it as the client’s super admin, press Save, done. One click, and it’s the only manual step left.
Three things that cost us an afternoon each
Some of what we learned is only interesting if you’re doing the same thing, in which case it’s very interesting.
enable_dasa needs the real customer ID. GAM’s service-account mode won’t run with customer_id = my_customer, which is the value every other part of Workspace happily accepts. It needs the actual C0xxxxxxx. We now read it from the Admin SDK during setup so nobody has to go hunting for it — but the failure, before we understood it, looked nothing like the cause.
Google’s consent screen dies quietly if you ask for too much. Our first version requested every scope GAM might ever want — 57 of them — in one grant. The consent screen loaded, listed them all, and then hung on submit. No error, no timeout, just a greyed-out button and a loading bar going nowhere. We trimmed the request to the 16 scopes the setup actually needs and it went through instantly. Everything else goes through delegation anyway.
A brand new service account doesn’t exist yet. IAM will create a service account, return it to you, and then — for the best part of a minute — answer that it does not exist. Asking it for a key immediately is the request that finds out. Nothing has gone wrong; it’s eventual consistency, and the fix is to wait it out rather than to show your operator a 404 about an account you just watched get created.
The plain-English box
Newer, and the part people react to: you can describe what you want and get the command.
“Suspend this person and take them out of every group” comes back as the GAM command that does it, along with an explanation and any warnings — and it lands in the command box for you to read. Claude writes the draft; a person still presses Run command.
That distinction is deliberate and it’s enforced in the design, not in a policy document. The drafting endpoint returns text. The only thing done with that text is putting it in a textarea. Running it takes a human click, which goes through the same tokenizer, the same command policy and the same audit trail as anything typed by hand. There is a test in the suite whose entire job is to assert that drafting a delete user command creates no run and queues no job.
Two other details worth knowing, because they’re what makes it useful rather than a party trick. First, the prompt carries the exact GAM version installed on the server, read from the binary rather than assumed. GAM’s arguments change between releases, and a flag remembered from a different version is a wrong answer that looks right — so the model is told which version it’s writing for, and told to say so in the warnings when it isn’t certain a subcommand exists in that one. Second, whether a command is destructive isn’t taken on trust from the model. We read that from the command’s own tokens. Asking it to warn you is not the same as believing it when it doesn’t.
It’s optional. No API key, no box.
What it isn’t
It isn’t SaaS. There’s no hosted version and no sign-up, and we’re not planning one. It runs on a server you control, behind your own identity provider and zero trust, for a team that already holds Workspace admin authority over the tenants connected to it.
It should not be exposed to anyone you wouldn’t hand an admin console to, because that is functionally what it is. An administrator running a destructive command against a client they’ve been given is the tool working — the audit log is the control, not the permission check.
Want a look?
We’re not publishing the source, but we’re happy to share it with people who have the same problem. If you’re an MSP running GAM across a pile of tenants and this sounds like it would save you the forty authorisations it saved us, email [email protected] and we’ll give you access to the repository. If you’re interested in contributing features or capabilities to it we’re open to that too!
Tell us a bit about how you’re handling it now, too. That part we’re genuinely curious about.