diff --git a/CLAUDE.md b/CLAUDE.md index e5ecb90..8326245 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `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 -user obtains `offline_access` when creating the mandate, the refresh token is -stored encrypted, and the exchange happens at trigger time. `sub` stays the user -who mandated, `azp` says which agent acted. +**Superseded, 2026-08-29.** An earlier revision had these agents act under an +`offline_access` mandate: a user authorises the agent, their refresh token is +stored encrypted, and it is redeemed at trigger time so `sub` stays the person +who mandated. -The MCP servers see no difference between an autonomous and a conversational -call — same `sub`, same `azp`, same audiences. That is deliberate: downstream -policy must not need to know the trigger mode. +That was built and then withdrawn, because it is not true. **An agent running on +its own account acts for itself.** Nobody asked for the 06:00 run; naming a +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 diff --git a/deploy/agent-secrets.yaml b/deploy/agent-secrets.yaml index 0609104..7d1b846 100644 --- a/deploy/agent-secrets.yaml +++ b/deploy/agent-secrets.yaml @@ -22,9 +22,3 @@ spec: remoteRef: key: keycloak/classic-models-realm 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 diff --git a/deploy/flux-kustomization.yaml b/deploy/flux-kustomization.yaml index ccf05ae..38a47d1 100644 --- a/deploy/flux-kustomization.yaml +++ b/deploy/flux-kustomization.yaml @@ -37,7 +37,6 @@ spec: # The agents' Keycloak client secrets, so a workflow can exchange tokens # as the agent it represents. Created by agent-secrets.yaml beside this. EXTRA_ENV_SECRET: n8n-agent-secrets - EXTRA_ENV_SECRET_2: n8n-mandate-secrets EXTRA_CA_CONFIGMAP: sttlab-ca # "false" so a workflow can read $env._CLIENT_SECRET. It means any # workflow on this instance can read any variable in the pod — acceptable diff --git a/deploy/mandate-secrets.yaml b/deploy/mandate-secrets.yaml deleted file mode 100644 index 4179b71..0000000 --- a/deploy/mandate-secrets.yaml +++ /dev/null @@ -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 diff --git a/workflows/README.md b/workflows/README.md index 4cd479d..12197e0 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -106,18 +106,23 @@ scanned 110 of 110, complete 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 -design calls for an `offline_access` mandate — the authorising user's refresh -token, stored encrypted, exchanged at trigger time, so `sub` stays the person -who mandated and `azp` says which agent acted. +`client_credentials`, not a mandate. An agent running on its own account acts +for itself — nobody asked for the 06:00 run, so naming a person as its principal +is a fiction and choosing which person is arbitrary. -**That does not exist yet.** The first node looks for -`APPROVISIONNEMENT_MANDATE_TOKEN` and, finding none, halts before any exchange -and before reading anything. Verified: the run completes, `Exchange token` never -executes, nothing is read. An agent without an identity does nothing rather than -inventing an authority. +The service account carries **`read_only`**, and the MCP server maps that to a +consumer group at discovery, so `create-product` is not merely unused — it is +invisible to this identity: + +``` +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 diff --git a/workflows/approvisionnement-periodic-reorder.json b/workflows/approvisionnement-periodic-reorder.json index b5c761d..448163a 100644 --- a/workflows/approvisionnement-periodic-reorder.json +++ b/workflows/approvisionnement-periodic-reorder.json @@ -21,48 +21,6 @@ 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": { "method": "POST", @@ -73,7 +31,7 @@ "parameters": [ { "name": "grant_type", - "value": "urn:ietf:params:oauth:grant-type:token-exchange" + "value": "client_credentials" }, { "name": "client_id", @@ -83,14 +41,6 @@ "name": "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", "value": "openid mcp-catalog-audience token-exchange-broker-audience" @@ -99,13 +49,13 @@ }, "options": {} }, - "id": "exchange-token", - "name": "Exchange token", + "id": "machine-token", + "name": "Machine token", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 660, - -100 + 220, + 0 ] }, { @@ -141,8 +91,8 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 880, - -100 + 440, + 0 ] }, { @@ -154,7 +104,7 @@ "parameters": [ { "name": "Authorization", - "value": "=Bearer {{ $('Exchange token').item.json.access_token }}" + "value": "=Bearer {{ $('Machine token').item.json.access_token }}" }, { "name": "Accept", @@ -182,8 +132,8 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ - 1100, - -100 + 660, + 0 ], "alwaysOutputData": true }, @@ -196,19 +146,8 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1320, - -100 - ] - }, - { - "parameters": {}, - "id": "no-mandate", - "name": "No mandate", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 660, - 120 + 880, + 0 ] } ], @@ -217,43 +156,14 @@ "main": [ [ { - "node": "Mandate", + "node": "Machine token", "type": "main", "index": 0 } ] ] }, - "Mandate": { - "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": { + "Machine token": { "main": [ [ {