Policy-Scoped Keys
A policy-scoped Platform API key is a credential that can call
only three routes — /mcp/spec, /mcp/validate, /mcp/evidence —
across every MCP server in the organization that mints it. Every
other Platform API surface returns 403.
Policy-scoped keys are the recommended credential for Flex Gateway Policies: each policy instance holds a key that opens exactly the surface it needs and nothing more.
Why not just use a full-scope key?
A full-scope key that leaks from a Flex Gateway secret store gives an
attacker the whole authoring API — every tool create_*, every
publish_*, every dashboard read across the org. A policy-scoped key
leaks a much smaller blast radius: read pinned specs, validate MCP
calls against them, and post evidence events. The attacker cannot
pivot into other assets on the data plane, cannot mutate anything,
cannot list the authoring surface at all.
Every A²D-facing Flex Gateway policy demo in this repo assumes a
policy-scoped key. If you’re setting one up from scratch, mint a
policy-scoped key first, then wire it into the policy’s
apiKeySecretRef.
How to mint one
Settings → Platform MCP → API Keys.
- Enter a Name — e.g.
drift-a2d policy. - Under Scope, pick one:
- Full access — everything (default, today’s behavior).
- Policy-scoped (spec, validate, evidence) — only the three policy routes, across every MCP server in your organization.
- Click Generate Key.
- Copy the raw key from the confirmation dialog — it is shown once.
The Existing Keys list marks policy-scoped keys with a green
policy pill.
What the key can do
| Route | Method | Behavior for a policy-scoped key |
|---|---|---|
/api/platform/[id]/mcp/spec | GET | ✅ 200 with the pinned spec when [id] belongs to the key’s org. |
/api/platform/[id]/mcp/validate | POST | ✅ 200 with the verdict when [id] belongs to the key’s org. |
/api/platform/[id]/mcp/evidence | POST | ✅ 202 (fire-and-forget) when [id] belongs to the key’s org. |
/api/platform-mcp/mcp | POST | ❌ 403 always — the data plane is closed to policy keys. |
| Anything else that authenticates via Platform API key | — | ❌ 403 by scope, then 404/401 by route. |
The reverse-guard on /api/platform-mcp/mcp is what makes the model
tight: even if a policy-scoped key somehow tried a tools/list call,
the platform refuses before dispatching to any tool.
Cross-org access
Cross-org access is impossible regardless of scope: authorizePolicyRequest
looks up mcp_servers.organization_id for [id] and rejects when it
does not match the token’s org. A key minted in org A cannot read a
spec in org B even if scope is full.
Rate limits
Each policy route has its own per-key per-minute ceiling:
| Route | Ceiling / minute |
|---|---|
spec | 60 |
validate | 600 |
evidence | 120 |
Buckets are keyed by (key_id, route, minute) in the
policy_rate_limit_buckets table. When the ceiling is hit the route
returns 429 with a Retry-After header set to the number of
seconds remaining in the current minute.
The rate limiter is fail-open: if the counter table is unreachable the request is allowed through. Policies are latency-sensitive and a DB blip should not cause a global evidence outage.
Audit trail
Every request against the three policy routes is written to
policy_api_audit. The insert is fire-and-forget — a failing audit
never blocks the response.
select
route,
status,
count(*) as calls,
round(avg(latency_ms))::int as p50_ms
from policy_api_audit
where key_id = '<key-uuid>'
and received_at > now() - interval '1 hour'
group by 1, 2
order by 1, 2;Columns: id, key_id, asset_id, route, method, status,
latency_ms, user_agent, ip_inet, received_at.
The ip_inet column is extracted from the first token of
X-Forwarded-For; user agents are truncated to 256 characters.
End-to-end smoke test
Mint a policy-scoped key, then:
export A2D=https://<your-a2d-host>
export KEY=<the-key-shown-in-the-dialog>
export ASSET_ID=<any-mcp-server-id-in-your-org>
# 1. Fetch the pinned spec (should be 200).
curl -sS "$A2D/api/platform/$ASSET_ID/mcp/spec" \
-H "Authorization: Bearer $KEY" | jq '.tools | length'
# 2. Validate a tools/list call (should be 200 with decision=allow).
curl -sS -X POST "$A2D/api/platform/$ASSET_ID/mcp/validate" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"method":"tools/list"}' | jq '.decision'
# 3. Post evidence (should be 202).
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "$A2D/api/platform/$ASSET_ID/mcp/evidence" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"events":[{"class":"descriptor_drift","severity":"low"}]}'
# 4. Data plane must refuse this key (should be 403).
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "$A2D/api/platform-mcp/mcp" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 5. A different org's asset must refuse this key (should be 403).
curl -sS -o /dev/null -w '%{http_code}\n' \
"$A2D/api/platform/00000000-0000-0000-0000-000000000000/mcp/spec" \
-H "Authorization: Bearer $KEY"Wiring a policy to a policy-scoped key
Each Flex Gateway policy declares an apiKeySecretRef on its
instance. Point that secret at the raw key value; the policy will send
it on every call to A²D. See the per-policy pages for the exact
instance.json layout:
Rotate policy-scoped keys on the same cadence you rotate any other production secret. Even a policy-scoped key can be used to poison an audit trail with garbage evidence events if it leaks — the blast radius is small but not zero.
Advanced: per-asset binding (API only)
The platform_api_keys.bound_asset_ids column still exists. If you
POST directly to /api/platform-mcp/keys with
scope: 'policy' and a boundAssetIds array of ≤100
mcp_servers.id values owned by your org, the key is further
restricted so it can only access those specific assets. The Settings
UI never sets this — it’s an operator override for advanced setups
and existing bound keys minted before this UI simplification.
Backward compatibility
- Existing keys minted before scope was added still resolve to
fullat validation time. Every route that accepted them still accepts them. - Existing policy-scoped keys with a populated
bound_asset_idslist keep working — the guard honors the binding. - Only new behavior sits behind the
scope='policy'path. If you never mint a policy-scoped key, nothing about your setup changes.
Related surfaces
- Platform API Keys — mint, revoke, rotate.
- Platform MCP overview — the endpoint policy-scoped keys are locked out of.
- Flex Gateway Policies — the consumer that this scope was designed for.
Next Steps
- Mint one policy-scoped key per policy instance instead of sharing full-scope keys.
- Query
policy_api_auditto spot broken policies and 403 hot spots. - Watch
policy_rate_limit_bucketsfor keys pushing the 600/min validate ceiling — that’s usually a policy sampling too aggressively.
Policy-scoped keys make it safe to hand a Flex Gateway policy a credential: the credential can only do what the policy is designed to do, nothing more.