use futures::StreamExt;
use http::header::ACCEPT;
use http::header::CONTENT_TYPE;
use regex::Regex;
use serde_json_bytes::json;
use tower::ServiceExt;
use crate::Context;
use crate::MockedSubgraphs;
use crate::TestHarness;
use crate::graphql;
use crate::plugin::test::MockSubgraph;
use crate::plugin::test::MockSubgraphService;
use crate::plugins::authorization::APOLLO_AUTHENTICATION_JWT_CLAIMS;
use crate::plugins::authorization::CacheKeyMetadata;
use crate::services::router;
use crate::services::router::body;
use crate::services::subgraph;
use crate::services::supergraph;
use crate::test_harness::tracing_test;
const SCHEMA: &str = include_str!("../../testdata/orga_supergraph.graphql");
fn assert_span_contains_authorization_error_event(span: &str) {
let pattern = format!(
r"^[0-9TZ\-:.]+ ERROR router\{{[^}}]+}}:{span}.*Authorization error unauthorized_query_paths=\[.*]$"
);
let span_regex = Regex::new(&pattern).unwrap();
let contains_err_event_in_span = tracing_test::logs_assert(|lines| {
for line in lines {
if span_regex.captures(line).is_some() {
return Ok(());
}
}
Err(lines.join("\n"))
});
assert!(contains_err_event_in_span.is_ok());
}
fn assert_logs_contain_entire_request_authorization_error() {
assert_span_contains_authorization_error_event("query_planning");
}
fn assert_logs_contain_partial_authorization_error() {
assert_span_contains_authorization_error_event("format_response");
}
#[tokio::test]
async fn authenticated_request() {
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){... on User{name phone}}}",
"variables": {
"representations": [
{ "__typename": "User", "id":0 }
],
}
}},
serde_json::json! {{
"data": {
"_entities":[
{
"name":"Ada",
"phone": "1234"
}
]
}
}},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"require_authentication": true
}}))
.unwrap()
.schema(SCHEMA)
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context
.insert(APOLLO_AUTHENTICATION_JWT_CLAIMS, "placeholder".to_string())
.unwrap();
let request = supergraph::Request::fake_builder()
.query("query { orga(id: 1) { id creatorUser { id name phone } } }")
.variables(
json! {{ "isAuthenticated": true }}
.as_object()
.unwrap()
.clone(),
)
.context(context)
.build()
.unwrap();
let response = service
.oneshot(request)
.await
.unwrap()
.next_response()
.await
.unwrap();
insta::assert_json_snapshot!(response);
}
#[tokio::test]
async fn unauthenticated_request() {
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {
"representations": [
{ "__typename": "User", "id":0 }
],
}
}},
serde_json::json! {{
"data": {
"_entities":[
{
"name":"Ada"
}
]
}
}},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"require_authentication": true
}}))
.unwrap()
.schema(SCHEMA)
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let context = Context::new();
let request = supergraph::Request::fake_builder()
.query("query { orga(id: 1) { id creatorUser { id name phone } } }")
.variables(
json! {{ "isAuthenticated": false }}
.as_object()
.unwrap()
.clone(),
)
.context(context)
.build()
.unwrap();
let response = service
.oneshot(request)
.await
.unwrap()
.next_response()
.await
.unwrap();
insta::assert_json_snapshot!(response);
}
const AUTHENTICATED_SCHEMA: &str = r#"schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
@link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY)
{
query: Query
}
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR
directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar link__Import
enum link__Purpose {
"""
`SECURITY` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
`EXECUTION` features provide metadata necessary for operation execution.
"""
EXECUTION
}
directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
scalar join__FieldSet
enum join__Graph {
USER @join__graph(name: "user", url: "http://localhost:4001/graphql")
ORGA @join__graph(name: "orga", url: "http://localhost:4002/graphql")
}
type Query
@join__type(graph: ORGA)
@join__type(graph: USER){
currentUser: User @join__field(graph: USER)
orga(id: ID): Organization @join__field(graph: ORGA)
}
type User
@join__type(graph: ORGA, key: "id")
@join__type(graph: USER, key: "id"){
id: ID!
name: String
phone: String @authenticated
activeOrganization: Organization
}
type Organization
@join__type(graph: ORGA, key: "id")
@join__type(graph: USER, key: "id") {
id: ID @authenticated
creatorUser: User
name: String
nonNullId: ID!
suborga: [Organization]
}"#;
#[tokio::test]
async fn authenticated_directive() {
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": {"_entities":[{ "name":"Ada" }] }}},
)
.with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": {"_entities":[{"name":"Ada", "phone": "1234"}] }}},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "creatorUser": { "id": 0, "name":"Ada" } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": {"id": 0, "name":"Ada", "phone": "1234" } }}}}
).build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true
}
}}))
.unwrap()
.schema(AUTHENTICATED_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
variables: json! {{ "isAuthenticated": false }}
.as_object()
.unwrap()
.clone(),
..Default::default()
};
let context = Context::new();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
let context = Context::new();
context
.insert(
APOLLO_AUTHENTICATION_JWT_CLAIMS,
json! {{ "scope": "user:read" }},
)
.unwrap();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
println!("req2");
insta::assert_json_snapshot!(response);
}
#[tokio::test]
async fn authenticated_directive_reject_unauthorized() {
let _guard = tracing_test::dispatcher_guard();
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": {"_entities":[{ "name":"Ada" }] }}},
)
.with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": {"_entities":[{"name":"Ada", "phone": "1234"}] }}},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "creatorUser": { "id": 0, "name":"Ada" } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": {"id": 0, "name":"Ada", "phone": "1234" } }}}}
).build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true,
"reject_unauthorized": true
}
}}))
.unwrap()
.schema(AUTHENTICATED_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
variables: json! {{ "isAuthenticated": false }}
.as_object()
.unwrap()
.clone(),
..Default::default()
};
let context = Context::new();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
assert_logs_contain_entire_request_authorization_error();
}
#[tokio::test]
async fn authenticated_directive_dry_run() {
let _guard = tracing_test::dispatcher_guard();
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": {"_entities":[{ "name":"Ada" }] }}},
)
.with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": {"_entities":[{"name":"Ada", "phone": "1234"}] }}},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "creatorUser": { "id": 0, "name":"Ada" } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": {"id": 0, "name":"Ada", "phone": "1234" } }}}}
).build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true,
"dry_run": true
}
}}))
.unwrap()
.schema(AUTHENTICATED_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
variables: json! {{ "isAuthenticated": false }}
.as_object()
.unwrap()
.clone(),
..Default::default()
};
let context = Context::new();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
assert_logs_contain_partial_authorization_error();
}
const SCOPES_SCHEMA: &str = r#"schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
@link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY)
{
query: Query
}
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR
directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar link__Import
enum link__Purpose {
"""
`SECURITY` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
`EXECUTION` features provide metadata necessary for operation execution.
"""
EXECUTION
}
scalar federation__Scope
directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
scalar join__FieldSet
enum join__Graph {
USER @join__graph(name: "user", url: "http://localhost:4001/graphql")
ORGA @join__graph(name: "orga", url: "http://localhost:4002/graphql")
}
type Query
@join__type(graph: ORGA)
@join__type(graph: USER){
currentUser: User @join__field(graph: USER)
orga(id: ID): Organization @join__field(graph: ORGA)
}
type User
@join__type(graph: ORGA, key: "id")
@join__type(graph: USER, key: "id")
@requiresScopes(scopes: [["user:read"], ["admin"]]) {
id: ID!
name: String
phone: String @requiresScopes(scopes: [["pii"]])
activeOrganization: Organization
}
type Organization
@join__type(graph: ORGA, key: "id")
@join__type(graph: USER, key: "id") {
id: ID
creatorUser: User
name: String
nonNullId: ID!
suborga: [Organization]
}"#;
#[tokio::test]
async fn scopes_directive() {
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada"}] } }},
).with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada", "phone": "1234"}] } }},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){id}}"}},
serde_json::json!{{"data": {"orga": { "id": 1 }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada" } }}}}
)
.with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada", "phone": "1234" } }}}}
)
.build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true
}
}}))
.unwrap()
.schema(SCOPES_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
..Default::default()
};
let request = router::Request {
context: Context::new(),
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
let context = Context::new();
context
.insert(
APOLLO_AUTHENTICATION_JWT_CLAIMS,
json! {{ "scope": "user:read" }},
)
.unwrap();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
let context = Context::new();
context
.insert(
APOLLO_AUTHENTICATION_JWT_CLAIMS,
json! {{ "scope": "user:read pii" }},
)
.unwrap();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
let context = Context::new();
context
.insert(
APOLLO_AUTHENTICATION_JWT_CLAIMS,
json! {{ "scope": "admin" }},
)
.unwrap();
let request = router::Request {
context,
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
}
#[tokio::test]
async fn scopes_directive_reject_unauthorized() {
let _guard = tracing_test::dispatcher_guard();
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada"}] } }},
).with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada", "phone": "1234"}] } }},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){id}}"}},
serde_json::json!{{"data": {"orga": { "id": 1 }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada" } }}}}
)
.with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada", "phone": "1234" } }}}}
)
.build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true,
"reject_unauthorized": true,
}
}}))
.unwrap()
.schema(SCOPES_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
..Default::default()
};
let request = router::Request {
context: Context::new(),
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
assert_logs_contain_entire_request_authorization_error();
}
#[tokio::test]
async fn scopes_directive_dry_run() {
let _guard = tracing_test::dispatcher_guard();
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada"}] } }},
).with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada", "phone": "1234"}] } }},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){id}}"}},
serde_json::json!{{"data": {"orga": { "id": 1 }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada" } }}}}
)
.with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada", "phone": "1234" } }}}}
)
.build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true,
"dry_run": true,
}
}}))
.unwrap()
.schema(SCOPES_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
..Default::default()
};
let request = router::Request {
context: Context::new(),
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
assert_logs_contain_partial_authorization_error();
}
#[tokio::test]
async fn errors_in_extensions() {
let _guard = tracing_test::dispatcher_guard();
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada"}] } }},
).with_json(
serde_json::json!{{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name phone}}}",
"variables": {"representations": [{ "__typename": "User", "id":0 }],}
}},
serde_json::json! {{ "data": { "_entities":[{"name":"Ada", "phone": "1234"}] } }},
).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json!{{"query":"{orga(id:1){id}}"}},
serde_json::json!{{"data": {"orga": { "id": 1 }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{__typename id}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "__typename": "User", "id": 0 } }}}}
).with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada" } }}}}
)
.with_json(
serde_json::json!{{"query":"{orga(id:1){id creatorUser{id name phone}}}"}},
serde_json::json!{{"data": {"orga": { "id": 1, "creatorUser": { "id": 0, "name":"Ada", "phone": "1234" } }}}}
)
.build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true,
"errors": {
"response": "extensions"
}
}
}}))
.unwrap()
.schema(SCOPES_SCHEMA)
.extra_plugin(subgraphs)
.build_router()
.await
.unwrap();
let req = graphql::Request {
query: Some("query { orga(id: 1) { id creatorUser { id name phone } } }".to_string()),
..Default::default()
};
let request = router::Request {
context: Context::new(),
router_request: http::Request::builder()
.method("POST")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.body(body::from_bytes(serde_json::to_vec(&req).unwrap()))
.unwrap(),
};
let response = service
.clone()
.oneshot(request)
.await
.unwrap()
.into_graphql_response_stream()
.await
.next()
.await
.unwrap()
.unwrap();
insta::assert_json_snapshot!(response);
assert_logs_contain_partial_authorization_error();
}
const CACHE_KEY_SCHEMA: &str = r#"schema
@link(url: "https://specs.apollo.dev/link/v1.0")
@link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION)
@link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY)
@link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY)
@link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY)
{
query: Query
}
directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA
directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE
directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
directive @join__graph(name: String!, url: String!) on ENUM_VALUE
directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE
directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR
directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION
scalar link__Import
enum link__Purpose {
"""
`SECURITY` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
`EXECUTION` features provide metadata necessary for operation execution.
"""
EXECUTION
}
directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
scalar federation__Scope
directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
directive @policy(policies: [[String!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
scalar join__FieldSet
enum join__Graph {
USER @join__graph(name: "user", url: "http://localhost:4001/graphql")
ORGA @join__graph(name: "orga", url: "http://localhost:4002/graphql")
}
type Query
@join__type(graph: ORGA)
@join__type(graph: USER){
currentUser: User @join__field(graph: USER)
orga(id: ID): Organization @join__field(graph: ORGA)
}
type User
@join__type(graph: ORGA, key: "id")
@join__type(graph: USER, key: "id"){
id: ID! @requiresScopes(scopes: [["id"]])
name: String @policy(policies: [["name"]])
phone: String @authenticated
activeOrganization: Organization
}
type Organization
@join__type(graph: ORGA, key: "id")
@join__type(graph: USER, key: "id") {
id: ID @authenticated
creatorUser: User
name: String
nonNullId: ID!
suborga: [Organization]
}"#;
#[tokio::test]
async fn cache_key_metadata() {
let query = "query { currentUser { id name phone } }";
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": {
"all": true
},
"authorization": {
"directives": {
"enabled": true
}
}
}))
.unwrap()
.schema(CACHE_KEY_SCHEMA)
.subgraph_hook(|_name, _service| {
let mut mock_subgraph_service = MockSubgraphService::new();
mock_subgraph_service.expect_call().times(1).returning(
move |req: subgraph::Request| {
assert_eq!(
*req.authorization,
CacheKeyMetadata {
is_authenticated: true,
scopes: vec!["id".to_string()],
policies: vec![]
}
);
Ok(subgraph::Response::fake_builder()
.context(req.context)
.data(serde_json::json! {{
"currentUser": {
"id": 1,
"name": "A", "phone": "1234"
}
}})
.build())
},
);
mock_subgraph_service.boxed()
})
.build_router()
.await
.unwrap();
let context = Context::new();
context
.insert(
APOLLO_AUTHENTICATION_JWT_CLAIMS,
json! {{ "scope": "id test" }},
)
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.build()
.unwrap();
let mut response = service
.oneshot(router::Request::try_from(request).unwrap())
.await
.unwrap();
let response = response.next_response().await.unwrap().unwrap();
let response: serde_json::Value = serde_json::from_slice(&response).unwrap();
insta::assert_json_snapshot!(response);
}
mod config_parsing {
use serde_json::json;
use crate::plugins::authorization::Conf;
use crate::plugins::authorization::ErrorConfig;
fn old_enabled(value: &serde_json::Value) -> Option<bool> {
value
.get("directives")
.and_then(|v| v.as_object())
.and_then(|v| v.get("enabled").and_then(|v| v.as_bool()))
}
fn old_errors(value: &serde_json::Value) -> Option<ErrorConfig> {
value
.get("directives")
.and_then(|v| v.as_object())
.and_then(|v| {
v.get("errors")
.and_then(|v| serde_json::from_value(v.clone()).ok())
})
}
fn old_directives_flags(value: &serde_json::Value) -> Option<(bool, bool)> {
value
.get("directives")
.and_then(|v| v.as_object())
.map(|config| {
(
config
.get("reject_unauthorized")
.and_then(|v| v.as_bool())
.unwrap_or(false),
config
.get("dry_run")
.and_then(|v| v.as_bool())
.unwrap_or(false),
)
})
}
fn new_conf(value: &serde_json::Value) -> Option<Conf> {
serde_json::from_value(value.clone()).ok()
}
#[rstest::rstest]
#[case(json!({}))]
#[case(json!({ "directives": {} }))]
#[case(json!({ "directives": { "enabled": true } }))]
#[case(json!({ "directives": { "enabled": false } }))]
#[case(json!({ "directives": { "dry_run": true } }))]
#[case(json!({ "directives": { "reject_unauthorized": true } }))]
#[case(json!({ "directives": { "dry_run": true, "reject_unauthorized": true } }))]
#[case(json!({ "directives": { "errors": {} } }))]
#[case(json!({ "directives": { "errors": { "log": false } } }))]
#[case(json!({ "directives": { "errors": { "response": "extensions" } } }))]
#[case(json!({ "directives": { "errors": { "response": "disabled" } } }))]
#[case(json!({ "directives": { "errors": { "log": false, "response": "extensions" } } }))]
#[case(json!({
"directives": {
"enabled": true,
"dry_run": true,
"reject_unauthorized": true,
"errors": { "log": true, "response": "errors" }
}
}))]
fn config_parsing_matches(#[case] value: serde_json::Value) {
let conf = new_conf(&value);
let old_enabled = old_enabled(&value).unwrap_or(true);
let old_errors = old_errors(&value).unwrap_or_default();
let old_flags = old_directives_flags(&value).unwrap_or((false, false));
let new_conf = conf.clone().unwrap_or_default();
let new_enabled = new_conf.directives.enabled;
let new_errors = new_conf.directives.errors;
let new_flags = (
new_conf.directives.reject_unauthorized,
new_conf.directives.dry_run,
);
assert_eq!(old_enabled, new_enabled);
assert_eq!(old_errors, new_errors);
assert_eq!(old_flags, new_flags);
}
}