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