Skip to main content

unitycatalog_server/api/
external_locations.rs

1use itertools::Itertools;
2
3use unitycatalog_common::models::external_locations::v1::*;
4use unitycatalog_common::models::{
5    ObjectLabel, ResourceExt, ResourceIdent, ResourceName, ResourceRef,
6};
7
8use super::{RequestContext, SecuredAction};
9pub use crate::codegen::external_locations::ExternalLocationHandler;
10use crate::policy::{Permission, Policy, process_resources};
11use crate::services::ProvidesLocalStoragePolicy;
12use crate::services::location::StorageLocationUrl;
13use crate::services::object_store::{list_external_locations, locations_overlap};
14use crate::store::ResourceStore;
15use crate::{Error, Result};
16
17#[async_trait::async_trait]
18impl<T: ResourceStore + Policy<RequestContext> + ProvidesLocalStoragePolicy>
19    ExternalLocationHandler<RequestContext> for T
20{
21    #[tracing::instrument(skip(self, context), fields(resource_name))]
22    async fn create_external_location(
23        &self,
24        request: CreateExternalLocationRequest,
25        context: RequestContext,
26    ) -> Result<ExternalLocation> {
27        tracing::Span::current().record("resource_name", &request.name);
28        self.check_required(&request, &context).await?;
29
30        // Local (file://) locations must sit within an allowed host root;
31        // deny-by-default unless the server is configured otherwise. Cloud
32        // schemes pass through.
33        self.local_storage_policy()
34            .check(&StorageLocationUrl::parse(&request.url)?)?;
35
36        // Reject locations that overlap an existing external location: Unity
37        // Catalog forbids one external location from nesting inside another (in
38        // either direction), so credential vending can resolve a single owner
39        // for any storage path.
40        check_no_overlap(self, &request.url, &request.name).await?;
41
42        let mut resource = ExternalLocation {
43            name: request.name,
44            url: request.url,
45            credential_name: request.credential_name,
46            read_only: request.read_only.unwrap_or(false),
47            comment: request.comment,
48            ..Default::default()
49        };
50        let cred_ident = ResourceIdent::Credential(
51            ResourceName::from_naive_str_split(&resource.credential_name).into(),
52        );
53        let (_credential, credential_ref) = self.get(&cred_ident).await?;
54        if let ResourceRef::Uuid(uuid) = credential_ref {
55            resource.credential_id = uuid.hyphenated().to_string();
56        }
57
58        // TODO: validate we can access the url with the provide credential
59
60        let info = self.create(resource.into()).await?.0.try_into()?;
61        Ok(info)
62    }
63
64    #[tracing::instrument(skip(self, context), fields(resource_name))]
65    async fn delete_external_location(
66        &self,
67        request: DeleteExternalLocationRequest,
68        context: RequestContext,
69    ) -> Result<()> {
70        tracing::Span::current().record("resource_name", &request.name);
71        self.check_required(&request, &context).await?;
72        // TODO: check if the location is used by any resources
73        Ok(self.delete(&request.resource()).await?)
74    }
75
76    #[tracing::instrument(skip(self, context), fields(resource_name))]
77    async fn get_external_location(
78        &self,
79        request: GetExternalLocationRequest,
80        context: RequestContext,
81    ) -> Result<ExternalLocation> {
82        tracing::Span::current().record("resource_name", &request.name);
83        self.check_required(&request, &context).await?;
84
85        // TODO: populate relation fields (updated_* etc.)
86
87        Ok(self.get(&request.resource()).await?.0.try_into()?)
88    }
89
90    #[tracing::instrument(skip(self, context))]
91    async fn list_external_locations(
92        &self,
93        request: ListExternalLocationsRequest,
94        context: RequestContext,
95    ) -> Result<ListExternalLocationsResponse> {
96        self.check_required(&request, &context).await?;
97        let (mut resources, next_page_token) = self
98            .list(
99                &ObjectLabel::ExternalLocation,
100                None,
101                request.max_results.map(|v| v as usize),
102                request.page_token,
103            )
104            .await?;
105        process_resources(self, &context, &Permission::Read, &mut resources).await?;
106        Ok(ListExternalLocationsResponse {
107            external_locations: resources.into_iter().map(|r| r.try_into()).try_collect()?,
108            next_page_token,
109            ..Default::default()
110        })
111    }
112
113    #[tracing::instrument(skip(self, context), fields(resource_name))]
114    async fn update_external_location(
115        &self,
116        request: UpdateExternalLocationRequest,
117        context: RequestContext,
118    ) -> Result<ExternalLocation> {
119        tracing::Span::current().record("resource_name", &request.name);
120        self.check_required(&request, &context).await?;
121
122        let (current, _) = self.get(&request.resource()).await?;
123        let curr_ident = current.resource_ident();
124        let mut current: ExternalLocation = current.try_into()?;
125
126        if let Some(name) = request.new_name {
127            current.name = name;
128        }
129        if let Some(url) = request.url {
130            // Re-validate overlap when the URL changes, excluding this
131            // location's own record (matched by name) from the comparison.
132            if url != current.url {
133                // A new local (file://) URL must sit within an allowed root.
134                self.local_storage_policy()
135                    .check(&StorageLocationUrl::parse(&url)?)?;
136                check_no_overlap(self, &url, &current.name).await?;
137            }
138            current.url = url;
139        }
140        if let Some(credential_name) = request.credential_name {
141            current.credential_name = credential_name;
142        }
143        if let Some(read_only) = request.read_only {
144            current.read_only = read_only;
145        }
146        if let Some(comment) = request.comment {
147            current.comment = Some(comment);
148        }
149
150        // TODO:
151        // - add update_* relations
152        // - update owner if necessary
153
154        Ok(self
155            .update(&curr_ident, current.into())
156            .await?
157            .0
158            .try_into()?)
159    }
160}
161
162/// Reject `url` if it overlaps any existing external location other than the
163/// one named `self_name` (so updating a location to its own URL is a no-op).
164///
165/// Unity Catalog forbids external locations from overlapping one another in
166/// either direction (ancestor or descendant), matched on path-segment
167/// boundaries.
168async fn check_no_overlap(
169    store: &(impl ResourceStore + ?Sized),
170    url: &str,
171    self_name: &str,
172) -> Result<()> {
173    let new_url = StorageLocationUrl::parse(url).map_err(|e| {
174        Error::invalid_argument(format!("invalid external location url '{url}': {e}"))
175    })?;
176    for existing in list_external_locations(store).await? {
177        if existing.name == self_name {
178            continue;
179        }
180        let Ok(existing_url) = StorageLocationUrl::parse(&existing.url) else {
181            continue;
182        };
183        if locations_overlap(&new_url, &existing_url) {
184            return Err(Error::invalid_argument(format!(
185                "external location url '{url}' overlaps existing external location '{}' ('{}')",
186                existing.name, existing.url
187            )));
188        }
189    }
190    Ok(())
191}
192
193impl SecuredAction for CreateExternalLocationRequest {
194    fn resource(&self) -> ResourceIdent {
195        ResourceIdent::external_location(ResourceName::new([self.name.as_str()]))
196    }
197
198    fn permission(&self) -> &'static Permission {
199        &Permission::Create
200    }
201}
202
203impl SecuredAction for ListExternalLocationsRequest {
204    fn resource(&self) -> ResourceIdent {
205        ResourceIdent::external_location(ResourceRef::Undefined)
206    }
207
208    fn permission(&self) -> &'static Permission {
209        &Permission::Read
210    }
211}
212
213impl SecuredAction for GetExternalLocationRequest {
214    fn resource(&self) -> ResourceIdent {
215        ResourceIdent::external_location(ResourceName::new([self.name.as_str()]))
216    }
217
218    fn permission(&self) -> &'static Permission {
219        &Permission::Read
220    }
221}
222
223impl SecuredAction for UpdateExternalLocationRequest {
224    fn resource(&self) -> ResourceIdent {
225        ResourceIdent::external_location(ResourceName::new([self.name.as_str()]))
226    }
227
228    fn permission(&self) -> &'static Permission {
229        &Permission::Manage
230    }
231}
232
233impl SecuredAction for DeleteExternalLocationRequest {
234    fn resource(&self) -> ResourceIdent {
235        ResourceIdent::external_location(ResourceName::new([self.name.as_str()]))
236    }
237
238    fn permission(&self) -> &'static Permission {
239        &Permission::Manage
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use std::sync::Arc;
246
247    use unitycatalog_common::models::credentials::v1::{
248        AwsIamRoleConfig, CreateCredentialRequest, Purpose,
249    };
250    use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
251
252    use super::*;
253    use crate::api::CredentialHandler;
254    use crate::memory::InMemoryResourceStore;
255    use crate::policy::ConstantPolicy;
256    use crate::services::ServerHandler;
257
258    fn handler() -> ServerHandler<RequestContext> {
259        let encryptor =
260            EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
261        let store = Arc::new(InMemoryResourceStore::new(encryptor));
262        let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
263        ServerHandler::try_new_tokio(policy, store).unwrap()
264    }
265
266    /// A handler whose local-storage policy allows `root`. Only used by the
267    /// POSIX-only allow-under-root test.
268    #[cfg(not(windows))]
269    fn handler_with_local_root(root: &std::path::Path) -> ServerHandler<RequestContext> {
270        let local = crate::services::LocalStoragePolicy::new([root]).unwrap();
271        handler().with_local_storage_policy(local)
272    }
273
274    fn ctx() -> RequestContext {
275        RequestContext {
276            recipient: crate::policy::Principal::anonymous(),
277        }
278    }
279
280    async fn create_credential(h: &ServerHandler<RequestContext>, name: &str) {
281        h.create_credential(
282            CreateCredentialRequest {
283                name: name.to_string(),
284                purpose: Purpose::Storage.into(),
285                aws_iam_role: Some(AwsIamRoleConfig {
286                    role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
287                    ..Default::default()
288                })
289                .into(),
290                ..Default::default()
291            },
292            ctx(),
293        )
294        .await
295        .unwrap();
296    }
297
298    async fn create_location(
299        h: &ServerHandler<RequestContext>,
300        name: &str,
301        url: &str,
302    ) -> Result<ExternalLocation> {
303        h.create_external_location(
304            CreateExternalLocationRequest {
305                name: name.to_string(),
306                url: url.to_string(),
307                credential_name: "cred".to_string(),
308                ..Default::default()
309            },
310            ctx(),
311        )
312        .await
313    }
314
315    #[tokio::test]
316    async fn file_location_denied_by_default() {
317        // No local-storage policy configured ⇒ every file:// is rejected, even
318        // though a cloud location at the same name would be accepted.
319        let h = handler();
320        create_credential(&h, "cred").await;
321        let res = create_location(&h, "local", "file:///tmp/uc-denied").await;
322        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
323    }
324
325    // POSIX-only: allow-under-root matching depends on canonicalized paths that
326    // don't line up on Windows (\\?\ prefix), and local file:// is gated off on
327    // Windows anyway. Deny-by-default (above) is what matters there.
328    #[cfg(not(windows))]
329    #[tokio::test]
330    async fn file_location_allowed_under_configured_root() {
331        let dir = tempfile::tempdir().unwrap();
332        let root = dir.path().canonicalize().unwrap();
333        let h = handler_with_local_root(&root);
334        create_credential(&h, "cred").await;
335
336        // Under the allowed root: accepted.
337        let inside = url::Url::from_directory_path(root.join("ext"))
338            .unwrap()
339            .to_string();
340        create_location(&h, "ok", &inside).await.unwrap();
341
342        // Outside the allowed root: rejected.
343        let res = create_location(&h, "bad", "file:///etc/uc").await;
344        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
345
346        // Cloud schemes are unaffected by the local policy.
347        create_location(&h, "cloud", "s3://bucket/data")
348            .await
349            .unwrap();
350    }
351
352    #[tokio::test]
353    async fn rejects_overlapping_external_location() {
354        let h = handler();
355        create_credential(&h, "cred").await;
356
357        // Base location succeeds.
358        create_location(&h, "base", "s3://bucket/data")
359            .await
360            .unwrap();
361
362        // A nested location (descendant) is rejected.
363        let nested = create_location(&h, "nested", "s3://bucket/data/sub").await;
364        assert!(
365            matches!(nested, Err(Error::InvalidArgument(_))),
366            "{nested:?}"
367        );
368
369        // An ancestor of the base location is rejected too (symmetric).
370        let parent = create_location(&h, "parent", "s3://bucket").await;
371        assert!(
372            matches!(parent, Err(Error::InvalidArgument(_))),
373            "{parent:?}"
374        );
375
376        // A sibling that merely shares a textual prefix is allowed.
377        create_location(&h, "sibling", "s3://bucket/data-secret")
378            .await
379            .unwrap();
380
381        // A disjoint location is allowed.
382        create_location(&h, "other", "s3://bucket/other")
383            .await
384            .unwrap();
385    }
386
387    #[tokio::test]
388    async fn update_to_overlapping_url_is_rejected_but_self_noop_ok() {
389        let h = handler();
390        create_credential(&h, "cred").await;
391        create_location(&h, "base", "s3://bucket/data")
392            .await
393            .unwrap();
394        create_location(&h, "other", "s3://bucket/other")
395            .await
396            .unwrap();
397
398        // Updating `other` to overlap `base` is rejected.
399        let res = h
400            .update_external_location(
401                UpdateExternalLocationRequest {
402                    name: "other".to_string(),
403                    url: Some("s3://bucket/data/inner".to_string()),
404                    ..Default::default()
405                },
406                ctx(),
407            )
408            .await;
409        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
410
411        // Re-saving `base` to its own URL is a no-op, not a self-overlap error.
412        h.update_external_location(
413            UpdateExternalLocationRequest {
414                name: "base".to_string(),
415                url: Some("s3://bucket/data".to_string()),
416                ..Default::default()
417            },
418            ctx(),
419        )
420        .await
421        .unwrap();
422    }
423}