Skip to main content

objectstore_server/
config.rs

1//! Configuration for the objectstore server.
2//!
3//! This module provides the configuration system for the objectstore HTTP server. Configuration can
4//! be loaded from multiple sources with the following precedence (highest to lowest):
5//!
6//! 1. Environment variables (prefixed with `OS__`)
7//! 2. YAML configuration file (specified via `-c` or `--config` flag)
8//! 3. Defaults
9//!
10//! See [`Config`] for a description of all configuration fields and their defaults.
11//!
12//! # Environment Variables
13//!
14//! Environment variables use `OS__` as a prefix and double underscores (`__`) to denote nested
15//! configuration structures. For example:
16//!
17//! - `OS__HTTP_ADDR=0.0.0.0:8888` sets the HTTP server address
18//! - `OS__STORAGE__TYPE=filesystem` sets the storage type
19//! - `OS__STORAGE__PATH=/data` sets the directory path
20//!
21//! # YAML Configuration File
22//!
23//! Configuration can also be provided via a YAML file. The above configuration in YAML format would
24//! look like this:
25//!
26//! ```yaml
27//! http_addr: 0.0.0.0:8888
28//!
29//! storage:
30//!   type: filesystem
31//!   path: /data
32//! ```
33//!
34//! # Variable References
35//!
36//! Any configuration value may be written as a reference, which is resolved after all
37//! sources have been merged. `${file:PATH}` is replaced by that file's contents and
38//! `${VAR_NAME}` by that environment variable:
39//!
40//! ```yaml
41//! storage_cogs:
42//!   type: kafka
43//!   override_params:
44//!     sasl.password: ${file:/var/secrets/kafka-password}
45//! ```
46//!
47//! A reference must be the entire value: `${A}` works, `prefix-${A}` does not. Referencing
48//! a file that cannot be read, or an environment variable that is not set, is an error at
49//! startup.
50//!
51//! ## Relationship to `OS__` environment variables
52//!
53//! These are different mechanisms and neither replaces the other. They can even be used
54//! together: `OS__SENTRY__DSN=${SENTRY_DSN}` will set the `sentry.dsn` YAML key to the
55//! value of the `SENTRY_DSN` environment variable.
56
57use std::borrow::Cow;
58use std::collections::{BTreeMap, HashSet};
59use std::fmt;
60use std::net::SocketAddr;
61use std::path::{Path, PathBuf};
62use std::time::Duration;
63
64use anyhow::Result;
65use bytes::Bytes;
66use figment::providers::{Env, Format, Serialized, Yaml};
67use objectstore_service::backend::local_fs::FileSystemConfig;
68#[cfg(feature = "storage-cogs")]
69use objectstore_service::change_stream::CostTrackerConfig;
70use objectstore_service::encryption::Cipher;
71use objectstore_types::auth::Permission;
72use secrecy::{CloneableSecret, SecretBox, SerializableSecret, zeroize::Zeroize};
73use serde::{Deserialize, Serialize};
74
75pub use objectstore_log::{LevelFilter, LogFormat, LoggingConfig};
76pub use objectstore_service::backend::StorageConfig;
77
78use crate::killswitches::Killswitches;
79use crate::rate_limits::RateLimits;
80use crate::usecases::UseCases;
81
82/// Environment variable prefix for all configuration options.
83const ENV_PREFIX: &str = "OS__";
84
85/// Newtype around `String` that may protect against accidental
86/// logging of secrets in our configuration struct. Use with
87/// [`secrecy::SecretBox`].
88#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
89pub struct ConfigSecret(String);
90
91impl ConfigSecret {
92    /// Returns the secret value as a string slice.
93    pub fn as_str(&self) -> &str {
94        self.0.as_str()
95    }
96}
97
98impl From<&str> for ConfigSecret {
99    fn from(str: &str) -> Self {
100        ConfigSecret(str.to_string())
101    }
102}
103
104impl std::ops::Deref for ConfigSecret {
105    type Target = str;
106    fn deref(&self) -> &Self::Target {
107        &self.0
108    }
109}
110
111impl fmt::Debug for ConfigSecret {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
113        write!(f, "[redacted]")
114    }
115}
116
117impl CloneableSecret for ConfigSecret {}
118impl SerializableSecret for ConfigSecret {}
119impl Zeroize for ConfigSecret {
120    fn zeroize(&mut self) {
121        self.0.zeroize();
122    }
123}
124
125/// Runtime configuration for the Tokio async runtime.
126///
127/// Controls the threading behavior of the server's async runtime.
128///
129/// Used in: [`Config::runtime`]
130#[derive(Debug, Deserialize, Serialize)]
131#[serde(default)]
132pub struct Runtime {
133    /// Number of worker threads for the server runtime.
134    ///
135    /// This controls the size of the Tokio thread pool used to execute async tasks. More threads
136    /// can improve concurrency for CPU-bound workloads, but too many threads can increase context
137    /// switching overhead.
138    ///
139    /// Set this in accordance with the resources available to the server, especially in Kubernetes
140    /// environments.
141    ///
142    /// # Default
143    ///
144    /// Defaults to the number of CPU cores on the host machine.
145    ///
146    /// # Environment Variable
147    ///
148    /// `OS__RUNTIME__WORKER_THREADS`
149    ///
150    /// # Considerations
151    ///
152    /// - For I/O-bound workloads, the default (number of CPU cores) is usually sufficient
153    /// - For CPU-intensive workloads, consider matching or exceeding the number of cores
154    /// - Setting this too high can lead to increased memory usage and context switching
155    pub worker_threads: usize,
156
157    /// Interval in seconds for reporting internal runtime metrics.
158    ///
159    /// Defaults to `10` seconds.
160    #[serde(with = "humantime_serde")]
161    pub metrics_interval: Duration,
162}
163
164impl Default for Runtime {
165    fn default() -> Self {
166        Self {
167            worker_threads: num_cpus::get(),
168            metrics_interval: Duration::from_secs(10),
169        }
170    }
171}
172
173/// [Sentry](https://sentry.io/) error tracking and performance monitoring configuration.
174///
175/// Configures integration with Sentry for error tracking, performance monitoring, and distributed
176/// tracing. Sentry is disabled by default and only enabled when a DSN is provided.
177///
178/// Used in: [`Config::sentry`]
179#[derive(Debug, Deserialize, Serialize)]
180pub struct Sentry {
181    /// Sentry DSN (Data Source Name).
182    ///
183    /// When set, enables Sentry error tracking and performance monitoring. When `None`, Sentry
184    /// integration is completely disabled.
185    ///
186    /// # Default
187    ///
188    /// `None` (Sentry disabled)
189    ///
190    /// # Environment Variable
191    ///
192    /// `OS__SENTRY__DSN`
193    pub dsn: Option<SecretBox<ConfigSecret>>,
194
195    /// Environment name for this deployment.
196    ///
197    /// Used to distinguish events from different environments (e.g., "production", "staging",
198    /// "development"). This appears in the Sentry UI and can be used for filtering.
199    ///
200    /// # Default
201    ///
202    /// `None`
203    ///
204    /// # Environment Variable
205    ///
206    /// `OS__SENTRY__ENVIRONMENT`
207    pub environment: Option<Cow<'static, str>>,
208
209    /// Server name or identifier.
210    ///
211    /// Used to identify which server instance sent an event. Useful in multi-server deployments for
212    /// tracking which instance encountered an error. Set to the hostname or pod name of the server.
213    ///
214    /// # Default
215    ///
216    /// `None`
217    ///
218    /// # Environment Variable
219    ///
220    /// `OS__SENTRY__SERVER_NAME`
221    pub server_name: Option<Cow<'static, str>>,
222
223    /// Error event sampling rate.
224    ///
225    /// Controls what percentage of error events are sent to Sentry. A value of `1.0` sends all
226    /// errors, while `0.5` sends 50% of errors, and `0.0` sends no errors. Must be between `0.0`
227    /// and `1.0`, inclusive; other values are rejected when loading the configuration.
228    ///
229    /// # Default
230    ///
231    /// `1.0` (send all errors)
232    ///
233    /// # Environment Variable
234    ///
235    /// `OS__SENTRY__SAMPLE_RATE`
236    #[serde(deserialize_with = "deserialize_sample_rate")]
237    pub sample_rate: f32,
238
239    /// Performance trace sampling rate.
240    ///
241    /// Controls what percentage of transactions (traces) are sent to Sentry for performance
242    /// monitoring. A value of `1.0` sends all traces, while `0.01` sends 1% of traces. Must be
243    /// between `0.0` and `1.0`, inclusive; other values are rejected when loading the
244    /// configuration.
245    ///
246    /// **Important**: Performance traces can generate significant data volume in high-traffic
247    /// systems. Start with a low rate (0.01-0.1) and adjust based on traffic and Sentry quota.
248    ///
249    /// # Default
250    ///
251    /// `0.01` (send 1% of traces)
252    ///
253    /// # Environment Variable
254    ///
255    /// `OS__SENTRY__TRACES_SAMPLE_RATE`
256    #[serde(deserialize_with = "deserialize_sample_rate")]
257    pub traces_sample_rate: f32,
258
259    /// Whether to inherit sampling decisions from incoming traces.
260    ///
261    /// When `true` (default), if an incoming request contains a distributed tracing header with a
262    /// sampling decision (e.g., from an upstream service), that decision is honored. When `false`,
263    /// the local `traces_sample_rate` is always used instead.
264    ///
265    /// When this is enabled, the calling service effectively controls the sampling decision for the
266    /// entire trace. Set this to `false` if you want to have independent sampling control at the
267    /// objectstore level.
268    ///
269    /// # Default
270    ///
271    /// `true`
272    ///
273    /// # Environment Variable
274    ///
275    /// `OS__SENTRY__INHERIT_SAMPLING_DECISION`
276    pub inherit_sampling_decision: bool,
277
278    /// Whether to attach stack traces to captured errors and messages.
279    ///
280    /// When enabled, the attached stack trace starts where the event is captured.
281    ///
282    /// # Default
283    ///
284    /// `false`
285    ///
286    /// # Environment Variable
287    ///
288    /// `OS__SENTRY__ATTACH_STACKTRACE`
289    pub attach_stacktrace: bool,
290
291    /// Enable Sentry SDK debug mode.
292    ///
293    /// When enabled, the Sentry SDK will output debug information to stderr, which can be useful
294    /// for troubleshooting Sentry integration issues. It is discouraged to enable this in
295    /// production as it generates verbose logging.
296    ///
297    /// # Default
298    ///
299    /// `false`
300    ///
301    /// # Environment Variable
302    ///
303    /// `OS__SENTRY__DEBUG`
304    pub debug: bool,
305
306    /// Capacity of the Sentry transport's envelope queue.
307    ///
308    /// Maximum number of envelopes (events, transactions, logs) buffered for sending to Sentry.
309    /// When the queue is full, new envelopes are dropped. Raise this if Sentry data is being
310    /// dropped under load.
311    ///
312    /// # Default
313    ///
314    /// `60`
315    ///
316    /// # Environment Variable
317    ///
318    /// `OS__SENTRY__TRANSPORT_CHANNEL_CAPACITY`
319    pub transport_channel_capacity: usize,
320
321    /// Additional tags to attach to all Sentry events.
322    ///
323    /// Key-value pairs that are sent as tags with every event reported to Sentry. Useful for adding
324    /// context such as deployment identifiers or environment details.
325    ///
326    /// # Default
327    ///
328    /// Empty (no tags)
329    ///
330    /// # Environment Variables
331    ///
332    /// Each tag is set individually:
333    /// - `OS__SENTRY__TAGS__FOO=foo`
334    /// - `OS__SENTRY__TAGS__BAR=bar`
335    ///
336    /// # YAML Example
337    ///
338    /// ```yaml
339    /// sentry:
340    ///   tags:
341    ///     foo: foo
342    ///     bar: bar
343    /// ```
344    pub tags: BTreeMap<String, String>,
345}
346
347/// Deserializes a sample rate, rejecting values outside of `0.0..=1.0`.
348fn deserialize_sample_rate<'de, D>(deserializer: D) -> std::result::Result<f32, D::Error>
349where
350    D: serde::Deserializer<'de>,
351{
352    let rate = f32::deserialize(deserializer)?;
353    if !(0.0..=1.0).contains(&rate) {
354        return Err(serde::de::Error::custom(format!(
355            "sample rate must be between 0.0 and 1.0, got {rate}"
356        )));
357    }
358    Ok(rate)
359}
360
361impl Sentry {
362    /// Returns whether Sentry integration is enabled.
363    ///
364    /// Sentry is considered enabled if a DSN is configured.
365    pub fn is_enabled(&self) -> bool {
366        self.dsn.is_some()
367    }
368}
369
370impl Default for Sentry {
371    fn default() -> Self {
372        Self {
373            dsn: None,
374            environment: None,
375            server_name: None,
376            sample_rate: 1.0,
377            traces_sample_rate: 0.01,
378            inherit_sampling_decision: true,
379            attach_stacktrace: false,
380            debug: false,
381            transport_channel_capacity: 60,
382            tags: BTreeMap::new(),
383        }
384    }
385}
386
387// Logging configuration is defined in `objectstore_log::LoggingConfig`.
388// Metrics configuration is defined in `objectstore_metrics::MetricsConfig`.
389
390/// A key that may be used to verify a request's auth token and its associated
391/// permissions. May contain multiple key versions to facilitate rotation.
392#[derive(Debug, Deserialize, Serialize)]
393pub struct AuthZVerificationKey {
394    /// Files that contain versions of this key's key material which may be used to verify
395    /// signatures.
396    ///
397    /// If a key is being rotated, the old and new versions of that key should both be
398    /// configured so objectstore can verify signatures while the updated key is still
399    /// rolling out. Otherwise, this should only contain the most recent version of a key.
400    pub key_files: Vec<PathBuf>,
401
402    /// The maximum set of permissions that this key's signer is authorized to grant.
403    ///
404    /// If a request's auth token grants full permission but it was signed by a key
405    /// that is only allowed to grant read permission, then the request only has
406    /// read permission.
407    #[serde(default)]
408    pub max_permissions: HashSet<Permission>,
409}
410
411/// Configuration for content-based authorization.
412#[derive(Debug, Deserialize, Serialize)]
413pub struct AuthZ {
414    /// Whether to enforce content-based authorization or not.
415    ///
416    /// Defaults to `true`, resulting in `403 Unauthorized` responses for unauthorized requests. Set
417    /// to `false` to permit unauthorized requests. Authorization checks are still performed if
418    /// keys are configured, but only result in warnings.
419    #[serde(default = "default_enforce")]
420    pub enforce: bool,
421
422    /// Keys that may be used to verify a request's auth token.
423    ///
424    /// The auth token is read from the `X-Os-Auth` header (preferred)
425    /// or the standard `Authorization` header (fallback). This field is a
426    /// container keyed on a key's ID. When verifying a JWT, the `kid` field
427    /// should be read from the JWT header and used to index into this map to
428    /// select the appropriate key.
429    #[serde(default)]
430    pub keys: BTreeMap<String, AuthZVerificationKey>,
431}
432
433fn default_enforce() -> bool {
434    true
435}
436
437impl AuthZ {
438    /// Returns whether content-based authorization is active.
439    ///
440    /// Authorization is considered active if enforcement is enabled or at least one key is
441    /// configured. Without enforcement, authorization checks are still performed and reported but
442    /// failures will not result in `403 Unauthorized`
443    pub fn is_active(&self) -> bool {
444        self.enforce || !self.keys.is_empty()
445    }
446}
447
448impl Default for AuthZ {
449    fn default() -> Self {
450        Self {
451            enforce: true,
452            keys: BTreeMap::new(),
453        }
454    }
455}
456
457/// Main configuration struct for the objectstore server.
458///
459/// This is the top-level configuration that combines all server settings including networking,
460/// storage backends, runtime, and observability options.
461///
462/// Configuration is loaded with the following precedence (highest to lowest):
463/// 1. Environment variables (prefixed with `OS__`)
464/// 2. YAML configuration file (if provided via `-c` flag)
465/// 3. Default values
466///
467/// See individual field documentation for details on each configuration option, including
468/// defaults and environment variables.
469#[derive(Debug, Deserialize, Serialize)]
470pub struct Config {
471    /// HTTP server bind address.
472    ///
473    /// The socket address (IP and port) where the HTTP server will listen for incoming
474    /// connections. Supports both IPv4 and IPv6 addresses. Note that binding to `0.0.0.0`
475    /// makes the server accessible from all network interfaces.
476    ///
477    /// # Default
478    ///
479    /// `0.0.0.0:8888` (listens on all network interfaces, port 8888)
480    ///
481    /// # Environment Variable
482    ///
483    /// `OS__HTTP_ADDR`
484    pub http_addr: SocketAddr,
485
486    /// Storage backend configuration.
487    ///
488    /// Configures the storage backend used by the server. Use `type: "filesystem"` for
489    /// development, `type: "tiered"` for production two-tier routing (small objects to a
490    /// high-volume backend, large objects to a long-term backend), or any other single backend
491    /// type for simple deployments.
492    ///
493    /// # Default
494    ///
495    /// Filesystem storage in the `./data` directory
496    ///
497    /// # Environment Variables
498    ///
499    /// - `OS__STORAGE__TYPE` — backend type (`filesystem`, `tiered`, `gcs`, `bigtable`,
500    ///   `s3compatible`)
501    /// - Additional fields depending on the type (see [`StorageConfig`])
502    ///
503    /// For tiered storage, sub-backend fields are nested under `high_volume` and `long_term`:
504    /// - `OS__STORAGE__TYPE=tiered`
505    /// - `OS__STORAGE__HIGH_VOLUME__TYPE=bigtable`
506    /// - `OS__STORAGE__LONG_TERM__TYPE=gcs`
507    ///
508    /// # Example (tiered)
509    ///
510    /// ```yaml
511    /// storage:
512    ///   type: tiered
513    ///   high_volume:
514    ///     type: bigtable
515    ///     project_id: my-project
516    ///     instance_name: objectstore
517    ///     table_name: objectstore
518    ///   long_term:
519    ///     type: gcs
520    ///     bucket: my-objectstore-bucket
521    /// ```
522    pub storage: StorageConfig,
523
524    /// Cost tracking sink for backends' change streams.
525    ///
526    /// A transport owns connections and a send queue, so it is configured once here and
527    /// shared by every backend. What each backend reports, and how much of it, is
528    /// configured per backend under [`storage`](Self::storage).
529    ///
530    /// Absent is the default, and disables reporting entirely.
531    ///
532    /// # Example
533    ///
534    /// ```yaml
535    /// storage_cogs:
536    ///   type: kafka
537    ///   topic: shared-resources-inventory
538    ///   bootstrap_servers: [kafka:9092]
539    /// ```
540    ///
541    /// # Environment Variables
542    ///
543    /// - `OS__STORAGE_COGS__TYPE=kafka`
544    /// - `OS__STORAGE_COGS__TOPIC=shared-resources-inventory`
545    /// - `OS__STORAGE_COGS__BOOTSTRAP_SERVERS=kafka:9092`
546    /// - `OS__STORAGE_COGS__OVERRIDE_PARAMS__<PROPERTY>=<value>`
547    ///
548    /// Requires the `storage-cogs` Cargo feature.
549    #[cfg(feature = "storage-cogs")]
550    #[serde(default)]
551    pub storage_cogs: Option<CostTrackerConfig>,
552
553    /// Configuration of the internal task runtime.
554    ///
555    /// Controls the thread pool size and behavior of the async runtime powering the server.
556    /// See [`Runtime`] for configuration options.
557    pub runtime: Runtime,
558
559    /// Logging configuration.
560    ///
561    /// Controls log verbosity and output format. See [`LoggingConfig`] for configuration options.
562    pub logging: LoggingConfig,
563
564    /// Sentry error tracking configuration.
565    ///
566    /// Optional integration with Sentry for error tracking and performance monitoring.
567    /// See [`Sentry`] for configuration options.
568    pub sentry: Sentry,
569
570    /// Internal metrics configuration.
571    ///
572    /// Configures submission of internal metrics to a DogStatsD-compatible endpoint.
573    /// See [`objectstore_metrics::MetricsConfig`] for configuration options.
574    pub metrics: objectstore_metrics::MetricsConfig,
575
576    /// Content-based authorization configuration.
577    ///
578    /// Controls the verification and enforcement of content-based access control based on the
579    /// JWT in a request's `X-Os-Auth` or `Authorization` header.
580    pub auth: AuthZ,
581
582    /// A list of matchers for requests to discard without processing.
583    pub killswitches: Killswitches,
584
585    /// Definitions for rate limits to enforce on incoming requests.
586    pub rate_limits: RateLimits,
587
588    /// Per-use-case configuration.
589    ///
590    /// Controls properties of individual use cases such as which expiration
591    /// policies are permitted and their maximum durations. Use cases not
592    /// present in the map receive default configuration (all policies allowed,
593    /// no duration caps).
594    pub usecases: UseCases,
595
596    /// Configuration for the [`StorageService`](objectstore_service::StorageService).
597    pub service: Service,
598
599    /// Configuration for the HTTP layer.
600    ///
601    /// Controls HTTP-level settings that operate before requests reach the
602    /// storage service. See [`Http`] for configuration options.
603    pub http: Http,
604}
605
606/// Configuration for the [`StorageService`](objectstore_service::StorageService).
607///
608/// Controls operational parameters of the storage service layer that sits
609/// between the HTTP server and the storage backends.
610///
611/// Used in: [`Config::service`]
612///
613/// # Environment Variables
614///
615/// - `OS__SERVICE__MAX_CONCURRENCY`
616/// - `OS__SERVICE__CONCURRENCY_QUEUE`
617/// - `OS__SERVICE__CONCURRENCY_TIMEOUT`
618/// - `OS__SERVICE__BULK_CONCURRENCY_PCT`
619/// - `OS__SERVICE__BACKGROUND_QUEUE`
620/// - `OS__SERVICE__ENCRYPTION__ACTIVE_KEY_ID`
621/// - `OS__SERVICE__ENCRYPTION__KEYS`
622#[derive(Debug, Deserialize, Serialize)]
623#[serde(default)]
624pub struct Service {
625    /// Maximum number of concurrent backend operations.
626    ///
627    /// This caps the total number of in-flight storage operations (reads,
628    /// writes, deletes) across all requests. Operations that exceed the limit
629    /// are rejected with HTTP 503.
630    ///
631    /// # Default
632    ///
633    /// [`DEFAULT_CONCURRENCY_LIMIT`](objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT)
634    pub max_concurrency: u32,
635
636    /// Maximum number of requests that may wait for a concurrency permit.
637    ///
638    /// When all `max_concurrency` execution slots are held, up to this many
639    /// additional requests will park and wait (for at most
640    /// `concurrency_timeout`) instead of being rejected immediately.
641    /// Requests beyond that are rejected with HTTP 503.
642    ///
643    /// Sizing guidance: `concurrency_queue ≈ permit_release_rate ×
644    /// acceptable_added_latency`.
645    ///
646    /// # Default
647    ///
648    /// `0`
649    pub concurrency_queue: u32,
650
651    /// Maximum time a caller may wait for a concurrency permit.
652    ///
653    /// Applies to both queued normal requests and bulk operations
654    /// waiting for the bulk and execution semaphores.
655    ///
656    /// # Default
657    ///
658    /// `1s`
659    #[serde(with = "humantime_serde")]
660    pub concurrency_timeout: Duration,
661
662    /// Percentage of `max_concurrency` available to bulk operations
663    /// (e.g. parallelized batch requests).
664    ///
665    /// This sets a safe operating point: below this level there is
666    /// little-to-no performance degradation, leaving room for more tasks
667    /// to be admitted via the queue before rejection is necessary.
668    ///
669    /// Clamped to 1..=100. At 100, bulk operations can use all execution
670    /// slots. Lower values leave headroom for single-object requests.
671    ///
672    /// # Default
673    ///
674    /// `60`
675    pub bulk_concurrency_pct: u32,
676
677    /// Maximum number of deduplicated TTI renewals waiting for background processing.
678    ///
679    /// Scheduling never waits for space. A renewal is dropped when this queue is full.
680    /// Values below one are clamped to one.
681    ///
682    /// # Default
683    ///
684    /// `1000`
685    pub background_queue: usize,
686
687    /// Persistent symmetric encryption keys.
688    ///
689    /// Currently, the keys are used for encryption and decryption of session tokens
690    /// of the Resumable Uploads API.
691    /// If keys are not explicitly configured, session token encryption will use an ephemeral key
692    /// generated at startup, which means that resumable upload sessions won't work in a
693    /// multi-instance deployment or survive a restart.
694    /// Configure a persistent keyring in production.
695    /// Using the ephemeral fallback emits a startup warning.
696    ///
697    /// Keys must contain exactly 32 raw bytes.
698    ///
699    /// ```yaml
700    /// service:
701    ///   encryption:
702    ///     active_key_id: v1
703    ///     keys:
704    ///       v1: ${file:/var/run/secrets/objectstore/encryption-v1}
705    /// ```
706    pub encryption: Option<EncryptionConfig>,
707}
708
709impl Service {
710    /// Loads and validates the configured encryption keys, constructing a [`Cipher`].
711    pub(crate) fn cipher(&self) -> Result<Option<Cipher>> {
712        let Some(config) = &self.encryption else {
713            return Ok(None);
714        };
715
716        let keys = config
717            .keys
718            .iter()
719            .map(|(key_id, key)| (key_id.clone(), key.0.to_vec()))
720            .collect();
721
722        Cipher::new(config.active_key_id.clone(), keys).map(Some)
723    }
724}
725
726/// Keys used to encrypt data that should remain confidential when crossing the service boundary.
727#[derive(Clone, Deserialize, Serialize)]
728pub struct EncryptionConfig {
729    /// Key used to encrypt new values.
730    pub active_key_id: String,
731    /// Exactly 32 raw key bytes, indexed by ID.
732    ///
733    /// File-backed secrets should use `${file:PATH}` so they are loaded during configuration
734    /// deserialization.
735    #[serde(default)]
736    pub keys: BTreeMap<String, EncryptionKey>,
737}
738
739/// A raw 256-bit encryption key.
740#[derive(Clone)]
741pub struct EncryptionKey([u8; 32]);
742
743impl From<[u8; 32]> for EncryptionKey {
744    fn from(key: [u8; 32]) -> Self {
745        Self(key)
746    }
747}
748
749impl fmt::Debug for EncryptionKey {
750    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
751        f.write_str("EncryptionKey")
752    }
753}
754
755impl<'de> Deserialize<'de> for EncryptionKey {
756    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
757    where
758        D: serde::Deserializer<'de>,
759    {
760        let bytes = Bytes::deserialize(deserializer)?;
761        let length = bytes.len();
762        let key = bytes
763            .as_ref()
764            .try_into()
765            .map_err(|_| serde::de::Error::custom(format!("expected 32 bytes, got {length}")))?;
766        Ok(Self(key))
767    }
768}
769
770impl Serialize for EncryptionKey {
771    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
772    where
773        S: serde::Serializer,
774    {
775        serializer.serialize_bytes(&self.0)
776    }
777}
778
779impl fmt::Debug for EncryptionConfig {
780    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
781        f.debug_struct("EncryptionConfig")
782            .field("active_key_id", &self.active_key_id)
783            .field("key_ids", &self.keys.keys().collect::<Vec<_>>())
784            .finish()
785    }
786}
787
788impl Default for Service {
789    fn default() -> Self {
790        Self {
791            max_concurrency: objectstore_service::service::DEFAULT_CONCURRENCY_LIMIT,
792            concurrency_queue: 0,
793            concurrency_timeout: Duration::from_secs(1),
794            bulk_concurrency_pct: 60,
795            background_queue: objectstore_service::service::DEFAULT_BACKGROUND_QUEUE_LIMIT,
796            encryption: None,
797        }
798    }
799}
800
801/// Default maximum number of concurrent in-flight HTTP requests.
802///
803/// Requests beyond this limit are rejected with HTTP 503.
804pub const DEFAULT_MAX_HTTP_REQUESTS: usize = 10_000;
805
806/// Configuration for the HTTP layer.
807///
808/// Controls behaviour at the HTTP request level, before requests reach the
809/// storage service. Grouping these settings separately from [`Service`] keeps
810/// HTTP-layer and service-layer concerns distinct and provides a natural home
811/// for future HTTP-level settings (e.g. timeouts, body size limits).
812///
813/// Used in: [`Config::http`]
814///
815/// # Environment Variables
816///
817/// - `OS__HTTP__MAX_REQUESTS`
818#[derive(Debug, Deserialize, Serialize)]
819#[serde(default)]
820pub struct Http {
821    /// Maximum number of concurrent in-flight HTTP requests.
822    ///
823    /// This is a flood protection limit. When the number of requests currently
824    /// being processed reaches this value, new requests are rejected immediately
825    /// with HTTP 503. Health and readiness endpoints (`/health`, `/ready`) are
826    /// excluded from this limit.
827    ///
828    /// Unlike readiness-based backpressure, direct rejection responds in
829    /// milliseconds and recovers the moment any in-flight request completes.
830    ///
831    /// # Default
832    ///
833    /// [`DEFAULT_MAX_HTTP_REQUESTS`]
834    ///
835    /// # Environment Variable
836    ///
837    /// `OS__HTTP__MAX_REQUESTS`
838    pub max_requests: usize,
839}
840
841impl Default for Http {
842    fn default() -> Self {
843        Self {
844            max_requests: DEFAULT_MAX_HTTP_REQUESTS,
845        }
846    }
847}
848
849impl Default for Config {
850    fn default() -> Self {
851        Self {
852            http_addr: "0.0.0.0:8888".parse().unwrap(),
853
854            storage: StorageConfig::FileSystem(FileSystemConfig {
855                path: PathBuf::from("data"),
856                cogs: None,
857            }),
858
859            #[cfg(feature = "storage-cogs")]
860            storage_cogs: None,
861            runtime: Runtime::default(),
862            logging: LoggingConfig::default(),
863            sentry: Sentry::default(),
864            metrics: objectstore_metrics::MetricsConfig::default(),
865            auth: AuthZ::default(),
866            killswitches: Killswitches::default(),
867            rate_limits: RateLimits::default(),
868            usecases: UseCases::default(),
869            service: Service::default(),
870            http: Http::default(),
871        }
872    }
873}
874
875impl Config {
876    /// Loads configuration from the provided arguments.
877    ///
878    /// Configuration is merged in the following order (later sources override earlier ones):
879    /// 1. Default values
880    /// 2. YAML configuration file (if provided in `args`)
881    /// 3. Environment variables (prefixed with `OS__`)
882    ///
883    /// Any value in the merged configuration may then be written as `${file:PATH}` or
884    /// `${VAR_NAME}` to have it replaced by that file's contents or that environment
885    /// variable — see [variable references](self#variable-references).
886    ///
887    /// # Errors
888    ///
889    /// Returns an error if:
890    /// - The YAML configuration file cannot be read or parsed
891    /// - Environment variables contain invalid values
892    /// - Required fields are missing or invalid
893    /// - A `${file:PATH}` reference names a file that cannot be read, or a `${VAR_NAME}`
894    ///   reference names an environment variable that is not set
895    pub fn load(path: Option<&Path>) -> Result<Self> {
896        let mut figment = figment::Figment::from(Serialized::defaults(Config::default()));
897        if let Some(path) = path {
898            figment = figment.merge(Yaml::file(path));
899        }
900
901        // Merge first, then resolve variables against the merged value, so a reference is
902        // resolved wherever it came from and whichever layer won.
903        let merged: figment::value::Value = figment
904            .merge(Env::prefixed(ENV_PREFIX).split("__"))
905            .extract()?;
906
907        let base_path = path.and_then(Path::parent).unwrap_or(Path::new(""));
908
909        // The file source must come first: `${file:x}` also matches the environment
910        // source's `${` prefix, which would otherwise look up a variable named `file:x`.
911        let mut source = (
912            serde_vars::FileSource::new()
913                .with_variable_prefix("${file:")
914                .with_variable_suffix("}")
915                .with_base_path(base_path),
916            serde_vars::EnvSource::default()
917                .with_variable_prefix("${")
918                .with_variable_suffix("}"),
919        );
920        let config = serde_vars::deserialize(&merged, &mut source)?;
921
922        Ok(config)
923    }
924}
925
926#[cfg(test)]
927#[expect(
928    clippy::result_large_err,
929    reason = "figment::Error is inherently large"
930)]
931mod tests {
932    use std::io::Write;
933
934    use objectstore_service::backend::HighVolumeStorageConfig;
935    use secrecy::ExposeSecret;
936
937    use crate::killswitches::Killswitch;
938    use crate::rate_limits::{BandwidthLimits, RateLimits, ThroughputLimits, ThroughputRule};
939
940    use super::*;
941
942    #[test]
943    fn configurable_via_env() {
944        figment::Jail::expect_with(|jail| {
945            jail.set_env("OS__STORAGE__TYPE", "s3compatible");
946            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:8888");
947            jail.set_env("OS__STORAGE__BUCKET", "whatever");
948            jail.set_env("OS__METRICS__TAGS__FOO", "bar");
949            jail.set_env("OS__METRICS__TAGS__BAZ", "qux");
950            jail.set_env("OS__SENTRY__DSN", "abcde");
951            jail.set_env("OS__SENTRY__SAMPLE_RATE", "0.5");
952            jail.set_env("OS__SENTRY__ENVIRONMENT", "production");
953            jail.set_env("OS__SENTRY__SERVER_NAME", "objectstore-deadbeef");
954            jail.set_env("OS__SENTRY__TRACES_SAMPLE_RATE", "0.5");
955            jail.set_env("OS__SENTRY__ATTACH_STACKTRACE", "true");
956            jail.set_env("OS__SERVICE__BACKGROUND_QUEUE", "2048");
957
958            let config = Config::load(None).unwrap();
959
960            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
961                panic!("expected s3 storage");
962            };
963            assert_eq!(c.endpoint, "http://localhost:8888");
964            assert_eq!(c.bucket, "whatever");
965            assert_eq!(
966                config.metrics.tags,
967                [("foo".into(), "bar".into()), ("baz".into(), "qux".into())].into()
968            );
969
970            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
971            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
972            assert_eq!(
973                config.sentry.server_name.as_deref(),
974                Some("objectstore-deadbeef")
975            );
976            assert_eq!(config.sentry.sample_rate, 0.5);
977            assert_eq!(config.sentry.traces_sample_rate, 0.5);
978            assert!(config.sentry.attach_stacktrace);
979            assert_eq!(config.service.background_queue, 2048);
980
981            Ok(())
982        });
983    }
984
985    #[test]
986    fn configurable_via_yaml() {
987        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
988        tempfile
989            .write_all(
990                br#"
991            storage:
992                type: s3compatible
993                endpoint: http://localhost:8888
994                bucket: whatever
995            sentry:
996                dsn: abcde
997                environment: production
998                server_name: objectstore-deadbeef
999                sample_rate: 0.5
1000                traces_sample_rate: 0.5
1001                attach_stacktrace: true
1002            service:
1003                background_queue: 2048
1004            "#,
1005            )
1006            .unwrap();
1007
1008        figment::Jail::expect_with(|_jail| {
1009            let config = Config::load(Some(tempfile.path())).unwrap();
1010
1011            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
1012                panic!("expected s3 storage");
1013            };
1014            assert_eq!(c.endpoint, "http://localhost:8888");
1015            assert_eq!(c.bucket, "whatever");
1016
1017            assert_eq!(config.sentry.dsn.unwrap().expose_secret().as_str(), "abcde");
1018            assert_eq!(config.sentry.environment.as_deref(), Some("production"));
1019            assert_eq!(
1020                config.sentry.server_name.as_deref(),
1021                Some("objectstore-deadbeef")
1022            );
1023            assert_eq!(config.sentry.sample_rate, 0.5);
1024            assert_eq!(config.sentry.traces_sample_rate, 0.5);
1025            assert!(config.sentry.attach_stacktrace);
1026            assert_eq!(config.service.background_queue, 2048);
1027
1028            Ok(())
1029        });
1030    }
1031
1032    #[test]
1033    fn encryption_rejects_invalid_configuration() {
1034        let mut valid = tempfile::NamedTempFile::new().unwrap();
1035        valid.write_all(&[7; 32]).unwrap();
1036        for yaml in [
1037            "service:\n  encryption:\n    active_key_id: v1\n".to_owned(),
1038            format!(
1039                "service:\n  encryption:\n    active_key_id: missing\n    keys:\n      v1: ${{file:{}}}\n",
1040                valid.path().display(),
1041            ),
1042            format!(
1043                "service:\n  encryption:\n    active_key_id: bad_key\n    keys:\n      'bad key': ${{file:{}}}\n",
1044                valid.path().display(),
1045            ),
1046        ] {
1047            let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1048            tempfile.write_all(yaml.as_bytes()).unwrap();
1049            figment::Jail::expect_with(|_jail| {
1050                let config = Config::load(Some(tempfile.path())).unwrap();
1051                assert!(config.service.cipher().is_err(), "accepted {yaml}");
1052                Ok(())
1053            });
1054        }
1055    }
1056
1057    #[test]
1058    fn encryption_rejects_wrong_key_length() {
1059        let mut short = tempfile::NamedTempFile::new().unwrap();
1060        short.write_all(&[7; 31]).unwrap();
1061        let mut config = tempfile::NamedTempFile::new().unwrap();
1062        write!(
1063            config,
1064            "service:\n  encryption:\n    active_key_id: v1\n    keys:\n      v1: ${{file:{}}}\n",
1065            short.path().display(),
1066        )
1067        .unwrap();
1068
1069        figment::Jail::expect_with(|_jail| {
1070            assert!(Config::load(Some(config.path())).is_err());
1071            Ok(())
1072        });
1073    }
1074
1075    #[test]
1076    fn configured_with_env_and_yaml() {
1077        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1078        tempfile
1079            .write_all(
1080                br#"
1081            storage:
1082                type: s3compatible
1083                endpoint: http://localhost:8888
1084                bucket: whatever
1085            "#,
1086            )
1087            .unwrap();
1088
1089        figment::Jail::expect_with(|jail| {
1090            jail.set_env("OS__STORAGE__ENDPOINT", "http://localhost:9001");
1091
1092            let config = Config::load(Some(tempfile.path())).unwrap();
1093
1094            let StorageConfig::S3Compatible(c) = &dbg!(&config).storage else {
1095                panic!("expected s3 storage");
1096            };
1097            // Env should overwrite the yaml config
1098            assert_eq!(c.endpoint, "http://localhost:9001");
1099
1100            Ok(())
1101        });
1102    }
1103
1104    #[test]
1105    fn tiered_storage_via_yaml() {
1106        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1107        tempfile
1108            .write_all(
1109                br#"
1110            storage:
1111                type: tiered
1112                high_volume:
1113                    type: bigtable
1114                    project_id: my-project
1115                    instance_name: objectstore
1116                    table_name: objectstore
1117                long_term:
1118                    type: gcs
1119                    bucket: my-objectstore-bucket
1120            "#,
1121            )
1122            .unwrap();
1123
1124        figment::Jail::expect_with(|_jail| {
1125            let config = Config::load(Some(tempfile.path())).unwrap();
1126
1127            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
1128                panic!("expected tiered storage");
1129            };
1130            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
1131            assert_eq!(hv.project_id, "my-project");
1132            assert_eq!(hv.rpc_timeout, Duration::from_secs(2));
1133            let StorageConfig::Gcs(lt) = c.long_term.as_ref() else {
1134                panic!("expected gcs long_term");
1135            };
1136            assert_eq!(lt.bucket, "my-objectstore-bucket");
1137
1138            Ok(())
1139        });
1140    }
1141
1142    #[test]
1143    fn tiered_storage_via_env() {
1144        figment::Jail::expect_with(|jail| {
1145            jail.set_env("OS__STORAGE__TYPE", "tiered");
1146            jail.set_env("OS__STORAGE__HIGH_VOLUME__TYPE", "bigtable");
1147            jail.set_env("OS__STORAGE__HIGH_VOLUME__PROJECT_ID", "my-project");
1148            jail.set_env("OS__STORAGE__HIGH_VOLUME__INSTANCE_NAME", "my-instance");
1149            jail.set_env("OS__STORAGE__HIGH_VOLUME__TABLE_NAME", "my-table");
1150            jail.set_env("OS__STORAGE__HIGH_VOLUME__RPC_TIMEOUT", "750ms");
1151            jail.set_env("OS__STORAGE__LONG_TERM__TYPE", "filesystem");
1152            jail.set_env("OS__STORAGE__LONG_TERM__PATH", "/data/lt");
1153
1154            let config = Config::load(None).unwrap();
1155
1156            let StorageConfig::Tiered(c) = &dbg!(&config).storage else {
1157                panic!("expected tiered storage");
1158            };
1159            let HighVolumeStorageConfig::BigTable(hv) = &c.high_volume;
1160            assert_eq!(hv.project_id, "my-project");
1161            assert_eq!(hv.instance_name, "my-instance");
1162            assert_eq!(hv.table_name, "my-table");
1163            assert_eq!(hv.rpc_timeout, Duration::from_millis(750));
1164            let StorageConfig::FileSystem(lt) = c.long_term.as_ref() else {
1165                panic!("expected filesystem long_term");
1166            };
1167            assert_eq!(lt.path, Path::new("/data/lt"));
1168
1169            Ok(())
1170        });
1171    }
1172
1173    #[test]
1174    #[cfg(feature = "storage-cogs")]
1175    fn storage_cogs_via_env() {
1176        figment::Jail::expect_with(|jail| {
1177            jail.set_env("OS__STORAGE__TYPE", "bigtable");
1178            jail.set_env("OS__STORAGE__PROJECT_ID", "my-project");
1179            jail.set_env("OS__STORAGE__INSTANCE_NAME", "my-instance");
1180            jail.set_env("OS__STORAGE__TABLE_NAME", "my-table");
1181            jail.set_env(
1182                "OS__STORAGE__COGS__SHARED_RESOURCE_ID",
1183                "bigtable_objectstore",
1184            );
1185            jail.set_env("OS__STORAGE__COGS__SAMPLE_RATE", "0.5");
1186            jail.set_env("OS__STORAGE_COGS__TYPE", "kafka");
1187            jail.set_env("OS__STORAGE_COGS__TOPIC", "my-topic");
1188            jail.set_env("OS__STORAGE_COGS__BOOTSTRAP_SERVERS", "[kafka:9092]");
1189
1190            let config = Config::load(None).unwrap();
1191
1192            let StorageConfig::BigTable(storage) = &dbg!(&config).storage else {
1193                panic!("expected bigtable storage");
1194            };
1195            let stream = storage.cogs.as_ref().expect("change stream");
1196            assert_eq!(stream.shared_resource_id, "bigtable_objectstore");
1197            assert_eq!(stream.sample_rate, 0.5);
1198
1199            let CostTrackerConfig::Kafka(kafka) =
1200                config.storage_cogs.as_ref().expect("kafka transport");
1201            assert_eq!(kafka.topic, "my-topic");
1202            assert_eq!(kafka.bootstrap_servers, ["kafka:9092"]);
1203
1204            Ok(())
1205        });
1206    }
1207
1208    #[test]
1209    fn a_backend_stream_defaults_to_reporting_everything_and_no_transport() {
1210        figment::Jail::expect_with(|jail| {
1211            jail.set_env("OS__STORAGE__TYPE", "gcs");
1212            jail.set_env("OS__STORAGE__BUCKET", "my-bucket");
1213            jail.set_env("OS__STORAGE__COGS__SHARED_RESOURCE_ID", "gcs_objectstore");
1214
1215            let config = Config::load(None).unwrap();
1216
1217            let StorageConfig::Gcs(storage) = &dbg!(&config).storage else {
1218                panic!("expected gcs storage");
1219            };
1220            let stream = storage.cogs.as_ref().expect("change stream");
1221            assert_eq!(stream.sample_rate, 1.0, "reports everything by default");
1222            #[cfg(feature = "storage-cogs")]
1223            assert!(
1224                config.storage_cogs.is_none(),
1225                "no transport is configured by default"
1226            );
1227
1228            Ok(())
1229        });
1230    }
1231
1232    #[test]
1233    #[cfg(feature = "storage-cogs")]
1234    fn serde_var_yaml_references() {
1235        let secrets = tempfile::tempdir().unwrap();
1236        let absolute_secret = secrets.path().join("kafka-password");
1237        std::fs::write(&absolute_secret, "hunter2").unwrap();
1238
1239        let dir = tempfile::tempdir().unwrap();
1240        std::fs::write(dir.path().join("relative-password"), "hunter3").unwrap();
1241        std::fs::write(dir.path().join("encryption-key"), [255; 32]).unwrap();
1242
1243        let config_path = dir.path().join("config.yml");
1244        std::fs::write(
1245            &config_path,
1246            format!(
1247                r#"
1248            storage_cogs:
1249                type: kafka
1250                override_params:
1251                    sasl.mechanism: SCRAM-SHA-256
1252                    not.a.reference: prod-${{NOT_A_VAR
1253                    from.env: ${{KAFKA_SASL_PASSWORD}}
1254                    from.relative.file: ${{file:relative-password}}
1255                    from.absolute.file: ${{file:{}}}
1256            service:
1257                encryption:
1258                    active_key_id: v1
1259                    keys:
1260                        v1: ${{file:encryption-key}}
1261            "#,
1262                absolute_secret.display()
1263            ),
1264        )
1265        .unwrap();
1266
1267        figment::Jail::expect_with(|jail| {
1268            jail.set_env("KAFKA_SASL_PASSWORD", "hunter1");
1269
1270            let config = Config::load(Some(&config_path)).unwrap();
1271
1272            let CostTrackerConfig::Kafka(sink) =
1273                config.storage_cogs.as_ref().expect("kafka transport");
1274            assert_eq!(sink.override_params["from.env"], "hunter1");
1275            assert_eq!(sink.override_params["from.relative.file"], "hunter3");
1276            assert_eq!(
1277                sink.override_params["from.absolute.file"], "hunter2",
1278                "an absolute path ignores the config directory"
1279            );
1280            assert_eq!(sink.override_params["sasl.mechanism"], "SCRAM-SHA-256");
1281            assert_eq!(
1282                sink.override_params["not.a.reference"], "prod-${NOT_A_VAR",
1283                "a value that is not a reference is left alone"
1284            );
1285            assert!(config.service.cipher().unwrap().is_some());
1286
1287            Ok(())
1288        });
1289    }
1290
1291    #[test]
1292    fn serde_vars_yaml_reference_failure() {
1293        let dir = tempfile::tempdir().unwrap();
1294        for (name, reference) in [
1295            ("missing-file.yml", "${file:nope}"),
1296            ("unset-var.yml", "${FAKE_VAR}"),
1297        ] {
1298            let config_path = dir.path().join(name);
1299            std::fs::write(
1300                &config_path,
1301                format!(
1302                    r#"
1303                storage_cogs:
1304                    type: kafka
1305                    override_params:
1306                        sasl.password: {reference}
1307                "#
1308                ),
1309            )
1310            .unwrap();
1311
1312            figment::Jail::expect_with(|_jail| {
1313                assert!(Config::load(Some(&config_path)).is_err(), "{reference}");
1314                Ok(())
1315            });
1316        }
1317    }
1318
1319    #[test]
1320    fn serde_vars_env_reference() {
1321        figment::Jail::expect_with(|jail| {
1322            jail.set_env("SENTRY_DSN", "https://public@example.invalid/1");
1323            jail.set_env("OS__SENTRY__DSN", "${SENTRY_DSN}");
1324
1325            let config = Config::load(None).unwrap();
1326
1327            assert_eq!(
1328                config.sentry.dsn.unwrap().expose_secret().as_str(),
1329                "https://public@example.invalid/1"
1330            );
1331
1332            Ok(())
1333        });
1334    }
1335
1336    #[test]
1337    fn metrics_addr_via_env() {
1338        figment::Jail::expect_with(|jail| {
1339            jail.set_env("OS__METRICS__ADDR", "127.0.0.1:8125");
1340
1341            let config = Config::load(None).unwrap();
1342            assert_eq!(config.metrics.addr.as_deref(), Some("127.0.0.1:8125"));
1343
1344            Ok(())
1345        });
1346    }
1347
1348    #[test]
1349    fn configure_auth_with_env() {
1350        figment::Jail::expect_with(|jail| {
1351            jail.set_env("OS__AUTH__ENFORCE", "true");
1352            jail.set_env(
1353                "OS__AUTH__KEYS",
1354                r#"{kid1={key_files=["abcde","fghij","this is a test\n  multiline string\nend of string\n"],max_permissions=["object.read", "object.write"],}, kid2={key_files=["12345"],}}"#,
1355            );
1356
1357            let config = Config::load(None).unwrap();
1358
1359            assert!(config.auth.enforce);
1360
1361            let kid1 = config.auth.keys.get("kid1").unwrap();
1362            assert_eq!(kid1.key_files[0], Path::new("abcde"));
1363            assert_eq!(kid1.key_files[1], Path::new("fghij"));
1364            assert_eq!(
1365                kid1.key_files[2],
1366                Path::new("this is a test\n  multiline string\nend of string\n"),
1367            );
1368            assert_eq!(
1369                kid1.max_permissions,
1370                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
1371            );
1372
1373            let kid2 = config.auth.keys.get("kid2").unwrap();
1374            assert_eq!(kid2.key_files[0], Path::new("12345"));
1375            assert_eq!(kid2.max_permissions, HashSet::new());
1376
1377            Ok(())
1378        });
1379    }
1380
1381    #[test]
1382    fn configure_auth_with_yaml() {
1383        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1384        tempfile
1385            .write_all(
1386                br#"
1387                auth:
1388                    enforce: true
1389                    keys:
1390                        kid1:
1391                            key_files:
1392                                - "abcde"
1393                                - "fghij"
1394                                - |
1395                                  this is a test
1396                                    multiline string
1397                                  end of string
1398                            max_permissions:
1399                                - "object.read"
1400                                - "object.write"
1401                        kid2:
1402                            key_files:
1403                                - "12345"
1404            "#,
1405            )
1406            .unwrap();
1407
1408        figment::Jail::expect_with(|_jail| {
1409            let config = Config::load(Some(tempfile.path())).unwrap();
1410
1411            assert!(config.auth.enforce);
1412
1413            let kid1 = config.auth.keys.get("kid1").unwrap();
1414            assert_eq!(kid1.key_files[0], Path::new("abcde"));
1415            assert_eq!(kid1.key_files[1], Path::new("fghij"));
1416            assert_eq!(
1417                kid1.key_files[2],
1418                Path::new("this is a test\n  multiline string\nend of string\n")
1419            );
1420            assert_eq!(
1421                kid1.max_permissions,
1422                HashSet::from([Permission::ObjectRead, Permission::ObjectWrite])
1423            );
1424
1425            let kid2 = config.auth.keys.get("kid2").unwrap();
1426            assert_eq!(kid2.key_files[0], Path::new("12345"));
1427            assert_eq!(kid2.max_permissions, HashSet::new());
1428
1429            Ok(())
1430        });
1431    }
1432
1433    #[test]
1434    fn auth_enforce_defaults_to_true() {
1435        figment::Jail::expect_with(|_jail| {
1436            let config = Config::load(None).unwrap();
1437            assert!(config.auth.enforce);
1438            Ok(())
1439        });
1440    }
1441
1442    #[test]
1443    fn auth_enforce_defaults_to_true_when_omitted_from_yaml() {
1444        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1445        tempfile
1446            .write_all(
1447                br#"
1448                auth:
1449                    keys: {}
1450            "#,
1451            )
1452            .unwrap();
1453
1454        figment::Jail::expect_with(|_jail| {
1455            let config = Config::load(Some(tempfile.path())).unwrap();
1456            assert!(config.auth.enforce);
1457            Ok(())
1458        });
1459    }
1460
1461    #[test]
1462    fn auth_enforce_can_be_disabled() {
1463        figment::Jail::expect_with(|jail| {
1464            jail.set_env("OS__AUTH__ENFORCE", "false");
1465            let config = Config::load(None).unwrap();
1466            assert!(!config.auth.enforce);
1467            Ok(())
1468        });
1469    }
1470
1471    #[test]
1472    fn configure_killswitches_with_yaml() {
1473        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1474        tempfile
1475            .write_all(
1476                br#"
1477                killswitches:
1478                  - usecase: broken_usecase
1479                  - scopes:
1480                      org: "42"
1481                  - service: "test-*"
1482                  - scopes:
1483                      org: "42"
1484                      project: "4711"
1485                  - usecase: attachments
1486                    scopes:
1487                      org: "42"
1488                    service: "test-*"
1489                "#,
1490            )
1491            .unwrap();
1492
1493        figment::Jail::expect_with(|_jail| {
1494            let expected = [
1495                Killswitch {
1496                    usecase: Some("broken_usecase".parse().unwrap()),
1497                    scopes: BTreeMap::new(),
1498                    service: None,
1499                },
1500                Killswitch {
1501                    usecase: None,
1502                    scopes: BTreeMap::from([("org".into(), "42".into())]),
1503                    service: None,
1504                },
1505                Killswitch {
1506                    usecase: None,
1507                    scopes: BTreeMap::new(),
1508                    service: Some("test-*".into()),
1509                },
1510                Killswitch {
1511                    usecase: None,
1512                    scopes: BTreeMap::from([
1513                        ("org".into(), "42".into()),
1514                        ("project".into(), "4711".into()),
1515                    ]),
1516                    service: None,
1517                },
1518                Killswitch {
1519                    usecase: Some("attachments".parse().unwrap()),
1520                    scopes: BTreeMap::from([("org".into(), "42".into())]),
1521                    service: Some("test-*".into()),
1522                },
1523            ];
1524
1525            let config = Config::load(Some(tempfile.path())).unwrap();
1526            assert_eq!(config.killswitches.as_slice(), &expected);
1527
1528            Ok(())
1529        });
1530    }
1531
1532    #[test]
1533    fn configure_rate_limits_with_yaml() {
1534        let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1535        tempfile
1536            .write_all(
1537                br#"
1538                rate_limits:
1539                  throughput:
1540                    global_rps: 1000
1541                    burst: 100
1542                    usecase_pct: 50
1543                    scope_pct: 25
1544                    rules:
1545                      - usecase: "high_priority"
1546                        scopes:
1547                          - ["org", "123"]
1548                        rps: 500
1549                      - scopes:
1550                          - ["org", "456"]
1551                          - ["project", "789"]
1552                        pct: 10
1553                  bandwidth:
1554                    global_bps: 1048576
1555                    burst_ms: 2000
1556                    usecase_pct: 50
1557                    scope_pct: 25
1558                    report_only: true
1559                "#,
1560            )
1561            .unwrap();
1562
1563        figment::Jail::expect_with(|_jail| {
1564            let expected = RateLimits {
1565                throughput: ThroughputLimits {
1566                    global_rps: Some(1000),
1567                    burst: 100,
1568                    usecase_pct: Some(50),
1569                    scope_pct: Some(25),
1570                    rules: vec![
1571                        ThroughputRule {
1572                            usecase: Some("high_priority".parse().unwrap()),
1573                            scopes: vec![("org".to_string(), "123".to_string())],
1574                            rps: Some(500),
1575                            pct: None,
1576                        },
1577                        ThroughputRule {
1578                            usecase: None,
1579                            scopes: vec![
1580                                ("org".to_string(), "456".to_string()),
1581                                ("project".to_string(), "789".to_string()),
1582                            ],
1583                            rps: None,
1584                            pct: Some(10),
1585                        },
1586                    ],
1587                },
1588                bandwidth: BandwidthLimits {
1589                    global_bps: Some(1_048_576),
1590                    burst_ms: 2000,
1591                    usecase_pct: Some(50),
1592                    scope_pct: Some(25),
1593                    report_only: true,
1594                },
1595            };
1596
1597            let config = Config::load(Some(tempfile.path())).unwrap();
1598            assert_eq!(config.rate_limits, expected);
1599
1600            Ok(())
1601        });
1602    }
1603}