Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
eeef5c6
test: characterize authorization whole-query rejection behaviour
Jul 30, 2026
0d9252e
test: assert plan cache segments by authorization state
Jul 31, 2026
bd912d7
test: pin the invariant that keeps warm-up from caching unfiltered plans
Jul 31, 2026
2c69fd3
test: cover __typename retention in authorization-filtered queries
Jul 31, 2026
15bf899
chore: remove orphaned response cache authorization snapshots
Jul 31, 2026
2f08e5e
test: drive authorization cache-key tests through the production meta…
rohan-b99 Aug 10, 2026
043547f
chore: lint
rohan-b99 Aug 10, 2026
5d94484
test: derive the __typename retention fixture from the query planner
Aug 17, 2026
f0e6c61
test: pin that the filtered response pass strips overfetched data
Aug 17, 2026
08bf779
test: pin the rejection response contract before moving it out of the…
Aug 17, 2026
0912ce2
test: pin the licensed operation count for an unreported operation
Aug 17, 2026
b8241ee
docs: changeset for reporting authorization-rejected operations to St…
Aug 17, 2026
fd7e1e9
refactor: return authorization refusal as data rather than an error
Aug 17, 2026
75bef51
test: pin a filtered operation that leaves no executable work
Aug 17, 2026
29867ed
test: pin the span and cardinality of the authorization error event
Aug 17, 2026
168ad57
refactor: answer authorization refusals on the execution service
Aug 17, 2026
7abe084
refactor: remove QueryPlannerContent::Response
Aug 17, 2026
b7fed03
test: address review feedback on authorization test helpers
Aug 17, 2026
db487c7
test: pin the multi-operation gap in document emptiness, rename the f…
Aug 17, 2026
b9d5647
docs: authorization and query planning before and after ROUTER-1973
Aug 18, 2026
f40c739
feat!: run emptied operations through the normal response pipeline
Aug 18, 2026
ecd91ed
docs: emptied operations run the normal pipeline, marker considered a…
Aug 18, 2026
8382510
test: pin cross-operation authorization effects in multi-operation do…
Aug 18, 2026
694a91c
test: polish the authorization tests added on this branch
Aug 18, 2026
135f61f
Merge remote-tracking branch 'origin/dev-v3.x' into agent/ROUTER-1973
Aug 18, 2026
f3c1796
refactor: collapse single-variant QueryPlannerContent into a type alias
Aug 18, 2026
e2eb4f0
refactor: collapse FilterResult::Emptied into Filtered
Aug 19, 2026
70cbf5e
docs: mark the refusal response as knowingly non-compliant
Aug 19, 2026
5f9f5c9
test: assert the execution error that nullifies an overfetched field
Aug 19, 2026
6e975af
test: apply review feedback to the refusal test docs
Aug 19, 2026
b8d9c8a
test: pin validation gates answering before the authorization refusal
Aug 19, 2026
20aab33
test: exercise both authorization error emitters in the span test
Aug 19, 2026
6eb7282
docs: reword the authorization changesets per review
Aug 19, 2026
c867886
docs: record review-deferred issues in the authorization dev doc
Aug 19, 2026
91f3859
Merge remote-tracking branch 'origin/dev-v3.x' into agent/ROUTER-1973
Aug 19, 2026
2305e35
refactor: share the filtered-then-original formatting between executi…
Aug 19, 2026
0ec06b8
test: drive the empty-plan test through the planner's public service API
Aug 19, 2026
95980bf
test: name the abstract-type formatting tests by what they verify
Aug 19, 2026
33a892f
test: policy directive tests assert the shaped response
Aug 19, 2026
b661e3c
test: read the query plan cache entry without the removed enum tag
Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 5 additions & 0 deletions .changesets/breaking_bryn_router_1973_gate_ordering.md
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
10 changes: 1 addition & 9 deletions apollo-router/src/batching/query_plan_analysis_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ mod tests {
use crate::graphql;
use crate::query_planner::QueryPlan;
use crate::query_planner::QueryPlannerService;
use crate::services::QueryPlannerContent;
use crate::services::QueryPlannerRequest;
use crate::services::execution;
use crate::spec::Query;
Expand All @@ -228,9 +227,7 @@ mod tests {
) -> Arc<QueryPlan> {
let document = Query::parse_document(query, None, &schema, &configuration).unwrap();

let QueryPlannerContent::Plan {
plan: query_plan, ..
} = QueryPlannerService::for_test(schema, configuration)
QueryPlannerService::for_test(schema, configuration)
.unwrap()
.oneshot(
QueryPlannerRequest::builder()
Expand All @@ -245,11 +242,6 @@ mod tests {
.unwrap()
.content
.unwrap()
else {
panic!("unexpected query planner output");
};

query_plan
}

#[tokio::test]
Expand Down
4 changes: 0 additions & 4 deletions apollo-router/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,6 @@ pub(crate) enum QueryPlannerError {
/// spec error: {0}
SpecError(SpecError),

// Safe to cache because user scopes and policies are included in the cache key.
/// Unauthorized field or type
Unauthorized(Vec<Path>),

/// Federation error: {0}
FederationError(FederationErrorBridge),

Expand Down
67 changes: 55 additions & 12 deletions apollo-router/src/plugins/authorization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -194,6 +205,7 @@ fn default_enable_directives() -> bool {

pub(crate) struct AuthorizationPlugin {
require_authentication: bool,
reject_unauthorized: bool,
}

impl AuthorizationPlugin {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

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_unauthorized refusal 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_query returned QueryPlannerError::Unauthorized and service_call answered from the (now-deleted) QueryPlannerContent::Response arm, which sat above the Plan arm and skipped everything in it. Now every case reaches the Plan arm in services/supergraph/service.rs:272, where is_subscription/is_deferred (406) and plan.query.validate_variables (400) run before execution_service.call. Concretely: subscription { secretFeed } with secretFeed marked @authenticated, an unauthenticated client, reject_unauthorized: true, POSTed with Accept: application/json. Filtering empties the document, so FilterResult::Emptied yields a plan with root: None, but QueryPlan::is_subscription() reads query.operation.kind() (plan.rs:81), not root, so it is still true. The router replies 406 SUBSCRIPTION_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 no Authorization error is logged and no UNAUTHORIZED_FIELD_OR_TYPE error is returned. Previously the same request got 200 + UNAUTHORIZED_FIELD_OR_TYPE. The same substitution happens for a partially filtered @defer operation without multipart/mixed (406 DEFER_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.

@goto-bus-stop goto-bus-stop Aug 18, 2026

Copy link
Copy Markdown
Collaborator

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_unauthorized setting.

Copy link
Copy Markdown
Collaborator Author

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_VARIABLE with no authorization error where it previously returned the refusal. b8d9c8a adds the changeset (breaking_bryn_router_1973_gate_ordering) and pins the variable case with variable_validation_answers_before_the_refusal. The subscription and @defer cases are unpinned — they need a schema with subscriptions — and are noted in the dev-doc's outstanding-issues section.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
let mut response = graphql::Response::builder().data(Value::Null).build();
// 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();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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);
Expand Down
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 phone: String (i.e., it's nullable), we should actually have { currentUser: { phone: null } } as the data here. Unless currentUser was null in the subgraph, then we should have { currentUser: null }. This might get at a more fundamental GraphQL correctness problem with query filtering right now. Let's at least not address it in this PR

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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: phone: String erroring should yield {"currentUser": {"phone": null}}, and {"currentUser": null} should only appear when propagation forces it. The parent-nulling comes from filtering removing phone from the operation entirely, so formatting never sees a position to null. Recorded in the dev-doc's outstanding-issues section (c867886) so it doesn't get lost — it likely belongs alongside ROUTER-2063 since both are "filtering changes the shape the client observes".

},
"errors": [
{
"message": "Unauthorized field or type",
"path": [
"currentUser",
"phone"
],
"extensions": {
"code": "UNAUTHORIZED_FIELD_OR_TYPE"
}
}
]
}
Loading