Skip to main content

unitycatalog_storage_proxy/
backend.rs

1//! The `StorageProxyBackend` port and its request vocabulary.
2//!
3//! The port is intentionally tiny: it [authorizes](StorageProxyBackend::authorize)
4//! a request (including the confused-deputy scope guard) and [opens](StorageProxyBackend::open)
5//! a **verb-scoped, securable-prefixed** object store. All the HTTP/streaming
6//! semantics live in the [handler](crate::handler), so a backend implementer only
7//! decides *who may touch what* and *which credential-scoped store to hand back* —
8//! never how bytes move.
9
10use std::sync::Arc;
11
12use object_store::{DynObjectStore, GetRange};
13use url::Url;
14
15use crate::error::ProxyResult;
16
17/// The HTTP verb, which decides the credential scope: `Get`/`Head` open a
18/// read-scoped store, `Put` a write-scoped one.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ProxyVerb {
21    /// A body-returning read.
22    Get,
23    /// A metadata-only read.
24    Head,
25    /// A whole-object write.
26    Put,
27}
28
29impl ProxyVerb {
30    /// Whether this verb requires write access.
31    pub fn is_write(self) -> bool {
32        matches!(self, ProxyVerb::Put)
33    }
34}
35
36/// What a request targets, parsed from the `{securable}` path segment.
37///
38/// The wire encoding is a single typed, colon-delimited path segment:
39/// - `table:<catalog.schema.table>`
40/// - `vol:<catalog.schema.volume>`
41/// - `path:<percent-encoded cloud URL>` (e.g. `path:s3%3A%2F%2Fbucket%2Fprefix%2F`)
42///
43/// Keeping the securable in one path segment (no unescaped `/`) lets the router's
44/// `{*key}` wildcard cleanly capture the object key as the remaining path.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Securable {
47    /// A managed/external table, by fully-qualified `catalog.schema.table` name.
48    Table {
49        /// The fully-qualified table name.
50        full_name: String,
51    },
52    /// A volume, by fully-qualified `catalog.schema.volume` name.
53    Volume {
54        /// The fully-qualified volume name.
55        full_name: String,
56    },
57    /// A raw cloud storage URL (`s3://`, `abfss://`, `gs://`, …).
58    Path {
59        /// The parsed cloud URL.
60        url: Url,
61    },
62}
63
64impl Securable {
65    /// Parse a `{securable}` path segment (already percent-decoded by the router's
66    /// path extractor) into a [`Securable`].
67    ///
68    /// Returns [`ProxyError::InvalidArgument`](crate::error::ProxyError::InvalidArgument)
69    /// for an unknown kind prefix, an empty identifier, or a `path:` value that is
70    /// not a valid URL.
71    pub fn parse(segment: &str) -> ProxyResult<Securable> {
72        use crate::error::ProxyError;
73
74        let (kind, ident) = segment.split_once(':').ok_or_else(|| {
75            ProxyError::InvalidArgument(format!(
76                "securable must be `<kind>:<ident>` (got `{segment}`)"
77            ))
78        })?;
79        if ident.is_empty() {
80            return Err(ProxyError::InvalidArgument(format!(
81                "securable `{kind}:` has an empty identifier"
82            )));
83        }
84        match kind {
85            "table" => Ok(Securable::Table {
86                full_name: ident.to_string(),
87            }),
88            "vol" => Ok(Securable::Volume {
89                full_name: ident.to_string(),
90            }),
91            "path" => {
92                let url = Url::parse(ident).map_err(|e| {
93                    ProxyError::InvalidArgument(format!("path securable is not a valid URL: {e}"))
94                })?;
95                Ok(Securable::Path { url })
96            }
97            other => Err(ProxyError::InvalidArgument(format!(
98                "unknown securable kind `{other}` (expected table|vol|path)"
99            ))),
100        }
101    }
102}
103
104/// A fully-parsed proxy request handed to the [port](StorageProxyBackend).
105///
106/// The handler has already parsed `Range`/`If-Match` from HTTP headers, so the
107/// backend never touches axum or `http` types.
108#[derive(Debug, Clone)]
109pub struct ProxyReq {
110    /// The verb (decides read vs write credential scope).
111    pub verb: ProxyVerb,
112    /// The target securable.
113    pub securable: Securable,
114    /// The object key **relative to the securable root** (the `{*key}` tail).
115    pub key: String,
116    /// The parsed `Range` header (`Get` only; `None` otherwise).
117    pub range: Option<GetRange>,
118    /// The parsed `If-Match` header value (`Put` only), relayed to storage as a
119    /// conditional write.
120    pub if_match: Option<String>,
121}
122
123/// What the server announces about its storage-access posture.
124///
125/// Surfaced by the server-level capabilities endpoint as
126/// `storageAccess: "proxy" | "direct"`; extensible with further flags.
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128pub struct ProxyCapabilities {
129    /// Whether the proxy surface is mounted (announced as `"proxy"` when true).
130    pub enabled: bool,
131}
132
133/// Read a forwarded end-user identity out of a backend's per-request context.
134///
135/// The [`StorageProxyBackend`] port is generic over the host's context type `Cx`,
136/// but the client arm needs to forward the caller's identity to the upstream
137/// Unity Catalog credential vend. This trait lets a backend extract that identity
138/// from any `Cx` without the backend having to name the host's concrete context
139/// type: the standalone binary's context is a `ForwardedIdentity` (which forwards
140/// the validated reverse-proxy user), while a host that has no forwarded identity
141/// (or the unit context `()` used by tests) simply returns `None`.
142pub trait ForwardedUser {
143    /// The forwarded end-user name, or `None` when the request is anonymous /
144    /// carries no forwarded identity.
145    fn forwarded_user(&self) -> Option<&str>;
146}
147
148/// The unit context carries no identity — always anonymous.
149impl ForwardedUser for () {
150    fn forwarded_user(&self) -> Option<&str> {
151        None
152    }
153}
154
155/// The backend port: authorize a request and open a verb-scoped, prefixed store.
156///
157/// `Cx` is the host's per-request context (mangrove uses its `RequestContext`; the
158/// in-memory testing backend uses `()`). [`authorize`](Self::authorize) runs first
159/// and MUST enforce both the caller's permission for the verb and the
160/// confused-deputy path-scope guard; [`open`](Self::open) then returns a store
161/// **prefixed at the securable root**, so a [`ProxyReq::key`] addresses relative to
162/// it and cannot escape.
163#[async_trait::async_trait]
164pub trait StorageProxyBackend<Cx = ()>: Send + Sync + 'static {
165    /// The proxy capabilities this backend advertises.
166    fn capabilities(&self) -> ProxyCapabilities {
167        ProxyCapabilities::default()
168    }
169
170    /// Authorize + scope-check the request. Runs before [`open`](Self::open).
171    ///
172    /// Must reject a caller lacking the verb's access level
173    /// ([`ProxyError::PermissionDenied`](crate::error::ProxyError::PermissionDenied)
174    /// / [`Unauthenticated`](crate::error::ProxyError::Unauthenticated)) and any key
175    /// that escapes the securable's authorized root
176    /// ([`ProxyError::OutOfScope`](crate::error::ProxyError::OutOfScope)).
177    async fn authorize(&self, req: &ProxyReq, cx: &Cx) -> ProxyResult<()>;
178
179    /// Open a store scoped to `req.securable` at the access level implied by the
180    /// verb (`Get`/`Head` → read, `Put` → read-write). The returned store is
181    /// **prefixed at the securable root**, so `req.key` addresses relative to it.
182    ///
183    /// `open` performs no authorization — that already happened in
184    /// [`authorize`](Self::authorize).
185    async fn open(&self, req: &ProxyReq, cx: &Cx) -> ProxyResult<Arc<DynObjectStore>>;
186}
187
188/// Reject an object key that could escape the securable root (confused-deputy
189/// guard). Rejects `..` segments, absolute keys (leading `/`), backslashes, and
190/// NUL bytes; tolerates `.`/empty segments.
191///
192/// This is layer 1 of the defense; prefix confinement (the store returned by
193/// [`StorageProxyBackend::open`] is rooted at the securable) and verb-scoped
194/// credentials are layers 2–3. Backends should call this from
195/// [`authorize`](StorageProxyBackend::authorize).
196pub fn reject_key_traversal(key: &str) -> ProxyResult<()> {
197    use crate::error::ProxyError;
198
199    if key.starts_with('/') {
200        return Err(ProxyError::OutOfScope("absolute key not allowed".into()));
201    }
202    if key.contains('\\') || key.contains('\0') {
203        return Err(ProxyError::OutOfScope(
204            "key contains an illegal character".into(),
205        ));
206    }
207    for segment in key.split('/') {
208        if segment == ".." {
209            return Err(ProxyError::OutOfScope(
210                "key contains a `..` path traversal".into(),
211            ));
212        }
213    }
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn parse_table_and_volume() {
223        assert_eq!(
224            Securable::parse("table:main.default.events").unwrap(),
225            Securable::Table {
226                full_name: "main.default.events".into()
227            }
228        );
229        assert_eq!(
230            Securable::parse("vol:main.default.landing").unwrap(),
231            Securable::Volume {
232                full_name: "main.default.landing".into()
233            }
234        );
235    }
236
237    #[test]
238    fn parse_path_url() {
239        let s = Securable::parse("path:s3://bucket/prefix/").unwrap();
240        match s {
241            Securable::Path { url } => assert_eq!(url.as_str(), "s3://bucket/prefix/"),
242            other => panic!("expected Path, got {other:?}"),
243        }
244    }
245
246    #[test]
247    fn parse_rejects_unknown_kind_and_empty_and_bad_url() {
248        assert!(Securable::parse("bogus:x").is_err());
249        assert!(Securable::parse("table:").is_err());
250        assert!(Securable::parse("no-colon").is_err());
251        assert!(Securable::parse("path:not a url").is_err());
252    }
253
254    #[test]
255    fn traversal_guard() {
256        assert!(reject_key_traversal("a/b/c.parquet").is_ok());
257        assert!(reject_key_traversal("_delta_log/00000000000000000001.json").is_ok());
258        assert!(reject_key_traversal("a/../../etc/passwd").is_err());
259        assert!(reject_key_traversal("/absolute").is_err());
260        assert!(reject_key_traversal("a\\b").is_err());
261        // A leading `..` and a bare `..` segment are both rejected.
262        assert!(reject_key_traversal("../sibling").is_err());
263        assert!(reject_key_traversal("..").is_err());
264        // `.` and empty segments are tolerated (normalized by object_store::Path).
265        assert!(reject_key_traversal("a/./b").is_ok());
266        assert!(reject_key_traversal("a//b").is_ok());
267    }
268}