-
Notifications
You must be signed in to change notification settings - Fork 341
refactor: query planner service no longer returns graphql response #9911
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
Changes from all commits
eeef5c6
0d9252e
bd912d7
2c69fd3
15bf899
2f08e5e
043547f
5d94484
f0e6c61
08bf779
0912ce2
b8241ee
fd7e1e9
75bef51
29867ed
168ad57
7abe084
b7fed03
db487c7
b9d5647
f40c739
ecd91ed
8382510
694a91c
135f61f
f3c1796
e2eb4f0
70cbf5e
5f9f5c9
6e975af
b8d9c8a
20aab33
6eb7282
c867886
91f3859
2305e35
0ec06b8
95980bf
33a892f
b661e3c
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,13 @@ | ||
| ### Operations with only authorization errors return spec-compliant data ([PR #9911](https://github.com/apollographql/router/pull/9911)) | ||
|
|
||
| When every field in an operation fails authorization, the response now carries each requested root field as `null` alongside an error for each field, the same shape clients receive when some fields fail: | ||
|
|
||
| ```json | ||
| {"data": {"orga": null}, "errors": [{"message": "Unauthorized field or type", "path": ["orga", "id"], "extensions": {"code": "UNAUTHORIZED_FIELD_OR_TYPE"}}]} | ||
| ``` | ||
|
|
||
| The router previously returned `"data": null` here, incorrectly reporting that execution never produced a result. Clients that detect this case by checking `data` for `null` should check for errors with the `UNAUTHORIZED_FIELD_OR_TYPE` code instead, which covers partial failures as well. | ||
|
|
||
| `authorization.directives.reject_unauthorized` keeps returning `"data": null`; spec compliance for refused operations is tracked separately. | ||
|
|
||
| By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/9911 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| ### Request validation errors answer before authorization errors ([PR #9911](https://github.com/apollographql/router/pull/9911)) | ||
|
|
||
| Authorization enforcement runs at execution, after the router validates the request. An operation that fails both checks now receives the validation error alone: a missing or invalid variable returns the 400 validation response, and a subscription or `@defer` operation sent without the matching `Accept` header returns the 406, where these previously received the authorization errors. Fixing the request then surfaces the authorization errors. | ||
|
|
||
| By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/9911 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| ### Report operations whose fields all fail authorization to Apollo Studio ([PR #9911](https://github.com/apollographql/router/pull/9911)) | ||
|
|
||
| When authorization raises errors for every field in an operation, or `authorization.directives.reject_unauthorized` refuses the operation outright, Apollo Studio now receives it as an operation, identified by the signature of the query the client sent and carrying the client name, version, and request count. Studio previously received the operation count alone and had nothing to attribute it to. | ||
|
|
||
| Such an operation counts as one licensed operation. | ||
|
|
||
| The `Authorization error` log event for these operations now appears under the `execution` span instead of inside `query_planning`. Update log or trace filters that match this event by span name. | ||
|
|
||
| By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/9911 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -34,7 +34,6 @@ use crate::layers::ServiceBuilderExt; | |||||||||||||||
| use crate::plugin::Plugin; | ||||||||||||||||
| use crate::plugin::PluginInit; | ||||||||||||||||
| use crate::plugins::authentication; | ||||||||||||||||
| use crate::query_planner::FilteredQuery; | ||||||||||||||||
| use crate::query_planner::QueryKey; | ||||||||||||||||
| use crate::services::execution; | ||||||||||||||||
| use crate::services::supergraph; | ||||||||||||||||
|
|
@@ -138,6 +137,18 @@ pub(crate) struct UnauthorizedPaths { | |||||||||||||||
| pub(crate) errors: ErrorConfig, | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// What [`AuthorizationPlugin::filter_query`] did to an operation. | ||||||||||||||||
| pub(crate) enum FilterResult { | ||||||||||||||||
| /// The operation asks for nothing the request lacks authorization for. | ||||||||||||||||
| Unchanged, | ||||||||||||||||
| /// `document` is the operation with `paths` removed. Filtering can empty the | ||||||||||||||||
| /// document entirely, leaving no definitions. | ||||||||||||||||
| Filtered { | ||||||||||||||||
| paths: Vec<Path>, | ||||||||||||||||
| document: ast::Document, | ||||||||||||||||
| }, | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| impl UnauthorizedPaths { | ||||||||||||||||
| pub(crate) fn log_unauthorized_paths(&self) { | ||||||||||||||||
| // nothing to do if we have no paths or we're not supposed to log | ||||||||||||||||
|
|
@@ -194,6 +205,7 @@ fn default_enable_directives() -> bool { | |||||||||||||||
|
|
||||||||||||||||
| pub(crate) struct AuthorizationPlugin { | ||||||||||||||||
| require_authentication: bool, | ||||||||||||||||
| reject_unauthorized: bool, | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| impl AuthorizationPlugin { | ||||||||||||||||
|
|
@@ -337,8 +349,7 @@ impl AuthorizationPlugin { | |||||||||||||||
| configuration: &Conf, | ||||||||||||||||
| key: &QueryKey, | ||||||||||||||||
| schema: &Schema, | ||||||||||||||||
| ) -> Result<Option<FilteredQuery>, QueryPlannerError> { | ||||||||||||||||
| let reject_unauthorized = configuration.directives.reject_unauthorized; | ||||||||||||||||
| ) -> Result<FilterResult, QueryPlannerError> { | ||||||||||||||||
| let dry_run = configuration.directives.dry_run; | ||||||||||||||||
|
|
||||||||||||||||
| // The filtered query will then be used | ||||||||||||||||
|
|
@@ -366,7 +377,10 @@ impl AuthorizationPlugin { | |||||||||||||||
|
|
||||||||||||||||
| // FIXME: consider only `filtered_doc.operations.get(key.operation_name)`? | ||||||||||||||||
| if filtered_doc.definitions.is_empty() { | ||||||||||||||||
| return Err(QueryPlannerError::Unauthorized(unauthorized_paths)); | ||||||||||||||||
| return Ok(FilterResult::Filtered { | ||||||||||||||||
| paths: unauthorized_paths, | ||||||||||||||||
| document: filtered_doc, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| is_filtered = true; | ||||||||||||||||
|
|
@@ -384,7 +398,10 @@ impl AuthorizationPlugin { | |||||||||||||||
|
|
||||||||||||||||
| // FIXME: consider only `filtered_doc.operations.get(key.operation_name)`? | ||||||||||||||||
| if filtered_doc.definitions.is_empty() { | ||||||||||||||||
| return Err(QueryPlannerError::Unauthorized(unauthorized_paths)); | ||||||||||||||||
| return Ok(FilterResult::Filtered { | ||||||||||||||||
| paths: unauthorized_paths, | ||||||||||||||||
| document: filtered_doc, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| is_filtered = true; | ||||||||||||||||
|
|
@@ -402,7 +419,10 @@ impl AuthorizationPlugin { | |||||||||||||||
|
|
||||||||||||||||
| // FIXME: consider only `filtered_doc.operations.get(key.operation_name)`? | ||||||||||||||||
| if filtered_doc.definitions.is_empty() { | ||||||||||||||||
| return Err(QueryPlannerError::Unauthorized(unauthorized_paths)); | ||||||||||||||||
| return Ok(FilterResult::Filtered { | ||||||||||||||||
| paths: unauthorized_paths, | ||||||||||||||||
| document: filtered_doc, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| is_filtered = true; | ||||||||||||||||
|
|
@@ -411,14 +431,13 @@ impl AuthorizationPlugin { | |||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| if reject_unauthorized && !unauthorized_paths.is_empty() { | ||||||||||||||||
| return Err(QueryPlannerError::Unauthorized(unauthorized_paths)); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if is_filtered { | ||||||||||||||||
| Ok(Some((unauthorized_paths, doc))) | ||||||||||||||||
| Ok(FilterResult::Filtered { | ||||||||||||||||
| paths: unauthorized_paths, | ||||||||||||||||
| document: doc, | ||||||||||||||||
| }) | ||||||||||||||||
| } else { | ||||||||||||||||
| Ok(None) | ||||||||||||||||
| Ok(FilterResult::Unchanged) | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -542,6 +561,7 @@ impl Plugin for AuthorizationPlugin { | |||||||||||||||
| async fn new(init: PluginInit<Self::Config>) -> Result<Self, BoxError> { | ||||||||||||||||
| Ok(AuthorizationPlugin { | ||||||||||||||||
| require_authentication: init.config.require_authentication, | ||||||||||||||||
| reject_unauthorized: init.config.directives.reject_unauthorized, | ||||||||||||||||
| }) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -580,7 +600,30 @@ impl Plugin for AuthorizationPlugin { | |||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| fn execution_service(&self, service: execution::BoxCloneService) -> execution::BoxCloneService { | ||||||||||||||||
| let reject_unauthorized = self.reject_unauthorized; | ||||||||||||||||
|
|
||||||||||||||||
| ServiceBuilder::new() | ||||||||||||||||
| // Ahead of the counter below, so a refused operation stays uncounted. | ||||||||||||||||
| .checkpoint_async(move |request: execution::Request| async move { | ||||||||||||||||
| if reject_unauthorized && !request.query_plan.query.unauthorized.paths.is_empty() { | ||||||||||||||||
| let unauthorized = request.query_plan.query.unauthorized.clone(); | ||||||||||||||||
| unauthorized.log_unauthorized_paths(); | ||||||||||||||||
|
|
||||||||||||||||
| // We knowingly build an invalid response here. Execution was prevented, | ||||||||||||||||
| // so we should respond with a request error and no data. Instead, we're | ||||||||||||||||
| // responding with execution errors and a fake/incorrect `data: null`. We | ||||||||||||||||
| // maintain backwards compatibility for the time being. | ||||||||||||||||
| // Tracked in ROUTER-2063. | ||||||||||||||||
| let mut response = graphql::Response::builder().data(Value::Null).build(); | ||||||||||||||||
|
Collaborator
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. Let's clarify that this is technically not up to spec. Not a fan of including ticket references in general, but it might be helpful in this case, as it explains future work rather than describing work that's already done (which is what bothers me most when Claude sticks references in comments)
Suggested change
Collaborator
Author
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. Taken verbatim in 70cbf5e. |
||||||||||||||||
| unauthorized.update_response_with_unauthorized_path_errors(&mut response); | ||||||||||||||||
|
|
||||||||||||||||
| return Ok(ControlFlow::Break( | ||||||||||||||||
| execution::Response::new_from_graphql_response(response, request.context), | ||||||||||||||||
| )); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| Ok(ControlFlow::Continue(request)) | ||||||||||||||||
| }) | ||||||||||||||||
| .map_request(|request: execution::Request| { | ||||||||||||||||
| let filtered = !request.query_plan.query.unauthorized.paths.is_empty(); | ||||||||||||||||
| let needs_authenticated = request.context.contains_key(AUTHENTICATION_REQUIRED_KEY); | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| --- | ||
| source: apollo-router/src/plugins/authorization/tests.rs | ||
| expression: body | ||
| --- | ||
| { | ||
| "data": { | ||
| "currentUser": null | ||
|
Collaborator
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. Hmm, might need to think about this a bit. My intuition says that since the field is
Collaborator
Author
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. Agreed, and left alone here as you say. Your intuition matches the spec's field-error semantics: |
||
| }, | ||
| "errors": [ | ||
| { | ||
| "message": "Unauthorized field or type", | ||
| "path": [ | ||
| "currentUser", | ||
| "phone" | ||
| ], | ||
| "extensions": { | ||
| "code": "UNAUTHORIZED_FIELD_OR_TYPE" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
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.
This came up during a claude
code-review, it seems like an unintended change:Moving the
reject_unauthorizedrefusal from the query planner to the execution service puts three supergraph-level gates ahead of it, so an unauthorized operation can be answered with an accept-header or variable-validation error instead of the authorization response.Before this change,
filter_queryreturnedQueryPlannerError::Unauthorizedandservice_callanswered from the (now-deleted)QueryPlannerContent::Responsearm, which sat above thePlanarm and skipped everything in it. Now every case reaches thePlanarm in services/supergraph/service.rs:272, whereis_subscription/is_deferred(406) andplan.query.validate_variables(400) run beforeexecution_service.call. Concretely:subscription { secretFeed }withsecretFeedmarked@authenticated, an unauthenticated client,reject_unauthorized: true, POSTed withAccept: application/json. Filtering empties the document, soFilterResult::Emptiedyields a plan withroot: None, butQueryPlan::is_subscription()readsquery.operation.kind()(plan.rs:81), notroot, so it is stilltrue. The router replies 406SUBSCRIPTION_BAD_HEADER— telling the caller to fix its Accept header for an operation it is not allowed to run — and this checkpoint never executes, so noAuthorization erroris logged and noUNAUTHORIZED_FIELD_OR_TYPEerror is returned. Previously the same request got 200 +UNAUTHORIZED_FIELD_OR_TYPE. The same substitution happens for a partially filtered@deferoperation withoutmultipart/mixed(406DEFER_BAD_HEADER) and for an unauthorized operation with a missing/invalid required variable (400 variable-validation errors). None of these are covered by the new tests or mentioned in either changeset.Uh oh!
There was an error while loading. Please reload this page.
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.
Yeah, we should call it out in a changeset, it's a correct change in behaviour given that authorization enforcement conceptually moves to execution regardless of the
reject_unauthorizedsetting.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.
Verified on the wire: the variable case returns 400
VALIDATION_INVALID_TYPE_VARIABLEwith no authorization error where it previously returned the refusal. b8d9c8a adds the changeset (breaking_bryn_router_1973_gate_ordering) and pins the variable case withvariable_validation_answers_before_the_refusal. The subscription and@defercases are unpinned — they need a schema with subscriptions — and are noted in the dev-doc's outstanding-issues section.