From de87ffc84ee1a0b6835ac32fbd76259f8201934e Mon Sep 17 00:00:00 2001 From: Rafael Guimaraes Siqueira Date: Tue, 4 Aug 2026 16:33:41 -0400 Subject: [PATCH] feat(router): custom authorization rules with the `@policy` directive Adds support for the federation `@policy` directive, evaluated by a coprocessor in the `graphql.analysis` stage. The router publishes the policies an operation depends on to the request context under `hive::authorization::required_policies` (read-only), the coprocessor answers with the subset it grants in `hive::authorization::granted_policies`, and authorization enforcement then runs against that decision. Policies left out of the answer are denied, so an absent or empty answer grants nothing. Unauthorized fields go through the existing `authorization.directives.unauthorized.mode` handling, and subgraph requests that would only resolve unauthorized fields are still never sent. `AuthorizationRule` becomes a struct instead of an enum so a field can carry `@authenticated`, `@requiresScopes` and `@policy` at once, all of which must be satisfied. `@policy` is independent of JWT authentication and stays enforced even when JWT is not configured. Closes #1134 Co-Authored-By: Claude Opus 5 --- .../policy_directive_via_coprocessor.md | 43 ++ bin/router/benches/router_benches.rs | 18 + .../src/pipeline/authorization/metadata.rs | 280 ++++++++--- bin/router/src/pipeline/authorization/mod.rs | 127 ++++- .../src/pipeline/authorization/tests.rs | 315 +++++++++++- bin/router/src/pipeline/mod.rs | 31 +- e2e/src/coprocessor/authorization_policies.rs | 471 ++++++++++++++++++ e2e/src/coprocessor/mod.rs | 2 + e2e/supergraph-policy.graphql | 169 +++++++ .../request_context/domains/authorization.rs | 115 +++++ .../src/request_context/domains/mod.rs | 3 + lib/executor/src/request_context/mod.rs | 3 + lib/internal/src/authorization/metadata.rs | 42 +- .../consumer_schema/strip_schema_internals.rs | 11 +- .../src/federation_spec/authorization.rs | 47 ++ .../src/federation_spec/definitions.rs | 6 + .../src/federation_spec/directives.rs | 1 + .../src/state/supergraph_state.rs | 37 +- 18 files changed, 1609 insertions(+), 112 deletions(-) create mode 100644 .changeset/policy_directive_via_coprocessor.md create mode 100644 e2e/src/coprocessor/authorization_policies.rs create mode 100644 e2e/supergraph-policy.graphql create mode 100644 lib/executor/src/request_context/domains/authorization.rs diff --git a/.changeset/policy_directive_via_coprocessor.md b/.changeset/policy_directive_via_coprocessor.md new file mode 100644 index 000000000..112a75696 --- /dev/null +++ b/.changeset/policy_directive_via_coprocessor.md @@ -0,0 +1,43 @@ +--- +hive-router-query-planner: minor +hive-router-internal: minor +hive-router-plan-executor: minor +hive-router: minor +--- + +# Custom authorization rules with the `@policy` directive + +Adds support for the federation `@policy` directive, letting a coprocessor decide custom +authorization rules that the router cannot evaluate on its own. + +`@policy(policies: [[...]])` takes an OR of AND groups, the same shape as `@requiresScopes`. +Access is granted when every policy of at least one group is granted for the request. + +The decision is made in the `graphql.analysis` coprocessor stage, through two request context keys: + +- `hive::authorization::required_policies` — written by the router, listing every policy the + incoming operation depends on. It is read-only, a coprocessor that writes to it fails the request. +- `hive::authorization::granted_policies` — written by the coprocessor with the subset it grants. + Policies left out are denied, so an absent or empty answer grants nothing. + +Unauthorized fields are then handled by the existing +`authorization.directives.unauthorized.mode` setting: `filter` (default) nulls them and reports an +`UNAUTHORIZED_FIELD_OR_TYPE` error, `reject` fails the whole operation. As with the other +authorization directives, subgraph requests that would only resolve unauthorized fields are never +sent. + +`@policy` is independent of `@authenticated` and `@requiresScopes`: it is enforced even when JWT +authentication is not configured, and when several directives sit on the same field all of them +must be satisfied. + +Example coprocessor answer for the `graphql.analysis` stage: + +```json +{ + "version": 1, + "control": "continue", + "context": { + "hive::authorization::granted_policies": ["read_profile"] + } +} +``` diff --git a/bin/router/benches/router_benches.rs b/bin/router/benches/router_benches.rs index ec6d0a534..9edb1e5af 100644 --- a/bin/router/benches/router_benches.rs +++ b/bin/router/benches/router_benches.rs @@ -106,6 +106,8 @@ fn authorization_benchmark(c: &mut Criterion) { bubble_up.schema_metadata, bubble_up.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -139,6 +141,8 @@ fn authorization_benchmark(c: &mut Criterion) { complex.schema_metadata, complex.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -177,6 +181,8 @@ fn authorization_benchmark(c: &mut Criterion) { complex_partially.schema_metadata, complex_partially.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -261,6 +267,8 @@ fn authorization_benchmark(c: &mut Criterion) { large_mostly_auth.schema_metadata, large_mostly_auth.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -332,6 +340,8 @@ fn authorization_benchmark(c: &mut Criterion) { large_partially_denied.schema_metadata, large_partially_denied.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -414,6 +424,8 @@ fn authorization_benchmark(c: &mut Criterion) { deep_nested.schema_metadata, deep_nested.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -472,6 +484,8 @@ fn authorization_benchmark(c: &mut Criterion) { large_unauth.schema_metadata, large_unauth.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -519,6 +533,8 @@ fn authorization_benchmark(c: &mut Criterion) { interface_auth_inline_unauth.schema_metadata, interface_auth_inline_unauth.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), @@ -572,6 +588,8 @@ fn authorization_benchmark(c: &mut Criterion) { interface_auth_inline_auth.schema_metadata, interface_auth_inline_auth.variable_payload, &jwt_req_details, + &Default::default(), + true, false, ) .unwrap(), diff --git a/bin/router/src/pipeline/authorization/metadata.rs b/bin/router/src/pipeline/authorization/metadata.rs index 12f93babc..fe8209bde 100644 --- a/bin/router/src/pipeline/authorization/metadata.rs +++ b/bin/router/src/pipeline/authorization/metadata.rs @@ -1,13 +1,14 @@ use ahash::{HashMap, HashSet}; use hive_router_internal::authorization::metadata::{ - AuthorizationMetadata, AuthorizationRule, FieldRulesMap, RequiredScopes, ScopeAndGroup, - ScopeId, ScopeInterner, TypeFieldRulesMap, TypeRulesMap, + AuthorizationMetadata, AuthorizationRule, FieldRulesMap, PolicyAndGroup, PolicyId, + PolicyInterner, RequiredPolicies, RequiredScopes, ScopeAndGroup, ScopeId, ScopeInterner, + TypeFieldRulesMap, TypeRulesMap, }; use hive_router_plan_executor::execution::client_request_details::JwtRequestDetails; use hive_router_plan_executor::introspection::schema::SchemaMetadata; use hive_router_query_planner::ast::value::Value; use hive_router_query_planner::federation_spec::authorization::{ - AuthenticatedDirective, RequiresScopesDirective, + AuthenticatedDirective, PolicyDirective, RequiresScopesDirective, }; use hive_router_query_planner::state::supergraph_state::{SupergraphDefinition, SupergraphState}; @@ -16,6 +17,8 @@ use hive_router_query_planner::state::supergraph_state::{SupergraphDefinition, S pub struct UserAuthContext { pub is_authenticated: bool, pub scope_ids: HashSet, + /// Policies granted for this request, as decided by a coprocessor or a plugin. + pub granted_policy_ids: HashSet, } impl UserAuthContext { @@ -31,6 +34,7 @@ impl UserAuthContext { .iter() .filter_map(|s| auth_metadata.scopes.get(s)) .collect(), + granted_policy_ids: HashSet::default(), } } @@ -45,6 +49,21 @@ impl UserAuthContext { JwtRequestDetails::Unauthenticated => Self::new(false, &[], auth_metadata), } } + + /// Records the policies that were granted for this request. + /// Policies unknown to the schema are silently ignored. + pub fn with_granted_policies<'a>( + mut self, + granted_policies: impl IntoIterator, + auth_metadata: &AuthorizationMetadata, + ) -> Self { + self.granted_policy_ids = granted_policies + .into_iter() + .filter_map(|policy| auth_metadata.policies.get(policy)) + .collect(); + + self + } } /// Errors that can occur during authorization metadata construction. @@ -56,6 +75,12 @@ pub enum AuthorizationMetadataError { InvalidRequiresScopesArgs(String), #[error("Duplicate @requiresScopes directives found")] DuplicateRequiresScopesDirective, + #[error("Invalid policy value: {0}")] + InvalidPolicyValue(String), + #[error("Invalid @policy(policies:) argument: {0}")] + InvalidPolicyArgs(String), + #[error("Duplicate @policy directives found")] + DuplicatePolicyDirective, } pub trait AuthorizationMetadataExt @@ -71,6 +96,9 @@ where fn is_empty(&self) -> bool; + /// Whether the schema declares any `@policy` requirement. + fn has_policies(&self) -> bool; + /// Computes whether each type has auth rules in its subtree. fn compute_type_auth_metadata( definitions: &std::collections::HashMap, @@ -102,19 +130,26 @@ where /// Example: [["a"], ["b"]] AND [["c"], ["d"]] = [["a", "c"], ["a", "d"], ["b", "c"], ["b", "d"]] fn cross_product_required_scopes(member_scopes: &[&RequiredScopes]) -> RequiredScopes; + /// Combines multiple RequiredPolicies using AND logic via cross product. + /// Follows the same rules as [`Self::cross_product_required_scopes`]. + fn cross_product_required_policies(member_policies: &[&RequiredPolicies]) -> RequiredPolicies; + /// Processes a type definition, extracting authorization rules for the type and its fields. fn process_type_definition( type_def: &SupergraphDefinition, type_rules: &mut TypeRulesMap, field_rules: &mut TypeFieldRulesMap, scopes_interner: &mut ScopeInterner, + policies_interner: &mut PolicyInterner, ) -> Result<(), AuthorizationMetadataError>; /// Extracts authorization rule from directives. fn extract_rule_from_directives( authenticated_directives: &[AuthenticatedDirective], requires_scopes_directives: &[RequiresScopesDirective], - interner: &mut ScopeInterner, + policy_directives: &[PolicyDirective], + scopes_interner: &mut ScopeInterner, + policies_interner: &mut PolicyInterner, ) -> Result, AuthorizationMetadataError>; /// Parses and normalizes the `scopes` argument from a `@requiresScopes` directive. @@ -127,6 +162,17 @@ where value: &Value, interner: &mut ScopeInterner, ) -> Result; + + /// Parses and normalizes the `policies` argument from a `@policy` directive. + fn normalize_policies_arg( + value: &Value, + interner: &mut PolicyInterner, + ) -> Result; + + fn normalize_policy_and_group( + value: &Value, + interner: &mut PolicyInterner, + ) -> Result; } impl AuthorizationMetadataExt for AuthorizationMetadata { @@ -139,6 +185,7 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { let mut type_rules = HashMap::default(); let mut field_rules = HashMap::default(); let mut scopes = ScopeInterner::new(); + let mut policies = PolicyInterner::new(); for type_def in supergraph.definitions.values() { Self::process_type_definition( @@ -146,6 +193,7 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { &mut type_rules, &mut field_rules, &mut scopes, + &mut policies, )?; } @@ -164,6 +212,7 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { type_rules, field_rules, scopes, + policies, type_has_any_auth, }) } @@ -172,6 +221,10 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { self.type_rules.is_empty() && self.field_rules.is_empty() } + fn has_policies(&self) -> bool { + !self.policies.is_empty() + } + /// Computes whether each type has auth rules in its subtree. fn compute_type_auth_metadata( definitions: &std::collections::HashMap, @@ -275,35 +328,36 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { type_rules: &TypeRulesMap, ) -> Option { let mut member_scopes: Vec<&RequiredScopes> = Vec::new(); + let mut member_policies: Vec<&RequiredPolicies> = Vec::new(); let mut needs_authenticated = false; // Collect rules from all members for member_name in member_names { - if let Some(rule) = type_rules.get(member_name) { - match rule { - AuthorizationRule::Authenticated => { - needs_authenticated = true; - } - AuthorizationRule::RequiresScopes(scopes) => { - needs_authenticated = true; // scopes implies authenticated - member_scopes.push(scopes); - } - } + let Some(rule) = type_rules.get(member_name) else { + continue; + }; + + // scopes imply authenticated, policies do not + needs_authenticated |= rule.authenticated || rule.scopes.is_some(); + + if let Some(scopes) = &rule.scopes { + member_scopes.push(scopes); } - } - if !needs_authenticated { - return None; + if let Some(policies) = &rule.policies { + member_policies.push(policies); + } } - // Some members have @authenticated but no scopes - if member_scopes.is_empty() { - return Some(AuthorizationRule::Authenticated); - } + let rule = AuthorizationRule { + authenticated: needs_authenticated, + scopes: (!member_scopes.is_empty()) + .then(|| Self::cross_product_required_scopes(&member_scopes)), + policies: (!member_policies.is_empty()) + .then(|| Self::cross_product_required_policies(&member_policies)), + }; - Some(AuthorizationRule::RequiresScopes( - Self::cross_product_required_scopes(&member_scopes), - )) + (!rule.is_empty()).then_some(rule) } /// Combines multiple RequiredScopes using AND logic via cross product. @@ -331,44 +385,82 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { RequiredScopes(result) } + fn cross_product_required_policies(member_policies: &[&RequiredPolicies]) -> RequiredPolicies { + let mut result: Vec = vec![PolicyAndGroup(vec![])]; + + for member_policy in member_policies { + let mut new_result = Vec::new(); + + for existing_and_group in &result { + for member_and_group in &member_policy.0 { + let mut combined = existing_and_group.0.clone(); + combined.extend(member_and_group.0.iter().copied()); + combined.sort(); + combined.dedup(); + new_result.push(PolicyAndGroup(combined)); + } + } + + result = new_result; + } + + RequiredPolicies(result) + } + /// Processes a type definition, extracting authorization rules for the type and its fields. fn process_type_definition( type_def: &SupergraphDefinition, type_rules: &mut TypeRulesMap, field_rules: &mut TypeFieldRulesMap, scopes_interner: &mut ScopeInterner, + policies_interner: &mut PolicyInterner, ) -> Result<(), AuthorizationMetadataError> { - let (type_name, authenticated_directives, requires_scopes_directives, maybe_fields) = - match type_def { - SupergraphDefinition::Scalar(s) => { - (&s.name, &s.authenticated, &s.requires_scopes, None) - } - SupergraphDefinition::Object(o) => ( - &o.name, - &o.authenticated, - &o.requires_scopes, - Some(&o.fields), - ), - SupergraphDefinition::Interface(i) => ( - &i.name, - &i.authenticated, - &i.requires_scopes, - Some(&i.fields), - ), - SupergraphDefinition::Enum(e) => { - (&e.name, &e.authenticated, &e.requires_scopes, None) - } - // Unions and InputObjects do not have output authorization rules applicable here. - SupergraphDefinition::Union(_) | SupergraphDefinition::InputObject(_) => { - return Ok(()) - } - }; + let ( + type_name, + authenticated_directives, + requires_scopes_directives, + policy_directives, + maybe_fields, + ) = match type_def { + SupergraphDefinition::Scalar(s) => ( + &s.name, + &s.authenticated, + &s.requires_scopes, + &s.policy, + None, + ), + SupergraphDefinition::Object(o) => ( + &o.name, + &o.authenticated, + &o.requires_scopes, + &o.policy, + Some(&o.fields), + ), + SupergraphDefinition::Interface(i) => ( + &i.name, + &i.authenticated, + &i.requires_scopes, + &i.policy, + Some(&i.fields), + ), + SupergraphDefinition::Enum(e) => ( + &e.name, + &e.authenticated, + &e.requires_scopes, + &e.policy, + None, + ), + // Unions and InputObjects do not have output authorization rules applicable here. + SupergraphDefinition::Union(_) | SupergraphDefinition::InputObject(_) => return Ok(()), + }; // Extract type-level rules if let Some(rule) = Self::extract_rule_from_directives( authenticated_directives, requires_scopes_directives, + policy_directives, scopes_interner, + policies_interner, )? { type_rules.insert(type_name.clone(), rule); } @@ -380,7 +472,9 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { let maybe_field_rules = Self::extract_rule_from_directives( &field_def.authenticated, &field_def.requires_scopes, + &field_def.policy, scopes_interner, + policies_interner, )?; if let Some(rule) = maybe_field_rules { type_field_rules.insert(field_name.clone(), rule); @@ -398,22 +492,36 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { fn extract_rule_from_directives( authenticated_directives: &[AuthenticatedDirective], requires_scopes_directives: &[RequiresScopesDirective], - interner: &mut ScopeInterner, + policy_directives: &[PolicyDirective], + scopes_interner: &mut ScopeInterner, + policies_interner: &mut PolicyInterner, ) -> Result, AuthorizationMetadataError> { if requires_scopes_directives.len() > 1 { return Err(AuthorizationMetadataError::DuplicateRequiresScopesDirective); } - if let Some(directive) = requires_scopes_directives.first() { - let scopes = Self::normalize_scopes_arg(&directive.scopes, interner)?; - return Ok(Some(AuthorizationRule::RequiresScopes(scopes))); + if policy_directives.len() > 1 { + return Err(AuthorizationMetadataError::DuplicatePolicyDirective); } - if !authenticated_directives.is_empty() { - return Ok(Some(AuthorizationRule::Authenticated)); - } + let scopes = requires_scopes_directives + .first() + .map(|directive| Self::normalize_scopes_arg(&directive.scopes, scopes_interner)) + .transpose()?; + + let policies = policy_directives + .first() + .map(|directive| Self::normalize_policies_arg(&directive.policies, policies_interner)) + .transpose()?; + + let rule = AuthorizationRule { + // `@requiresScopes` implies `@authenticated`, `@policy` does not. + authenticated: !authenticated_directives.is_empty() || scopes.is_some(), + scopes, + policies, + }; - Ok(None) + Ok((!rule.is_empty()).then_some(rule)) } /// Parses and normalizes the `scopes` argument from a `@requiresScopes` directive. @@ -472,4 +580,62 @@ impl AuthorizationMetadataExt for AuthorizationMetadata { and_group.sort(); Ok(ScopeAndGroup(and_group)) } + + /// Parses and normalizes the `policies` argument from a `@policy` directive. + fn normalize_policies_arg( + value: &Value, + interner: &mut PolicyInterner, + ) -> Result { + let Value::List(or_groups_val) = value else { + return Err(AuthorizationMetadataError::InvalidPolicyArgs(format!( + "expected a list, got '{}'", + value + ))); + }; + + let mut or_groups: Vec<_> = or_groups_val + .iter() + .map(|v| Self::normalize_policy_and_group(v, interner)) + .collect::>()?; + + if or_groups.is_empty() { + return Err(AuthorizationMetadataError::InvalidPolicyArgs( + "expected at least one AND group, got none".to_string(), + )); + } + + or_groups.sort(); + Ok(RequiredPolicies(or_groups)) + } + + fn normalize_policy_and_group( + value: &Value, + interner: &mut PolicyInterner, + ) -> Result { + let Value::List(and_group_val) = value else { + return Err(AuthorizationMetadataError::InvalidPolicyArgs( + "expected a list for AND group".to_string(), + )); + }; + + let mut and_group: Vec = and_group_val + .iter() + .map(|v| match v { + Value::String(s) => Ok(interner.get_or_intern(s)), + _ => Err(AuthorizationMetadataError::InvalidPolicyValue(format!( + "expected policy to be a string, got: '{}'", + v + ))), + }) + .collect::>()?; + + if and_group.is_empty() { + return Err(AuthorizationMetadataError::InvalidPolicyArgs( + "empty AND group, expected at least one policy".to_string(), + )); + } + + and_group.sort(); + Ok(PolicyAndGroup(and_group)) + } } diff --git a/bin/router/src/pipeline/authorization/mod.rs b/bin/router/src/pipeline/authorization/mod.rs index 92a08ebe1..dd25ad10c 100644 --- a/bin/router/src/pipeline/authorization/mod.rs +++ b/bin/router/src/pipeline/authorization/mod.rs @@ -10,6 +10,7 @@ mod tests; pub mod metadata; +use std::collections::HashSet; use std::sync::Arc; use crate::pipeline::error::PipelineError; @@ -86,6 +87,10 @@ fn unauthorized_error() -> GraphQLError { struct AuthorizationChecker<'a, 'op> { auth_metadata: &'a AuthorizationMetadata, user_context: &'a UserAuthContext, + /// When JWT authentication is not configured there is no way for a request to + /// authenticate, so `@authenticated`/`@requiresScopes` are not enforced. + /// `@policy` is decided outside of the router and stays enforced either way. + enforce_jwt_rules: bool, cache: HashMap, bool>, } @@ -133,19 +138,98 @@ impl<'op> AuthorizationChecker<'_, 'op> { } fn is_rule_satisfied(&self, rule: &AuthorizationRule) -> bool { - match rule { - AuthorizationRule::Authenticated => self.user_context.is_authenticated, - AuthorizationRule::RequiresScopes(scopes) => { - self.user_context.is_authenticated - && scopes.0.iter().any(|and_group| { - and_group - .0 - .iter() - .all(|scope_id| self.user_context.scope_ids.contains(scope_id)) - }) + if self.enforce_jwt_rules { + if rule.authenticated && !self.user_context.is_authenticated { + return false; + } + + if let Some(scopes) = &rule.scopes { + let has_scopes = scopes.0.iter().any(|and_group| { + and_group + .0 + .iter() + .all(|scope_id| self.user_context.scope_ids.contains(scope_id)) + }); + + if !has_scopes { + return false; + } } } + + if let Some(policies) = &rule.policies { + let has_policies = policies.0.iter().any(|and_group| { + and_group + .0 + .iter() + .all(|policy_id| self.user_context.granted_policy_ids.contains(policy_id)) + }); + + if !has_policies { + return false; + } + } + + true + } +} + +/// Collects the `@policy` policies the given operation depends on. +/// +/// The result is handed to coprocessors (through the request context) so they can +/// decide which of them are granted, before authorization is enforced. +pub fn collect_required_policies( + router_config: &HiveRouterConfig, + normalized_payload: &GraphQLNormalizationPayload, + auth_metadata: &AuthorizationMetadata, + schema_metadata: &SchemaMetadata, + variable_payload: &CoerceVariablesPayload, +) -> Result, PipelineError> { + if !router_config.authorization.directives.enabled || !auth_metadata.has_policies() { + return Ok(HashSet::new()); } + + let mut required: HashSet = HashSet::new(); + + let mut collect = |rule: Option<&AuthorizationRule>| { + let Some(policies) = rule.and_then(|rule| rule.policies.as_ref()) else { + return; + }; + + for and_group in &policies.0 { + for policy_id in &and_group.0 { + required.insert(auth_metadata.policies.resolve(policy_id).to_string()); + } + } + }; + + OperationFilter::new(schema_metadata).filter( + &normalized_payload.root_type_name, + &normalized_payload.operation_for_plan.selection_set, + variable_payload, + |selection| { + match selection { + Selection::Field(field) => { + collect(auth_metadata.type_rules.get(field.parent_type_name)); + collect( + auth_metadata + .field_rules + .get(field.parent_type_name) + .and_then(|fields| fields.get(field.field_name)), + ); + collect(auth_metadata.type_rules.get(field.output_type_name)); + } + Selection::Fragment(fragment) => { + collect(auth_metadata.type_rules.get(fragment.type_condition)); + } + } + + // This pass only observes the operation, it never filters it. + selection.keep() + }, + )?; + + Ok(required) } /// Main entry point for authorization enforcement. @@ -160,12 +244,15 @@ pub fn enforce_operation_authorization( schema_metadata: &SchemaMetadata, variable_payload: &CoerceVariablesPayload, jwt_request_details: &JwtRequestDetails, + granted_policies: &HashSet, ) -> Result<(Arc, Vec), PipelineError> { if !router_config.authorization.directives.enabled { return Ok((normalized_payload.clone(), vec![])); } - if !router_config.jwt.enabled { + // Without JWT authentication there is nothing to enforce, unless the schema also + // carries `@policy` requirements, which are resolved externally. + if !router_config.jwt.enabled && !auth_metadata.has_policies() { return Ok((normalized_payload.clone(), vec![])); } @@ -181,6 +268,8 @@ pub fn enforce_operation_authorization( schema_metadata, variable_payload, jwt_request_details, + granted_policies, + router_config.jwt.enabled, reject_mode, )?; @@ -200,22 +289,33 @@ pub fn enforce_operation_authorization( }) } +#[allow(clippy::too_many_arguments)] pub fn apply_authorization_to_operation( normalized_payload: &GraphQLNormalizationPayload, auth_metadata: &AuthorizationMetadata, schema_metadata: &SchemaMetadata, variable_payload: &CoerceVariablesPayload, jwt_request_details: &JwtRequestDetails, + // The `@policy` policies granted for this request by a coprocessor or a plugin. + granted_policies: &HashSet, + // When JWT authentication is not configured there is no way for a request to + // authenticate, so `@authenticated`/`@requiresScopes` are not enforced. + // `@policy` is decided outside of the router and stays enforced either way. + enforce_jwt_rules: bool, reject_mode: bool, ) -> Result { if auth_metadata.is_empty() { return Ok(AuthorizationDecision::NoChange); } - let user_context = UserAuthContext::from_jwt(jwt_request_details, auth_metadata); + let user_context = UserAuthContext::from_jwt(jwt_request_details, auth_metadata) + .with_granted_policies(granted_policies.iter().map(String::as_str), auth_metadata); // Early exit if authenticated users satisfy all rules - if user_context.is_authenticated && auth_metadata.scopes.is_empty() { + if user_context.is_authenticated + && auth_metadata.scopes.is_empty() + && !auth_metadata.has_policies() + { return Ok(AuthorizationDecision::NoChange); } @@ -226,6 +326,7 @@ pub fn apply_authorization_to_operation( let mut checker = AuthorizationChecker { auth_metadata, user_context: &user_context, + enforce_jwt_rules, cache: HashMap::default(), }; diff --git a/bin/router/src/pipeline/authorization/tests.rs b/bin/router/src/pipeline/authorization/tests.rs index 20c62a8cb..0fc2d3767 100644 --- a/bin/router/src/pipeline/authorization/tests.rs +++ b/bin/router/src/pipeline/authorization/tests.rs @@ -1,6 +1,7 @@ -use std::{fmt::Display, sync::Arc}; +use std::{collections::HashSet, fmt::Display, sync::Arc}; use graphql_tools::parser::parse_query; +use hive_router_config::HiveRouterConfig; use hive_router_internal::authorization::metadata::AuthorizationMetadata; use hive_router_plan_executor::{ execution::client_request_details::JwtRequestDetails, @@ -20,8 +21,8 @@ use hive_router_query_planner::{ use crate::pipeline::{ authorization::{ - apply_authorization_to_operation, metadata::AuthorizationMetadataExt, - AuthorizationDecision, AuthorizationError, + apply_authorization_to_operation, collect_required_policies, + metadata::AuthorizationMetadataExt, AuthorizationDecision, AuthorizationError, }, normalize::{hash_normalized_operation, GraphQLNormalizationPayload, OperationIdentity}, }; @@ -75,6 +76,64 @@ impl Display for AuthorizationDecision { impl SupergraphTestData { fn decide(&self, scopes: Option>, operation: &'static str) -> AuthorizationDecision { + self.decide_with_policies(scopes, &[], operation) + } + + fn decide_with_policies( + &self, + scopes: Option>, + granted_policies: &[&str], + operation: &'static str, + ) -> AuthorizationDecision { + let payload = self.normalize(operation); + + let jwt = if let Some(scopes) = scopes { + JwtRequestDetails::Authenticated { + token: "asd".into(), + prefix: None, + claims: Default::default(), + scopes: Some(scopes.iter().map(|s| s.to_string()).collect()), + } + } else { + JwtRequestDetails::Unauthenticated + }; + + let granted_policies: HashSet = + granted_policies.iter().map(|s| s.to_string()).collect(); + + apply_authorization_to_operation( + &payload, + &self.auth_metadata, + &self.schema_metadata, + &Default::default(), + &jwt, + &granted_policies, + true, + false, + ) + .expect("test schema/operation should only reference fields declared in the schema") + } + + /// Returns the policies the operation requires, sorted for stable assertions. + fn required_policies(&self, operation: &'static str) -> Vec { + let payload = self.normalize(operation); + + let mut policies: Vec = collect_required_policies( + &HiveRouterConfig::default(), + &payload, + &self.auth_metadata, + &self.schema_metadata, + &Default::default(), + ) + .expect("test schema/operation should only reference fields declared in the schema") + .into_iter() + .collect(); + + policies.sort(); + policies + } + + fn normalize(&self, operation: &'static str) -> GraphQLNormalizationPayload { let parsed_query = parse_query(operation).unwrap(); let doc = normalize_operation(&self.supergraph_state, &parsed_query, None).unwrap(); let operation = doc.operation; @@ -93,7 +152,7 @@ impl SupergraphTestData { let hashes = hash_normalized_operation(&operation_for_plan, operation_for_introspection.as_deref()); - let payload = GraphQLNormalizationPayload { + GraphQLNormalizationPayload { root_type_name, operation_kind, projection_plan: Arc::new(projection_plan), @@ -107,28 +166,7 @@ impl SupergraphTestData { operation_type: OperationKind::Query, client_document_hash: "".to_string(), }, - }; - - let jwt = if let Some(scopes) = scopes { - JwtRequestDetails::Authenticated { - token: "asd".into(), - prefix: None, - claims: Default::default(), - scopes: Some(scopes.iter().map(|s| s.to_string()).collect()), - } - } else { - JwtRequestDetails::Unauthenticated - }; - - apply_authorization_to_operation( - &payload, - &self.auth_metadata, - &self.schema_metadata, - &Default::default(), - &jwt, - false, - ) - .expect("test schema/operation should only reference fields declared in the schema") + } } } @@ -152,6 +190,7 @@ static FED: &str = r#" @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) @link(url: "https://specs.apollo.dev/requiresScopes/v0.1", for: SECURITY) @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) { query: Query mutation: Mutation @@ -160,8 +199,10 @@ static FED: &str = r#" scalar link__Import enum link__Purpose { SECURITY EXECUTION } scalar federation__Scope + scalar federation__Policy directive @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM + directive @policy(policies: [[federation__Policy!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM "#; fn build_supergraph_sdl(sdl: &str) -> String { @@ -2287,3 +2328,225 @@ mod authenticated_directive { } } } + +#[cfg(test)] +mod policy_directive { + use super::*; + + static POLICY_SCHEMA: &str = r#" + type Query { + publicPosts: [Post!] + profile: Profile @policy(policies: [["read_profile"]]) + billing: Billing + audit: AuditLog @policy(policies: [["admin"], ["auditor", "compliance"]]) + secret: String @authenticated @policy(policies: [["read_secret"]]) + } + + type Post { + id: ID! + title: String + } + + type Profile { + name: String + email: String @policy(policies: [["read_email"]]) + } + + type Billing @policy(policies: [["read_billing"]]) { + plan: String + } + + type AuditLog { + entries: [String!] + } + "#; + + #[test] + fn denies_policy_protected_field_when_nothing_is_granted() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let decision = supergraph_data.decide_with_policies( + None, + &[], + "{ publicPosts { id } profile { name } }", + ); + insta::assert_snapshot!(decision, @r#" + [Modified] + Operation: {publicPosts{id}} + Errors: ["Unauthorized field or type @ profile"] + "#); + } + + #[test] + fn allows_policy_protected_field_when_policy_is_granted() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let decision = supergraph_data.decide_with_policies( + None, + &["read_profile"], + "{ publicPosts { id } profile { name } }", + ); + insta::assert_snapshot!(decision, @"[NoChange]"); + } + + #[test] + fn denies_nested_policy_protected_field_while_keeping_its_parent() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let decision = supergraph_data.decide_with_policies( + None, + &["read_profile"], + "{ profile { name email } }", + ); + insta::assert_snapshot!(decision, @r#" + [Modified] + Operation: {profile{name}} + Errors: ["Unauthorized field or type @ profile.email"] + "#); + } + + #[test] + fn denies_field_whose_output_type_carries_a_policy() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let decision = supergraph_data.decide_with_policies(None, &[], "{ billing { plan } }"); + insta::assert_snapshot!(decision, @r#" + [Modified] + Operation: + Errors: ["Unauthorized field or type @ billing"] + "#); + } + + #[test] + fn allows_field_whose_output_type_carries_a_granted_policy() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let decision = + supergraph_data.decide_with_policies(None, &["read_billing"], "{ billing { plan } }"); + insta::assert_snapshot!(decision, @"[NoChange]"); + } + + /// `[["admin"], ["auditor", "compliance"]]` is an OR of ANDs: either "admin" + /// alone, or both "auditor" and "compliance". + #[test] + fn treats_policy_groups_as_or_of_ands() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let granted_admin = + supergraph_data.decide_with_policies(None, &["admin"], "{ audit { entries } }"); + insta::assert_snapshot!(granted_admin, @"[NoChange]"); + + let granted_both = supergraph_data.decide_with_policies( + None, + &["auditor", "compliance"], + "{ audit { entries } }", + ); + insta::assert_snapshot!(granted_both, @"[NoChange]"); + + let granted_partial = + supergraph_data.decide_with_policies(None, &["auditor"], "{ audit { entries } }"); + insta::assert_snapshot!(granted_partial, @r#" + [Modified] + Operation: + Errors: ["Unauthorized field or type @ audit"] + "#); + } + + /// `@policy` is independent of `@authenticated`: both must be satisfied when + /// they sit on the same field. + #[test] + fn requires_both_authentication_and_policy_when_combined() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let policy_only = + supergraph_data.decide_with_policies(None, &["read_secret"], "{ secret }"); + insta::assert_snapshot!(policy_only, @r#" + [Modified] + Operation: + Errors: ["Unauthorized field or type @ secret"] + "#); + + let authenticated_only = + supergraph_data.decide_with_policies(Some(vec![]), &[], "{ secret }"); + insta::assert_snapshot!(authenticated_only, @r#" + [Modified] + Operation: + Errors: ["Unauthorized field or type @ secret"] + "#); + + let both = + supergraph_data.decide_with_policies(Some(vec![]), &["read_secret"], "{ secret }"); + insta::assert_snapshot!(both, @"[NoChange]"); + } + + /// Unknown policies are ignored rather than granting anything. + #[test] + fn ignores_policies_that_are_not_declared_in_the_schema() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + let decision = + supergraph_data.decide_with_policies(None, &["not_a_policy"], "{ profile { name } }"); + insta::assert_snapshot!(decision, @r#" + [Modified] + Operation: + Errors: ["Unauthorized field or type @ profile"] + "#); + } + + mod required_policies { + use super::*; + + #[test] + fn collects_nothing_for_an_operation_without_policies() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + assert!(supergraph_data + .required_policies("{ publicPosts { id } }") + .is_empty()); + } + + #[test] + fn collects_policies_from_fields_and_output_types() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + insta::assert_debug_snapshot!( + supergraph_data.required_policies("{ profile { name email } billing { plan } }"), + @r#" + [ + "read_billing", + "read_email", + "read_profile", + ] + "# + ); + } + + /// Every policy of an OR group is reported, the decision of which + /// combination is enough belongs to enforcement. + #[test] + fn collects_every_policy_of_an_or_group() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + insta::assert_debug_snapshot!( + supergraph_data.required_policies("{ audit { entries } }"), + @r#" + [ + "admin", + "auditor", + "compliance", + ] + "# + ); + } + + /// Fields excluded by `@skip`/`@include` must not drag their policies in. + #[test] + fn ignores_fields_excluded_by_skip() { + let supergraph_data = build_supergraph_data(POLICY_SCHEMA); + + assert!(supergraph_data + .required_policies("{ publicPosts { id } profile @skip(if: true) { name } }") + .is_empty()); + } + } +} diff --git a/bin/router/src/pipeline/mod.rs b/bin/router/src/pipeline/mod.rs index 672500893..530423089 100644 --- a/bin/router/src/pipeline/mod.rs +++ b/bin/router/src/pipeline/mod.rs @@ -53,7 +53,7 @@ use xxhash_rust::xxh3::Xxh3; use crate::{ pipeline::{ active_subscriptions::SubscriptionEvent, - authorization::enforce_operation_authorization, + authorization::{collect_required_policies, enforce_operation_authorization}, client_identification::identify_client, coerce_variables::coerce_request_variables, csrf_prevention::perform_csrf_prevention, @@ -745,14 +745,20 @@ pub async fn execute_pipeline<'exec>( let cancellation_token = CancellationToken::with_timeout(shared_state.router_config.query_planner.timeout); - let (mut normalize_payload, authorization_errors) = enforce_operation_authorization( + // `@policy` requirements are resolved outside of the router, so they are published + // to the request context before the analysis stage runs, and read back after it. + let required_policies = collect_required_policies( &shared_state.router_config, &normalize_payload, &supergraph.runtime.authorization, &supergraph.snapshot.metadata, &variable_payload, - &client_request_details.jwt, )?; + if !required_policies.is_empty() { + request_context.update(|ctx| { + ctx.authorization.required_policies = Some(required_policies); + })?; + } let mut progressive_override_ctx = RequestOverrideContext::new( &shared_state.override_labels_evaluator, @@ -803,6 +809,25 @@ pub async fn execute_pipeline<'exec>( } } + // Enforcement runs after the analysis stage so that `@policy` decisions taken by a + // coprocessor are already available in the request context. + let granted_policies = request_context + .read_lock()? + .authorization + .granted_policies + .clone() + .unwrap_or_default(); + + let (mut normalize_payload, authorization_errors) = enforce_operation_authorization( + &shared_state.router_config, + &normalize_payload, + &supergraph.runtime.authorization, + &supergraph.snapshot.metadata, + &variable_payload, + &client_request_details.jwt, + &granted_policies, + )?; + let client_request_details = Arc::new(client_request_details.freeze()); let mut plugin_graphql_errors = Vec::new(); diff --git a/e2e/src/coprocessor/authorization_policies.rs b/e2e/src/coprocessor/authorization_policies.rs new file mode 100644 index 000000000..63c1a2797 --- /dev/null +++ b/e2e/src/coprocessor/authorization_policies.rs @@ -0,0 +1,471 @@ +use jsonwebtoken::{encode, EncodingKey}; +use sonic_rs::{json, JsonContainerTrait, JsonValueTrait, Value}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::testkit::{ + coprocessor::TestCoprocessor, some_header_map, ClientResponseExt, TestRouter, TestSubgraphs, +}; + +const REQUIRED_POLICIES_KEY: &str = "hive::authorization::required_policies"; +const GRANTED_POLICIES_KEY: &str = "hive::authorization::granted_policies"; + +/// Router config wiring the `graphql.analysis` stage to the policy supergraph. +/// The stage receives the request context and answers with the granted policies. +fn policy_router_config(host: &str) -> String { + format!( + r#" + supergraph: + source: file + path: supergraph-policy.graphql + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + analysis: + include: + context: true + "# + ) +} + +/// Same as [`policy_router_config`], with JWT authentication enabled so that +/// `@authenticated`/`@requiresScopes` are enforced alongside `@policy`. +fn policy_router_config_with_jwt(host: &str) -> String { + format!( + r#" + supergraph: + source: file + path: supergraph-policy.graphql + jwt: + enabled: true + require_authentication: false + jwks_providers: + - source: file + path: jwks.rsa512.json + coprocessor: + url: http://{host}/coprocessor + protocol: http1 + stages: + graphql: + analysis: + include: + context: true + "# + ) +} + +fn granting(policies: &[&str]) -> String { + json!({ + "version": 1, + "control": "continue", + "context": { + GRANTED_POLICIES_KEY: policies, + } + }) + .to_string() +} + +/// Reads `hive::authorization::required_policies` out of a coprocessor payload, +/// sorted so assertions do not depend on set ordering. +fn required_policies(payload: &Value) -> Option> { + let mut policies: Vec = payload + .get("context")? + .pointer(&[REQUIRED_POLICIES_KEY])? + .as_array()? + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect(); + + policies.sort(); + Some(policies) +} + +fn generate_jwt(payload: &Value) -> String { + let pem = include_str!("../../jwks.rsa512.pem"); + + encode::( + &jsonwebtoken::Header { + alg: jsonwebtoken::Algorithm::RS512, + kid: Some("test_id".to_string()), + ..Default::default() + }, + payload, + &EncodingKey::from_rsa_pem(pem.as_bytes()).expect("failed to read pem"), + ) + .expect("failed to create token") +} + +fn authorization_header() -> http::HeaderMap { + some_header_map! { + http::header::AUTHORIZATION => format!( + "Bearer {}", + generate_jwt(&json!({ + "sub": "user2", + "iat": 1516239022, + "exp": SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600, + })) + ) + } + .unwrap() +} + +/// The router publishes the policies the operation depends on so the coprocessor +/// knows what it has to decide on. +#[ntex::test] +async fn publishes_required_policies_to_the_coprocessor() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + required_policies(payload).as_deref() == Some(&["read_inventory".to_string()]) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(granting(&["read_inventory"])) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { upc inStock } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "inStock": true, + "upc": "1" + } + ] + } + } + "#); + + analysis_stage_mock.assert_async().await; +} + +/// Every policy of every OR group is published, deciding which combination is +/// enough is up to the router, not the coprocessor. +#[ntex::test] +async fn publishes_every_policy_of_an_or_group() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + required_policies(payload).as_deref() + == Some(&[ + "admin".to_string(), + "internal".to_string(), + "read_users".to_string(), + ]) + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(granting(&["admin"])) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ users { id } }", None, None) + .await; + + assert!( + response + .json_body_string_pretty_stable() + .await + .contains("\"users\""), + "the operation should have been executed" + ); + + analysis_stage_mock.assert_async().await; +} + +/// A policy the coprocessor did not grant nulls the field it protects and reports +/// an error, exactly like the JWT-based authorization directives do. +#[ntex::test] +async fn filters_fields_whose_policy_was_not_granted() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage("graphql.analysis") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(granting(&[])) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { upc inStock } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "inStock": null, + "upc": "1" + } + ] + }, + "errors": [ + { + "extensions": { + "affectedPath": "topProducts.inStock", + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + }, + "message": "Unauthorized field or type" + } + ] + } + "#); + + analysis_stage_mock.assert_async().await; +} + +/// A coprocessor that leaves the granted policies out of its answer denies +/// everything, so the unresolved decision never defaults to "allowed". +#[ntex::test] +async fn denies_policies_left_undecided_by_the_coprocessor() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage("graphql.analysis") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { upc inStock } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "inStock": null, + "upc": "1" + } + ] + }, + "errors": [ + { + "extensions": { + "affectedPath": "topProducts.inStock", + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + }, + "message": "Unauthorized field or type" + } + ] + } + "#); + + analysis_stage_mock.assert_async().await; +} + +/// `required_policies` is owned by the router, a coprocessor trying to rewrite it +/// gets the request rejected instead of widening what it is asked to decide. +#[ntex::test] +async fn rejects_coprocessor_writes_to_required_policies() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage("graphql.analysis") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 1, + "control": "continue", + "context": { + REQUIRED_POLICIES_KEY: [], + } + }) + .to_string(), + ) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { upc inStock } }", None, None) + .await; + + assert!( + !response.status().is_success(), + "the router should reject a write to a reserved context key" + ); + + analysis_stage_mock.assert_async().await; +} + +/// Operations that touch no `@policy` field must not pay for a policy round trip: +/// nothing is published, and the coprocessor has nothing to decide. +#[ntex::test] +async fn publishes_no_policies_for_an_unprotected_operation() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage_with_matcher("graphql.analysis", |payload| { + required_policies(payload).is_none() + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"version": 1, "control": "continue"}).to_string()) + .expect(1) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config(&host)) + .build() + .start() + .await; + + let response = router + .send_graphql_request("{ topProducts(first: 1) { upc name } }", None, None) + .await; + + insta::assert_snapshot!(response.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "name": "Table", + "upc": "1" + } + ] + } + } + "#); + + analysis_stage_mock.assert_async().await; +} + +/// `@policy` and `@authenticated` on the same field are independent requirements, +/// granting the policy alone is not enough. +#[ntex::test] +async fn requires_authentication_on_top_of_the_granted_policy() { + let subgraphs = TestSubgraphs::builder().build().start().await; + let mut coprocessor = TestCoprocessor::new().await; + let host = coprocessor.host_with_port(); + + let analysis_stage_mock = coprocessor + .mock_stage("graphql.analysis") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(granting(&["read_weight"])) + .expect(2) + .create(); + + let router = TestRouter::builder() + .with_subgraphs(&subgraphs) + .inline_config(policy_router_config_with_jwt(&host)) + .build() + .start() + .await; + + let anonymous = router + .send_graphql_request("{ topProducts(first: 1) { upc weight } }", None, None) + .await; + + insta::assert_snapshot!(anonymous.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "upc": "1", + "weight": null + } + ] + }, + "errors": [ + { + "extensions": { + "affectedPath": "topProducts.weight", + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + }, + "message": "Unauthorized field or type" + } + ] + } + "#); + + let authenticated = router + .send_graphql_request( + "{ topProducts(first: 1) { upc weight } }", + None, + Some(authorization_header()), + ) + .await; + + insta::assert_snapshot!(authenticated.json_body_string_pretty_stable().await, @r#" + { + "data": { + "topProducts": [ + { + "upc": "1", + "weight": 100 + } + ] + } + } + "#); + + analysis_stage_mock.assert_async().await; +} diff --git a/e2e/src/coprocessor/mod.rs b/e2e/src/coprocessor/mod.rs index 66fa014c4..5cfade2eb 100644 --- a/e2e/src/coprocessor/mod.rs +++ b/e2e/src/coprocessor/mod.rs @@ -1,4 +1,6 @@ #[cfg(test)] +mod authorization_policies; +#[cfg(test)] mod context; #[cfg(test)] mod failures; diff --git a/e2e/supergraph-policy.graphql b/e2e/supergraph-policy.graphql new file mode 100644 index 000000000..3773690fb --- /dev/null +++ b/e2e/supergraph-policy.graphql @@ -0,0 +1,169 @@ +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) + @link(url: "https://specs.apollo.dev/authenticated/v0.1", for: SECURITY) + @link(url: "https://specs.apollo.dev/policy/v0.1", for: SECURITY) { + query: Query +} + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @requiresScopes( + scopes: [[requiresScopes__Scope!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @policy( + policies: [[policy__Policy!]!]! +) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + +scalar requiresScopes__Scope +scalar policy__Policy + +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 { + ACCOUNTS @join__graph(name: "accounts", url: "http://0.0.0.0:4200/accounts") + INVENTORY + @join__graph(name: "inventory", url: "http://0.0.0.0:4200/inventory") + PRODUCTS @join__graph(name: "products", url: "http://0.0.0.0:4200/products") + REVIEWS @join__graph(name: "reviews", url: "http://0.0.0.0:4200/reviews") +} + +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: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + upc: String! + weight: Int + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + @authenticated + @policy(policies: [["read_weight"]]) + price: Int! + @join__field(graph: INVENTORY, external: true) + @join__field(graph: PRODUCTS) + @requiresScopes(scopes: [["read:price"]]) + inStock: Boolean + @join__field(graph: INVENTORY) + @policy(policies: [["read_inventory"]]) + shippingEstimate: Int + @join__field(graph: INVENTORY, requires: "price weight") + @requiresScopes(scopes: [["read:shipping"]]) + name: String @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + notes: String + @join__field(graph: PRODUCTS) + @requiresScopes(scopes: [["read:notes"], ["admin"]]) + internal: String + @join__field(graph: PRODUCTS) + @requiresScopes(scopes: [["read:internal", "admin"]]) +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) @authenticated + user(id: ID!): User @join__field(graph: ACCOUNTS) + users: [User] + @join__field(graph: ACCOUNTS) + @policy(policies: [["admin"], ["read_users", "internal"]]) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + body: String @authenticated + product: Product + author: User @join__field(graph: REVIEWS, provides: "username") +} + +interface SocialAccount @join__type(graph: ACCOUNTS) { + url: String! @authenticated + handle: String! + @requiresScopes(scopes: [["read:twitter_handle", "read:github_handle"]]) +} + +type TwitterAccount implements SocialAccount + @join__implements(graph: ACCOUNTS, interface: "SocialAccount") + @join__type(graph: ACCOUNTS) { + url: String! @authenticated + handle: String! @requiresScopes(scopes: [["read:twitter_handle"]]) + followers: Int! +} + +type GitHubAccount implements SocialAccount + @join__implements(graph: ACCOUNTS, interface: "SocialAccount") + @join__type(graph: ACCOUNTS) { + url: String! @authenticated + handle: String! @requiresScopes(scopes: [["read:github_handle"]]) + repoCount: Int! +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + birthday: Int + @join__field(graph: ACCOUNTS) + @requiresScopes(scopes: [["read:birthday"]]) + reviews: [Review] @join__field(graph: REVIEWS) + socialAccounts: [SocialAccount!]! @join__field(graph: ACCOUNTS) +} diff --git a/lib/executor/src/request_context/domains/authorization.rs b/lib/executor/src/request_context/domains/authorization.rs new file mode 100644 index 000000000..1c6cc45f0 --- /dev/null +++ b/lib/executor/src/request_context/domains/authorization.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; + +use serde::ser::SerializeMap; +use sonic_rs::{JsonValueTrait, Value}; + +use super::super::api::plugin::{RequestContextPluginRead, RequestContextPluginWrite}; +use super::super::deser::RequestContextValueExt; +use super::RequestContextDomain; +use super::RequestContextError; +use crate::hooks; + +pub trait CanWriteAuthorization {} +impl CanWriteAuthorization for hooks::OnGraphqlAnalysis {} + +pub(crate) const REQUIRED_POLICIES_KEY: &str = "hive::authorization::required_policies"; +pub(crate) const GRANTED_POLICIES_KEY: &str = "hive::authorization::granted_policies"; + +/// Context domain for custom authorization policies (`@policy`). +/// +/// The router publishes the policies the current operation depends on in +/// `required_policies`, and a coprocessor (or plugin) answers with the subset it +/// grants in `granted_policies`. Anything not granted is treated as denied. +#[derive(Debug, Clone, Default)] +pub struct AuthorizationContext { + /// The policies the current operation requires a decision on. + pub required_policies: Option>, + /// The policies that were granted for this request. + pub granted_policies: Option>, +} + +impl AuthorizationContext { + fn set_granted_policies_value(&mut self, value: Value) -> Result<(), RequestContextError> { + if value.is_null() { + self.granted_policies = None; + return Ok(()); + } + + let array = value.expect_array(GRANTED_POLICIES_KEY, "array of strings or null")?; + let mut policies = HashSet::with_capacity(array.len()); + for item in array { + let policy = item.expect_str(GRANTED_POLICIES_KEY, "array of strings or null")?; + policies.insert(policy.to_string()); + } + + self.granted_policies = Some(policies); + Ok(()) + } +} + +/// A read-only view of the authorization policy state for plugins. +pub struct RequestContextAuthorizationRead<'a> { + context: &'a AuthorizationContext, +} + +impl RequestContextAuthorizationRead<'_> { + /// Returns the policies the current operation requires a decision on. + pub fn required_policies(&self) -> Option<&HashSet> { + self.context.required_policies.as_ref() + } + + /// Returns the policies currently granted for this request. + pub fn granted_policies(&self) -> Option<&HashSet> { + self.context.granted_policies.as_ref() + } +} + +/// A writable interface for the authorization policy state for plugins. +pub struct RequestContextAuthorizationWrite<'a> { + context: &'a mut AuthorizationContext, +} + +impl RequestContextAuthorizationWrite<'_> { + /// Sets the policies granted for the current request. + /// Providing `None` is equivalent to an empty set, so nothing is granted. + pub fn set_granted_policies(&mut self, policies: Option>) -> &mut Self { + self.context.granted_policies = policies; + self + } +} + +impl RequestContextPluginRead { + /// Returns the authorization read API. + pub fn authorization(&self) -> RequestContextAuthorizationRead<'_> { + RequestContextAuthorizationRead { + context: &self.snapshot.authorization, + } + } +} + +impl RequestContextPluginWrite<'_, Hook> { + /// Returns the authorization write API. + /// Only available in hooks that implement `CanWriteAuthorization`. + pub fn authorization(&mut self) -> RequestContextAuthorizationWrite<'_> { + RequestContextAuthorizationWrite { + context: &mut self.context.authorization, + } + } +} + +impl RequestContextDomain for AuthorizationContext { + const DOMAIN_PREFIX: &'static str = "hive::authorization::"; + + fn set_key_value(&mut self, key: &str, value: Value) -> Result<(), RequestContextError> { + match key { + REQUIRED_POLICIES_KEY => self.forbidden_mutation(key), + GRANTED_POLICIES_KEY => self.set_granted_policies_value(value), + _ => self.unknown_key(key), + } + } + + super::impl_domain_serde!( + REQUIRED_POLICIES_KEY => required_policies, + GRANTED_POLICIES_KEY => granted_policies, + ); +} diff --git a/lib/executor/src/request_context/domains/mod.rs b/lib/executor/src/request_context/domains/mod.rs index e186fc2b7..c964ecab5 100644 --- a/lib/executor/src/request_context/domains/mod.rs +++ b/lib/executor/src/request_context/domains/mod.rs @@ -1,4 +1,5 @@ use super::domains::authentication::AuthenticationContext; +use super::domains::authorization::AuthorizationContext; use super::domains::operation::OperationContext; use super::domains::persisted_documents::PersistedDocumentsContext; use super::domains::progressive_override::ProgressiveOverrideContext; @@ -13,6 +14,7 @@ use sonic_rs::Value; use std::sync::{Arc, Mutex, MutexGuard}; mod authentication; +pub(crate) mod authorization; mod operation; pub(crate) mod persisted_documents; mod progressive_override; @@ -179,6 +181,7 @@ reserved_domains! { operation: OperationContext, progressive_override: ProgressiveOverrideContext, authentication: AuthenticationContext, + authorization: AuthorizationContext, telemetry: TelemetryContext, persisted_documents: PersistedDocumentsContext, } diff --git a/lib/executor/src/request_context/mod.rs b/lib/executor/src/request_context/mod.rs index 3050aabb2..4df940d13 100644 --- a/lib/executor/src/request_context/mod.rs +++ b/lib/executor/src/request_context/mod.rs @@ -6,6 +6,9 @@ mod web; pub use api::coprocessor::RequestContextPatch; pub use api::plugin::RequestContextPluginApi; +pub use domains::authorization::{ + RequestContextAuthorizationRead, RequestContextAuthorizationWrite, +}; pub use domains::persisted_documents::{ RequestContextPersistedDocumentsRead, RequestContextPersistedDocumentsWrite, }; diff --git a/lib/internal/src/authorization/metadata.rs b/lib/internal/src/authorization/metadata.rs index 311430774..726fd2ec9 100644 --- a/lib/internal/src/authorization/metadata.rs +++ b/lib/internal/src/authorization/metadata.rs @@ -7,6 +7,12 @@ pub type ScopeId = Spur; /// String interner for scope values, enabling O(1) comparisons. pub type ScopeInterner = Rodeo; +/// Unique identifier for a policy string, interned for fast comparisons. +pub type PolicyId = Spur; + +/// String interner for policy values, enabling O(1) comparisons. +pub type PolicyInterner = Rodeo; + /// Group of scopes required together (AND logic). /// /// Example: `["read:posts", "read:users"]` means user needs both scopes. @@ -20,13 +26,37 @@ pub struct ScopeAndGroup(pub Vec); #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RequiredScopes(pub Vec); +/// Group of policies required together (AND logic). +/// +/// Example: `["read_profile", "read_email"]` means both policies must be granted. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PolicyAndGroup(pub Vec); + +/// Full requirements of a `@policy` directive (OR logic). +/// +/// Example: `[["admin"], ["read_profile", "read_email"]]` means either the +/// "admin" policy is granted, or both "read_profile" and "read_email" are. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RequiredPolicies(pub Vec); + /// Authorization rule for a field or type. -#[derive(Debug, Clone)] -pub enum AuthorizationRule { +/// +/// A single field or type can carry several authorization directives at once. +/// All the parts present here must be satisfied for access to be granted. +#[derive(Debug, Clone, Default)] +pub struct AuthorizationRule { /// `@authenticated` - User must have valid JWT token. - Authenticated, - /// `@requiresScopes` - User must be authenticated with required scopes. - RequiresScopes(RequiredScopes), + pub authenticated: bool, + /// `@requiresScopes` - User must be authenticated with the required scopes. + pub scopes: Option, + /// `@policy` - The required policies must have been granted for this request. + pub policies: Option, +} + +impl AuthorizationRule { + pub fn is_empty(&self) -> bool { + !self.authenticated && self.scopes.is_none() && self.policies.is_none() + } } pub type TypeRulesMap = HashMap; @@ -42,6 +72,8 @@ pub struct AuthorizationMetadata { pub field_rules: TypeFieldRulesMap, /// Interner for scope strings pub scopes: ScopeInterner, + /// Interner for policy strings + pub policies: PolicyInterner, /// Type's subtree has any auth rules? pub type_has_any_auth: HashMap, } diff --git a/lib/query-planner/src/consumer_schema/strip_schema_internals.rs b/lib/query-planner/src/consumer_schema/strip_schema_internals.rs index e2d69137b..3af993b29 100644 --- a/lib/query-planner/src/consumer_schema/strip_schema_internals.rs +++ b/lib/query-planner/src/consumer_schema/strip_schema_internals.rs @@ -5,13 +5,14 @@ use crate::{ federation_spec::{ definitions::{ CorePurposesEnum, JoinDirectiveArgumentsScalar, JoinFieldSetScalar, JoinGraphEnum, - LinkImportScalar, LinkPurposeEnum, RequiresScopesScopeScalar, + LinkImportScalar, LinkPurposeEnum, PolicyPolicyScalar, RequiresScopesScopeScalar, }, demand_control::{CostDirective, ListSizeDirective}, directives::{ AuthenticatedDirective, CoreDirective, InaccessibleDirective, JoinEnumValueDirective, JoinFieldDirective, JoinGraphDirective, JoinImplementsDirective, JoinTypeDirective, - JoinUnionMemberDirective, LinkDirective, RequiresScopesDirective, TagDirective, + JoinUnionMemberDirective, LinkDirective, PolicyDirective, RequiresScopesDirective, + TagDirective, }, join_directive::JoinDirectiveDirective, join_owner::JoinOwnerDirective, @@ -22,7 +23,7 @@ use crate::{ // directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ENUM | ENUM_VALUE | SCALAR | INPUT_OBJECT | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION pub(crate) struct StripSchemaInternals; -static DIRECTIVES_TO_STRIP: [&str; 16] = [ +static DIRECTIVES_TO_STRIP: [&str; 17] = [ JoinTypeDirective::NAME, JoinEnumValueDirective::NAME, JoinFieldDirective::NAME, @@ -37,11 +38,12 @@ static DIRECTIVES_TO_STRIP: [&str; 16] = [ CoreDirective::NAME, AuthenticatedDirective::NAME, RequiresScopesDirective::NAME, + PolicyDirective::NAME, CostDirective::NAME, ListSizeDirective::NAME, ]; -static DEFINITIONS_TO_STRIP: [&str; 7] = [ +static DEFINITIONS_TO_STRIP: [&str; 8] = [ LinkPurposeEnum::NAME, LinkImportScalar::NAME, JoinGraphEnum::NAME, @@ -49,6 +51,7 @@ static DEFINITIONS_TO_STRIP: [&str; 7] = [ JoinDirectiveArgumentsScalar::NAME, CorePurposesEnum::NAME, RequiresScopesScopeScalar::NAME, + PolicyPolicyScalar::NAME, ]; impl StripSchemaInternals { diff --git a/lib/query-planner/src/federation_spec/authorization.rs b/lib/query-planner/src/federation_spec/authorization.rs index d882507da..31129109d 100644 --- a/lib/query-planner/src/federation_spec/authorization.rs +++ b/lib/query-planner/src/federation_spec/authorization.rs @@ -87,3 +87,50 @@ impl PartialOrd for RequiresScopesDirective { Some(self.cmp(other)) } } + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct PolicyDirective { + pub policies: AstValue, +} + +impl PolicyDirective { + pub const NAME: &str = "policy"; +} + +impl FederationDirective for PolicyDirective { + fn directive_name() -> &'static str { + Self::NAME + } + + fn parse(directive: &Directive<'_, String>) -> Self + where + Self: Sized, + { + let mut result = Self { + policies: AstValue::Null, + }; + + for (arg_name, arg_value) in &directive.arguments { + if arg_name.eq("policies") { + // Same reasoning as `@requiresScopes(scopes:)` above: we only "read" + // the argument here and leave validation to the higher level code, + // which can report errors with `Result`. + result.policies = arg_value.into() + } + } + + result + } +} + +impl Ord for PolicyDirective { + fn cmp(&self, _other: &Self) -> std::cmp::Ordering { + std::cmp::Ordering::Equal + } +} + +impl PartialOrd for PolicyDirective { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} diff --git a/lib/query-planner/src/federation_spec/definitions.rs b/lib/query-planner/src/federation_spec/definitions.rs index bced6f5e7..9e09724bc 100644 --- a/lib/query-planner/src/federation_spec/definitions.rs +++ b/lib/query-planner/src/federation_spec/definitions.rs @@ -34,6 +34,12 @@ impl RequiresScopesScopeScalar { pub const NAME: &str = "requiresScopes__Scope"; } +pub struct PolicyPolicyScalar {} + +impl PolicyPolicyScalar { + pub const NAME: &str = "policy__Policy"; +} + pub struct JoinDirectiveArgumentsScalar {} impl JoinDirectiveArgumentsScalar { diff --git a/lib/query-planner/src/federation_spec/directives.rs b/lib/query-planner/src/federation_spec/directives.rs index 63fc88c8a..cd91e7984 100644 --- a/lib/query-planner/src/federation_spec/directives.rs +++ b/lib/query-planner/src/federation_spec/directives.rs @@ -1,4 +1,5 @@ pub use crate::federation_spec::authorization::AuthenticatedDirective; +pub use crate::federation_spec::authorization::PolicyDirective; pub use crate::federation_spec::authorization::RequiresScopesDirective; pub use crate::federation_spec::directive_trait::FederationDirective; pub use crate::federation_spec::inacessible::InaccessibleDirective; diff --git a/lib/query-planner/src/state/supergraph_state.rs b/lib/query-planner/src/state/supergraph_state.rs index 45950fcfb..e0e9f42c8 100644 --- a/lib/query-planner/src/state/supergraph_state.rs +++ b/lib/query-planner/src/state/supergraph_state.rs @@ -14,7 +14,7 @@ use crate::{ directives::{ AuthenticatedDirective, FederationDirective, InaccessibleDirective, JoinEnumValueDirective, JoinFieldDirective, JoinGraphDirective, - JoinImplementsDirective, JoinTypeDirective, JoinUnionMemberDirective, + JoinImplementsDirective, JoinTypeDirective, JoinUnionMemberDirective, PolicyDirective, RequiresScopesDirective, }, }, @@ -63,10 +63,11 @@ type InterfaceToObjectTypesMap = HashMap>; type DefinitionMap = HashMap; /// Information about linked specifications used in the supergraph -/// (e.g., authenticated, requiresScopes) +/// (e.g., authenticated, requiresScopes, policy) struct LinkedSpecifications { pub authenticated: bool, pub requires_scopes: bool, + pub policy: bool, } impl LinkedSpecifications { @@ -78,15 +79,17 @@ impl LinkedSpecifications { return Self { authenticated: false, requires_scopes: false, + policy: false, }; }; let mut authenticated = false; let mut requires_scopes = false; + let mut policy = false; for directive in &schema_def.directives { - // Found both? Stop searching. - if authenticated && requires_scopes { + // Found all of them? Stop searching. + if authenticated && requires_scopes && policy { break; } @@ -113,12 +116,15 @@ impl LinkedSpecifications { && url.starts_with("https://specs.apollo.dev/requiresScopes/") { requires_scopes = true; + } else if !policy && url.starts_with("https://specs.apollo.dev/policy/") { + policy = true; } } Self { authenticated, requires_scopes, + policy, } } @@ -146,6 +152,18 @@ impl LinkedSpecifications { } } + /// Conditionally extract @policy directives based on whether the spec is enabled + fn extract_policy_directives( + &self, + directives: &[Directive<'static, String>], + ) -> Vec { + if self.policy { + SupergraphState::extract_directives::(directives) + } else { + Default::default() + } + } + fn extract_cost_directive( &self, directives: &[Directive<'static, String>], @@ -493,6 +511,7 @@ impl SupergraphState { authenticated: linked_specs.extract_authenticated_directives(&scalar_type.directives), requires_scopes: linked_specs .extract_requires_scopes_directives(&scalar_type.directives), + policy: linked_specs.extract_policy_directives(&scalar_type.directives), cost: linked_specs.extract_cost_directive(&scalar_type.directives), } } @@ -519,6 +538,7 @@ impl SupergraphState { join_type: Self::extract_directives::(&enum_type.directives), authenticated: linked_specs.extract_authenticated_directives(&enum_type.directives), requires_scopes: linked_specs.extract_requires_scopes_directives(&enum_type.directives), + policy: linked_specs.extract_policy_directives(&enum_type.directives), values: enum_type .values .iter() @@ -553,6 +573,7 @@ impl SupergraphState { .extract_authenticated_directives(&field.directives), requires_scopes: linked_specs .extract_requires_scopes_directives(&field.directives), + policy: linked_specs.extract_policy_directives(&field.directives), inaccessible: !Self::extract_directives::( &field.directives, ) @@ -597,6 +618,7 @@ impl SupergraphState { ), authenticated: Default::default(), requires_scopes: Default::default(), + policy: Default::default(), inaccessible: !Self::extract_directives::( &field.directives, ) @@ -632,6 +654,7 @@ impl SupergraphState { .extract_authenticated_directives(&interface_type.directives), requires_scopes: linked_specs .extract_requires_scopes_directives(&interface_type.directives), + policy: linked_specs.extract_policy_directives(&interface_type.directives), used_in_subgraphs, } } @@ -674,6 +697,7 @@ impl SupergraphState { authenticated: linked_specs.extract_authenticated_directives(&object_type.directives), requires_scopes: linked_specs .extract_requires_scopes_directives(&object_type.directives), + policy: linked_specs.extract_policy_directives(&object_type.directives), cost: linked_specs.extract_cost_directive(&object_type.directives), } } @@ -818,6 +842,7 @@ pub struct SupergraphObjectType { pub root_type: Option, pub used_in_subgraphs: HashSet, pub requires_scopes: Vec, + pub policy: Vec, pub authenticated: Vec, pub cost: Option, } @@ -881,6 +906,7 @@ pub struct SupergraphInterfaceType { pub join_implements: Vec, pub used_in_subgraphs: HashSet, pub requires_scopes: Vec, + pub policy: Vec, pub authenticated: Vec, } @@ -902,6 +928,7 @@ pub struct SupergraphScalarType { pub name: String, pub join_type: Vec, pub requires_scopes: Vec, + pub policy: Vec, pub authenticated: Vec, pub cost: Option, } @@ -912,6 +939,7 @@ pub struct SupergraphEnumType { pub values: Vec, pub join_type: Vec, pub requires_scopes: Vec, + pub policy: Vec, pub authenticated: Vec, pub cost: Option, } @@ -1060,6 +1088,7 @@ pub struct SupergraphField { pub inaccessible: bool, pub join_field: Vec, pub requires_scopes: Vec, + pub policy: Vec, pub authenticated: Vec, pub cost: Option, pub list_size: Option,