Skip to main content

unitycatalog_storage_proxy/cli/
config.rs

1//! YAML configuration for the standalone `storage-proxy` binary.
2//!
3//! A single optional config file, dotenv-style env indirection for secrets, and
4//! sensible defaults so a config-less run works with a couple of CLI flags. The
5//! shape is deliberately small — the proxy has no database, so it configures
6//! only the bind address, the mount prefix, the upstream Unity Catalog to
7//! resolve + vend through, and how incoming requests are authenticated.
8//!
9//! This mirrors the relevant subset of the `server` crate's config (the
10//! `ConfigValue` env indirection, `StorageProxyClientConfig`, `AuthConfig`) so
11//! operators see a familiar surface, without depending on that crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::auth::{AuthMode, DEFAULT_FORWARDED_USER_HEADER};
16
17/// A `{ env: "VAR_NAME" }` reference resolved from the process environment.
18#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
19pub struct EnvValue {
20    pub env: String,
21}
22
23/// A leaf configuration value: either an inline literal or an environment
24/// variable reference. Keeps secrets (the upstream token) out of the file.
25#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
26#[serde(untagged)]
27pub enum ConfigValue {
28    Value(String),
29    Environment(EnvValue),
30}
31
32impl ConfigValue {
33    /// Resolve the value: the inline string, or the named environment variable
34    /// (`None` if unset).
35    pub fn value(&self) -> Option<String> {
36        match self {
37            ConfigValue::Value(value) => Some(value.clone()),
38            ConfigValue::Environment(env) => std::env::var(&env.env).ok(),
39        }
40    }
41}
42
43/// Default bind host when neither the config file nor a CLI flag sets one.
44pub const DEFAULT_HOST: &str = "0.0.0.0";
45/// Default listen port when neither the config file nor a CLI flag sets one.
46pub const DEFAULT_PORT: u16 = 8080;
47/// Default mount prefix for the byte-proxy surface — matches the path a UC
48/// server announces at `/capabilities` (`storageProxy.basePath`).
49pub const DEFAULT_BASE_PATH: &str = "/storage-proxy";
50
51/// Standalone storage-proxy configuration.
52#[derive(Debug, Deserialize, Serialize, PartialEq)]
53pub struct Config {
54    /// Host/interface to bind. Defaults to [`DEFAULT_HOST`] (all interfaces).
55    #[serde(default)]
56    pub host: Option<String>,
57
58    /// TCP port to listen on. Defaults to [`DEFAULT_PORT`].
59    #[serde(default)]
60    pub port: Option<u16>,
61
62    /// URL prefix the byte-proxy surface is mounted under. Defaults to
63    /// [`DEFAULT_BASE_PATH`].
64    #[serde(default)]
65    pub base_path: Option<String>,
66
67    /// Upstream Unity Catalog to resolve securables and vend credentials
68    /// through. Required.
69    pub upstream: UpstreamConfig,
70
71    /// How incoming requests are authenticated. Defaults to anonymous.
72    #[serde(default)]
73    pub auth: AuthConfig,
74}
75
76/// Upstream Unity Catalog connection.
77#[derive(Debug, Deserialize, Serialize, PartialEq)]
78pub struct UpstreamConfig {
79    /// Base URL of the UC REST API, e.g.
80    /// `http://uc:8080/api/2.1/unity-catalog/`.
81    pub base_url: String,
82
83    /// Optional bearer token (inline or `{ env: ... }`). Absent = the upstream
84    /// is contacted unauthenticated (anonymous UC).
85    #[serde(default)]
86    pub token: Option<ConfigValue>,
87}
88
89/// How the proxy authenticates incoming requests.
90#[derive(Debug, Deserialize, Serialize, Default, PartialEq)]
91#[serde(rename_all = "kebab-case")]
92pub struct AuthConfig {
93    /// The authentication mode. Defaults to [`AuthKind::Anonymous`].
94    #[serde(default)]
95    pub mode: AuthKind,
96
97    /// Header the reverse-proxy mode reads the forwarded user from. Defaults to
98    /// `x-forwarded-user`. Ignored unless `mode` is `reverse-proxy`.
99    #[serde(default)]
100    pub forwarded_user_header: Option<String>,
101
102    /// In reverse-proxy mode, treat a request lacking the forwarded-user header
103    /// as anonymous instead of rejecting it (`401`). Default `false` (reject).
104    #[serde(default)]
105    pub allow_missing_identity: bool,
106}
107
108/// The request-authentication strategy (config surface). Maps to the runtime
109/// [`AuthMode`] via [`AuthConfig::to_mode`].
110#[derive(Debug, Deserialize, Serialize, Default, Clone, Copy, PartialEq, Eq)]
111#[serde(rename_all = "kebab-case")]
112pub enum AuthKind {
113    /// Every request is anonymous. The default.
114    #[default]
115    Anonymous,
116    /// Trust a forwarded-identity header set by a trusted upstream reverse proxy.
117    ReverseProxy,
118}
119
120impl AuthConfig {
121    /// The header name the forwarded end-user identity is read from (and, in
122    /// turn, re-emitted under on upstream UC requests): the configured
123    /// `forwarded-user-header`, else [`DEFAULT_FORWARDED_USER_HEADER`].
124    ///
125    /// Meaningful only in `reverse-proxy` mode; in `anonymous` mode no forwarded
126    /// identity is ever present, so the value is unused.
127    pub fn resolved_forwarded_header(&self) -> String {
128        self.forwarded_user_header
129            .clone()
130            .unwrap_or_else(|| DEFAULT_FORWARDED_USER_HEADER.to_string())
131    }
132
133    /// Build the runtime [`AuthMode`] from this config.
134    pub fn to_mode(&self) -> AuthMode {
135        match self.mode {
136            AuthKind::Anonymous => AuthMode::Anonymous,
137            AuthKind::ReverseProxy => AuthMode::ReverseProxy {
138                header: self
139                    .forwarded_user_header
140                    .clone()
141                    .unwrap_or_else(|| DEFAULT_FORWARDED_USER_HEADER.to_string()),
142                allow_missing: self.allow_missing_identity,
143            },
144        }
145    }
146}
147
148impl Config {
149    /// Load configuration from an optional YAML file path.
150    ///
151    /// `None` (or a path that does not exist) yields an error, because the
152    /// upstream URL is required and has no default — a config-less run must
153    /// supply it via CLI flags instead (see the serve command's
154    /// `--upstream-url`). This is only called when a path is given.
155    pub fn load(path: &str) -> Result<Self, String> {
156        let p = std::path::Path::new(path);
157        if !p.exists() {
158            return Err(format!("config file not found at {}", p.display()));
159        }
160        let contents =
161            std::fs::read_to_string(p).map_err(|e| format!("reading config `{path}`: {e}"))?;
162        serde_yml::from_str(&contents).map_err(|e| format!("parsing config `{path}`: {e}"))
163    }
164
165    /// Resolved bind host: the configured value, else [`DEFAULT_HOST`].
166    pub fn resolved_host(&self) -> &str {
167        self.host.as_deref().unwrap_or(DEFAULT_HOST)
168    }
169
170    /// Resolved listen port: the configured value, else [`DEFAULT_PORT`].
171    pub fn resolved_port(&self) -> u16 {
172        self.port.unwrap_or(DEFAULT_PORT)
173    }
174
175    /// Resolved mount prefix: the configured value, else [`DEFAULT_BASE_PATH`].
176    /// Normalized to a single leading slash with no trailing slash; an empty
177    /// value means "mount at root".
178    pub fn resolved_base_path(&self) -> String {
179        let raw = self.base_path.as_deref().unwrap_or(DEFAULT_BASE_PATH);
180        let trimmed = raw.trim().trim_matches('/');
181        if trimmed.is_empty() {
182            String::new()
183        } else {
184            format!("/{trimmed}")
185        }
186    }
187
188    /// The `/health` URL a `healthcheck` probe should GET. A wildcard bind host
189    /// maps to loopback, since that is not a connectable address for a client.
190    pub fn health_url(&self) -> String {
191        let host = match self.resolved_host() {
192            "0.0.0.0" | "" | "::" => "127.0.0.1",
193            other => other,
194        };
195        format!("http://{host}:{}/health", self.resolved_port())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn minimal_config_defaults() {
205        let yaml = r#"
206            upstream:
207              base_url: "http://uc:8080/api/2.1/unity-catalog/"
208        "#;
209        let cfg: Config = serde_yml::from_str(yaml).unwrap();
210        assert_eq!(cfg.resolved_host(), "0.0.0.0");
211        assert_eq!(cfg.resolved_port(), 8080);
212        assert_eq!(cfg.resolved_base_path(), "/storage-proxy");
213        assert!(cfg.upstream.token.is_none());
214        assert_eq!(cfg.auth.mode, AuthKind::Anonymous);
215        assert_eq!(cfg.auth.to_mode(), AuthMode::Anonymous);
216    }
217
218    #[test]
219    fn base_path_normalization() {
220        for raw in ["storage-proxy", "/storage-proxy", "/storage-proxy/"] {
221            let cfg = Config {
222                host: None,
223                port: None,
224                base_path: Some(raw.to_string()),
225                upstream: UpstreamConfig {
226                    base_url: "http://uc/".into(),
227                    token: None,
228                },
229                auth: AuthConfig::default(),
230            };
231            assert_eq!(cfg.resolved_base_path(), "/storage-proxy", "input {raw:?}");
232        }
233        // Empty ⇒ root mount.
234        let cfg = Config {
235            host: None,
236            port: None,
237            base_path: Some(String::new()),
238            upstream: UpstreamConfig {
239                base_url: "http://uc/".into(),
240                token: None,
241            },
242            auth: AuthConfig::default(),
243        };
244        assert_eq!(cfg.resolved_base_path(), "");
245    }
246
247    #[test]
248    fn reverse_proxy_auth_roundtrips_and_maps() {
249        let yaml = r#"
250            upstream:
251              base_url: "http://uc:8080/api/2.1/unity-catalog/"
252              token:
253                env: "UC_TOKEN"
254            auth:
255              mode: reverse-proxy
256              forwarded-user-header: "x-user"
257              allow-missing-identity: true
258        "#;
259        let cfg: Config = serde_yml::from_str(yaml).unwrap();
260        assert_eq!(
261            cfg.upstream.token,
262            Some(ConfigValue::Environment(EnvValue {
263                env: "UC_TOKEN".into()
264            }))
265        );
266        assert_eq!(
267            cfg.auth.to_mode(),
268            AuthMode::ReverseProxy {
269                header: "x-user".into(),
270                allow_missing: true
271            }
272        );
273
274        // Round-trips through YAML.
275        let reparsed: Config = serde_yml::from_str(&serde_yml::to_string(&cfg).unwrap()).unwrap();
276        assert_eq!(reparsed, cfg);
277    }
278
279    #[test]
280    fn reverse_proxy_defaults_to_standard_header() {
281        let yaml = r#"
282            upstream:
283              base_url: "http://uc/"
284            auth:
285              mode: reverse-proxy
286        "#;
287        let cfg: Config = serde_yml::from_str(yaml).unwrap();
288        assert_eq!(
289            cfg.auth.to_mode(),
290            AuthMode::ReverseProxy {
291                header: DEFAULT_FORWARDED_USER_HEADER.into(),
292                allow_missing: false
293            }
294        );
295    }
296
297    #[test]
298    fn health_url_maps_wildcard_host_to_loopback() {
299        let cfg = Config {
300            host: Some("0.0.0.0".into()),
301            port: Some(9000),
302            base_path: None,
303            upstream: UpstreamConfig {
304                base_url: "http://uc/".into(),
305                token: None,
306            },
307            auth: AuthConfig::default(),
308        };
309        assert_eq!(cfg.health_url(), "http://127.0.0.1:9000/health");
310    }
311}