Skip to content

Secrets IAM

strad’s secrets surfaces are governed by two independent layers, and neither substitutes for the other:

  • A feature flag decides what a surface offers. SECRETS_REVEAL_ENABLED turns the console’s reveal control on; absent means off. It is convenience and clarity — a deployment that has decided not to display production secrets in a browser should not render a button that does.
  • A service-account grant decides what is possible. A flag is a UI decision and a reader can talk themselves past it. An IAM permission that does not exist cannot be talked past.

Where the two disagree, strad fails closed and says which way they disagree, because the two directions call for opposite fixes. See the console’s reveal posture for what the page shows.

This page is the second layer: the exact permission each operation needs, the gcloud to apply it, and an assertion to prove it landed. It requires project IAM admin on the two secrets projects, which no strad process holds — these are human commands.

Derived from src/secrets/parameters/gcp.ts, not from the role names. This is the table the grants below are built from.

Console operationAPI callsPermissions required
list(namespace)PM parameters.list, …/versions list, …/versions/{v} GETparametermanager.parameters.list, parametermanager.parameterVersions.list, parametermanager.parameterVersions.get
create — non-secretPM parameter POST, PM version POSTparametermanager.parameters.create, parametermanager.parameterVersions.create
create — secretSM secrets POST, SM :addVersion, PM parameter POST, PM parameter GET, SM :getIamPolicy + :setIamPolicy, PM version POST+ secretmanager.secrets.create, secretmanager.versions.add, parametermanager.parameters.get, secretmanager.secrets.getIamPolicy, secretmanager.secrets.setIamPolicy
rotate — non-secretPM version GET + POSTas create, non-secret
rotate — secretPM versions list + version GET, SM :addVersionparametermanager.parameterVersions.list, …get, secretmanager.versions.add
setNotePM version GET + POSTas create, non-secret
reveal — secretPM version GET, then SM versions/latest:accesssecretmanager.versions.access ← the one prod must not hold, on top of the PM reads
reveal — non-secretPM version GET onlyparametermanager.parameterVersions.get
destroyPM parameter GET, versions list + DELETE, parameter DELETE, SM secret DELETEparametermanager.parameters.get, …parameters.delete, …parameterVersions.list, …parameterVersions.delete, secretmanager.secrets.delete
resolvethe list row, then PM …/versions/{v}:render per parameterparametermanager.parameterVersions.render ← the resolver’s whole reason to exist, on top of the PM reads

Two things fall out of this table, and both matter:

Every row also needs the list row’s Parameter Manager reads, which the console performs before any mutation; they are not repeated per row. The custom roles below grant a few permissions no current code path calls (secrets.list, versions.list, parameters.update) — deliberately, so a future version of the console does not need an IAM change to enumerate or relabel what it already owns.

Reveal is the only operation that needs secretmanager.versions.access. Everything else the console does — create, rotate, note, delete, list — works without it. So write-without-read is achievable as a permission set: the console keeps managing secrets and loses the ability to display one. It is not a boundary on its own, because a secret create also needs secretmanager.secrets.setIamPolicy, and that permission can grant read back to its holder — see the danger note below.

One console feature is a reveal under another name: an oauth: connector reads its own OAuth client through it, so a connector needs versions.access on its two client secrets and nowhere else. That is the one exception to §1, granted per secret — see An oauth: connector reads its own OAuth client.

parameterVersions.get is safe to keep. It reads the parameter’s raw envelope, and for a secret parameter that envelope holds a __REF__ pointer, not a value. Listing keeps working; nothing in it dereferences.

/tokens mints, revokes and forgets /mcp tokens by rewriting one secret parameter, /strad/{env}/gateway/static/STRAD_TOKENS. Each of those is a read-modify-write, and the two halves use different credentials:

HalfCredentialWhat it callsPermissions
readthe gateway’s resolver (STRAD_PARAMS_RESOLVER_SERVICE_ACCOUNT_KEY_JSON)PM versions list + GET, then …/versions/{v}:render on that one paththe resolver’s roles below, plus the parameter’s own principal holding secretAccessor on its secret
writethe console’s admin (SECRETS_ADMIN_KEY_JSON)the rotate row above, or the create row on the first mint§1’s custom roles, unchanged

Why not the console’s own read. On prod the console holds no read path (§1), so a reveal() through it is a 403. Granting it one, even secretAccessor on this one secret, would put a standing read of the gateway’s access-control list on a credential that never needs to display it, and §1’s deny policy blocks that grant anyway. The resolver already holds exactly this read: /mcp makes it on every request, because the token store cannot work without it. Borrowing it adds no grant anywhere.

So there is nothing to apply for /tokens. A deployment where /mcp accepts its tokens already has every grant a mint or revoke needs. If /tokens reports the resolver was refused, /mcp is failing the same read; fix it with the resolver’s roles and the parameter’s own grant. If it reports the parameter would not render (400), the parameter’s own principal lacks secretAccessor on its secret — the shape of a parameter seeded outside strad; see Backfilling. If it reports the console’s admin credential was refused, the console is missing the write half of §1.

Never grant the secrets MCP credential parameterAccessor

Section titled “Never grant the secrets MCP credential parameterAccessor”

Goal. strad-secrets-admin@strad-secrets-prod.iam.gserviceaccount.com can add, rotate, note and delete secrets, and cannot read one back. The one exception is an oauth: connector’s own OAuth client, which the console has to read to run the consent. It is granted secret by secret, never here at project level, and the console makes that grant itself — see An oauth: connector reads its own OAuth client, which is also where the deny policy below stops it.

Bonus, and it is a real one. This service account’s key (SECRETS_ADMIN_KEY_JSON) is among the credentials exposed in tadasant-internal#215, and rotating it has been deliberately deferred. Downgrading the account shrinks the blast radius of the un-rotated key: an attacker holding it could no longer read prod secret values by simply asking for them, only write them. That does not make the key safe and it is not a substitute for rotation — a write-capable credential is still a serious one — but it raises the cost of exfiltration and makes it visible. Treat this change as a partial mitigation of an accepted risk, not only as a feature.

No predefined role gives Secret Manager create + delete + add-version without versions.access, so this needs two custom roles.

Terminal window
PROJECT=strad-secrets-prod
SA=strad-secrets-admin@${PROJECT}.iam.gserviceaccount.com
# --- Secret Manager: create, delete, add versions. NOT versions.access. -------
cat > /tmp/strad-console-sm.yaml <<'YAML'
title: strad secrets console (Secret Manager, write-only)
description: Create/delete secrets, add versions, and grant a parameter access to its own secret. Deliberately excludes secretmanager.versions.access.
stage: GA
includedPermissions:
- secretmanager.secrets.create
- secretmanager.secrets.delete
- secretmanager.secrets.get
- secretmanager.secrets.list
# The IAM half of a secret create. `create()` grants the PARAMETER's own
# principal secretAccessor on the secret it points at; without these two the
# create fails partway and rolls back, and the console cannot write a secret
# at all. Neither READS a value — but setIamPolicy can GRANT the ability to,
# to this same principal. See the danger note above; the deny policy is what
# actually closes that, not this role's contents.
- secretmanager.secrets.getIamPolicy
- secretmanager.secrets.setIamPolicy
- secretmanager.versions.add
- secretmanager.versions.list
YAML
gcloud iam roles create stradSecretsConsoleSm \
--project="${PROJECT}" --file=/tmp/strad-console-sm.yaml
# --- Parameter Manager: full management. NOT parameterVersions.render. --------
cat > /tmp/strad-console-pm.yaml <<'YAML'
title: strad secrets console (Parameter Manager)
description: Manage parameters and read raw envelopes. Deliberately excludes parametermanager.parameterVersions.render.
stage: GA
includedPermissions:
- parametermanager.parameters.create
- parametermanager.parameters.delete
- parametermanager.parameters.get
- parametermanager.parameters.list
- parametermanager.parameters.update
- parametermanager.parameterVersions.create
- parametermanager.parameterVersions.delete
- parametermanager.parameterVersions.get
- parametermanager.parameterVersions.list
- parametermanager.parameterVersions.update
YAML
gcloud iam roles create stradSecretsConsolePm \
--project="${PROJECT}" --file=/tmp/strad-console-pm.yaml
# --- Swap the broad roles for the narrow ones --------------------------------
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" \
--role="projects/${PROJECT}/roles/stradSecretsConsoleSm"
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" \
--role="projects/${PROJECT}/roles/stradSecretsConsolePm"
gcloud projects remove-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" --role="roles/secretmanager.admin"
gcloud projects remove-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" --role="roles/parametermanager.admin"
# Enable this API in both projects. It backs BOTH the runtime probe AND the audit
# commands below — they are the same call. Without it, the console reports
# "capability unverified" and withholds reveal (safe but opaque), and the audit
# itself answers 403 instead of answering the question.
gcloud services enable cloudresourcemanager.googleapis.com --project="${PROJECT}"

Audit assertion. Run as the service account itself; the first command is the one that matters.

There is no gcloud subcommand for this. testIamPermissions is a method on the resource, not a CLI verb, so the audit is a REST call to Cloud Resource Manager — the same endpoint capabilities.ts uses at runtime, which is what makes the assertion and the console’s own report the same fact.

Terminal window
gcloud auth activate-service-account --key-file=/path/to/prod-console-key.json
CRM="https://cloudresourcemanager.googleapis.com/v1/projects/${PROJECT}:testIamPermissions"
# MUST print {} — no read path, by either route.
curl -sS -X POST "${CRM}" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"permissions":["secretmanager.versions.access","parametermanager.parameterVersions.render"]}'
# MUST list all six — the console still manages secrets. The two IAM-policy
# permissions are part of a secret CREATE (see the section below); a role missing
# them reports writeSecretValues=false and the console withholds the create.
curl -sS -X POST "${CRM}" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"permissions":["secretmanager.secrets.create","secretmanager.versions.add","secretmanager.secrets.getIamPolicy","secretmanager.secrets.setIamPolicy","parametermanager.parameters.create","parametermanager.parameters.delete"]}'

The response carries only the subset the caller holds, so “none of them” comes back as an empty body — {}, not a list of false. The first command printing {} is the whole point of this section. strad’s own console reports the same fact on the page, from the same call.

A 403 from either command is not an answer about the grant. It means the Cloud Resource Manager API is not enabled on the project — the gcloud services enable line above — or that this credential has no access to the project at all. It is the same 403 the runtime probe reports as “capability unverified”.

An oauth: connector reads its own OAuth client

Section titled “An oauth: connector reads its own OAuth client”

A server with an oauth: connector has a Connect button, and the flow behind it reads the slug’s own OAuth client — the client id and client secret seeded under /strad/{env}/mcp/<slug>/static/. It reads them through the console’s store, with the console’s credential, using reveal(). The connect POST reads the client id before it sends anyone to the provider, and the callback reads both to exchange the code.

For a secret parameter, reveal() is Secret Manager’s versions/latest:access, and that needs secretmanager.versions.access: the permission §1 withholds. So on a console run the way §1 says, a connector whose client values are secret parameters lists as ready, and pressing Connect refuses. The page names the permission and the exact Secret Manager resource, and strad.connector_connect_failed records reason: client_unreadable with status: 403 (see Connectors).

strad makes this grant itself. Both halves of it:

  • On create. Creating a secret parameter whose path is a declared connector’s oauth.clientIdVariable or oauth.clientSecretVariable, under that slug’s own static/ namespace, also binds the console’s service account secretAccessor on that one secret. The console derives which paths qualify from the config’s oauth: blocks — no field on the create form can ask for it — so a connector seeded from /ui/<secrets-slug> today is connectable without anyone opening a terminal.
  • On the connector’s own page. Every connector that already exists was seeded before that, so a slug whose client is present and unreadable gets a Grant read on this client button beside the refusal it just produced. It binds the same member on the same two secrets, and then reads the values back before it claims anything.

The read-back is the point, not a formality. The deny policy in §1 is evaluated before any allow, so on a deployment that applied it the setIamPolicy succeeds and the read still fails. The page says exactly that — it names the permission, the resource and the deny policy — rather than reporting success and letting Connect fail after the human has consented. Two records say so in the logs: strad.connector_client_granted (slug, variable, result) when it worked, and strad.connector_client_grant_failed at ERROR with reason: denied when the binding landed and bought nothing.

This is only coherent on a deployment that accepts §1’s posture. §1 is explicit that write-without-read there is a speed bump and an audit signal rather than a wall: the console holds setIamPolicy at project level and is two API calls from granting itself read. The button changes how ergonomic that self-grant is, not whether it is possible. A deployment that closed the escalation properly with the deny policy has decided its connectors need a human, and it gets told that instead of a button that cannot work. What the button costs is the conspicuousness of the act, and what replaces it is the exported record above.

The oauth/ kind is never granted, for either path. The client id and client secret are values a human typed and holds a copy of; the credential strad mints and stores under oauth/ is one no person has ever seen, and the console being unable to read it back is the whole meaning of the kind. A client hand-seeded under oauth/ is refused by the button, with a sentence saying so, and keeps the manual grant below.

The fallback: the same grant by hand. For a client under oauth/, for a store this console does not front, or for any deployment that would rather not press the button, it is roles/secretmanager.secretAccessor for the console’s service account, bound on that slug’s own client-id and client-secret secrets only:

Terminal window
PROJECT=strad-secrets-prod
SA=strad-secrets-admin@${PROJECT}.iam.gserviceaccount.com
SLUG=google-sheets-tadas412-ro
ENV=prod
for VAR in GOOGLE_SHEETS_OAUTH_CLIENT_ID GOOGLE_SHEETS_OAUTH_CLIENT_SECRET; do
# A secret's id is the parameter id: strad's fold of the canonical path. Past
# 63 characters the fold keeps a 54-character head and appends a hash, so the
# two ids share a head and differ only in the hash — compute them, don't guess.
SECRET=$(node --experimental-transform-types --no-warnings -e \
'import("./src/secrets/parameters/wire.ts").then((w) => console.log(w.parameterId(process.argv[1])))' \
"/strad/${ENV}/mcp/${SLUG}/static/${VAR}")
gcloud secrets add-iam-policy-binding "${SECRET}" --project "${PROJECT}" \
--member="serviceAccount:${SA}" --role=roles/secretmanager.secretAccessor
done

Run it from a checkout of this repo, since the fold is strad’s — which is most of why this is the fallback rather than the procedure. The refusal page names the client id’s resource verbatim (projects/<project>/secrets/<id>), but nothing reads the client secret’s until the callback, after the human has already consented, so that id has to be computed rather than read off a refusal. Grant both before anyone presses Connect. If the client id is seeded as a non-secret parameter, it has no secret to grant on, and the loop needs only the second name. A client id is not a secret: it goes out in every authorization URL.

Why not a project-wide accessor. roles/secretmanager.secretAccessor on the project gives the console every secret value in it: every other slug’s credentials, every refresh token a connector has stored, strad’s own. Removing exactly that is what §1 is for. The per-secret binding gives the console two values it has to read anyway, for one slug, and nothing else. The tokens a connector writes under oauth/ stay unreadable to it, and those are the credentials worth protecting.

Four consequences to know:

  • The §1 audit cannot see this grant, in either direction. projects:testIamPermissions answers at project level, so it still prints {} after a per-secret binding. Audit the binding on the secret itself: gcloud secrets get-iam-policy "${SECRET}" --project "${PROJECT}".
  • The deny policy in §1 overrides it. Deny is evaluated before allow, so with strad-console-no-read applied, this binding grants nothing and Connect refuses with the same 403 — whether the binding was written by hand or by the button, which is why the button verifies instead of assuming. A deny rule’s condition can match resource tags but not resource names. Keeping both means tagging the connector’s client secrets and exempting that tag in the rule’s denialCondition, and strad has neither built nor tested that. Decide which one a deployment wants before applying the deny policy. Known limitations #99 has the trade.
  • The console’s reveal control reaches the same two values. The grant is on the console’s credential, and reveal uses that credential too. With SECRETS_REVEAL_ENABLED on, a signed-in session could display this slug’s client id and secret. It is off in production, and the flag is what keeps those two values off a screen.
  • The binding lives on the secret, not the version. Rotating the client secret adds a version and keeps it. Deleting the parameter deletes its secret and the binding with it — and since a create now makes the grant, re-seeding from scratch restores it on its own.

Why a parameter must be granted access to its own secret

Section titled “Why a parameter must be granted access to its own secret”

This is the least guessable thing on this page, and getting it wrong produces an error that points at the wrong credential.

A secret parameter’s Parameter Manager payload holds a __REF__ pointer, and :render dereferences it as the parameter, using a principal minted with the parameter itself. The caller’s permissions authorise the :render call; they have nothing to do with reaching the secret. So a secret must grant roles/secretmanager.secretAccessor to:

principal://parametermanager.googleapis.com/projects/PROJECT_NUMBER/uid/locations/global/parameters/PARAMETER_UID

Without it, every resolution of that parameter fails with 400 SECRET_REFERENCE_ERROR — while the resolver holds every permission the message seems to implicate, and a testIamPermissions probe of the resolver comes back completely clean. Nothing about the failure points at the parameter.

strad makes this grant itself. create() in src/secrets/parameters/gcp.ts (and the matching path in the secrets MCP server’s write-client.ts) reads policyMember.iamPolicyUidPrincipal from the parameter and sets the binding on the secret, after the parameter exists and before the first version — so a complete-looking parameter with an unresolvable reference never exists. A grant that fails rolls the whole create back. The only IAM work a human does is making sure the writing credential holds secretmanager.secrets.getIamPolicy and secretmanager.secrets.setIamPolicy, which the roles on this page include.

Backfilling parameters created before strad made this grant

Section titled “Backfilling parameters created before strad made this grant”

Parameters written by an earlier strad have no binding, and nothing repairs them automatically — rotate adds a secret version and does not touch IAM. Repair is per-parameter and idempotent:

Terminal window
PROJECT=strad-secrets-staging # or strad-secrets-prod
LOCATION=global
for ID in $(gcloud parametermanager parameters list \
--project "$PROJECT" --location "$LOCATION" \
--format='value(name.basename())'); do
# Only secret parameters have a secret to bind.
gcloud secrets describe "$ID" --project "$PROJECT" >/dev/null 2>&1 || continue
PRINCIPAL=$(gcloud parametermanager parameters describe "$ID" \
--project "$PROJECT" --location "$LOCATION" \
--format='value(policyMember.iamPolicyUidPrincipal)')
# An empty principal means the describe failed; binding "" is an error, not a
# no-op, and it would abort the loop partway through.
[ -n "$PRINCIPAL" ] || { echo "no principal for $ID, skipping" >&2; continue; }
gcloud secrets add-iam-policy-binding "$ID" --project "$PROJECT" \
--member="$PRINCIPAL" --role=roles/secretmanager.secretAccessor
done

Assert it worked. Pick one secret parameter and render it: this must print its real value, not a __REF__ and not an error. Set ID explicitly — do not rely on whatever the loop above left behind:

Terminal window
ID=<one secret parameter id from the loop above>
VERSION=$(gcloud parametermanager parameters versions list "$ID" \
--project "$PROJECT" --location "$LOCATION" \
--format='value(name.basename())' --limit 1)
gcloud parametermanager parameters versions render "$VERSION" \
--parameter "$ID" --project "$PROJECT" --location "$LOCATION"

A 400 SECRET_REFERENCE_ERROR here means the binding did not land. Give it a minute first — an IAM change takes up to ~2 minutes to propagate (occasionally longer), and during that window the failure is indistinguishable from the bug.

Two other 400s are not this bug, and the message does not distinguish them:

  • INVALID_ARGUMENT … injection detected means the secret’s bytes broke the JSON payload they were substituted into — a value carrying ", \, {, } or a newline, written before strad encoded secret values. Rotating it through the console or the secrets MCP server re-writes it encoded and fixes it. See Secrets.
  • The rendered value coming back as base64url rather than the value is not an error at all: that is the encoding, and strad’s own readers decode it. Pipe it through base64 -d if you are reading by hand.

The resolver’s roles: parameterViewer is not enough

Section titled “The resolver’s roles: parameterViewer is not enough”

roles/parametermanager.parameterViewer does not include parametermanager.parameterVersions.render. That permission is in roles/parametermanager.parameterAccessor, and rendering is exactly what the resolver does — resolve() is one list plus one :render per parameter.

A resolver provisioned with parameterViewer alone therefore lists fine and 403s on every render, which reads like an intermittent or partial outage rather than a missing role. It needs both:

Terminal window
PROJECT=strad-secrets-staging # or strad-secrets-prod
SA=strad-secrets-resolver@${PROJECT}.iam.gserviceaccount.com
# The parameters and their envelopes (list + get).
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" --role="roles/parametermanager.parameterViewer"
# The deref itself. WITHOUT THIS, every :render is a 403.
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" --role="roles/parametermanager.parameterAccessor"

The resolver does not need roles/secretmanager.secretAccessor. It never calls Secret Manager: the deref happens under the parameter’s own principal, which is what the grant in the previous section is for.

Prod’s resolver does currently hold it, alongside the two Parameter Manager roles, and that is fine — it grants nothing the resolver uses. Say so out loud because its presence is actively misleading during debugging: it looks like the thing that makes rendering work, so someone checking IAM sees it, concludes the grants are correct, and stops one step short of the parameter-side binding that is actually missing.

Audit assertion. Run as the resolver; both must be listed.

Terminal window
gcloud auth activate-service-account --key-file=/path/to/resolver-key.json
curl -sS -X POST \
"https://cloudresourcemanager.googleapis.com/v1/projects/${PROJECT}:testIamPermissions" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"permissions":["parametermanager.parameterVersions.list","parametermanager.parameterVersions.render"]}'

Holding render is not the same as rendering. The permission is the caller’s half; the parameter’s own principal must additionally reach the referenced secret (the section above), and that is a property of the parameter rather than of this credential — so no probe of this service account can tell you whether it is in place. Prove the round trip rather than the binding, with the credential the container will hold:

Terminal window
npm run store:check -- --config infra/strad.staging.yaml --resolve

It reports, by name and never by value, which of the namespace’s names the resolver can actually turn into a value — and reports a credential that cannot render at all as a render failure rather than as a namespace full of missing names, because the two have unrelated fixes. See Secrets.

Goal. The secrets MCP server on staging can read secret values and write parameters, so the store can be exercised end to end through the agent surface rather than only through the console.

This is scoped to staging on purpose, and what scopes it is the project. strad-secrets-staging is separate from the project holding strad’s own strad-<env>-* secrets, so the blast radius stops at its boundary. Inside that boundary sit the per-server credentials staging’s servers run on, so get_secret_value returns a real secret and the write tools can delete or rewrite a parameter a params: true server resolves at deploy time, breaking the next staging deploy. The bound is separation, not harmlessness.

In prod the MCP credential stays the Parameter Manager viewer with neither secretmanager.versions.access nor parametermanager.parameterVersions.render, so the property from tadasant-internal#202 — the /mcp channel cannot read a secret value — is unchanged there. That property is load-bearing and this does not weaken it.

Terminal window
PROJECT=strad-secrets-staging
SA=strad-secrets-viewer@${PROJECT}.iam.gserviceaccount.com
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" --role="roles/secretmanager.admin"
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${SA}" --role="roles/parametermanager.admin"
# Gates the server's startup probe AND the audit below — both are the same call.
gcloud services enable cloudresourcemanager.googleapis.com --project="${PROJECT}"

Audit assertion. All nine must be listed; the MCP server’s tool surface is derived from exactly this answer. The last three are the IAM half of a secret create — roles/secretmanager.admin and roles/parametermanager.admin above already carry them, so this asserts the grant rather than adding to it.

Terminal window
gcloud auth activate-service-account --key-file=/path/to/staging-viewer-key.json
curl -sS -X POST \
"https://cloudresourcemanager.googleapis.com/v1/projects/${PROJECT}:testIamPermissions" \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-d '{"permissions":["secretmanager.versions.access","secretmanager.versions.add","secretmanager.secrets.create","secretmanager.secrets.delete","parametermanager.parameters.create","parametermanager.parameterVersions.create","parametermanager.parameters.get","secretmanager.secrets.getIamPolicy","secretmanager.secrets.setIamPolicy"]}'

There is no flag to set for this. The server probes at startup and lists the tools the credential supports — get_secret_value, create_parameter, set_parameter_value, set_parameter_note, delete_parameter — so the grant above is the configuration. Revoke it and the tools disappear on the next boot.

The staging deploy runs this audit for you. deploy-staging.yml ends by running scripts/probe-capabilities.ts — the same probe, against the same two keys — and printing five booleans for each. It is non-fatal and prints no secret material. That exists because a grant that did not land is indistinguishable from a deliberate narrowing from the outside: both are just a smaller tool surface. See Operations.

Why a probe and not a flag, for the MCP surface

Section titled “Why a probe and not a flag, for the MCP surface”

A static flag can disagree with reality, and both directions are bad: claiming write on a read-only key advertises a tool that 403s on every call, and claiming read-only on a key that can read is a security property that exists in prose. The probe asks the authority that will answer the real call, so the surface cannot drift from the grant.

It fails closed. If cloudresourcemanager.googleapis.com is not enabled, or the probe is refused, or it times out, the server reports no capabilities and serves the four read-only tools — plus refresh_gateway, which the probe does not govern because it touches nothing in the store: it is offered wherever the bundle host can reach core, and only there.

Two honest limits on “cannot drift”. projects:testIamPermissions answers at project level, so a grant bound to an individual secret or parameter reads as absent — the surface narrows, which is the safe direction, but the audit assertions above would then be proving the wrong thing. And a conditional binding can be reported as held when its condition would not be met at call time, which widens the surface relative to reality; the call still fails at Google, but as a 403 rather than a missing tool. Grant at project level, unconditionally, and both go away. list_managed_namespaces carries the reason, so an agent can tell a human why an expected tool is missing instead of guessing.

The code ships first and the grants follow, in either order, because every state in between is safe:

  1. Now (prod, flag off, SA still admin). Reveal is hidden and the POST is refused. The console reports this as drift — the UI decision holds, the boundary does not — and names the fix. The un-rotated key from #215 is still read-capable until step 2.
  2. After the prod downgrade. Reveal is off by configuration and impossible by IAM. The console says so. Create, rotate, note and delete keep working; a reveal attempt could not succeed even if the flag were flipped by mistake.
  3. After the staging grant. Staging’s MCP surface grows the write tools and get_secret_value at the next boot. Prod’s does not, because prod’s credential is unchanged.