fix(auth): dispatch @auth.on handlers on every protocol route (#494)
* fix(auth): dispatch @auth.on handlers on every protocol route
Authorization was wired by hand inside each route body, so a route that
forgot the handle_event call was silently unprotected: no error, no
failing test, the user's handler just never ran. Thread state/history,
several run routes, cron creation and the v2 event-streaming pair all
reached the database without dispatching.
Verified against a running server with a handler denying threads.read:
GET /threads/{id} correctly returned 403 while /threads/{id}/state and
/threads/{id}/history returned 200 and served the conversation.
Route -> (resource, action) now lives in core/auth_registry.py and is
attached at registration. A route opts out only by being listed exempt,
and a coverage test fails when a mounted route is neither registered nor
exempt, so a new endpoint cannot rejoin the unprotected set.
Sub-resources authorize as their parent (state and history are a thread
read), and stateless runs authorize as threads/create_run so dropping the
thread_id does not bypass an @auth.on.threads rule. Routes that already
dispatch in-body are unaffected: the request is marked once authorized,
so a handler still runs exactly once.
Also apply handler filters to thread queries. Thread search accepted only
a nested "metadata" key and dropped the flat shape and every operator,
while list_threads computed the filter and discarded it (`if filters:
pass`). Both now compile through build_metadata_filter, as assistants
already did, and by-id read and delete honor it too.
Fixes #488
Fixes #490
Fixes #485
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cli): respect a pre-set AEGRA_CONFIG instead of overwriting it
`aegra dev` and `aegra serve` set os.environ["AEGRA_CONFIG"] from
directory discovery unconditionally, so an externally-set value (docker
compose, systemd unit, CI matrix) was ignored unless -c was passed on
every invocation.
Discovery now honors AEGRA_CONFIG first. A stale path warns and falls
back to discovery rather than failing, and a blank value is treated as
unset.
Fixes #492
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): keep one dispatch per event and preserve handler mutations
Review of the first pass found the dedup shim changed handler semantics in
two ways no existing test could see, because every suite exercising real
handlers was either mocked without enforcement or not run at all.
A single "already dispatched" flag collapsed multi-resource requests.
Creating a cron authorizes crons.create, then assistants.read, then the
thread — a documented three-layer contract so a caller with cron access
but no rights to the underlying assistant is refused. The flag stopped
after the first event and skipped the assistant and thread checks.
Handlers also inject data by mutating `value` in place, which the shipped
example does for team_id, org_id and created_by. Dispatching from route
registration ran the handler against a throwaway dict while skipping the
route's own call, so the injection never reached the request model.
`make e2e-auth` caught this: thread create came back with team_id unset.
Both come from two layers sharing one dispatch. Now exactly one layer
authorizes each event. Routes listed in SELF_DISPATCHING keep their
in-body call and get no enforcer, because their `value` is the live
request model; every other registered route is dispatched centrally. The
dedup guard in handle_event is gone rather than made smarter, since
nothing dispatches twice any more.
Also read the body on DELETE, which /store/items carries, and narrow the
body catch to JSONDecodeError and UnicodeDecodeError.
Tests cover all three: the cron chain reaches every layer, thread create
keeps injected metadata, and the store delete handler sees its namespace
and key. SELF_DISPATCHING is checked against the registry so a stale
entry cannot silently disable the enforcer.
Verified: 1839 unit and integration pass, 134 e2e in prod mode, and the
auth e2e suite is back to its pre-branch baseline. The two assistant
metadata failures that remain reproduce on main and are filed as #495.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cli): only honor AEGRA_CONFIG when it resolves inside the project
The first version accepted any AEGRA_CONFIG that existed on disk. An
AEGRA_CONFIG exported in a shell then leaked into every later `aegra dev`,
so the CLI would silently boot a different project than the directory the
user was standing in.
CI caught it: test-cli hung and was killed after 10 minutes.
`test_dev_fails_without_config` runs in an empty directory and expects
exit 1, but the ambient env var made discovery succeed, so `aegra dev`
started a server and pytest never returned.
The env var is now honored only when it resolves inside the current
directory. That still fixes the reported case — a container setting
AEGRA_CONFIG=aegra.json relative to its workdir — while an unrelated
absolute path falls back to discovery. Use -c to point outside the tree.
Tests cover both directions, including the empty-directory case that hung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(auth): pin route registry to a spec snapshot and flag non-portable names
The coverage tests prove each route has an auth identity but not that it has
the right one, so an action or resource could drift silently (the exact gap
that let this class of bug exist). Adds test_registry_matches_spec_snapshot_
exactly, which pins every (method, path) -> (resource, action) tuple to a
snapshot derived from AUTH_DISPATCH_SPEC.md. Any change now fails the test and
forces a reviewer to re-check it against the spec.
Also annotates the two entries that diverge from the Agent Protocol so the
registry stops looking authoritative where it is not:
- runs.* routes: the protocol has no `runs` resource; run ops authorize under
`threads` (verified against langgraph-api 0.12.1 grpc/ops/runs.py). A handler
written as @auth.on.threads.read will not fire on these. These names predate
this work and are load-bearing; retire to threads.* in the 0.10.0 sweep.
- /store/namespaces: protocol action is list_namespaces, not search.
No behavior change. The divergences are pre-existing and their removal is a
breaking change tracked for 0.10.0, not this PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): authorize run routes under threads, not a non-protocol runs resource
Run operations were dispatching under a `runs` resource that the Agent Protocol
does not define (verified against langgraph-api 0.12.1, where grpc/ops/runs.py
sets resource = "threads"). The langgraph SDK's @auth.on has no `runs` at all —
accessing auth.on.runs raises AttributeError — so no user handler could bind to
those names. A handler written to the docs as @auth.on.threads.read simply never
fired on run routes: a silent authorization bypass.
This aligns every run route to `threads.*` (search/read/update/delete) and the
store namespaces route to `store.list_namespaces`, the action the SDK actually
exposes. Because nothing could register the old names, this is non-breaking, not
a deprecation — no alias or migration window is needed.
Verified against a running server: with @auth.on.threads.read denying, GET on a
run now returns 403 (was 200 before), and list runs honors @auth.on.threads.search.
Adds tests asserting no route uses the `runs` resource and namespaces uses
list_namespaces, plus updates the spec snapshot. Closes the parity gap tracked
in #497.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): path params beat body, idempotent wiring, typed enforcer factory
Addresses reviewer findings on the enforcer:
- Path params now win over the request body when building the handler value.
A route operates on the URL-selected resource, so a crafted body field
(thread_id/run_id/cron_id) must not swap the object a value-sensitive handler
authorizes. Merge order flipped; test asserts the handler sees the URL id.
- apply_auth_enforcement is idempotent. A second pass over the same route
objects (double router include, create_app run twice) no longer prepends a
second enforcer and dispatches the handler twice. Guarded by a marker on the
route; test covers double application.
- build_auth_enforcer gets its return annotation, per the repo's strict typing.
The related "handler mutations dropped" and "broad except" comments were
already resolved in earlier commits (SELF_DISPATCHING keeps in-body dispatch;
the body catch is JSONDecodeError/UnicodeDecodeError).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(auth): authorize the assistant on run creation
Creating a run now dispatches assistants.read for the assistant it uses,
after threads.create_run, matching the cron-create chain. A per-assistant
handler rule applies to run creation instead of being consulted only on
cron creation. Docs drop the runs.* handler rows the SDK cannot bind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version to 0.10.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> I
Ibrahim committed
25475ad1ea8da82d0fbfa016195ff04140bc57eb
Parent: 7dd1f4b
Committed by GitHub <noreply@github.com>
on 8/15/2026, 10:41:37 AM