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}