Repository navigation
security(llc): agent API-key routes and the agent diary ignore the tenant context they bind #13771
Description
Summary
Four more handlers in llc/api/ bind require_org_context and never use the result. The same
AST scan that #13756's AC 4 asks for flags them:
autobot-backend/llc/api/api_keys.py:55 create_api_key(ctx)
autobot-backend/llc/api/api_keys.py:76 revoke_api_key(ctx)
autobot-backend/llc/api/api_keys.py:91 list_api_keys(ctx)
autobot-backend/llc/api/runs.py:182 get_agent_diary(ctx)
(llc/api/findings.py:105 get_policy(_ctx) also appears, but its parameter is underscore-named —
the dependency is there as a gate only and the policy it returns is not tenant data. Left alone.)
Evidence
POST /agents/{agent_id}/api-keys — privilege escalation, not just a read.
The company an agent key is minted for comes from the request body, never from the caller's context:
async def create_api_key(
agent_id: str,
body: ApiKeyCreate, # body.company_id
ctx: TenantContext = Depends(require_org_context), # bound, unused
) -> ApiKeyCreated:
record, plaintext = await _svc.issue_key(
session, agent_id=agent_id, company_id=body.company_id, name=body.name
)Any authenticated caller with an org context can issue a working agent API key scoped to another
company by naming that company's id in the body, and the plaintext is returned to them once.
DELETE /agents/{agent_id}/api-keys/{key_id} — ApiKeyService.revoke_key filters on
id + agent_id + revoked_at IS NULL only. Knowing an agent id and a key id is enough to revoke
another company's key (availability).
GET /agents/{agent_id}/api-keys — the query is select(LLCApiKey).where(LLCApiKey.agent_id == agent_id)
with no company predicate, so key metadata (names, company_id, last_used_at) for another tenant's
agent is readable.
GET /agents/{agent_id}/diary — reads AgentDiaryService.read(agent_name=agent_id) straight from
the KB with no tenant predicate; another company's agent diary entries are returned verbatim.
Impact
- Cross-tenant write: minting a valid agent credential for another company (create).
- Cross-tenant denial of service: revoking another company's live key.
- Cross-tenant read: key metadata and agent diary content.
LLCApiKey already carries company_id, so the scope exists in the data model — it is simply never
applied at these four seams.
Suggested fix
issue_key: take the company fromctx.org_id. Keepbody.company_idonly as an assertion —
a mismatch is a 403, not a silently-honoured override.revoke_key/ list: add a keyword-onlycompany_idtoApiKeyServiceand filter on it; a
mismatch returns "not found" (404), matching the conventionWorkItemService.getdocuments.get_agent_diary: resolve the agent's company and refuse ids outsidectx.org_id.- Keep the AST scan from security(llc): /api/llc/agent/context/{item_id} binds tenant context but never applies it — cross-tenant work-item + goal read #13756 green — no bound-but-unused
require_org_contextinllc/api/
except deliberately underscore-named gate parameters.
Acceptance criteria
- A caller in company B cannot mint an agent API key for company A (403, no key created)
- A caller in company B cannot revoke or list company A's agent keys (404)
- A caller in company B reading company A's agent diary gets no entries
- Regression tests for all four routes, two companies each
- The bound-but-unused scan over
llc/api/reports onlyfindings.get_policy(_ctx)
Provenance
Found while implementing #13756 (same defect class, different routes). Filed rather than folded in:
the API-key fix changes issuance semantics and touches ApiKeyService, which is a different blast
radius from the read-only context endpoint.
Activity
security(llc): scope agent API-key and diary routes to the caller's c…
github-actions commentedon Aug 9, 2026
PR #13773 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
mrveiss commentedon Aug 9, 2026
Delivered and merged: PR #13773, squashed as
874db93.All four bound-but-unused handlers are now scoped:
create_api_keymints forctx.org_id.ApiKeyCreate.company_idbecame optional and is honoured only as an assertion — naming another company is 403, never silently applied. This was the privilege escalation: a caller could mint a working agent credential for another company and receive the plaintext once.revoke_api_keypassescompany_id; a cross-tenant key raisesKeyError, which the route already maps to 404.list_api_keysfilters oncompany_id.get_agent_diarygates onLLCHeartbeatRun— the agent-to-company binding the sibling routes in the same router already scope on, rather than a second notion of which company an agent belongs to. An unknown agent reads as empty, not 404, so the response cannot be used to probe which agent ids exist elsewhere.revoke_keygrew a keyword-only, optionalcompany_id, matching the shape #13704 established onWorkItemService.get, so existing internal callers are unchanged.Mutation check — reverting all three source files to base: 7 failed, 1 passed. The one that stays green asserts only the route's
KeyError→ 404 mapping, which holds either way.Two assertions were tightened after a first mutation run showed them passing against unfixed code: the list test now matches the rendered WHERE clause rather than the whole statement (
company_idis also a selected column, so a full-statement match passed with no predicate at all), and the create test omitscompany_idfrom the body entirely instead of sending the caller's own.findings.get_policy(_ctx)is the only remaining scan hit, deliberately — underscore-named parameter, the dependency is a gate, and the policy is not tenant data.Closing.
security(llc): scope the goal ancestry walk, and stop it defaulting o…