fix(pat): close restricted-token paths through workflows, chat, schedules and conversations

A resource restriction was checked on ids in the request, not on what the
addressed row pulls in or belongs to. Closed:

- Workflow writes for tokens restricted on sources, tools or prompts (a graph
  names those inside its nodes), and attaching a workflow to an agent unless
  the token is restricted on workflows too.
- Chat for tools-restricted tokens (chat executes tools; rejected at token
  creation as well), and agent-less chat for tokens restricted on prompts or
  workflows.
- conversation_id on chat: it must belong to the agent being run, or to no
  agent for agent-less chat. Otherwise the server continued, appended to, or
  resumed pending tool calls of another agent's conversation.
- Schedules for tokens restricted on anything but agents; schedule-id routes
  for every restricted token.
- Conversations and analytics for every restricted token, not only
  agent-restricted ones.

Also: create, first publish and adopt return the agent API key masked to a
token without agents:keys; token ids must be canonical UUIDs (urn:uuid: gave
a 500); an expired token is reported as expired; token creation takes a
per-user advisory lock so the cap cannot be raced; admin revoke-sessions
writes a pat_revoked event per token. The UI drops a row whose revoke returns
404 and does not offer a tools restriction next to chat:run.
This commit is contained in:
arc53-machine committed 2026-09-20 19:46:43 +01:00
1 parent b1ff8f516e
commit 94b5924e36
15 files changed
+393 -64

No files matched your search

@@ -76,6 +76,13 @@ class PersonalAccessTokensRepository:
result = self._conn.execute(text(sql), {"user_id": user_id})
return [row_to_dict(r) for r in result.fetchall()]
def lock_user(self, user_id: str) -> None:
"""Hold a per-user advisory lock until the transaction ends."""
self._conn.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"),
{"key": f"personal_access_tokens:{user_id}"},
)
def count_active(self, user_id: str) -> int:
"""Live tokens only: revoked and expired rows don't count against the per-user cap."""
return self._conn.execute(
@@ -161,13 +168,14 @@ class PersonalAccessTokensRepository:
params["user_id"] = user_id
return self._conn.execute(text(sql), params).rowcount > 0
def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> int:
def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> list[str]:
"""Revoke every live token of a user. Returns the revoked token ids (for the audit trail)."""
result = self._conn.execute(
text(
"UPDATE personal_access_tokens "
"SET status = 'revoked', revoked_at = now(), revoke_reason = :reason "
"WHERE user_id = :user_id AND status = 'active'"
"WHERE user_id = :user_id AND status = 'active' RETURNING id"
),
{"user_id": user_id, "reason": reason},
)
return result.rowcount
return [str(row[0]) for row in result.fetchall()]