Per-agent governance policy
Each agent carries one policy: a kill switch, a token lifetime ceiling, a scope ceiling and an audience allowlist, enforced fail-closed at every token issuance and at introspection.
The agent's client defines what it could do at most; the governance policy defines what it may do right now, and an administrator can tighten it at any moment without touching the agent's code or credentials. Enforcement happens inside the token issuance path itself, so there is no way around it.
One policy per agent
PUT /v1/admin/agents/{clientId}/policy sets the policy (permission apps:manage; from the console the change requires a fresh MFA step-up). There is no GET on this path: the effective policy of every agent is read in the inventory, GET /v1/admin/agents.
| Field | Type | Effect |
|---|---|---|
enabled | boolean | Kill switch: false refuses every new token issuance |
maxTokenTtlSeconds | integer | Ceiling on the lifetime of issued tokens (0 means no ceiling) |
scopeCeiling | array of strings | Issued scopes are intersected with this list (empty means no ceiling) |
allowedAudiences | array of URIs | Allowlist of RFC 8707 resources for Token Exchange (empty means any valid resource) |
curl -sS -X PUT https://accounts.obexal.com/v1/admin/agents/8Zl2vQx0T9hK3mW1s5nDgA/policy \
-H "Authorization: Bearer $OBEXAL_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"maxTokenTtlSeconds": 300,
"scopeCeiling": ["tickets:read"],
"allowedAudiences": ["https://api.example.eu/tickets"]
}'
# 204 No ContentReplace accounts.obexal.com with your custom domain if your organization uses one.
PUT replaces the whole policy. A body that omits enabled sets it to false and neutralizes the agent. Always send the complete object.
The request is rejected with 400 invalid_request when the client is not an agent, when scopeCeiling is not a subset of the client's scopes (a ceiling above the perimeter would be meaningless), when an audience is not a valid absolute URI, when the TTL is negative, or when allowedAudiences is set on an agent that does not carry the token-exchange grant: that list only constrains the exchange, and accepting it where it has no effect would give a false sense of security.
Kill switch
enabled: false immediately refuses every new token issuance by the agent with invalid_grant, whatever the grant, and each attempt is recorded as a killed_use anomaly. Tokens already in circulation are reported active: false at introspection. The kill switch page covers containment end to end, including automatic containment on anomaly drift.
Token lifetime ceiling
The effective lifetime of every token issued to the agent is the minimum of the server default and the policy ceiling. The server default is 10 minutes, so "maxTokenTtlSeconds": 300 shortens the agent's tokens to 5 minutes; the ceiling can never extend beyond the server default. Combined with the absence of refresh tokens on the machine grants, this bounds the blast radius of a leaked token tightly.
Scope ceiling
The ceiling is applied by intersection at every issuance: the granted scopes are the requested scopes narrowed to the ceiling. The scope field of the token response is authoritative, and it can be narrower than what was requested. When nothing survives the intersection, the request fails:
curl -sS -X POST https://accounts.obexal.com/oauth/token \
-u "8Zl2vQx0T9hK3mW1s5nDgA:$AGENT_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="tickets:write"
# 400 {"error": "invalid_scope"}tickets:write is inside the client's scopes but outside the ["tickets:read"] ceiling set above: the intersection is empty and the request is refused.
Audience allowlist
Only meaningful for agents carrying the token-exchange grant. Once the list is set, the resource parameter of the exchange becomes mandatory and must belong to the list, otherwise invalid_target: a constrained agent can only mint tokens for the resource servers you approved. Comparison is canonical on both sides (scheme and host lowercased, default port removed, trailing slash removed), so HTTPS://api.example.eu:443/tickets/ and https://api.example.eu/tickets match.
Fail-closed, on every grant
Two distinct rules govern policy resolution:
- a missing policy resolves to safe defaults (enabled, no ceilings): governance is opt-in, an agent without a policy works normally;
- an unreadable policy (storage error) refuses the issuance: a kill switch that cannot be checked must fail closed, never silently reopen.
Enforcement is centralized in the issuance path, so an agent that also carries authorization_code or refresh_token cannot route around its policy through an interactive flow: the kill switch, the agent expiry, the scope ceiling and the TTL ceiling apply to those grants too. Introspection follows the same rule: a storage error or a disabled agent yields active: false.
Reset to defaults
DELETE /v1/admin/agents/{clientId}/policy removes the policy and returns 204: the agent is back to the defaults (enabled, no ceilings). The operation is idempotent, and like every policy change it is written to the audit log.