Skip to main content

cedarling/
lib.rs

1// This software is available under the Apache-2.0 license.
2// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text.
3//
4// Copyright (c) 2024, Gluu, Inc.
5
6#![deny(missing_docs)]
7#![warn(unreachable_pub)]
8#![allow(clippy::missing_errors_doc)]
9//! # Cedarling
10//! The Cedarling is a performant local authorization service that runs the Rust Cedar Engine.
11//! Cedar policies and schema are loaded at startup from a locally cached "Policy Store".
12//! In simple terms, the Cedarling returns the answer: should the application allow this action on this resource given these JWT tokens.
13//! "Fit for purpose" policies help developers build a better user experience.
14//! For example, why display form fields that a user is not authorized to see?
15//! The Cedarling is a more productive and flexible way to handle authorization.
16
17mod async_sleep;
18mod authz;
19mod bootstrap_config;
20mod common;
21mod context_data_api;
22mod entity_builder;
23mod http;
24mod http_utils;
25mod init;
26mod jwt;
27mod lock;
28mod log;
29// is reexported in hidden bindings module
30#[doc(hidden)]
31pub mod sparkv;
32
33#[cfg(not(target_arch = "wasm32"))]
34#[cfg(feature = "blocking")]
35pub mod blocking;
36
37#[doc(hidden)]
38#[cfg(test)]
39mod tests;
40
41use std::collections::HashSet;
42use std::{fmt::Write, sync::Arc};
43
44use crate::authz::metrics::MetricsCollector;
45use crate::context_data_api::DataStore;
46pub use crate::context_data_api::{
47    CedarType, CedarValueMapper, ConfigValidationError, DataApi, DataEntry, DataError,
48    DataStoreConfig, DataStoreStats, DataValidator, ExtensionValue, ValidationConfig,
49    ValidationError, ValidationResult, ValueMappingError,
50};
51pub use crate::jwt::TrustedIssuerLoadingInfo;
52use authz::Authz;
53pub use authz::request::{
54    AuthorizeMultiIssuerRequest, CedarEntityMapping, EntityData, RequestUnsigned, TokenInput,
55};
56pub use authz::{AuthorizeError, AuthorizeResult, MultiIssuerAuthorizeResult};
57pub use bootstrap_config::*;
58use common::app_types::{self, ApplicationName};
59pub use common::policy_store::{PolicyEffect, PolicyMetadata};
60pub use http::HttpClientConfig;
61use init::ServiceFactory;
62use init::policy_store::{LoadedPolicyStore, load_policy_store};
63use init::policy_store_refresh::{
64    AuthzRebuilder, PolicyStoreRefreshHandle, RefreshSource, RefreshWorkerSeed, WorkerContext,
65    spawn_refresh_worker,
66};
67use init::service_config::{ServiceConfig, ServiceConfigError};
68use init::service_factory::ServiceInitError;
69use lock::InitLockServiceError;
70use lock::health_registry::HealthStatus;
71use log::interface::LogWriter;
72use log::{BaseLogEntry, LogEntry};
73pub use log::{LogLevel, LogStorage};
74
75use semver::Version;
76
77/// Git commit hash at build time (`None` if git is unavailable or
78/// `CEDARLING_BUILD_COMMIT` was not set at compile time).
79const BUILD_COMMIT: Option<&str> = option_env!("CEDARLING_BUILD_COMMIT");
80/// Build timestamp in RFC 3339 format (`None` if
81/// `CEDARLING_BUILD_TIMESTAMP` was not set at compile time).
82const BUILD_TIMESTAMP: Option<&str> = option_env!("CEDARLING_BUILD_TIMESTAMP");
83
84#[doc(hidden)]
85pub mod bindings {
86    pub use cedar_policy;
87
88    pub use super::log::{
89        AuthorizationLogInfo, Decision, Diagnostics, LogEntry, PolicyEvaluationError,
90    };
91    pub use crate::common::policy_store::PolicyStore;
92    pub use crate::http::spawn_task;
93    pub use crate::sparkv;
94    pub use serde_json;
95    pub use serde_yaml_ng;
96}
97
98/// Errors that can occur during initialization Cedarling.
99#[derive(Debug, thiserror::Error)]
100pub enum InitCedarlingError {
101    /// Error while preparing config for internal services
102    #[error(transparent)]
103    ServiceConfig(#[from] ServiceConfigError),
104    /// Error while initializing a Service
105    #[error(transparent)]
106    ServiceInit(#[from] ServiceInitError),
107    /// Error while parse [`BootstrapConfigRaw`]
108    #[error(transparent)]
109    BootstrapConfigLoading(#[from] BootstrapConfigLoadingError),
110    /// Error while initializing the `DataStore` (invalid configuration)
111    #[error(transparent)]
112    DataStoreInit(#[from] ConfigValidationError),
113    #[cfg(feature = "blocking")]
114    /// Error while init tokio runtime
115    #[error(transparent)]
116    RuntimeInit(std::io::Error),
117    /// Error returned when Cedarling fails to obtain client credentials for sending
118    /// logs to the Lock Server.
119    #[error("failed to initialize the Lock Service: {0}")]
120    InitLockService(#[from] InitLockServiceError),
121}
122
123/// The instance of the Cedarling application.
124/// It is safe to share between threads.
125#[derive(Clone)]
126pub struct Cedarling {
127    log: log::Logger,
128    /// Wrapped in [`ArcSwap`] so the policy-store refresh worker can publish a
129    /// freshly built [`Authz`] (with new policy store, rebuilt JWT service and
130    /// entity builder) atomically. Every public method snapshots via
131    /// [`ArcSwap::load`] so an in-flight authorization keeps using the
132    /// pre-swap instance.
133    authz: Arc<arc_swap::ArcSwap<Authz>>,
134    data: Arc<DataStore>,
135    /// Held purely for its `Drop` side effect: dropping the last `Arc` closes
136    /// the worker's `oneshot` shutdown channel so the background refresh loop
137    /// exits when [`Cedarling`] goes away. The leading `_` tells the compiler
138    /// the field is intentionally not read.
139    _refresh_handle: Option<Arc<PolicyStoreRefreshHandle>>,
140}
141
142impl Cedarling {
143    /// Create a new instance of the Cedarling application.
144    /// Initialize instance from enviroment variables and from config.
145    /// Configuration structure has lower priority.
146    #[cfg(not(target_arch = "wasm32"))]
147    pub async fn new_with_env(
148        raw_config: Option<BootstrapConfigRaw>,
149    ) -> Result<Cedarling, InitCedarlingError> {
150        let config = BootstrapConfig::from_raw_config_and_env(raw_config)?;
151        Self::new(&config).await
152    }
153
154    /// Create a new instance of the Cedarling application.
155    pub async fn new(config: &BootstrapConfig) -> Result<Cedarling, InitCedarlingError> {
156        let pdp_id = app_types::PdpID::new();
157        let app_name = (!config.application_name.is_empty())
158            .then(|| ApplicationName::from(config.application_name.clone()));
159
160        let metrics = Arc::new(
161            if config
162                .lock_config
163                .as_ref()
164                .is_some_and(|c| c.telemetry_interval.is_some())
165            {
166                MetricsCollector::new(0)
167            } else {
168                MetricsCollector::disabled()
169            },
170        );
171
172        let log = crate::log::init_logger(
173            &config.log_config,
174            pdp_id,
175            app_name,
176            config.lock_config.as_ref(),
177            metrics.clone(),
178            config.http_client_config,
179        )
180        .await?;
181
182        log.log_any(
183            LogEntry::new(BaseLogEntry::new_system_opt_request_id(
184                LogLevel::INFO,
185                None,
186            ))
187            .set_message("Cedarling initialization started".to_string())
188            .set_build_info(BUILD_COMMIT, BUILD_TIMESTAMP),
189        );
190
191        // Bootstrap-load: build HttpClient + load policy store. Returns the
192        // service config plus the refresh-worker seed in one shot so the
193        // seed values (initial body hash, initial cache validators) stay in
194        // lexical scope right next to the refresh-worker spawn below.
195        let (service_config, refresh_seed) = perform_bootstrap_load(config, &log).await?;
196
197        let policy_count = service_config
198            .policy_store
199            .policies
200            .get_set()
201            .num_of_policies();
202        metrics.set_policy_count(policy_count);
203
204        // Initialize data store first so it can be passed to authz service
205        let data = Arc::new(DataStore::new(
206            config.data_store_config.clone(),
207            metrics.clone(),
208        )?);
209
210        let mut service_factory = ServiceFactory::new(
211            config,
212            service_config,
213            log.clone(),
214            data.clone(),
215            metrics.clone(),
216        );
217
218        // Log policy store metadata if available (new format only)
219        if let Some(metadata) = service_factory.policy_store_metadata() {
220            log_policy_store_metadata(&log, metadata);
221        }
222
223        if let Some(registry) = log.health_registry() {
224            registry.register("core", || HealthStatus::Success);
225            registry.register("policy_load", move || {
226                if policy_count > 0 {
227                    HealthStatus::Success
228                } else {
229                    HealthStatus::Failure
230                }
231            });
232        }
233
234        let authz = service_factory.authz_service().await?;
235        let authz_swap = Arc::new(arc_swap::ArcSwap::from(authz));
236
237        let refresh_handle = maybe_spawn_refresh_worker(
238            config,
239            &service_factory,
240            authz_swap.clone(),
241            log.clone(),
242            data.clone(),
243            metrics.clone(),
244            refresh_seed,
245        );
246
247        Ok(Cedarling {
248            log,
249            authz: authz_swap,
250            data,
251            _refresh_handle: refresh_handle,
252        })
253    }
254
255    // The following public methods retain async signatures for API compatibility
256    // to avoid breaking changes. They use #[allow(clippy::unused_async)] since
257    // they no longer await internally. Future maintainers can safely remove
258    // or refactor these methods when compatibility constraints allow.
259
260    /// Authorize request with unsigned data.
261    /// makes authorization decision based on the [`RequestUnverified`]
262    #[allow(clippy::unused_async)]
263    pub async fn authorize_unsigned(
264        &self,
265        request: RequestUnsigned,
266    ) -> Result<AuthorizeResult, AuthorizeError> {
267        self.authz.load().authorize_unsigned(&request)
268    }
269
270    /// Authorize multi-issuer request.
271    /// makes authorization decision based on multiple JWT tokens from different issuers
272    #[allow(clippy::unused_async)]
273    pub async fn authorize_multi_issuer(
274        &self,
275        request: AuthorizeMultiIssuerRequest,
276    ) -> Result<MultiIssuerAuthorizeResult, AuthorizeError> {
277        self.authz.load().authorize_multi_issuer(&request)
278    }
279
280    /// Returns metadata for all policies whose scope constraints are compatible
281    /// with the given principals, actions, and resources.
282    ///
283    /// This performs scope-level filtering only (principal/action/resource constraints).
284    /// Policies with `when`/`unless` conditions may still not apply at evaluation time.
285    pub fn get_matching_policies_unsigned(
286        &self,
287        principal: Option<&EntityData>,
288        actions: &[String],
289        resources: &[EntityData],
290    ) -> Result<Vec<PolicyMetadata>, AuthorizeError> {
291        self.authz
292            .load()
293            .get_matching_policies_unsigned(principal, actions, resources)
294    }
295
296    /// Returns metadata for all policies whose scope constraints are compatible
297    /// with the given token-derived principals, actions, and resources.
298    ///
299    /// Tokens are validated and their mapping types used as principal entity types.
300    pub fn get_matching_policies_multi_issuer(
301        &self,
302        tokens: &[TokenInput],
303        actions: &[String],
304        resources: &[EntityData],
305    ) -> Result<Vec<PolicyMetadata>, AuthorizeError> {
306        self.authz
307            .load()
308            .get_matching_policies_multi_issuer(tokens, actions, resources)
309    }
310
311    /// Closes the connections to the Lock Server and pushes all available logs.
312    pub async fn shut_down(&self) {
313        self.log.shut_down().await;
314    }
315}
316
317impl TrustedIssuerLoadingInfo for Cedarling {
318    fn is_trusted_issuer_loaded_by_name(&self, issuer_id: &str) -> bool {
319        self.authz
320            .load()
321            .is_trusted_issuer_loaded_by_name(issuer_id)
322    }
323
324    fn is_trusted_issuer_loaded_by_iss(&self, iss_claim: &str) -> bool {
325        self.authz.load().is_trusted_issuer_loaded_by_iss(iss_claim)
326    }
327
328    fn total_issuers(&self) -> usize {
329        self.authz.load().total_issuers()
330    }
331
332    fn loaded_trusted_issuers_count(&self) -> usize {
333        self.authz.load().loaded_trusted_issuers_count()
334    }
335
336    fn loaded_trusted_issuer_ids(&self) -> HashSet<String> {
337        self.authz.load().loaded_trusted_issuer_ids()
338    }
339
340    fn failed_trusted_issuer_ids(&self) -> HashSet<String> {
341        self.authz.load().failed_trusted_issuer_ids()
342    }
343}
344
345/// Build the HTTP client and load the policy store from the bootstrap config.
346/// Returns the parts the rest of `Cedarling::new` needs: the [`ServiceConfig`]
347/// for service-factory construction, and the [`RefreshWorkerSeed`] for the
348/// refresh worker's first-tick short-circuit. Wraps both fallible steps so
349/// the existing "configuration parsed successfully" / "...with error" log
350/// behavior covers the whole bootstrap unit.
351async fn perform_bootstrap_load(
352    config: &BootstrapConfig,
353    log: &log::Logger,
354) -> Result<(ServiceConfig, RefreshWorkerSeed), ServiceConfigError> {
355    let raw_load: Result<(http::HttpClient, LoadedPolicyStore), ServiceConfigError> = async {
356        let http_client = http::HttpClient::new(config.http_client_config)?;
357        let loaded = load_policy_store(
358            &config.policy_store_config,
359            &http_client,
360            config.authorization_config.strict_schema_validation,
361        )
362        .await?;
363        Ok((http_client, loaded))
364    }
365    .await;
366
367    let (http_client, loaded) = raw_load
368        .inspect(|_| {
369            log.log_any(
370                LogEntry::new(BaseLogEntry::new_system_opt_request_id(
371                    LogLevel::DEBUG,
372                    None,
373                ))
374                .set_message("configuration parsed successfully".to_string()),
375            );
376        })
377        .inspect_err(|err| {
378            log.log_any(
379                LogEntry::new(BaseLogEntry::new_system_opt_request_id(
380                    LogLevel::ERROR,
381                    None,
382                ))
383                .set_error(err.to_string())
384                .set_message("configuration parsed with error".to_string()),
385            );
386        })?;
387
388    let LoadedPolicyStore {
389        store: policy_store,
390        body_hash,
391        validators,
392    } = loaded;
393    Ok((
394        ServiceConfig {
395            policy_store,
396            http_client,
397        },
398        RefreshWorkerSeed {
399            initial_body_hash: body_hash,
400            initial_validators: validators,
401        },
402    ))
403}
404
405/// Spawn the background policy-store refresh worker if the source is a remote
406/// URL and a non-zero refresh interval was configured. Returns `None` for
407/// local sources or when refresh is disabled. The `seed` carries the
408/// `body_hash` and `validators` captured during initial bootstrap so the
409/// first periodic tick can short-circuit — passed in directly from the
410/// bootstrap-load result rather than detoured through `ServiceFactory`.
411fn maybe_spawn_refresh_worker(
412    config: &BootstrapConfig,
413    service_factory: &ServiceFactory<'_>,
414    authz_swap: Arc<arc_swap::ArcSwap<authz::Authz>>,
415    log: log::Logger,
416    data: Arc<context_data_api::DataStore>,
417    metrics: Arc<authz::metrics::MetricsCollector>,
418    seed: RefreshWorkerSeed,
419) -> Option<Arc<PolicyStoreRefreshHandle>> {
420    if !config.policy_store_config.refresh_enabled() {
421        return None;
422    }
423    let source = RefreshSource::from_policy_store_source(&config.policy_store_config.source)?;
424    let (interval_secs, clamped) = config.policy_store_config.effective_refresh_interval();
425    if clamped {
426        log.log_any(
427            LogEntry::new(BaseLogEntry::new_system_opt_request_id(LogLevel::WARN, None))
428                .set_message(format!(
429                    "CEDARLING_POLICY_STORE_REFRESH_INTERVAL={} is below the minimum; clamped to {} seconds",
430                    config.policy_store_config.refresh_interval_secs,
431                    interval_secs,
432                )),
433        );
434    }
435    let rebuilder = AuthzRebuilder {
436        jwt_config: config.jwt_config.clone(),
437        authorization_config: config.authorization_config.clone(),
438        http_client: service_factory.http_client_for_refresh(),
439        log: log.clone(),
440        data_store: data,
441        metrics: metrics.clone(),
442    };
443    let ctx = WorkerContext {
444        source,
445        interval_secs,
446        http_client: service_factory.http_client_for_refresh(),
447        rebuilder,
448        authz_swap,
449        metrics,
450        log,
451        initial_body_hash: seed.initial_body_hash,
452        initial_validators: seed.initial_validators,
453        strict_schema_validation: config.authorization_config.strict_schema_validation,
454    };
455    Some(Arc::new(spawn_refresh_worker(ctx)))
456}
457
458/// Log detailed information about the loaded policy store metadata, including
459/// ID, version, description, Cedar version, timestamps, and compatibility with
460/// the runtime Cedar version.
461fn log_policy_store_metadata(
462    log: &log::Logger,
463    metadata: &crate::common::policy_store::PolicyStoreMetadata,
464) {
465    // Build detailed log message using accessor methods
466    let mut details = format!(
467        "Policy store '{}' (ID: {}) v{} loaded",
468        metadata.name(),
469        if metadata.id().is_empty() {
470            "<auto>"
471        } else {
472            metadata.id()
473        },
474        metadata.version()
475    );
476
477    // Add description if available
478    if let Some(desc) = metadata.description() {
479        let _ = write!(details, " - {desc}");
480    }
481
482    // Add Cedar version info
483    let _ = write!(details, " [Cedar {}]", metadata.cedar_version());
484
485    // Add timestamp info if available
486    if let Some(created) = metadata.created_date() {
487        let _ = write!(details, " (created: {})", created.format("%Y-%m-%d"));
488    }
489    if let Some(updated) = metadata.updated_date() {
490        let _ = write!(details, " (updated: {})", updated.format("%Y-%m-%d"));
491    }
492
493    log.log_any(
494        LogEntry::new(BaseLogEntry::new_system_opt_request_id(
495            LogLevel::DEBUG,
496            None,
497        ))
498        .set_message(details),
499    );
500
501    // Log version compatibility check with current Cedar
502    let current_cedar_version: Version = cedar_policy::get_lang_version();
503    match metadata.is_compatible_with_cedar(&current_cedar_version) {
504        Ok(true) => {
505            log.log_any(
506                LogEntry::new(BaseLogEntry::new_system_opt_request_id(
507                    LogLevel::DEBUG,
508                    None,
509                ))
510                .set_message(format!(
511                    "Policy store Cedar version {} is compatible with runtime version {}",
512                    metadata.cedar_version(),
513                    current_cedar_version
514                )),
515            );
516        },
517        Ok(false) => {
518            log.log_any(
519                LogEntry::new(BaseLogEntry::new_system_opt_request_id(
520                    LogLevel::WARN,
521                    None,
522                ))
523                .set_message(format!(
524                    "Policy store Cedar version {} may not be compatible with runtime version {}",
525                    metadata.cedar_version(),
526                    current_cedar_version
527                )),
528            );
529        },
530        Err(e) => {
531            log.log_any(
532                LogEntry::new(BaseLogEntry::new_system_opt_request_id(
533                    LogLevel::WARN,
534                    None,
535                ))
536                .set_message(format!("Could not check Cedar version compatibility: {e}")),
537            );
538        },
539    }
540
541    // Log parsed version for debugging if available
542    if let Some(parsed_version) = metadata.version_parsed() {
543        log.log_any(
544            LogEntry::new(BaseLogEntry::new_system_opt_request_id(
545                LogLevel::TRACE,
546                None,
547            ))
548            .set_message(format!(
549                "Policy store semantic version: {}.{}.{}",
550                parsed_version.major, parsed_version.minor, parsed_version.patch
551            )),
552        );
553    }
554}
555
556// implements LogStorage for Cedarling
557// we can use this methods outside crate only when import trait
558impl LogStorage for Cedarling {
559    fn pop_logs(&self) -> Vec<serde_json::Value> {
560        self.log.pop_logs()
561    }
562
563    fn get_log_by_id(&self, id: &str) -> Option<serde_json::Value> {
564        self.log.get_log_by_id(id)
565    }
566
567    fn get_log_ids(&self) -> Vec<String> {
568        self.log.get_log_ids()
569    }
570
571    fn get_logs_by_tag(&self, tag: &str) -> Vec<serde_json::Value> {
572        self.log.get_logs_by_tag(tag)
573    }
574
575    fn get_logs_by_request_id(&self, request_id: &str) -> Vec<serde_json::Value> {
576        self.log.get_logs_by_request_id(request_id)
577    }
578
579    fn get_logs_by_request_id_and_tag(&self, id: &str, tag: &str) -> Vec<serde_json::Value> {
580        self.log.get_logs_by_request_id_and_tag(id, tag)
581    }
582}
583
584// implements DataApi for Cedarling
585// Helper function to calculate capacity usage and check memory alert threshold
586fn calculate_capacity_usage(
587    entry_count: usize,
588    max_entries: usize,
589    memory_alert_threshold: f64,
590) -> (f64, bool) {
591    // Precision loss is acceptable for percentage calculation
592    #[allow(clippy::cast_precision_loss)]
593    let capacity_usage_percent = if max_entries > 0 {
594        (entry_count as f64 / max_entries as f64) * 100.0
595    } else {
596        0.0 // Unlimited capacity, no percentage
597    };
598    let memory_alert_triggered = capacity_usage_percent >= memory_alert_threshold;
599    (capacity_usage_percent, memory_alert_triggered)
600}
601
602// provides public interface for pushing and retrieving data
603impl DataApi for Cedarling {
604    fn push_data_ctx(
605        &self,
606        key: &str,
607        value: serde_json::Value,
608        ttl: Option<std::time::Duration>,
609    ) -> Result<(), DataError> {
610        self.data.push(key, value, ttl)?;
611
612        // Check memory usage and log warning if threshold is exceeded
613        let config = self.data.config();
614        if config.max_entries > 0 {
615            let entry_count = self.data.count();
616            let (capacity_usage_percent, memory_alert_triggered) = calculate_capacity_usage(
617                entry_count,
618                config.max_entries,
619                config.memory_alert_threshold,
620            );
621            if memory_alert_triggered {
622                let log_entry = LogEntry::new(BaseLogEntry::new_system_opt_request_id(
623                    LogLevel::WARN,
624                    None,
625                ))
626                .set_message(format!(
627                    "DataStore memory usage alert: {:.1}% capacity used ({}/{} entries), threshold: {:.1}%",
628                    capacity_usage_percent,
629                    entry_count,
630                    config.max_entries,
631                    config.memory_alert_threshold
632                ));
633                self.log.log_any(log_entry);
634            }
635        }
636
637        Ok(())
638    }
639
640    fn get_data_ctx(&self, key: &str) -> Result<Option<serde_json::Value>, DataError> {
641        Ok(self.data.get(key))
642    }
643
644    fn get_data_entry_ctx(&self, key: &str) -> Result<Option<DataEntry>, DataError> {
645        Ok(self.data.get_entry(key))
646    }
647
648    fn remove_data_ctx(&self, key: &str) -> Result<bool, DataError> {
649        Ok(self.data.remove(key))
650    }
651
652    fn clear_data_ctx(&self) -> Result<(), DataError> {
653        self.data.clear();
654        Ok(())
655    }
656
657    fn list_data_ctx(&self) -> Result<Vec<DataEntry>, DataError> {
658        Ok(self.data.list_entries())
659    }
660
661    fn get_stats_ctx(&self) -> Result<DataStoreStats, DataError> {
662        let config = self.data.config();
663        let entry_count = self.data.count();
664        let total_size_bytes = self.data.total_size();
665        let avg_entry_size_bytes = total_size_bytes.checked_div(entry_count).unwrap_or(0);
666
667        // Calculate capacity usage percentage
668        let (capacity_usage_percent, memory_alert_triggered) = calculate_capacity_usage(
669            entry_count,
670            config.max_entries,
671            config.memory_alert_threshold,
672        );
673
674        Ok(DataStoreStats {
675            entry_count,
676            max_entries: config.max_entries,
677            max_entry_size: config.max_entry_size,
678            metrics_enabled: config.enable_metrics,
679            total_size_bytes,
680            avg_entry_size_bytes,
681            capacity_usage_percent,
682            memory_alert_threshold: config.memory_alert_threshold,
683            memory_alert_triggered,
684        })
685    }
686}