diff --git a/.changesets/feat_geal_authz_optout.md b/.changesets/feat_geal_authz_optout.md new file mode 100644 index 0000000000..fcf9ffc3f4 --- /dev/null +++ b/.changesets/feat_geal_authz_optout.md @@ -0,0 +1,5 @@ +### Authorization directives are enabled by default ([Issue #3842](https://github.com/apollographql/router/issues/3842)) + +If the router starts with an API key from an Enterprise account, and the schema contains the authorization directives, then they will be usable directly without further configuration. + +By [@Geal](https://github.com/Geal) in https://github.com/apollographql/router/pull/3713 \ No newline at end of file diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index ee92942e35..e003f757ab 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -525,7 +525,7 @@ expression: "&schema" "properties": { "enabled": { "description": "enables the `@authenticated` and `@requiresScopes` directives", - "default": false, + "default": true, "type": "boolean" } } diff --git a/apollo-router/src/plugins/authorization/mod.rs b/apollo-router/src/plugins/authorization/mod.rs index 0936b4f69e..88be20d41d 100644 --- a/apollo-router/src/plugins/authorization/mod.rs +++ b/apollo-router/src/plugins/authorization/mod.rs @@ -24,7 +24,6 @@ use self::scopes::ScopeExtractionVisitor; use self::scopes::ScopeFilteringVisitor; use self::scopes::REQUIRES_SCOPES_SPEC_URL; use crate::error::QueryPlannerError; -use crate::error::SchemaError; use crate::error::ServiceBuildError; use crate::graphql; use crate::json_ext::Path; @@ -61,7 +60,7 @@ pub(crate) struct CacheKeyMetadata { } /// Authorization plugin -#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[derive(Clone, Debug, serde_derive_default::Default, Deserialize, JsonSchema)] #[allow(dead_code)] pub(crate) struct Conf { /// Reject unauthenticated requests @@ -72,14 +71,18 @@ pub(crate) struct Conf { preview_directives: Directives, } -#[derive(Clone, Debug, Default, Deserialize, JsonSchema)] +#[derive(Clone, Debug, serde_derive_default::Default, Deserialize, JsonSchema)] #[allow(dead_code)] pub(crate) struct Directives { /// enables the `@authenticated` and `@requiresScopes` directives - #[serde(default)] + #[serde(default = "default_enable_directives")] enabled: bool, } +fn default_enable_directives() -> bool { + true +} + pub(crate) struct AuthorizationPlugin { require_authentication: bool, } @@ -96,20 +99,12 @@ impl AuthorizationPlugin { .find(|(s, _)| s.as_str() == "authorization") .and_then(|(_, v)| v.get("preview_directives").and_then(|v| v.as_object())) .and_then(|v| v.get("enabled").and_then(|v| v.as_bool())); + let has_authorization_directives = schema.has_spec(AUTHENTICATED_SPEC_URL) || schema.has_spec(REQUIRES_SCOPES_SPEC_URL) || schema.has_spec(POLICY_SPEC_URL); - match has_config { - Some(b) => Ok(b), - None => { - if has_authorization_directives { - Err(ServiceBuildError::Schema(SchemaError::Api("cannot start the router on a schema with authorization directives without configuring the authorization plugin".to_string()))) - } else { - Ok(false) - } - } - } + Ok(has_config.unwrap_or(true) && has_authorization_directives) } pub(crate) async fn query_analysis( diff --git a/apollo-router/src/spec/mod.rs b/apollo-router/src/spec/mod.rs index 7b662235bd..3e68663fde 100644 --- a/apollo-router/src/spec/mod.rs +++ b/apollo-router/src/spec/mod.rs @@ -23,6 +23,9 @@ use thiserror::Error; use crate::graphql::ErrorExtension; use crate::json_ext::Object; +pub(crate) const LINK_DIRECTIVE_NAME: &str = "link"; +pub(crate) const LINK_URL_ARGUMENT: &str = "url"; + /// GraphQL parsing errors. #[derive(Error, Debug, Display, Clone, Serialize, Deserialize)] #[non_exhaustive] diff --git a/apollo-router/src/spec/schema.rs b/apollo-router/src/spec/schema.rs index 19ff50f053..5842989dba 100644 --- a/apollo-router/src/spec/schema.rs +++ b/apollo-router/src/spec/schema.rs @@ -59,6 +59,21 @@ impl Schema { Ok(schema) } + pub(crate) fn make_compiler(sdl: &str) -> Result { + let mut parser = apollo_compiler::Parser::new(); + let ast = parser.parse_ast(sdl, "schema.graphql"); + + // Trace log recursion limit data + let recursion_limit = parser.recursion_reached(); + tracing::trace!(?recursion_limit, "recursion limit data"); + + ast.check_parse_errors() + .map_err(|errors| SchemaError::Parse(ParseErrors { errors }))?; + + let definitions = ast.to_schema(); + Ok(definitions) + } + pub(crate) fn parse(sdl: &str, configuration: &Configuration) -> Result { let start = Instant::now(); let mut parser = apollo_compiler::Parser::new(); diff --git a/apollo-router/src/state_machine.rs b/apollo-router/src/state_machine.rs index 688cc16d83..a9ee846c6c 100644 --- a/apollo-router/src/state_machine.rs +++ b/apollo-router/src/state_machine.rs @@ -35,6 +35,7 @@ use crate::configuration::ListenAddr; use crate::router::Event::UpdateLicense; use crate::router_factory::RouterFactory; use crate::router_factory::RouterSuperServiceFactory; +use crate::spec::Schema; use crate::uplink::license_enforcement::LicenseEnforcementReport; use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::LICENSE_EXPIRED_URL; @@ -316,8 +317,12 @@ impl State { S: HttpServerFactory, FA: RouterSuperServiceFactory, { - // Check the license - let report = LicenseEnforcementReport::build(&configuration); + let report = { + let parsed_schema = Schema::make_compiler(&schema) + .map_err(|e| ServiceCreationError(e.to_string().into()))?; + // Check the license + LicenseEnforcementReport::build(&configuration, &parsed_schema) + }; match license { LicenseState::Licensed => { diff --git a/apollo-router/src/uplink/license_enforcement.rs b/apollo-router/src/uplink/license_enforcement.rs index 6c8c3cfe5a..e51433c9e6 100644 --- a/apollo-router/src/uplink/license_enforcement.rs +++ b/apollo-router/src/uplink/license_enforcement.rs @@ -3,6 +3,7 @@ // Read more: https://github.com/hyperium/tonic/issues/1056 #![allow(clippy::derive_partial_eq_without_eq)] +use std::collections::HashSet; use std::fmt::Display; use std::fmt::Formatter; use std::str::FromStr; @@ -25,6 +26,8 @@ use serde::Serialize; use serde_json::Value; use thiserror::Error; +use crate::spec::LINK_DIRECTIVE_NAME; +use crate::spec::LINK_URL_ARGUMENT; use crate::Configuration; pub(crate) const LICENSE_EXPIRED_URL: &str = "https://go.apollo.dev/o/elp"; @@ -75,19 +78,24 @@ where #[derive(Debug)] pub(crate) struct LicenseEnforcementReport { restricted_config_in_use: Vec, + restricted_schema_in_use: Vec, } impl LicenseEnforcementReport { pub(crate) fn uses_restricted_features(&self) -> bool { - !self.restricted_config_in_use.is_empty() + !self.restricted_config_in_use.is_empty() || !self.restricted_schema_in_use.is_empty() } - pub(crate) fn build(configuration: &Configuration) -> LicenseEnforcementReport { + pub(crate) fn build( + configuration: &Configuration, + schema: &apollo_compiler::schema::Schema, + ) -> LicenseEnforcementReport { LicenseEnforcementReport { restricted_config_in_use: Self::validate_configuration( configuration, &Self::configuration_restrictions(), ), + restricted_schema_in_use: Self::validate_schema(schema, &Self::schema_restrictions()), } } @@ -119,6 +127,32 @@ impl LicenseEnforcementReport { configuration_violations } + fn validate_schema( + schema: &apollo_compiler::schema::Schema, + schema_restrictions: &Vec, + ) -> Vec { + let feature_urls = schema + .schema_definition + .directives + .iter() + .filter(|dir| dir.name.as_str() == LINK_DIRECTIVE_NAME) + .filter_map(|link| { + link.argument_by_name(LINK_URL_ARGUMENT) + .and_then(|value| value.as_str().map(|s| s.to_string())) + }) + .collect::>(); + + let mut schema_violations = Vec::new(); + + for restriction in schema_restrictions { + if feature_urls.contains(&restriction.url) { + schema_violations.push(restriction.clone()); + } + } + + schema_violations + } + fn configuration_restrictions() -> Vec { vec![ ConfigurationRestriction::builder() @@ -183,17 +217,47 @@ impl LicenseEnforcementReport { .build(), ] } + + fn schema_restrictions() -> Vec { + vec![ + SchemaRestriction::builder() + .name("@authenticated") + .url("https://specs.apollo.dev/authenticated/v0.1") + .build(), + SchemaRestriction::builder() + .name("@requiresScopes") + .url("https://specs.apollo.dev/requiresScopes/v0.1") + .build(), + ] + } } impl Display for LicenseEnforcementReport { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let restricted_config = self - .restricted_config_in_use - .iter() - .map(|v| format!("* {}\n {}", v.name, v.path.replace("$.", "."))) - .join("\n\n"); + if !self.restricted_config_in_use.is_empty() { + let restricted_config = self + .restricted_config_in_use + .iter() + .map(|v| format!("* {}\n {}", v.name, v.path.replace("$.", "."))) + .join("\n\n"); + write!(f, "Configuration yaml:\n{restricted_config}")?; + + if !self.restricted_schema_in_use.is_empty() { + writeln!(f)?; + } + } - write!(f, "Configuration yaml:\n{restricted_config}") + if !self.restricted_schema_in_use.is_empty() { + let restricted_schema = self + .restricted_schema_in_use + .iter() + .map(|v| format!("* {}\n {}", v.name, v.url)) + .join("\n\n"); + + write!(f, "Schema features:\n{restricted_schema}")? + } + + Ok(()) } } @@ -277,6 +341,13 @@ pub(crate) struct ConfigurationRestriction { value: Option, } +/// An individual check for the supergraph schema +#[derive(Builder, Clone, Debug, Serialize, Deserialize)] +pub(crate) struct SchemaRestriction { + name: String, + url: String, +} + impl License { pub(crate) fn jwks() -> &'static JwkSet { JWKS.get_or_init(|| { @@ -297,6 +368,7 @@ mod test { use insta::assert_snapshot; use serde_json::json; + use crate::spec::Schema; use crate::uplink::license_enforcement::Audience; use crate::uplink::license_enforcement::Claims; use crate::uplink::license_enforcement::License; @@ -304,15 +376,19 @@ mod test { use crate::uplink::license_enforcement::OneOrMany; use crate::Configuration; - fn check(router_yaml: &str) -> LicenseEnforcementReport { + fn check(router_yaml: &str, supergraph_schema: &str) -> LicenseEnforcementReport { let config = Configuration::from_str(router_yaml).expect("router config must be valid"); - - LicenseEnforcementReport::build(&config) + let schema = + Schema::make_compiler(supergraph_schema).expect("supergraph schema must be valid"); + LicenseEnforcementReport::build(&config, &schema) } #[test] fn test_oss() { - let report = check(include_str!("testdata/oss.router.yaml")); + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/oss.graphql"), + ); assert!( report.restricted_config_in_use.is_empty(), @@ -322,7 +398,10 @@ mod test { #[test] fn test_restricted_features_via_config() { - let report = check(include_str!("testdata/restricted.router.yaml")); + let report = check( + include_str!("testdata/restricted.router.yaml"), + include_str!("testdata/oss.graphql"), + ); assert!( !report.restricted_config_in_use.is_empty(), @@ -331,6 +410,20 @@ mod test { assert_snapshot!(report.to_string()); } + #[test] + fn test_restricted_authorization_directives_via_schema() { + let report = check( + include_str!("testdata/oss.router.yaml"), + include_str!("testdata/authorization.graphql"), + ); + + assert!( + !report.restricted_schema_in_use.is_empty(), + "should have found restricted features" + ); + assert_snapshot!(report.to_string()); + } + #[test] fn test_license_parse() { let license = License::from_str("eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJodHRwczovL3d3dy5hcG9sbG9ncmFwaHFsLmNvbS8iLCJzdWIiOiJhcG9sbG8iLCJhdWQiOiJTRUxGX0hPU1RFRCIsIndhcm5BdCI6MTY3NjgwODAwMCwiaGFsdEF0IjoxNjc4MDE3NjAwfQ.tXexfjZ2SQeqSwkWQ7zD4XBoxS_Hc5x7tSNJ3ln-BCL_GH7i3U9hsIgdRQTczCAjA_jjk34w39DeSV0nTc5WBw").expect("must be able to decode JWT"); diff --git a/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema.snap b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema.snap new file mode 100644 index 0000000000..60a9f08ef0 --- /dev/null +++ b/apollo-router/src/uplink/snapshots/apollo_router__uplink__license_enforcement__test__restricted_authorization_directives_via_schema.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/uplink/license_enforcement.rs +expression: report.to_string() +--- +Schema features: +* @authenticated + https://specs.apollo.dev/authenticated/v0.1 + +* @requiresScopes + https://specs.apollo.dev/requiresScopes/v0.1 diff --git a/apollo-router/src/uplink/testdata/authorization.graphql b/apollo-router/src/uplink/testdata/authorization.graphql new file mode 100644 index 0000000000..378b5f0572 --- /dev/null +++ b/apollo-router/src/uplink/testdata/authorization.graphql @@ -0,0 +1,76 @@ +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) +{ + query: Query +} + +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +scalar federation__Scope +directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM + +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 + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +scalar join__FieldSet + +enum join__Graph { + PRODUCTS @join__graph(name: "products", url: "http://localhost:4001/") + REVIEWS @join__graph(name: "reviews", url: "http://localhost:4002/") +} + +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 +} + +type Product + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") + @authenticated +{ + upc: String! + name: String! @join__field(graph: PRODUCTS) + price: String! @join__field(graph: PRODUCTS) @authenticated + related: Product! @join__field(graph: PRODUCTS) + price1: String @join__field(graph: PRODUCTS) + review: Review! @join__field(graph: REVIEWS) @requiresScopes(scopes: [["review"]]) +} + +type Query + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) +{ + products(limit: Int!, size: Int, random: String): [Product!]! @join__field(graph: PRODUCTS) +} + +type Review + @join__type(graph: REVIEWS) +{ + body: String! + product: Product! + body1: String +} diff --git a/docs/source/configuration/overview.mdx b/docs/source/configuration/overview.mdx index 869cecdb1a..220a2bd96b 100644 --- a/docs/source/configuration/overview.mdx +++ b/docs/source/configuration/overview.mdx @@ -517,6 +517,10 @@ See [Apollo Router's _experimental_ support for query batching](../executing-ope See [GraphQL subscriptions in the Apollo Router](../executing-operations/subscription-support/#router-setup). +### Authorization directives support + +See [Authorization in the Apollo Router](./authorization#authorization-directives). + ### External coprocessing See [External coprocessing in the Apollo Router](../customizations/coprocessor/).