apollo_router/plugins/authorization/
mod.rs

1//! Authorization plugin
2
3use std::collections::HashMap;
4use std::collections::HashSet;
5use std::ops::ControlFlow;
6
7use apollo_compiler::ExecutableDocument;
8use apollo_compiler::ast;
9use http::StatusCode;
10use schemars::JsonSchema;
11use serde::Deserialize;
12use serde::Serialize;
13use serde_json_bytes::Value;
14use tower::BoxError;
15use tower::ServiceBuilder;
16use tower::ServiceExt;
17
18use self::authenticated::AUTHENTICATED_SPEC_BASE_URL;
19use self::authenticated::AUTHENTICATED_SPEC_VERSION_RANGE;
20use self::authenticated::AuthenticatedCheckVisitor;
21use self::authenticated::AuthenticatedVisitor;
22use self::policy::POLICY_SPEC_BASE_URL;
23use self::policy::POLICY_SPEC_VERSION_RANGE;
24use self::policy::PolicyExtractionVisitor;
25use self::policy::PolicyFilteringVisitor;
26use self::scopes::REQUIRES_SCOPES_SPEC_BASE_URL;
27use self::scopes::REQUIRES_SCOPES_SPEC_VERSION_RANGE;
28use self::scopes::ScopeExtractionVisitor;
29use self::scopes::ScopeFilteringVisitor;
30use crate::Configuration;
31use crate::Context;
32use crate::error::QueryPlannerError;
33use crate::error::ServiceBuildError;
34use crate::graphql;
35use crate::json_ext::Path;
36use crate::layers::ServiceBuilderExt;
37use crate::plugin::Plugin;
38use crate::plugin::PluginInit;
39use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS;
40use crate::query_planner::FilteredQuery;
41use crate::query_planner::QueryKey;
42use crate::register_plugin;
43use crate::services::execution;
44use crate::services::layers::query_analysis::ParsedDocumentInner;
45use crate::services::supergraph;
46use crate::spec::Schema;
47use crate::spec::SpecError;
48use crate::spec::query::transform;
49use crate::spec::query::traverse;
50
51pub(crate) mod authenticated;
52pub(crate) mod policy;
53pub(crate) mod scopes;
54
55pub(crate) const AUTHENTICATION_REQUIRED_KEY: &str =
56    "apollo::authorization::authentication_required";
57pub(crate) const REQUIRED_SCOPES_KEY: &str = "apollo::authorization::required_scopes";
58pub(crate) const REQUIRED_POLICIES_KEY: &str = "apollo::authorization::required_policies";
59
60#[derive(Clone, Debug, Default, Hash, PartialEq, Eq, Serialize, Deserialize)]
61pub(crate) struct CacheKeyMetadata {
62    pub(crate) is_authenticated: bool,
63    pub(crate) scopes: Vec<String>,
64    pub(crate) policies: Vec<String>,
65}
66
67/// Authorization plugin
68#[derive(Clone, Debug, serde_derive_default::Default, Deserialize, JsonSchema)]
69#[allow(dead_code)]
70#[schemars(rename = "AuthorizationConfig")]
71pub(crate) struct Conf {
72    /// Reject unauthenticated requests
73    #[serde(default)]
74    require_authentication: bool,
75    /// `@authenticated`, `@requiresScopes` and `@policy` directives
76    #[serde(default)]
77    directives: Directives,
78}
79
80#[derive(Clone, Debug, serde_derive_default::Default, Deserialize, JsonSchema)]
81#[allow(dead_code)]
82#[schemars(rename = "AuthorizationDirectivesConfig")]
83pub(crate) struct Directives {
84    /// enables the `@authenticated` and `@requiresScopes` directives
85    #[serde(default = "default_enable_directives")]
86    enabled: bool,
87    /// generates the authorization error messages without modying the query
88    #[serde(default)]
89    dry_run: bool,
90    /// refuse a query entirely if any part would be filtered
91    #[serde(default)]
92    reject_unauthorized: bool,
93    /// authorization errors behaviour
94    #[serde(default)]
95    errors: ErrorConfig,
96}
97
98#[derive(
99    Clone, Debug, serde_derive_default::Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema,
100)]
101#[allow(dead_code)]
102#[schemars(rename = "AuthorizationErrorConfig")]
103pub(crate) struct ErrorConfig {
104    /// log authorization errors
105    #[serde(default = "enable_log_errors")]
106    pub(crate) log: bool,
107    /// location of authorization errors in the GraphQL response
108    #[serde(default)]
109    pub(crate) response: ErrorLocation,
110}
111
112fn enable_log_errors() -> bool {
113    true
114}
115
116#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
117#[serde(rename_all = "snake_case")]
118pub(crate) enum ErrorLocation {
119    /// store authorization errors in the response errors
120    #[default]
121    Errors,
122    /// store authorization errors in the response extensions
123    Extensions,
124    /// do not add the authorization errors to the GraphQL response
125    Disabled,
126}
127
128#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub(crate) struct UnauthorizedPaths {
130    pub(crate) paths: Vec<Path>,
131    pub(crate) errors: ErrorConfig,
132}
133
134fn default_enable_directives() -> bool {
135    true
136}
137
138pub(crate) struct AuthorizationPlugin {
139    require_authentication: bool,
140}
141
142impl AuthorizationPlugin {
143    pub(crate) fn enable_directives(
144        configuration: &Configuration,
145        schema: &Schema,
146    ) -> Result<bool, ServiceBuildError> {
147        let has_config = configuration
148            .apollo_plugins
149            .plugins
150            .iter()
151            .find(|(s, _)| s.as_str() == "authorization")
152            .and_then(|(_, v)| v.get("directives").and_then(|v| v.as_object()))
153            .and_then(|v| v.get("enabled").and_then(|v| v.as_bool()));
154
155        let has_authorization_directives = schema.has_spec(
156            AUTHENTICATED_SPEC_BASE_URL,
157            AUTHENTICATED_SPEC_VERSION_RANGE,
158        ) || schema.has_spec(
159            REQUIRES_SCOPES_SPEC_BASE_URL,
160            REQUIRES_SCOPES_SPEC_VERSION_RANGE,
161        ) || schema
162            .has_spec(POLICY_SPEC_BASE_URL, POLICY_SPEC_VERSION_RANGE);
163
164        Ok(has_config.unwrap_or(true) && has_authorization_directives)
165    }
166
167    pub(crate) fn log_errors(configuration: &Configuration) -> ErrorConfig {
168        configuration
169            .apollo_plugins
170            .plugins
171            .iter()
172            .find(|(s, _)| s.as_str() == "authorization")
173            .and_then(|(_, v)| v.get("directives").and_then(|v| v.as_object()))
174            .and_then(|v| {
175                v.get("errors")
176                    .and_then(|v| serde_json::from_value(v.clone()).ok())
177            })
178            .unwrap_or_default()
179    }
180
181    pub(crate) fn query_analysis(
182        doc: &ParsedDocumentInner,
183        operation_name: Option<&str>,
184        schema: &Schema,
185        context: &Context,
186    ) {
187        let CacheKeyMetadata {
188            is_authenticated,
189            scopes,
190            policies,
191        } = Self::generate_cache_metadata(
192            &doc.executable,
193            operation_name,
194            schema.supergraph_schema(),
195            false,
196        );
197        if is_authenticated {
198            context.insert(AUTHENTICATION_REQUIRED_KEY, true).unwrap();
199        }
200
201        if !scopes.is_empty() {
202            context.insert(REQUIRED_SCOPES_KEY, scopes).unwrap();
203        }
204
205        if !policies.is_empty() {
206            let policies: HashMap<String, Option<bool>> =
207                policies.into_iter().map(|policy| (policy, None)).collect();
208            context.insert(REQUIRED_POLICIES_KEY, policies).unwrap();
209        }
210    }
211
212    pub(crate) fn generate_cache_metadata(
213        document: &ExecutableDocument,
214        operation_name: Option<&str>,
215        schema: &apollo_compiler::Schema,
216        entity_query: bool,
217    ) -> CacheKeyMetadata {
218        let mut is_authenticated = false;
219        if let Some(mut visitor) = AuthenticatedCheckVisitor::new(schema, document, entity_query) {
220            // if this fails, the query is invalid and will fail at the query planning phase.
221            // We do not return validation errors here for now because that would imply a huge
222            // refactoring of telemetry and tests
223            if traverse::document(&mut visitor, document, operation_name).is_ok() && visitor.found {
224                is_authenticated = true;
225            }
226        }
227
228        let mut scopes = Vec::new();
229        if let Some(mut visitor) = ScopeExtractionVisitor::new(schema, document, entity_query) {
230            // if this fails, the query is invalid and will fail at the query planning phase.
231            // We do not return validation errors here for now because that would imply a huge
232            // refactoring of telemetry and tests
233            if traverse::document(&mut visitor, document, operation_name).is_ok() {
234                scopes = visitor.extracted_scopes.into_iter().collect();
235            }
236        }
237
238        let mut policies: Vec<String> = Vec::new();
239        if let Some(mut visitor) = PolicyExtractionVisitor::new(schema, document, entity_query) {
240            // if this fails, the query is invalid and will fail at the query planning phase.
241            // We do not return validation errors here for now because that would imply a huge
242            // refactoring of telemetry and tests
243            if traverse::document(&mut visitor, document, operation_name).is_ok() {
244                policies = visitor.extracted_policies.into_iter().collect();
245            }
246        }
247
248        CacheKeyMetadata {
249            is_authenticated,
250            scopes,
251            policies,
252        }
253    }
254
255    pub(crate) fn update_cache_key(context: &Context) {
256        let is_authenticated = context.contains_key(APOLLO_AUTHENTICATION_JWT_CLAIMS);
257
258        let request_scopes = context
259            .get_json_value(APOLLO_AUTHENTICATION_JWT_CLAIMS)
260            .and_then(|value| {
261                value.as_object().and_then(|object| {
262                    object.get("scope").and_then(|v| {
263                        v.as_str()
264                            .map(|s| s.split(' ').map(|s| s.to_string()).collect::<HashSet<_>>())
265                    })
266                })
267            });
268        let query_scopes = context.get_json_value(REQUIRED_SCOPES_KEY).and_then(|v| {
269            v.as_array().map(|v| {
270                v.iter()
271                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
272                    .collect::<HashSet<_>>()
273            })
274        });
275
276        let mut scopes = match (request_scopes, query_scopes) {
277            (None, _) => vec![],
278            (_, None) => vec![],
279            (Some(req), Some(query)) => req.intersection(&query).cloned().collect(),
280        };
281        scopes.sort();
282
283        let mut policies = context
284            .get_json_value(REQUIRED_POLICIES_KEY)
285            .and_then(|v| {
286                v.as_object().map(|v| {
287                    v.iter()
288                        .filter_map(|(policy, result)| match result {
289                            Value::Bool(true) => Some(policy.as_str().to_string()),
290                            _ => None,
291                        })
292                        .collect::<Vec<String>>()
293                })
294            })
295            .unwrap_or_default();
296        policies.sort();
297
298        context.extensions().with_lock(|lock| {
299            lock.insert(CacheKeyMetadata {
300                is_authenticated,
301                scopes,
302                policies,
303            })
304        });
305    }
306
307    pub(crate) fn intersect_cache_keys_subgraph(
308        left: &CacheKeyMetadata,
309        right: &CacheKeyMetadata,
310    ) -> CacheKeyMetadata {
311        CacheKeyMetadata {
312            is_authenticated: left.is_authenticated && right.is_authenticated,
313            scopes: left
314                .scopes
315                .iter()
316                .collect::<HashSet<_>>()
317                .intersection(&right.scopes.iter().collect::<HashSet<_>>())
318                .map(|s| s.to_string())
319                .collect(),
320            policies: left
321                .policies
322                .iter()
323                .collect::<HashSet<_>>()
324                .intersection(&right.policies.iter().collect::<HashSet<_>>())
325                .map(|s| s.to_string())
326                .collect(),
327        }
328    }
329
330    pub(crate) fn filter_query(
331        configuration: &Configuration,
332        key: &QueryKey,
333        schema: &Schema,
334    ) -> Result<Option<FilteredQuery>, QueryPlannerError> {
335        let (reject_unauthorized, dry_run) = configuration
336            .apollo_plugins
337            .plugins
338            .iter()
339            .find(|(s, _)| s.as_str() == "authorization")
340            .and_then(|(_, v)| v.get("directives").and_then(|v| v.as_object()))
341            .map(|config| {
342                (
343                    config
344                        .get("reject_unauthorized")
345                        .and_then(|v| v.as_bool())
346                        .unwrap_or(false),
347                    config
348                        .get("dry_run")
349                        .and_then(|v| v.as_bool())
350                        .unwrap_or(false),
351                )
352            })
353            .unwrap_or((false, false));
354
355        // The filtered query will then be used
356        // to generate selections for response formatting, to execute introspection and
357        // generating a query plan
358
359        // TODO: do we need to (re)parse here?
360        let doc = ast::Document::parse(&key.filtered_query, "filtered_query")
361            // Ignore parse errors: assume they’ve been handled elsewhere
362            .unwrap_or_else(|invalid| invalid.partial);
363
364        let is_authenticated = key.metadata.is_authenticated;
365        let scopes = &key.metadata.scopes;
366        let policies = &key.metadata.policies;
367
368        let mut is_filtered = false;
369        let mut unauthorized_paths: Vec<Path> = vec![];
370
371        let filter_res = Self::authenticated_filter_query(schema, dry_run, &doc, is_authenticated)?;
372
373        let doc = match filter_res {
374            None => doc,
375            Some((filtered_doc, paths)) => {
376                unauthorized_paths.extend(paths);
377
378                // FIXME: consider only `filtered_doc.operations.get(key.operation_name)`?
379                if filtered_doc.definitions.is_empty() {
380                    return Err(QueryPlannerError::Unauthorized(unauthorized_paths));
381                }
382
383                is_filtered = true;
384
385                filtered_doc
386            }
387        };
388
389        let filter_res = Self::scopes_filter_query(schema, dry_run, &doc, scopes)?;
390
391        let doc = match filter_res {
392            None => doc,
393            Some((filtered_doc, paths)) => {
394                unauthorized_paths.extend(paths);
395
396                // FIXME: consider only `filtered_doc.operations.get(key.operation_name)`?
397                if filtered_doc.definitions.is_empty() {
398                    return Err(QueryPlannerError::Unauthorized(unauthorized_paths));
399                }
400
401                is_filtered = true;
402
403                filtered_doc
404            }
405        };
406
407        let filter_res = Self::policies_filter_query(schema, dry_run, &doc, policies)?;
408
409        let doc = match filter_res {
410            None => doc,
411            Some((filtered_doc, paths)) => {
412                unauthorized_paths.extend(paths);
413
414                // FIXME: consider only `filtered_doc.operations.get(key.operation_name)`?
415                if filtered_doc.definitions.is_empty() {
416                    return Err(QueryPlannerError::Unauthorized(unauthorized_paths));
417                }
418
419                is_filtered = true;
420
421                filtered_doc
422            }
423        };
424
425        if reject_unauthorized && !unauthorized_paths.is_empty() {
426            return Err(QueryPlannerError::Unauthorized(unauthorized_paths));
427        }
428
429        if is_filtered {
430            Ok(Some((unauthorized_paths, doc)))
431        } else {
432            Ok(None)
433        }
434    }
435
436    fn authenticated_filter_query(
437        schema: &Schema,
438        dry_run: bool,
439        doc: &ast::Document,
440        is_authenticated: bool,
441    ) -> Result<Option<(ast::Document, Vec<Path>)>, QueryPlannerError> {
442        if let Some(mut visitor) = AuthenticatedVisitor::new(
443            schema.supergraph_schema(),
444            &schema.implementers_map,
445            dry_run,
446        ) {
447            let modified_query = transform::document(&mut visitor, doc)
448                .map_err(|e| SpecError::TransformError(e.to_string()))?;
449
450            if visitor.query_requires_authentication {
451                if is_authenticated {
452                    tracing::debug!(
453                        "the query contains @authenticated, the request is authenticated, keeping the query"
454                    );
455                    Ok(None)
456                } else {
457                    tracing::debug!(
458                        "the query contains @authenticated, modified query:\n{modified_query}\nunauthorized paths: {:?}",
459                        visitor
460                            .unauthorized_paths
461                            .iter()
462                            .map(|path| path.to_string())
463                            .collect::<Vec<_>>()
464                    );
465
466                    Ok(Some((modified_query, visitor.unauthorized_paths)))
467                }
468            } else {
469                tracing::debug!("the query does not contain @authenticated");
470                Ok(None)
471            }
472        } else {
473            tracing::debug!("the schema does not contain @authenticated");
474            Ok(None)
475        }
476    }
477
478    fn scopes_filter_query(
479        schema: &Schema,
480        dry_run: bool,
481        doc: &ast::Document,
482        scopes: &[String],
483    ) -> Result<Option<(ast::Document, Vec<Path>)>, QueryPlannerError> {
484        if let Some(mut visitor) = ScopeFilteringVisitor::new(
485            schema.supergraph_schema(),
486            &schema.implementers_map,
487            scopes.iter().cloned().collect(),
488            dry_run,
489        ) {
490            let modified_query = transform::document(&mut visitor, doc)
491                .map_err(|e| SpecError::TransformError(e.to_string()))?;
492            if visitor.query_requires_scopes {
493                tracing::debug!(
494                    "the query required scopes, the requests present scopes: {scopes:?}, modified query:\n{modified_query}\nunauthorized paths: {:?}",
495                    visitor
496                        .unauthorized_paths
497                        .iter()
498                        .map(|path| path.to_string())
499                        .collect::<Vec<_>>()
500                );
501                Ok(Some((modified_query, visitor.unauthorized_paths)))
502            } else {
503                tracing::debug!("the query does not require scopes");
504                Ok(None)
505            }
506        } else {
507            tracing::debug!("the schema does not contain @requiresScopes");
508            Ok(None)
509        }
510    }
511
512    fn policies_filter_query(
513        schema: &Schema,
514        dry_run: bool,
515
516        doc: &ast::Document,
517        policies: &[String],
518    ) -> Result<Option<(ast::Document, Vec<Path>)>, QueryPlannerError> {
519        if let Some(mut visitor) = PolicyFilteringVisitor::new(
520            schema.supergraph_schema(),
521            &schema.implementers_map,
522            policies.iter().cloned().collect(),
523            dry_run,
524        ) {
525            let modified_query = transform::document(&mut visitor, doc)
526                .map_err(|e| SpecError::TransformError(e.to_string()))?;
527
528            if visitor.query_requires_policies {
529                tracing::debug!(
530                    "the query required policies, the requests present policies: {policies:?}, modified query:\n{modified_query}\nunauthorized paths: {:?}",
531                    visitor
532                        .unauthorized_paths
533                        .iter()
534                        .map(|path| path.to_string())
535                        .collect::<Vec<_>>()
536                );
537                Ok(Some((modified_query, visitor.unauthorized_paths)))
538            } else {
539                tracing::debug!("the query does not require policies");
540                Ok(None)
541            }
542        } else {
543            tracing::debug!("the schema does not contain @policy");
544            Ok(None)
545        }
546    }
547}
548
549#[async_trait::async_trait]
550impl Plugin for AuthorizationPlugin {
551    type Config = Conf;
552
553    async fn new(init: PluginInit<Self::Config>) -> Result<Self, BoxError> {
554        Ok(AuthorizationPlugin {
555            require_authentication: init.config.require_authentication,
556        })
557    }
558
559    fn supergraph_service(&self, service: supergraph::BoxService) -> supergraph::BoxService {
560        if self.require_authentication {
561            ServiceBuilder::new()
562                .checkpoint(move |request: supergraph::Request| {
563                    // XXX(@goto-bus-stop): Why are we doing this here, as opposed to the
564                    // authentication plugin, which manages this context value?
565                    if request
566                        .context
567                        .contains_key(APOLLO_AUTHENTICATION_JWT_CLAIMS)
568                    {
569                        Ok(ControlFlow::Continue(request))
570                    } else {
571                        tracing::error!("rejecting unauthenticated request");
572                        let response = supergraph::Response::error_builder()
573                            .error(
574                                graphql::Error::builder()
575                                    .message("unauthenticated".to_string())
576                                    .extension_code("AUTH_ERROR")
577                                    .build(),
578                            )
579                            .status_code(StatusCode::UNAUTHORIZED)
580                            .context(request.context)
581                            .build()?;
582                        Ok(ControlFlow::Break(response))
583                    }
584                })
585                .service(service)
586                .boxed()
587        } else {
588            service
589        }
590    }
591
592    fn execution_service(&self, service: execution::BoxService) -> execution::BoxService {
593        ServiceBuilder::new()
594            .map_request(|request: execution::Request| {
595                let filtered = !request.query_plan.query.unauthorized.paths.is_empty();
596                let needs_authenticated = request.context.contains_key(AUTHENTICATION_REQUIRED_KEY);
597                let needs_requires_scopes = request.context.contains_key(REQUIRED_SCOPES_KEY);
598
599                if needs_authenticated || needs_requires_scopes {
600                    u64_counter!(
601                        "apollo.router.operations.authorization",
602                        "Number of subgraph requests requiring authorization",
603                        1,
604                        authorization.filtered = filtered,
605                        authorization.needs_authenticated = needs_authenticated,
606                        authorization.needs_requires_scopes = needs_requires_scopes
607                    );
608                }
609
610                request
611            })
612            .service(service)
613            .boxed()
614    }
615}
616
617// This macro allows us to use it in our plugin registry!
618// register_plugin takes a group name, and a plugin name.
619//
620// In order to keep the plugin names consistent,
621// we use using the `Reverse domain name notation`
622register_plugin!("apollo", "authorization", AuthorizationPlugin);
623
624#[cfg(test)]
625mod tests;