-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): revoke an agent credential without deleting the record #305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c2ab8c4
a6499b3
e5cc2ab
759ceeb
112b31a
ea82dc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| # Revoking an agent credential | ||
|
|
||
| For containing a leaked `at_live_` agent token. This invalidates the credential | ||
| and keeps the record. | ||
|
|
||
| ## What counts as done | ||
|
|
||
| **A negative-auth receipt: the credential is presented and authentication is | ||
| refused.** Nothing weaker is evidence. In particular these are *not* receipts, | ||
| and each has been mistaken for one: | ||
|
|
||
| | Observation | Why it proves nothing | | ||
| |---|---| | ||
| | The agent process is gone | `remove_agent` dispatches a *release* to the node. It stops a process. It never touches the credential. | | ||
| | The agent is absent from the roster | The roster reflects records, not credentials. | | ||
| | `status` is `offline` | `status` is not consulted during authentication at all. | | ||
| | An API call returned `dispatched` / `200` | Return shape is not behaviour. Read the state back. | | ||
|
|
||
| The only thing that settles it is a request carrying the token coming back | ||
| `401 agent_token_revoked`. | ||
|
|
||
| ## Do not use DELETE for this | ||
|
|
||
| `DELETE /v1/agents/:name` is not a containment tool and cannot be made into one: | ||
|
|
||
| - **It fails on any seat with history.** Four foreign keys onto `agents(id)` are | ||
| `ON DELETE NO ACTION` — `messages.agent_id`, `channels.created_by`, | ||
| `files.uploaded_by`, `webhooks.created_by`. A seat that has posted a single | ||
| message fails with `FOREIGN KEY constraint failed`. | ||
| - **It destroys history on the seats where it does succeed.** | ||
| `dm_participants.agent_id` cascades, which is how ordinary two-party DMs | ||
| collapsed into one-row rosters (see `scripts/audit-dm-reservations.mjs`). | ||
| - **It erases the distinction you need.** A deleted token authenticates as | ||
| `agent_token_invalid` — identical to a token that was never issued. You lose | ||
| the ability to prove the credential was deliberately contained. | ||
|
|
||
| Deletion succeeds only where there is no audit trail to protect and fails exactly | ||
| where there is one. | ||
|
|
||
| ## Do not rotate | ||
|
|
||
| `POST /v1/agents/:name/rotate-token` will look like the answer. It does | ||
| invalidate the leaked credential — it overwrites `token_hash`, so the old token | ||
| stops authenticating immediately. Do not use it for containment anyway: it | ||
| returns the replacement token in its response body, and `register_agent` returns | ||
| a live token in its reply too (relay#1389). Both put a working credential | ||
| straight back into a transcript, which is the leak you are containing. You would | ||
| trade a known-leaked token for a freshly-leaked one and call it done. | ||
|
|
||
| Rotation is the right tool when a seat must keep working and you control where | ||
| the new token lands. It is the wrong tool when the goal is containment. Revoke | ||
| without replacement; issue a new seat separately if the work still needs doing. | ||
|
|
||
| ## Handling the token safely | ||
|
|
||
| The token must never reach a shell argument, an environment listing, or shell | ||
| history. Keep it in a file with tight permissions and feed it to `curl` on stdin | ||
| via `--config`, which is the one path where the value is neither in `argv` nor | ||
| echoed: | ||
|
|
||
| ```sh | ||
| umask 077 | ||
| # Populate this from your secret store — do not paste it into the shell. | ||
| TOKEN_FILE=$(mktemp) | ||
|
|
||
| printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TOKEN_FILE")" \ | ||
| | curl --config - -s -o /dev/null -w '%{http_code}\n' \ | ||
| https://<relaycast-host>/v1/agent | ||
|
|
||
| shred -u "$TOKEN_FILE" 2>/dev/null || rm -P "$TOKEN_FILE" | ||
| ``` | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
|
|
||
| Never add `-v`, `--trace`, or `--trace-ascii` to a command carrying the token — | ||
| they print the `Authorization` header. If a token does appear in a transcript, | ||
| flag it for rotation of the *workspace* key and record it against relay#1389. | ||
|
|
||
| ## Procedure | ||
|
|
||
| Per seat, with `$WS_KEY` a workspace key (`rk_live_`) — agents cannot revoke | ||
| themselves or each other. | ||
|
|
||
| **1. Revoke.** | ||
|
|
||
| ```sh | ||
| curl -s -X POST \ | ||
| -H "Authorization: Bearer $WS_KEY" \ | ||
| https://<relaycast-host>/v1/agents/<name>/revoke | ||
| ``` | ||
|
|
||
| Returns `revoked_at` and `already_revoked`. It is idempotent: re-running reports | ||
| `already_revoked: true` and preserves the original timestamp, so the record of | ||
| when containment took effect cannot be rewritten by a repeat run. | ||
|
|
||
| **2. Take the receipt.** Present the leaked credential using the `--config` | ||
| pattern above and record the response: | ||
|
|
||
| ```sh | ||
| printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TOKEN_FILE")" \ | ||
| | curl --config - -s -w '\n%{http_code}\n' https://<relaycast-host>/v1/agent | ||
| ``` | ||
|
|
||
| Expected — and the only acceptable result: | ||
|
|
||
| ```text | ||
| {"ok":false,"error":{"code":"agent_token_revoked","message":"Agent token revoked"}} | ||
| 401 | ||
| ``` | ||
|
|
||
| `GET /v1/agent` is the right probe: it is read-only and does nothing but resolve | ||
| a token to its identity, so a live credential is confirmed without acting as the | ||
| agent. | ||
|
|
||
| `401 agent_token_revoked` is the receipt. Record the seat name, the timestamp, | ||
| and that code. Do not record the token. | ||
|
|
||
| If you get `200`, the credential is live and the seat is **not** contained — | ||
| check you targeted the right workspace and that the deployed build includes the | ||
| enforcement in `SqliteApiKeyAuthProvider.authenticate`. If the endpoint 404s, the | ||
| code is not deployed and no revocation has occurred, whatever else you saw. | ||
|
|
||
| ## Deploy ordering (read before shipping this) | ||
|
|
||
| **Migration 0034 must be applied before or with the code, never after.** The | ||
| drizzle schema enumerates every column on each query, so a build that knows about | ||
| `revoked_at` cannot talk to an `agents` table that lacks it — verified: an insert | ||
| against an unmigrated schema fails with `table agents has no column named | ||
| revoked_at`. That is agent registration and agent authentication down, not a | ||
| degraded revoke. The failure is at least loud rather than silent, but the | ||
| ordering is not optional. | ||
|
|
||
| Confirm which database you are migrating. Production is the D1 instance the | ||
| worker binds — resolve it through the SST resource `RelaycastDatabase`, never by | ||
| the name that happens to match the repo, and pass `--remote`. Two live instances | ||
| carry this data under confusingly similar names and audits have been run against | ||
| the wrong one before. | ||
|
|
||
| **3. Confirm history survived.** The agent row and its messages must still be | ||
| present. Revocation that took history with it has traded one problem for a worse | ||
| one. | ||
|
|
||
| ## Scope limits | ||
|
|
||
| - **Node tokens are separate credentials.** This revokes the agent's own token. A | ||
| node token that posts on the agent's behalf is unaffected and needs its own | ||
| decision. | ||
| - **A seat already deleted cannot be revoked.** There is no row to mark, and its | ||
| token now reports `agent_token_invalid`. That is containment by accident, not a | ||
| revocation receipt, and the audit trail for that seat is already gone. | ||
| - **Revocation is one-way here.** There is deliberately no un-revoke endpoint; | ||
| restoring access means issuing a new seat. | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,17 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight | |||||
| The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), | ||||||
| and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | ||||||
|
|
||||||
| ## [Unreleased] | ||||||
| ## [Unreleased - Minor] | ||||||
|
|
||||||
| ### Added | ||||||
|
|
||||||
| - `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This changelog entry is far longer and more implementation-heavy than the project's documented changelog convention. Prompt for AI agents
Suggested change
|
||||||
| - `AuthProvider.revokeAgentCredential` (optional). Providers backed by an external identity store leave it undefined and the endpoint refuses with `revocation_unsupported` (501) rather than recording a revocation their authenticator never consults. | ||||||
|
|
||||||
| ### Changed | ||||||
|
|
||||||
| - The A2A webhook resolves its bearer token through the configured `AuthProvider` instead of comparing `agents.token_hash` directly, so provider-level checks apply to that route. Previously a revoked A2A proxy credential still authenticated there. | ||||||
|
|
||||||
|
|
||||||
| ## [6.3.2] - 2026-08-02 | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: This entry is one long bullet full of implementation backstory (why deletion fails, what rotate-token returns) rather than a short impact-first note. Per AGENTS.md CHANGELOG rules, each user-visible change should be "one short impact-first bullet" with backstory omitted.
Prompt for AI agents