feat(auth): hoist auth dispatch into service layer (base + assistants)#401
feat(auth): hoist auth dispatch into service layer (base + assistants)#401ibbybuilds wants to merge 6 commits into
Conversation
Introduces services/_authenticated.py with an Authenticated base that carries the request identity (session, user) and exposes a single _dispatch(action, value) entry point. Tenant-scoped services will inherit from this so auth dispatch becomes mandatory at the service call site rather than scattered across route handlers. Dispatch policy is unchanged: handle_event stays default-allow when no handler matches, and the SQL-layer user_id == user.identity filter remains the tenant boundary (GHSA-m98r-6667-4wq7). This is purely structural groundwork for the auth hoist; no route behavior changes yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AssistantService now inherits Authenticated and dispatches @auth.on.assistants.* at the entry of each public method via self._dispatch(action, value). The identity moves into the constructor (session, user, langgraph_service), so the per-method user_identity/user parameters are gone and the routes drop all build_auth_context/handle_event boilerplate — handlers shrink to parse-and-call. Handler-returned filters now merge into the metadata containment predicate inside the service via a single _merged_metadata helper (DRY), replacing the three near-identical merge blocks that lived in the route layer. The #333 regression (a flat filter dict must not 500) and the read-endpoint dispatch coverage (versions/schemas/graph/subgraphs fire assistants.read) move from route-level tests to AssistantService unit tests where dispatch actually runs. Dispatch policy is unchanged: handle_event stays default-allow when no handler matches and the SQL-layer user_id == user.identity filter remains the tenant boundary. The single patch chokepoint for tests is now aegra_api.services._authenticated.handle_event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds core/auth_filters.py: compiles an @auth.on handler filter dict into a
SQLAlchemy JSONB predicate over a metadata column, per the documented filter
contract:
{"owner": "u1"} -> metadata @> {"owner": "u1"}
{"owner": {"$eq": "u1"}} -> same, explicit
{"tags": {"$contains": "x"}} -> metadata->'tags' @> ["x"]
{"$or": [...]} / {"$and": [...]} -> OR / AND (depth cap 2)
multiple top-level keys -> AND
Previously AssistantService flattened every handler return into a single
metadata @> containment, so $eq/$contains/$or/$and silently matched the wrong
rows (or none). Malformed filters now raise 500 with a clear message instead of
failing silently.
Separates the two handler conventions that were conflated:
- create/update: handlers inject by mutating value["metadata"] in place; only
that injection is read back onto the request (the return dict is a query
filter with no insert meaning).
- search/read/count/versions: the handler return is a query filter, applied as
an AND'd WHERE clause via build_metadata_filter, leaving request.metadata
(the caller's own filter) untouched.
Also fixes the versions endpoint to dispatch assistants.search (not read) with
the {assistant_id, metadata} value shape, per the auth dispatch spec — handlers
scoping version listing were previously bypassed.
The Aegra {"metadata": {...}} envelope handlers historically return
(examples/jwt_mock_auth_example.py) is unwrapped for back-compat. The SQL-layer
user_id == user.identity filter remains the tenant boundary; these filters are
additional optional narrowing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_assistants_search_sort.py imported _merge_handler_filters_into_metadata, removed from the route layer when filter merging moved into the service. Drops the dead import and the obsolete TestMergeHandlerFilters class (its behavior is now covered by test_auth_filters.py and test_assistant_service.py::TestAuthDispatch). Keeps the _resolve_sort coverage. Also patches handle_event via the imported module object so monkeypatch resolves the target reliably. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
📝 WalkthroughWalkthroughThis PR refactors authorization dispatch from FastAPI route handlers into the service layer. It introduces a new ChangesAssistants Authorization Layer Refactoring
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The underscore-prefixed module name was inconsistent with the rest of services/ (base classes use plain names like base_broker, base_executor). No behavior change; pure rename of services/_authenticated.py -> authenticated.py and its imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
libs/aegra-api/src/aegra_api/services/assistant_service.py (5)
618-631:⚠️ Potential issue | 🟠 Major | ⚡ Quick winSame issue: read filters discarded.
Apply the filters returned by
_dispatch("read", ...)to the query, consistent withget_assistant.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/aegra-api/src/aegra_api/services/assistant_service.py` around lines 618 - 631, The query in get_assistant_subgraphs ignores the access-control filters returned by await self._dispatch("read", {"assistant_id": assistant_id}); modify get_assistant_subgraphs to capture the dispatch result (like in get_assistant), extract and apply its returned filters/where-clause to the select(AssistantORM) statement (or combine with existing where conditions) so the same access filters from _dispatch("read", ...) are honored before calling self.session.scalar; reference the get_assistant_subgraphs method and the _dispatch("read", ...) call and follow the pattern used in get_assistant.
436-439:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGuard against
Noneafter re-fetch.After the UPDATE,
session.scalar(stmt)may returnNonefrom the type checker's perspective. Add an assertion or raise to satisfy the type checker and catch any unexpected state.🔧 Proposed fix
await self.session.execute(assistant_update) await self.session.commit() updated_assistant = await self.session.scalar(stmt) + assert updated_assistant is not None, "Assistant disappeared after update" return to_pydantic(updated_assistant)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/aegra-api/src/aegra_api/services/assistant_service.py` around lines 436 - 439, After executing the UPDATE and re-fetching with session.scalar(stmt) in the assistant service method, guard against a None result by asserting or raising if updated_assistant is None so the type checker and runtime are satisfied; update the block that calls await self.session.execute(assistant_update), await self.session.commit(), and updated_assistant = await self.session.scalar(stmt) to check updated_assistant (e.g., raise RuntimeError or AssertionError with a clear message including the assistant id/context) before returning to_pydantic(updated_assistant).
530-543:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHandle potentially
Noneversion name.
v.namemay beNone(per pipeline error), butAssistant.namerequiresstr. Provide a fallback.🔧 Proposed fix
version_list = [ Assistant( assistant_id=assistant_id, - name=v.name, + name=v.name or "", description=v.description, config=v.config or {}, context=v.context or {},🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/aegra-api/src/aegra_api/services/assistant_service.py` around lines 530 - 543, The Assistant construction in version_list can pass None for v.name which violates Assistant.name's str requirement; update the Assistant(...) call (in the version_list creation) to provide a fallback string when v.name is None (e.g., use v.name or f"unnamed-{v.version}" or a fixed "<unnamed>" value) so Assistant.name always receives a str; keep the rest of the fields unchanged and ensure any chosen fallback is descriptive for debugging.
185-198:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFix type errors:
configmay beNone.The pipeline failures indicate that
request.configcan beNone, makingconfig.get("configurable")and subscript operations fail type checking. Default to an empty dict.🔧 Proposed fix
- config = request.config - context = request.context + config = request.config or {} + context = request.context or {} - if config.get("configurable") and context: + if config.get("configurable") and context:🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/aegra-api/src/aegra_api/services/assistant_service.py` around lines 185 - 198, request.config can be None causing attribute/subscript errors; default config to an empty dict and ensure any updates are written back to request.config. Concretely, replace use of config = request.config with config = request.config or {} (or equivalent null-coalescing), then keep the same checks (config.get("configurable")) and assignments, and when you set config["configurable"] = context make sure to assign the modified dict back to request.config so the request object is updated.
496-499:⚠️ Potential issue | 🟠 Major | ⚡ Quick winSame
Noneguard needed after re-fetch.Same pattern as
update_assistant— add assertion after re-fetching the updated row.🔧 Proposed fix
await self.session.execute(assistant_update) await self.session.commit() updated_assistant = await self.session.scalar(stmt) + assert updated_assistant is not None, "Assistant disappeared after update" return to_pydantic(updated_assistant)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/aegra-api/src/aegra_api/services/assistant_service.py` around lines 496 - 499, After re-fetching the updated row using updated_assistant = await self.session.scalar(stmt) add the same None-guard/assertion used in update_assistant: verify updated_assistant is not None (raise an appropriate exception or assert) before calling to_pydantic(updated_assistant). Locate the re-fetch and return sequence (the self.session.scalar(stmt) call, the updated_assistant variable, and the to_pydantic call) and insert the null-check to avoid passing None into to_pydantic.
🧹 Nitpick comments (1)
libs/aegra-api/tests/unit/test_services/test_assistant_service.py (1)
1059-1063: ⚡ Quick win
_owned_assistant()appears unused.This helper isn't referenced by any test in
TestAuthDispatch(all dispatch tests short-circuit viascalar.return_value = None). Either drop it, or—if the intent was to also cover the ownership-pass-through path (handler allows, DB returns an owned row)—add that positive-path test so dispatch success isn't left untested.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@libs/aegra-api/tests/unit/test_services/test_assistant_service.py` around lines 1059 - 1063, The helper function _owned_assistant() is defined but unused; either remove it or add a positive-path unit test in TestAuthDispatch to exercise ownership-success flow: have the mocked query scalar.return_value return _owned_assistant() (instead of None), invoke the dispatch handler (the same call site used by other dispatch tests), and assert the handler proceeds/returns the expected success result and that graph_id was accessed; update TestAuthDispatch to include this case if you want ownership path covered, otherwise delete the unused _owned_assistant() helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@libs/aegra-api/src/aegra_api/services/assistant_service.py`:
- Around line 576-584: The read authorization filters produced by calling
self._dispatch("read", {"assistant_id": assistant_id}) are not applied to the
SQLAlchemy query in get_assistant_graph; modify get_assistant_graph to capture
the returned filters (as done in get_assistant) and incorporate them into the
select(AssistantORM) stmt (which currently filters only by
AssistantORM.assistant_id and user_id/system) so the final query uses those
dispatched read filters when calling self.session.scalar(stmt).
- Around line 549-556: The read dispatch in get_assistant_schemas discards the
returned filters so auth-scoped metadata filters are not applied; capture the
result of await self._dispatch("read", {"assistant_id": assistant_id}) into a
variable (as done in get_assistant) and apply those filters to the DB query
before executing (e.g., incorporate the returned filter into stmt or add stmt =
stmt.where(filters)); do the same fix pattern for get_assistant_graph and
get_assistant_subgraphs so authorization filtering is enforced consistently.
---
Outside diff comments:
In `@libs/aegra-api/src/aegra_api/services/assistant_service.py`:
- Around line 618-631: The query in get_assistant_subgraphs ignores the
access-control filters returned by await self._dispatch("read", {"assistant_id":
assistant_id}); modify get_assistant_subgraphs to capture the dispatch result
(like in get_assistant), extract and apply its returned filters/where-clause to
the select(AssistantORM) statement (or combine with existing where conditions)
so the same access filters from _dispatch("read", ...) are honored before
calling self.session.scalar; reference the get_assistant_subgraphs method and
the _dispatch("read", ...) call and follow the pattern used in get_assistant.
- Around line 436-439: After executing the UPDATE and re-fetching with
session.scalar(stmt) in the assistant service method, guard against a None
result by asserting or raising if updated_assistant is None so the type checker
and runtime are satisfied; update the block that calls await
self.session.execute(assistant_update), await self.session.commit(), and
updated_assistant = await self.session.scalar(stmt) to check updated_assistant
(e.g., raise RuntimeError or AssertionError with a clear message including the
assistant id/context) before returning to_pydantic(updated_assistant).
- Around line 530-543: The Assistant construction in version_list can pass None
for v.name which violates Assistant.name's str requirement; update the
Assistant(...) call (in the version_list creation) to provide a fallback string
when v.name is None (e.g., use v.name or f"unnamed-{v.version}" or a fixed
"<unnamed>" value) so Assistant.name always receives a str; keep the rest of the
fields unchanged and ensure any chosen fallback is descriptive for debugging.
- Around line 185-198: request.config can be None causing attribute/subscript
errors; default config to an empty dict and ensure any updates are written back
to request.config. Concretely, replace use of config = request.config with
config = request.config or {} (or equivalent null-coalescing), then keep the
same checks (config.get("configurable")) and assignments, and when you set
config["configurable"] = context make sure to assign the modified dict back to
request.config so the request object is updated.
- Around line 496-499: After re-fetching the updated row using updated_assistant
= await self.session.scalar(stmt) add the same None-guard/assertion used in
update_assistant: verify updated_assistant is not None (raise an appropriate
exception or assert) before calling to_pydantic(updated_assistant). Locate the
re-fetch and return sequence (the self.session.scalar(stmt) call, the
updated_assistant variable, and the to_pydantic call) and insert the null-check
to avoid passing None into to_pydantic.
---
Nitpick comments:
In `@libs/aegra-api/tests/unit/test_services/test_assistant_service.py`:
- Around line 1059-1063: The helper function _owned_assistant() is defined but
unused; either remove it or add a positive-path unit test in TestAuthDispatch to
exercise ownership-success flow: have the mocked query scalar.return_value
return _owned_assistant() (instead of None), invoke the dispatch handler (the
same call site used by other dispatch tests), and assert the handler
proceeds/returns the expected success result and that graph_id was accessed;
update TestAuthDispatch to include this case if you want ownership path covered,
otherwise delete the unused _owned_assistant() helper.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3f0ce72d-a91f-44d0-90ab-05f06e8ae797
📒 Files selected for processing (10)
libs/aegra-api/src/aegra_api/api/assistants.pylibs/aegra-api/src/aegra_api/core/auth_filters.pylibs/aegra-api/src/aegra_api/services/assistant_service.pylibs/aegra-api/src/aegra_api/services/authenticated.pylibs/aegra-api/tests/integration/test_api/test_assistants_crud.pylibs/aegra-api/tests/integration/test_services/test_assistant_service_db.pylibs/aegra-api/tests/unit/test_api/test_assistants_search_sort.pylibs/aegra-api/tests/unit/test_core/test_auth_filters.pylibs/aegra-api/tests/unit/test_services/test_assistant_service.pylibs/aegra-api/tests/unit/test_services/test_authenticated.py
Review fixes for PR #401. - get_assistant_schemas/graph/subgraphs, update_assistant, delete_assistant, and set_assistant_latest dispatched their action but discarded the returned filter, so a metadata-scoping handler was bypassable via those endpoints (the SQL user_id boundary still held). They now capture and apply the filter to the lookup query, matching get_assistant. The three read-derived endpoints share a _read_owned_assistant helper that dispatches, applies the filter, and 404s — removing the duplicated lookup block. - build_metadata_filter rejects empty or non-dict $or/$and branches. An empty branch compiled to and_() == TRUE, collapsing OR to TRUE and silently bypassing the constraint. - Hoist an inline HTTPException import to the top of test_authenticated.py. - Trim history-narrating docstrings on authenticated.py and _injected_metadata down to rationale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First PR in the auth dispatch hoist workstream. Makes
@auth.ondispatch mandatory at the service call site instead of optional route-handler boilerplate, so no endpoint can silently ship without authorization. Non-breaking; assistants only in this PR. Threads, runs, crons, and the breaking renames follow in separate PRs.What changes
Authenticatedservice base (services/_authenticated.py)Carries the request identity
(session, user)and exposes one_dispatch(action, value)entry point. Tenant-scoped services inherit from it and call_dispatchat the top of each public method. Dispatch policy is unchanged:handle_eventstays default-allow when no handler matches, and the SQL-layeruser_id == user.identityfilter remains the tenant boundary.Auth filter builder (
core/auth_filters.py)Compiles an
@auth.onhandler's returned filter dict into a SQLAlchemy JSONB predicate over a metadata column:{"owner": "u1"}→metadata @> {"owner": "u1"}{"owner": {"$eq": "u1"}}→ same, explicit{"tags": {"$contains": "x"}}→metadata->'tags' @> ["x"]{"$or": [...]}/{"$and": [...]}→ OR / AND (nesting depth capped at 2)Previously the service flattened every handler return into a single
metadata @>containment, so$eq/$contains/$or/$andsilently matched the wrong rows or none. Malformed filters now raise 500 with a clear message instead of failing silently.Assistants migration
AssistantServiceinheritsAuthenticated; every method dispatches at entry. The per-methoduser_identity/userparams are gone (identity is on the service), so routes shrink to parse-and-call. Two handler conventions are now cleanly separated:value["metadata"]in place; only that injection is read back onto the request.request.metadatauntouched.Also fixes the versions endpoint to dispatch
assistants.search(notread) with the{assistant_id, metadata}value shape, per the auth dispatch spec — handlers scoping version listing were previously bypassed.Tests
test_auth_filters.py— operator/boolean/envelope/validation coverage for the builder.test_assistant_service.py::TestAuthDispatch— per-method dispatch tuples, 403-before-DB, filter-as-WHERE.test_authenticated.py— base class dispatch + denial propagation.Follow-ups (separate PRs)
store.search→store.list_namespacesrename (breaking, batched for 0.10.0)runs.*→threads.*rename (breaking, batched for 0.10.0)🤖 Generated with Claude Code
Summary by CodeRabbit
Refactor
Bug Fixes
Tests
Greptile Summary
This PR hoists
@auth.ondispatch from route handlers into a newAuthenticatedservice base, ensuring every DB-touching method authorizes before querying. It also introduces a proper JSONB filter compiler (auth_filters.py) that replaces the old flat-metadata-merge approach with full$eq/$contains/$or/$andsupport and explicit rejection of malformed filters.Authenticatedbase +AssistantServicerefactor:_dispatchis now called at the top of every public method;_read_owned_assistantcorrectly funnels schemas/graph/subgraphs through a single auth-filtered lookup, closing the previously-reported bypass on those endpoints.auth_filters.py: Compiles handler filter dicts into SQLAlchemy JSONB predicates with operator support, depth capping, and empty-branch rejection; previously-reported$or/$andTRUE-collapse issue is addressed.update_assistant/set_assistant_latestre-read hazard: After committing the UPDATE, both methods re-execute the originalstmt— which includes the handler's auth filter onmetadata_dict. If the new metadata no longer satisfies the filter,session.scalar(stmt)returnsNoneandto_pydantic(None)raises an unhandledAttributeError, producing a 500 after a committed write.Confidence Score: 4/5
Safe to merge in environments without custom auth handlers returning metadata filters; the re-read bug only activates when a handler scopes by metadata and an update removes that metadata.
The auth dispatch consolidation is sound and the filter compiler is well-tested. The only functional defect introduced is in
update_assistantandset_assistant_latest: both re-execute the pre-update SELECT (which carries the handler's metadata filter) after committing the write. If the committed metadata no longer matches the filter, the re-read returns None andto_pydantic(None)crashes — a 500 on a write that already landed in the database.libs/aegra-api/src/aegra_api/services/assistant_service.py — specifically the post-commit scalar(stmt) calls in update_assistant (~line 446) and set_assistant_latest (~line 512).
Important Files Changed
Sequence Diagram
sequenceDiagram participant Route as FastAPI Route participant Svc as AssistantService participant Auth as Authenticated._dispatch participant Handler as @auth.on handler participant DB as PostgreSQL Route->>Svc: create_assistant(request) Svc->>Auth: _dispatch("create", value) Auth->>Handler: handle_event(ctx, value) Handler-->>Auth: mutates value["metadata"] / raises 403 Auth-->>Svc: None (return discarded for create) Svc->>DB: INSERT AssistantORM Route->>Svc: get_assistant(id) Svc->>Auth: "_dispatch("read", {assistant_id})" Auth->>Handler: handle_event(ctx, value) Handler-->>Auth: filter_dict or raises 403 Auth-->>Svc: filter_dict Svc->>DB: "SELECT WHERE id=? AND user_id=? AND metadata @> filter" DB-->>Svc: AssistantORM or None (404) Route->>Svc: update_assistant(id, request) Svc->>Auth: _dispatch("update", value) Auth->>Handler: handle_event(ctx, value) Handler-->>Auth: filter_dict or raises 403 Auth-->>Svc: filter_dict Svc->>DB: "SELECT WHERE id=? AND user_id=? AND metadata @> filter" DB-->>Svc: AssistantORM (or 404) Svc->>DB: "UPDATE SET metadata_dict=new_metadata" Svc->>DB: SELECT same stmt with old filter ⚠️ DB-->>Svc: None if new metadata breaks filter → crashComments Outside Diff (1)
libs/aegra-api/src/aegra_api/services/assistant_service.py, line 427-447 (link)update_assistantbuildsstmtwith the auth filter before the UPDATE, then re-executes that same statement after committing new metadata. Ifrequest.metadataremoves a field the handler filter requires (e.g. the auth filter is{"team": "x"}and the user clears metadata to{}), the post-commitsession.scalar(stmt)returnsNoneandto_pydantic(None)raises anAttributeError— a 500 after a write that already committed. The same problem exists inset_assistant_latestat line 512, where rolling back to an older version may swap in metadata that doesn't satisfy the current filter.Reviews (2): Last reviewed commit: "fix(auth): apply handler filters on all ..." | Re-trigger Greptile