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}