refactor: an autonomous run is a machine identity, not a mandate
Withdrawn: the offline_access mandate built an hour ago. An agent running on its own account acts for itself. Nobody asked for the 06:00 run, so naming a person as its principal was a fiction — and choosing which person was arbitrary, as the first implementation showed by picking whichever test user was to hand. It also cost more than it was worth. An offline token has no exp, dies only after 30 days unused, is not rotated on use, and is revocable only by killing that user's offline session: a permanent bearer credential standing in for a consent nobody gave. Now client_credentials, with the service account carrying read_only — the least privilege the job needs. That is not a convention the workflow observes: the MCP server maps the role to a consumer group at discovery, so tools/list under this identity returns get-product and list-products and nothing else. create-product is invisible rather than merely unused. Nothing downstream changes. azp still names the agent, audiences are still per-server, and policy still reads roles — a service account has roles like anyone else. The mandate client stays in the realm, unused, for the case where a person's own authority genuinely is in play: Recouvrement's dunning, which is human-validated already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,16 +83,40 @@ webhook, and token verification lands where it already lives. No new component.
|
|||||||
Alternative: a small FastA2A adapter, written once for both — see
|
Alternative: a small FastA2A adapter, written once for both — see
|
||||||
`classic-models-agent-catalogue`, which uses FastA2A already.
|
`classic-models-agent-catalogue`, which uses FastA2A already.
|
||||||
|
|
||||||
## The autonomous mandate
|
## An autonomous run is a machine identity
|
||||||
|
|
||||||
No user session exists when these run, so authority must be pre-established: the
|
**Superseded, 2026-08-29.** An earlier revision had these agents act under an
|
||||||
user obtains `offline_access` when creating the mandate, the refresh token is
|
`offline_access` mandate: a user authorises the agent, their refresh token is
|
||||||
stored encrypted, and the exchange happens at trigger time. `sub` stays the user
|
stored encrypted, and it is redeemed at trigger time so `sub` stays the person
|
||||||
who mandated, `azp` says which agent acted.
|
who mandated.
|
||||||
|
|
||||||
The MCP servers see no difference between an autonomous and a conversational
|
That was built and then withdrawn, because it is not true. **An agent running on
|
||||||
call — same `sub`, same `azp`, same audiences. That is deliberate: downstream
|
its own account acts for itself.** Nobody asked for the 06:00 run; naming a
|
||||||
policy must not need to know the trigger mode.
|
person as its principal is a fiction, and choosing which person is arbitrary —
|
||||||
|
the first implementation picked whichever test user was to hand.
|
||||||
|
|
||||||
|
It also cost more than it was worth. An offline token has no `exp`; it dies only
|
||||||
|
after 30 days unused, is not rotated on use (`revokeRefreshToken: false`), and
|
||||||
|
is revocable only by killing that user's offline session. A permanent bearer
|
||||||
|
credential, standing in for a consent nobody gave.
|
||||||
|
|
||||||
|
So: `client_credentials`, and the service account carries the realm role the job
|
||||||
|
needs. For the periodic reorder run that is **`read_only`** — so `create-product`
|
||||||
|
is not merely unused, it is **invisible** to that identity. Least privilege
|
||||||
|
enforced by the realm and the gateway rather than by the workflow behaving
|
||||||
|
itself. Verified: `tools/list` under the machine identity returns `get-product`
|
||||||
|
and `list-products`, nothing more.
|
||||||
|
|
||||||
|
What does *not* change: `azp` still names the agent, the audiences are still
|
||||||
|
per-server, and the MCP server still derives the consumer group from the token's
|
||||||
|
roles. Downstream policy still needs no knowledge of the trigger mode — it reads
|
||||||
|
roles, and a service account has roles like anyone else.
|
||||||
|
|
||||||
|
**Where a mandate would still be right:** when a person's own authority is
|
||||||
|
genuinely in play — an action they are accountable for, or data scoped to them.
|
||||||
|
Recouvrement's dunning is the candidate, and it is a human-validated step
|
||||||
|
already. The `classic-models-mandate` client is left in the realm, unused, for
|
||||||
|
that case.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,3 @@ spec:
|
|||||||
remoteRef:
|
remoteRef:
|
||||||
key: keycloak/classic-models-realm
|
key: keycloak/classic-models-realm
|
||||||
property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT
|
property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT
|
||||||
# Redeems a mandate. Held by the workflow rather than by the adapter because
|
|
||||||
# it is the workflow that acts as the agent.
|
|
||||||
- secretKey: MANDATE_CLIENT_SECRET
|
|
||||||
remoteRef:
|
|
||||||
key: keycloak/classic-models-realm
|
|
||||||
property: KC_SECRET_MANDATE
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ spec:
|
|||||||
# The agents' Keycloak client secrets, so a workflow can exchange tokens
|
# The agents' Keycloak client secrets, so a workflow can exchange tokens
|
||||||
# as the agent it represents. Created by agent-secrets.yaml beside this.
|
# as the agent it represents. Created by agent-secrets.yaml beside this.
|
||||||
EXTRA_ENV_SECRET: n8n-agent-secrets
|
EXTRA_ENV_SECRET: n8n-agent-secrets
|
||||||
EXTRA_ENV_SECRET_2: n8n-mandate-secrets
|
|
||||||
EXTRA_CA_CONFIGMAP: sttlab-ca
|
EXTRA_CA_CONFIGMAP: sttlab-ca
|
||||||
# "false" so a workflow can read $env.<AGENT>_CLIENT_SECRET. It means any
|
# "false" so a workflow can read $env.<AGENT>_CLIENT_SECRET. It means any
|
||||||
# workflow on this instance can read any variable in the pod — acceptable
|
# workflow on this instance can read any variable in the pod — acceptable
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# The standing authority the autonomous runs act under.
|
|
||||||
#
|
|
||||||
# A mandate is an `offline_access` refresh token, issued by the
|
|
||||||
# classic-models-mandate client for the user who authorised the agent. It does
|
|
||||||
# not expire, which is exactly why it is here and not in a workflow: it is
|
|
||||||
# revoked by that user's session being revoked in Keycloak, and by nothing else.
|
|
||||||
#
|
|
||||||
# Two ExternalSecrets rather than one, because they answer to different owners:
|
|
||||||
# the mandate is the agent estate's, the client secret that redeems it is the
|
|
||||||
# realm's.
|
|
||||||
apiVersion: external-secrets.io/v1
|
|
||||||
kind: ExternalSecret
|
|
||||||
metadata:
|
|
||||||
name: n8n-mandate-secrets
|
|
||||||
namespace: n8n
|
|
||||||
spec:
|
|
||||||
refreshInterval: 1h
|
|
||||||
secretStoreRef:
|
|
||||||
kind: ClusterSecretStore
|
|
||||||
name: vault-n8n
|
|
||||||
target:
|
|
||||||
name: n8n-mandate-secrets
|
|
||||||
dataFrom:
|
|
||||||
- extract:
|
|
||||||
key: n8n/mandates
|
|
||||||
+15
-10
@@ -106,18 +106,23 @@ scanned 110 of 110, complete
|
|||||||
escalate: true
|
escalate: true
|
||||||
```
|
```
|
||||||
|
|
||||||
### It stops without a mandate, and that is the point
|
### It acts under its own machine identity
|
||||||
|
|
||||||
No user session exists at 06:00, so authority has to be pre-established. The
|
`client_credentials`, not a mandate. An agent running on its own account acts
|
||||||
design calls for an `offline_access` mandate — the authorising user's refresh
|
for itself — nobody asked for the 06:00 run, so naming a person as its principal
|
||||||
token, stored encrypted, exchanged at trigger time, so `sub` stays the person
|
is a fiction and choosing which person is arbitrary.
|
||||||
who mandated and `azp` says which agent acted.
|
|
||||||
|
|
||||||
**That does not exist yet.** The first node looks for
|
The service account carries **`read_only`**, and the MCP server maps that to a
|
||||||
`APPROVISIONNEMENT_MANDATE_TOKEN` and, finding none, halts before any exchange
|
consumer group at discovery, so `create-product` is not merely unused — it is
|
||||||
and before reading anything. Verified: the run completes, `Exchange token` never
|
invisible to this identity:
|
||||||
executes, nothing is read. An agent without an identity does nothing rather than
|
|
||||||
inventing an authority.
|
```
|
||||||
|
tools/list under the machine identity → get-product, list-products
|
||||||
|
```
|
||||||
|
|
||||||
|
Least privilege enforced by the realm and the gateway rather than by this
|
||||||
|
workflow behaving itself. An earlier revision used an `offline_access` mandate;
|
||||||
|
see CLAUDE.md for why it was withdrawn.
|
||||||
|
|
||||||
### The proposal has nowhere to go
|
### The proposal has nowhere to go
|
||||||
|
|
||||||
|
|||||||
@@ -21,48 +21,6 @@
|
|||||||
0
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "// No user session exists at 6am, so the authority has to be pre-established.\n//\n// NOT IMPLEMENTED. The design calls for an offline_access mandate: the user who\n// authorises the agent obtains a refresh token, it is stored encrypted, and the\n// exchange happens at trigger time \u2014 so `sub` stays the person who mandated and\n// `azp` says which agent acted.\n//\n// Until that exists this run has no identity of its own. It reads\n// APPROVISIONNEMENT_MANDATE_TOKEN from the environment if one was placed there\n// by hand, and stops cleanly otherwise rather than inventing an authority.\nconst token = $env.APPROVISIONNEMENT_MANDATE_TOKEN;\nif (!token) {\n return [{ json: {\n halted: true,\n reason: 'No mandate. This run needs an offline_access token from the user who '\n + 'authorised the agent; none is configured, so it did nothing.',\n }}];\n}\nreturn [{ json: { halted: false, token } }];\n"
|
|
||||||
},
|
|
||||||
"id": "mandate",
|
|
||||||
"name": "Mandate",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
220,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"version": 2
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{ $json.halted }}",
|
|
||||||
"rightValue": false,
|
|
||||||
"operator": {
|
|
||||||
"type": "boolean",
|
|
||||||
"operation": "false"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": "has-a-mandate?",
|
|
||||||
"name": "Has a mandate?",
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
440,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
@@ -73,7 +31,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "grant_type",
|
"name": "grant_type",
|
||||||
"value": "urn:ietf:params:oauth:grant-type:token-exchange"
|
"value": "client_credentials"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "client_id",
|
"name": "client_id",
|
||||||
@@ -83,14 +41,6 @@
|
|||||||
"name": "client_secret",
|
"name": "client_secret",
|
||||||
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
|
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "subject_token",
|
|
||||||
"value": "={{ $json.token }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "subject_token_type",
|
|
||||||
"value": "urn:ietf:params:oauth:token-type:access_token"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "scope",
|
"name": "scope",
|
||||||
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
|
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
|
||||||
@@ -99,13 +49,13 @@
|
|||||||
},
|
},
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "exchange-token",
|
"id": "machine-token",
|
||||||
"name": "Exchange token",
|
"name": "Machine token",
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4.2,
|
"typeVersion": 4.2,
|
||||||
"position": [
|
"position": [
|
||||||
660,
|
220,
|
||||||
-100
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,8 +91,8 @@
|
|||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4.2,
|
"typeVersion": 4.2,
|
||||||
"position": [
|
"position": [
|
||||||
880,
|
440,
|
||||||
-100
|
0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -154,7 +104,7 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"value": "=Bearer {{ $('Exchange token').item.json.access_token }}"
|
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Accept",
|
"name": "Accept",
|
||||||
@@ -182,8 +132,8 @@
|
|||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4.2,
|
"typeVersion": 4.2,
|
||||||
"position": [
|
"position": [
|
||||||
1100,
|
660,
|
||||||
-100
|
0
|
||||||
],
|
],
|
||||||
"alwaysOutputData": true
|
"alwaysOutputData": true
|
||||||
},
|
},
|
||||||
@@ -196,19 +146,8 @@
|
|||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [
|
"position": [
|
||||||
1320,
|
880,
|
||||||
-100
|
0
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {},
|
|
||||||
"id": "no-mandate",
|
|
||||||
"name": "No mandate",
|
|
||||||
"type": "n8n-nodes-base.noOp",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
660,
|
|
||||||
120
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -217,43 +156,14 @@
|
|||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "Mandate",
|
"node": "Machine token",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Mandate": {
|
"Machine token": {
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Has a mandate?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Has a mandate?": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Exchange token",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "No mandate",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Exchange token": {
|
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user