FeaturesPlatform MCPPolicy-Scoped Keys

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.

  1. Enter a Name — e.g. drift-a2d policy.
  2. 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.
  3. Click Generate Key.
  4. 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

RouteMethodBehavior for a policy-scoped key
/api/platform/[id]/mcp/specGET✅ 200 with the pinned spec when [id] belongs to the key’s org.
/api/platform/[id]/mcp/validatePOST✅ 200 with the verdict when [id] belongs to the key’s org.
/api/platform/[id]/mcp/evidencePOST✅ 202 (fire-and-forget) when [id] belongs to the key’s org.
/api/platform-mcp/mcpPOST❌ 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:

RouteCeiling / minute
spec60
validate600
evidence120

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 full at validation time. Every route that accepted them still accepts them.
  • Existing policy-scoped keys with a populated bound_asset_ids list 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.

Next Steps

  • Mint one policy-scoped key per policy instance instead of sharing full-scope keys.
  • Query policy_api_audit to spot broken policies and 403 hot spots.
  • Watch policy_rate_limit_buckets for 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.