bulwark_config/
config.rs

1//! The config module provides the internal representation of Bulwark's configuration.
2
3use crate::ResolutionError;
4use bytes::Bytes;
5use itertools::Itertools;
6use regex::Regex;
7use serde::Serialize;
8use std::collections::{HashMap, HashSet};
9use std::fmt::{Display, Formatter};
10use std::path::PathBuf;
11use url::Url;
12use validator::Validate;
13
14lazy_static! {
15    static ref RE_VALID_REFERENCE: Regex = Regex::new(r"^[a-z_]([a-z0-9_])*$").unwrap();
16}
17
18/// The root of a Bulwark configuration.
19///
20/// Wraps all child configuration structures and provides the internal representation of Bulwark's configuration.
21#[derive(Debug, Clone)]
22pub struct Config {
23    /// Configuration for the services being launched.
24    pub service: Service,
25    /// Configuration for the services being launched.
26    pub runtime: Runtime,
27    /// Configuration for state managed by Bulwark plugins.
28    pub state: State,
29    /// Configuration for the decision thresholds.
30    pub thresholds: Thresholds,
31    /// Configuration for metrics collection.
32    pub metrics: Metrics,
33    /// A list of configurations for individual secrets.
34    pub secrets: Vec<Secret>,
35    /// A list of configurations for individual plugins.
36    pub plugins: Vec<Plugin>,
37    /// A list of plugin groups that allows a plugin set to be loaded with a single reference.
38    pub presets: Vec<Preset>,
39    /// A list of routes that maps from resource paths to plugins or presets.
40    pub resources: Vec<Resource>,
41    // TODO: It might make sense to convert the vectors to maps since both routes and references should be unique.
42}
43
44impl Config {
45    /// Looks up the [`Secret`] corresponding to the `reference` string.
46    ///
47    /// # Arguments
48    ///
49    /// * `reference` - A string that corresponds to a [`Secret::reference`] value.
50    pub fn secret<'a>(&self, reference: &str) -> Option<&Secret>
51    where
52        Secret: 'a,
53    {
54        self.secrets
55            .iter()
56            .find(|&secret| secret.reference == reference)
57    }
58
59    /// Looks up the [`Plugin`] corresponding to the `reference` string.
60    ///
61    /// # Arguments
62    ///
63    /// * `reference` - A string that corresponds to a [`Plugin::reference`] value.
64    pub fn plugin<'a>(&self, reference: &str) -> Option<&Plugin>
65    where
66        Plugin: 'a,
67    {
68        self.plugins
69            .iter()
70            .find(|&plugin| plugin.reference == reference)
71    }
72
73    /// Looks up the [`Preset`] corresponding to the `reference` string.
74    ///
75    /// # Arguments
76    ///
77    /// * `reference` - A string that corresponds to a [`Preset::reference`] value.
78    pub fn preset<'a>(&self, reference: &str) -> Option<&Preset>
79    where
80        Preset: 'a,
81    {
82        self.presets
83            .iter()
84            .find(|&preset| preset.reference == reference)
85    }
86}
87
88/// Configuration for the services being launched.
89#[derive(Debug, Clone)]
90pub struct Service {
91    /// The port for the primary service.
92    pub port: u16,
93    /// The port for the admin service and health checks.
94    pub admin_port: u16,
95    /// True if the admin service is enabled, false otherwise.
96    pub admin_enabled: bool,
97    /// The number of trusted proxy hops expected to be exterior to Bulwark.
98    ///
99    /// This number does not include Bulwark or the proxy hosting it in the proxy hop count. Zero implies that
100    /// there are no other proxies exterior to Bulwark. This is used to ensure the `Forwarded` and `X-Forwarded-For`
101    /// headers are not spoofed. If this is set incorrectly, the client IP reported to plugins will be incorrect.
102    pub proxy_hops: u8,
103    // TODO: it may be useful to introduce an "auto" setting for `proxy_hops` since it's possible to auto-discover
104}
105
106/// The default [`Service::port`] value.
107pub const DEFAULT_PORT: u16 = 8089;
108/// The default [`Service::admin_port`] value.
109pub const DEFAULT_ADMIN_PORT: u16 = 8090;
110
111impl Default for Service {
112    /// Default service config
113    fn default() -> Self {
114        Self {
115            port: DEFAULT_PORT,
116            admin_port: DEFAULT_ADMIN_PORT,
117            admin_enabled: true,
118            proxy_hops: 0,
119        }
120    }
121}
122
123/// Configuration for the runtime environment.
124#[derive(Debug, Clone)]
125pub struct Runtime {
126    /// The maximum number of concurrent incoming requests that the runtime will process before blocking.
127    pub max_concurrent_requests: usize,
128    /// The maximum number of concurrent plugin tasks that the runtime will launch.
129    pub max_plugin_tasks: usize,
130}
131
132/// The default [`Runtime::max_concurrent_requests`] value.
133pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 8;
134
135/// The default [`Runtime::max_plugin_tasks`] value.
136pub const DEFAULT_MAX_PLUGIN_TASKS: usize = 16;
137
138impl Default for Runtime {
139    /// Default runtime config
140    fn default() -> Self {
141        Self {
142            max_concurrent_requests: DEFAULT_MAX_CONCURRENT_REQUESTS,
143            max_plugin_tasks: DEFAULT_MAX_PLUGIN_TASKS,
144        }
145    }
146}
147
148/// Configuration for state managed by Bulwark plugins.
149#[derive(Debug, Clone)]
150pub struct State {
151    /// The URI for the external Redis state store.
152    pub redis_uri: Option<String>,
153    /// The size of the Redis connection pool.
154    pub redis_pool_size: usize,
155}
156
157impl Default for State {
158    /// Default runtime config
159    fn default() -> Self {
160        Self {
161            redis_uri: None,
162            redis_pool_size: num_cpus::get_physical() * 4,
163        }
164    }
165}
166
167/// Configuration for the decision thresholds.
168///
169/// No threshold is necessary for the default `allowed` outcome because it is defined by the range between the
170/// `suspicious` threshold and the `trusted` threshold. The thresholds must have values in descending order, with
171/// `restrict` > `suspicious` > `trusted`. None of the threshold values may be equal.
172#[derive(Debug, Clone, Copy)]
173pub struct Thresholds {
174    /// True if the primary service should take no action in response to restrict decisions.
175    pub observe_only: bool,
176    /// Any decision value above the `restrict` threshold will cause the corresponding request to be blocked.
177    pub restrict: f64,
178    /// Any decision value above the `suspicious` threshold will cause the corresponding request to be flagged as
179    /// suspicious but it will still be allowed.
180    pub suspicious: f64,
181    /// Any decision value below the `trust` threshold will cause the corresponding request to be flagged as trusted.
182    /// This primarily affects plugins which use feedback loops.
183    pub trust: f64,
184}
185
186/// The default [`Thresholds::observe_only`] value.
187pub const DEFAULT_OBSERVE_ONLY: bool = false;
188/// The default [`Thresholds::restrict`] value.
189pub const DEFAULT_RESTRICT_THRESHOLD: f64 = 0.8;
190/// The default [`Thresholds::suspicious`] value.
191pub const DEFAULT_SUSPICIOUS_THRESHOLD: f64 = 0.6;
192/// The default [`Thresholds::trust`] value.
193pub const DEFAULT_TRUST_THRESHOLD: f64 = 0.2;
194
195impl Default for Thresholds {
196    /// Default decision thresholds.
197    fn default() -> Self {
198        Self {
199            observe_only: DEFAULT_OBSERVE_ONLY,
200            restrict: DEFAULT_RESTRICT_THRESHOLD,
201            suspicious: DEFAULT_SUSPICIOUS_THRESHOLD,
202            trust: DEFAULT_TRUST_THRESHOLD,
203        }
204    }
205}
206
207/// Configuration for metrics collection.
208#[derive(Debug, Clone)]
209pub struct Metrics {
210    pub statsd_host: Option<String>,
211    pub statsd_port: Option<u16>,
212    pub statsd_queue_size: usize,
213    pub statsd_buffer_size: usize,
214    pub statsd_prefix: String,
215}
216
217/// The default [`Metrics::statsd_port`] value.
218pub const DEFAULT_STATSD_PORT: Option<u16> = Some(8125);
219
220/// The default [`Metrics::statsd_queue_size`] value.
221pub const DEFAULT_STATSD_QUEUE_SIZE: usize = 5000;
222
223/// The default [`Metrics::statsd_buffer_size`] value.
224pub const DEFAULT_STATSD_BUFFER_SIZE: usize = 1024;
225
226impl Default for Metrics {
227    /// Default metrics config
228    fn default() -> Self {
229        Self {
230            statsd_host: None,
231            statsd_port: DEFAULT_STATSD_PORT,
232            statsd_queue_size: DEFAULT_STATSD_QUEUE_SIZE,
233            statsd_buffer_size: DEFAULT_STATSD_BUFFER_SIZE,
234            statsd_prefix: String::from(""),
235        }
236    }
237}
238
239/// Configuration for a secret that Bulwark will need to reference.
240#[derive(Debug, Validate, Clone, Default)]
241pub struct Secret {
242    /// The secret reference key. Should be limited to ASCII lowercase a-z plus underscores. Maximum 96 characters.
243    #[validate(length(min = 1, max = 96), regex(path = "RE_VALID_REFERENCE"))]
244    pub reference: String,
245    /// The location where the secret can be loaded from.
246    pub location: SecretLocation,
247}
248
249/// The location where a secret is stored.
250#[derive(Debug, Clone)]
251pub enum SecretLocation {
252    /// The secret is an environment variable.
253    EnvVar(String),
254    /// The secret is mounted as a file.
255    File(PathBuf),
256}
257
258impl Default for SecretLocation {
259    /// Defaults to an unusable empty environment variable.
260    fn default() -> Self {
261        Self::EnvVar(String::new())
262    }
263}
264
265/// The location where the plugin WASM can be loaded from.
266#[derive(Debug, Clone)]
267pub enum PluginLocation {
268    /// The plugin is a local file.
269    Local(PathBuf),
270    /// The plugin is a remote file served over HTTPS.
271    Remote(Url),
272    /// The plugin is an binary blob.
273    Bytes(Bytes),
274}
275
276impl Default for PluginLocation {
277    /// Defaults to an unusable empty byte vector.
278    fn default() -> Self {
279        Self::Bytes(Bytes::new())
280    }
281}
282
283impl Display for PluginLocation {
284    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
285        match self {
286            PluginLocation::Local(path) => write!(f, "[local: {}]", path.display()),
287            PluginLocation::Remote(uri) => write!(f, "[remote: {}]", uri),
288            PluginLocation::Bytes(bytes) => write!(f, "[{} bytes]", bytes.len()),
289        }
290    }
291}
292
293/// The access control applied to the plugin.
294///
295/// Typically used in conjunction with privately distributed remote plugins.
296#[derive(Debug, Clone)]
297pub enum PluginAccess {
298    /// No authentication provided.
299    None,
300    /// The authentication is provided via an HTTPS Authorization header.
301    ///
302    /// The entire header value will be sent verbatim. This will generally only work for
303    /// basic authentication or bearer tokens.
304    ///
305    /// This is a secret reference. A corresponding [`Secret`] must be provided.
306    Header(String),
307}
308
309impl Default for PluginAccess {
310    /// Defaults to no authentication.
311    fn default() -> Self {
312        Self::None
313    }
314}
315
316/// Verification that plugin contents are what was expected.
317#[derive(Debug, Clone)]
318pub enum PluginVerification {
319    /// No verification.
320    None,
321    /// The plugin is hashed with a SHA-256 digest.
322    Sha256(Bytes),
323}
324
325impl Default for PluginVerification {
326    /// Defaults to no verification.
327    fn default() -> Self {
328        Self::None
329    }
330}
331
332/// The configuration for an individual plugin.
333///
334/// This structure will be wrapped by structs in the host environment.
335#[derive(Debug, Validate, Clone, Default)]
336pub struct Plugin {
337    /// The plugin reference key. Should be limited to ASCII lowercase a-z plus underscores. Maximum 96 characters.
338    #[validate(length(min = 1, max = 96), regex(path = "RE_VALID_REFERENCE"))]
339    pub reference: String,
340    /// The location where the plugin WASM can be loaded from.
341    pub location: PluginLocation,
342    /// The access requirements for the plugin. If the plugin requires authentication, this can be provided here.
343    pub access: PluginAccess,
344    /// Verification that plugin contents match what was expected.
345    pub verification: PluginVerification,
346    /// A weight to multiply this plugin's decision values by.
347    ///
348    /// A 1.0 value has no effect on the decision. See [`bulwark_decision::Decision::weight`].
349    #[validate(range(min = 0.0))]
350    pub weight: f64,
351    // TODO: this might be better represented as a valuable::Mappable / valuable::Value
352    /// JSON-serializable configuration passed into the plugin environment.
353    ///
354    /// The host environment will not do anything with this value beyond serialization.
355    pub config: serde_json::map::Map<String, serde_json::Value>,
356    /// The permissions granted to this plugin.
357    ///
358    /// Any attempt to perform an operation within the plugin sandbox that requires a permission to be set will fail.
359    pub permissions: Permissions,
360}
361
362/// The default [`Plugin::weight`] value.
363pub const DEFAULT_PLUGIN_WEIGHT: f64 = 1.0;
364
365/// The permissions granted to an associated plugin.
366#[derive(Debug, Clone, Default)]
367pub struct Permissions {
368    /// A list of environment variables a plugin may acquire values for.
369    ///
370    /// This permission may be used to grant a plugin fine-grained access to a specific secret.
371    pub env: Vec<String>,
372    /// A list of domains that a plugin may make HTTP requests to.
373    ///
374    /// The permission value must case-insensitively match the entire host component of the request URI.
375    pub http: Vec<String>,
376    /// A list of key prefixes that a plugin may get or set within the external state store.
377    ///
378    /// This permission also affects rate limits and circuit breakers since they also use the external state store.
379    pub state: Vec<String>,
380}
381
382/// A mapping between a reference identifier and a list of plugins that form a preset plugin group.
383#[derive(Debug, Validate, Clone)]
384pub struct Preset {
385    /// The preset reference key. Should be limited to ASCII lowercase a-z plus underscores. Maximum 96 characters.
386    #[validate(length(min = 1, max = 96), regex(path = "RE_VALID_REFERENCE"))]
387    pub reference: String,
388    /// The list of references to plugins and other presets contained within this preset.
389    #[validate(length(min = 1))]
390    pub plugins: Vec<Reference>,
391}
392
393impl Preset {
394    /// Resolves all references within a `Preset`, producing a flattened list of the corresponding [`Plugin`]s.
395    ///
396    /// # Arguments
397    ///
398    /// * `config` - A [`Config`] reference to perform lookups againsts.
399    ///   `Preset`s do not maintain their own references to their parent [`Config`] so this must be passed in.
400    ///
401    /// See [`Config::plugin`] and [`Config::preset`].
402    pub fn resolve_plugins<'a>(
403        &'a self,
404        config: &'a Config,
405    ) -> Result<Vec<&Plugin>, ResolutionError> {
406        let mut resolved_presets = HashSet::new();
407        self.resolve_plugins_recursive(config, &mut resolved_presets)
408    }
409
410    /// Resolves all references, checking for cycles.
411    fn resolve_plugins_recursive<'a>(
412        &'a self,
413        config: &'a Config,
414        resolved_presets: &mut HashSet<String>,
415    ) -> Result<Vec<&Plugin>, ResolutionError> {
416        let mut plugins: HashMap<String, &Plugin> = HashMap::with_capacity(self.plugins.len());
417        for reference in &self.plugins {
418            match reference {
419                Reference::Plugin(ref_name) => {
420                    if let Some(plugin) = config.plugin(ref_name.as_str()) {
421                        plugins.insert(plugin.reference.to_string(), plugin);
422                    }
423                }
424                Reference::Preset(ref_name) => {
425                    if resolved_presets.contains(ref_name) {
426                        return Err(ResolutionError::CircularPreset(ref_name.to_string()));
427                    } else {
428                        resolved_presets.insert(ref_name.to_string());
429                    }
430                    if let Some(preset) = config.preset(ref_name.as_str()) {
431                        let inner_plugins =
432                            preset.resolve_plugins_recursive(config, resolved_presets)?;
433                        for inner_plugin in inner_plugins {
434                            plugins.insert(inner_plugin.reference.to_string(), inner_plugin);
435                        }
436                    }
437                }
438                Reference::Missing(ref_name) => {
439                    return Err(ResolutionError::Missing(ref_name.to_string()));
440                }
441            }
442        }
443        Ok(plugins.values().cloned().collect())
444    }
445}
446
447/// A mapping between a route pattern and the plugins that should be run for matching requests.
448#[derive(Debug, Clone)]
449pub struct Resource {
450    /// The route pattern used to match requests with.
451    ///
452    /// Uses `matchit` router patterns.
453    pub routes: Vec<String>,
454    /// The plugin references for this route.
455    pub plugins: Vec<Reference>,
456    /// The maximum amount of time a plugin may take for each execution phase.
457    pub timeout: Option<u64>,
458}
459
460impl Resource {
461    /// Expands routes to make them more user-friendly.
462    ///
463    /// # Arguments
464    ///
465    /// * `routes` - The route patterns to expand.
466    /// * `exact` - Whether route expansion should ignore trailing slashes or not.
467    /// * `prefix` - Whether route expansion should add a catch-all '/{*suffix}' pattern to each route.
468    pub(crate) fn expand_routes(routes: &[String], exact: bool, prefix: bool) -> Vec<String> {
469        let mut new_routes = routes.to_vec();
470        if !exact {
471            for route in routes.iter() {
472                let new_route = if route.ends_with('/') && route.len() > 1 {
473                    route[..route.len() - 1].to_string()
474                } else if !route.contains("{*") && !route.ends_with('/') {
475                    route.clone() + "/"
476                } else {
477                    continue;
478                };
479                if !new_routes.contains(&new_route) {
480                    new_routes.push(new_route);
481                }
482            }
483        }
484        if prefix {
485            for route in new_routes.clone().iter() {
486                if !route.contains("{*") {
487                    let new_route = PathBuf::from(route)
488                        .join("{*suffix}")
489                        .to_string_lossy()
490                        .to_string();
491                    if !new_routes.contains(&new_route) {
492                        new_routes.push(new_route);
493                    }
494                }
495            }
496        }
497        new_routes.sort_by_key(|route| -(route.len() as i64));
498        new_routes
499    }
500
501    /// Resolves all references within a `Resource`, producing a flattened list of the corresponding [`Plugin`]s.
502    ///
503    /// # Arguments
504    ///
505    /// * `config` - A [`Config`] reference to perform lookups againsts.
506    ///   `Resource`s do not maintain their own references to their parent [`Config`] so this must be passed in.
507    ///
508    /// See [`Config::plugin`] and [`Config::preset`].
509    pub fn resolve_plugins<'a>(
510        &'a self,
511        config: &'a Config,
512    ) -> Result<Vec<&Plugin>, ResolutionError> {
513        let mut plugins: Vec<&Plugin> = Vec::with_capacity(self.plugins.len());
514        for reference in &self.plugins {
515            match reference {
516                Reference::Plugin(ref_name) => {
517                    if let Some(plugin) = config.plugin(ref_name.as_str()) {
518                        plugins.push(plugin);
519                    }
520                }
521                Reference::Preset(ref_name) => {
522                    if let Some(preset) = config.preset(ref_name.as_str()) {
523                        let mut inner_plugins = preset.resolve_plugins(config)?;
524                        plugins.append(&mut inner_plugins);
525                    }
526                }
527                Reference::Missing(ref_name) => {
528                    return Err(ResolutionError::Missing(ref_name.to_string()));
529                }
530            }
531        }
532        Ok(plugins
533            .iter()
534            .sorted_by(|a, b| Ord::cmp(&a.reference, &b.reference))
535            .copied()
536            .collect())
537    }
538}
539
540/// Wraps reference strings and differentiates what the reference points to.
541#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
542pub enum Reference {
543    /// A reference to a [`Plugin`].
544    Plugin(String),
545    /// A reference to a [`Preset`].
546    Preset(String),
547    /// A reference that could not be resolved.
548    Missing(String),
549}