Skip to main content

unitycatalog_server/api/
catalogs.rs

1use itertools::Itertools;
2
3use unitycatalog_common::models::catalogs::v1::*;
4use unitycatalog_common::models::{ObjectLabel, ResourceIdent, ResourceName, ResourceRef};
5
6use super::{RequestContext, SecuredAction};
7pub use crate::codegen::catalogs::CatalogHandler;
8use crate::policy::{Permission, Policy, process_resources};
9use crate::services::location::StorageLocationUrl;
10use crate::services::{ProvidesLocalStoragePolicy, ProvidesManagedStorageRoot};
11use crate::store::ResourceStore;
12use crate::{Error, Result};
13
14#[async_trait::async_trait]
15impl<
16    T: ResourceStore
17        + Policy<RequestContext>
18        + ProvidesLocalStoragePolicy
19        + ProvidesManagedStorageRoot,
20> CatalogHandler<RequestContext> for T
21{
22    #[tracing::instrument(skip(self, context), fields(resource_name))]
23    async fn create_catalog(
24        &self,
25        request: CreateCatalogRequest,
26        context: RequestContext,
27    ) -> Result<Catalog> {
28        tracing::Span::current().record("resource_name", &request.name);
29        self.check_required(&request, &context).await?;
30
31        // A Delta Sharing catalog references a share on a remote provider; it is
32        // identified by `provider_name`/`share_name`, which must be set together
33        // and never alongside a managed `storage_root`. These cross-field rules
34        // are also expressed as protovalidate CEL on `CreateCatalogRequest` (for
35        // client-side form validation), but protovalidate has no Rust runtime —
36        // the server re-asserts them here as the authoritative check.
37        let is_sharing = request.provider_name.is_some() || request.share_name.is_some();
38        if request.provider_name.is_some() != request.share_name.is_some() {
39            return Err(Error::invalid_argument(
40                "provider_name and share_name must be set together for a Delta Sharing catalog",
41            ));
42        }
43        let has_root = request
44            .storage_root
45            .as_deref()
46            .is_some_and(|s| !s.is_empty());
47        if is_sharing && has_root {
48            return Err(Error::invalid_argument(
49                "a Delta Sharing catalog must not set storage_root",
50            ));
51        }
52
53        let catalog_type = if is_sharing {
54            CatalogType::DeltasharingCatalog
55        } else {
56            CatalogType::ManagedCatalog
57        };
58
59        // A managed catalog must have a resolvable managed storage root: either
60        // an explicit `storage_root` on the request, or the metastore-level
61        // default. This mirrors Unity Catalog ("if the metastore has no managed
62        // storage set, you must set one at the catalog level"). The resolved
63        // root is materialized onto the catalog so an inherited metastore root
64        // is recorded and table-time resolution finds it directly. Delta Sharing
65        // catalogs have no managed storage and are exempt.
66        let storage_root = if catalog_type == CatalogType::ManagedCatalog {
67            match request.storage_root.filter(|s| !s.is_empty()) {
68                // Client-supplied root: it must pass the local-storage policy, lie
69                // outside any reserved `__unitystorage` region, and be covered by a
70                // registered external location — mirroring the reference's
71                // `CatalogService` `AuthorizeExpression`, which requires external
72                // location coverage only for a client-supplied root.
73                // TODO(auth): also authorize CREATE_MANAGED_STORAGE/OWNER on the
74                // covering external location once the policy layer exists
75                // (feedback_auth_pattern).
76                Some(root) => {
77                    let url = StorageLocationUrl::parse(&root)?;
78                    crate::services::object_store::validate_managed_storage_root(self, &url)
79                        .await?;
80                    Some(root)
81                }
82                // No client root: fall back to the metastore-level managed storage
83                // root. That is server configuration, so it is only checked against
84                // the local-storage allowlist — it is not required to be covered by
85                // a registered external location.
86                None => {
87                    let root =
88                        self.managed_storage_root()
89                            .map(str::to_string)
90                            .ok_or_else(|| {
91                                Error::invalid_argument(format!(
92                                    "managed catalog '{}' requires a storage_root, or a metastore \
93                                 managed storage root to be configured on the server",
94                                    request.name
95                                ))
96                            })?;
97                    // A local (file://) managed root must sit within an allowed host root.
98                    self.local_storage_policy()
99                        .check(&StorageLocationUrl::parse(&root)?)?;
100                    Some(root)
101                }
102            }
103        } else {
104            None
105        };
106
107        // Pre-allocate the catalog id so the managed storage location can embed
108        // it (`<root>/__unitystorage/catalogs/<id>`), mirroring the reference's
109        // `CatalogRepository`. The store honors a pre-set id (else it mints a
110        // v7). Managed catalogs with a resolvable root get a materialized
111        // `storage_location`; sharing catalogs have no managed storage.
112        let id = uuid::Uuid::now_v7().hyphenated().to_string();
113        let storage_location = storage_root
114            .as_deref()
115            .map(|root| super::staging_tables::catalog_location(root, &id));
116
117        let resource = Catalog {
118            id: Some(id),
119            name: request.name,
120            comment: request.comment,
121            properties: request.properties,
122            storage_root,
123            storage_location,
124            provider_name: request.provider_name,
125            share_name: request.share_name,
126            catalog_type: Some(catalog_type.into()),
127            ..Default::default()
128        };
129        let info = self.create(resource.into()).await?.0.try_into()?;
130
131        // TODO:
132        // - make current actor the owner of the catalog including permissions
133        // - create updated_* relations
134
135        Ok(info)
136    }
137
138    #[tracing::instrument(skip(self, context), fields(resource_name))]
139    async fn delete_catalog(
140        &self,
141        request: DeleteCatalogRequest,
142        context: RequestContext,
143    ) -> Result<()> {
144        tracing::Span::current().record("resource_name", &request.name);
145        self.check_required(&request, &context).await?;
146        Ok(self.delete(&request.resource()).await?)
147    }
148
149    #[tracing::instrument(skip(self, context), fields(resource_name))]
150    async fn get_catalog(
151        &self,
152        request: GetCatalogRequest,
153        context: RequestContext,
154    ) -> Result<Catalog> {
155        tracing::Span::current().record("resource_name", &request.name);
156        self.check_required(&request, &context).await?;
157        Ok(self.get(&request.resource()).await?.0.try_into()?)
158    }
159
160    #[tracing::instrument(skip(self, context))]
161    async fn list_catalogs(
162        &self,
163        request: ListCatalogsRequest,
164        context: RequestContext,
165    ) -> Result<ListCatalogsResponse> {
166        self.check_required(&request, &context).await?;
167        let (mut resources, next_page_token) = self
168            .list(
169                &ObjectLabel::Catalog,
170                None,
171                request.max_results.map(|v| v as usize),
172                request.page_token,
173            )
174            .await?;
175        process_resources(self, &context, &Permission::Read, &mut resources).await?;
176        Ok(ListCatalogsResponse {
177            catalogs: resources.into_iter().map(|r| r.try_into()).try_collect()?,
178            next_page_token,
179            ..Default::default()
180        })
181    }
182
183    #[tracing::instrument(skip(self, context), fields(resource_name))]
184    async fn update_catalog(
185        &self,
186        request: UpdateCatalogRequest,
187        context: RequestContext,
188    ) -> Result<Catalog> {
189        tracing::Span::current().record("resource_name", &request.name);
190        self.check_required(&request, &context).await?;
191        let ident = request.resource();
192        let resource = Catalog {
193            name: request.new_name.unwrap_or(request.name),
194            comment: request.comment,
195            properties: request.properties,
196            ..Default::default()
197        };
198        // TODO:
199        // - add update_* relations
200        // - update owner if necessary
201        Ok(self.update(&ident, resource.into()).await?.0.try_into()?)
202    }
203}
204
205impl SecuredAction for CreateCatalogRequest {
206    fn resource(&self) -> ResourceIdent {
207        ResourceIdent::catalog(ResourceName::new([self.name.as_str()]))
208    }
209
210    fn permission(&self) -> &'static Permission {
211        &Permission::Create
212    }
213}
214
215impl SecuredAction for ListCatalogsRequest {
216    fn resource(&self) -> ResourceIdent {
217        ResourceIdent::catalog(ResourceRef::Undefined)
218    }
219
220    fn permission(&self) -> &'static Permission {
221        &Permission::Read
222    }
223}
224
225impl SecuredAction for GetCatalogRequest {
226    fn resource(&self) -> ResourceIdent {
227        ResourceIdent::catalog(ResourceName::new([self.name.as_str()]))
228    }
229
230    fn permission(&self) -> &'static Permission {
231        &Permission::Read
232    }
233}
234
235impl SecuredAction for UpdateCatalogRequest {
236    fn resource(&self) -> ResourceIdent {
237        ResourceIdent::catalog(ResourceName::new([self.name.as_str()]))
238    }
239
240    fn permission(&self) -> &'static Permission {
241        &Permission::Manage
242    }
243}
244
245impl SecuredAction for DeleteCatalogRequest {
246    fn resource(&self) -> ResourceIdent {
247        ResourceIdent::catalog(ResourceName::new([self.name.as_str()]))
248    }
249
250    fn permission(&self) -> &'static Permission {
251        &Permission::Manage
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::sync::Arc;
258
259    use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
260
261    use unitycatalog_common::models::credentials::v1::{
262        AwsIamRoleConfig, CreateCredentialRequest, Purpose,
263    };
264    use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
265
266    use super::*;
267    use crate::api::{CredentialHandler, ExternalLocationHandler};
268    use crate::memory::InMemoryResourceStore;
269    use crate::policy::{ConstantPolicy, Principal};
270    use crate::services::{LocalStoragePolicy, ServerHandler};
271
272    /// Build a handler with an optional metastore managed storage root. When
273    /// `allowed_root` is set, local (file://) storage beneath it is permitted.
274    fn handler(
275        metastore_root: Option<&str>,
276        allowed_root: Option<&std::path::Path>,
277    ) -> ServerHandler<RequestContext> {
278        let encryptor =
279            EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
280        let store = Arc::new(InMemoryResourceStore::new(encryptor));
281        let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
282        let mut h = ServerHandler::try_new_tokio(policy, store).unwrap();
283        if let Some(root) = allowed_root {
284            h = h.with_local_storage_policy(LocalStoragePolicy::new([root]).unwrap());
285        }
286        h.with_managed_storage_root(metastore_root.map(str::to_string))
287    }
288
289    fn ctx() -> RequestContext {
290        RequestContext {
291            recipient: Principal::anonymous(),
292        }
293    }
294
295    fn create_req(name: &str) -> CreateCatalogRequest {
296        CreateCatalogRequest {
297            name: name.to_string(),
298            ..Default::default()
299        }
300    }
301
302    /// Register a credential and an external location at `url` so a managed
303    /// `storage_root` under it passes the external-location coverage check. The
304    /// credential name is derived from `name` (external-location create resolves
305    /// `credential_name` → `credential_id`, so the credential must exist first).
306    async fn make_covering_location(h: &ServerHandler<RequestContext>, name: &str, url: &str) {
307        h.create_credential(
308            CreateCredentialRequest {
309                name: format!("{name}-cred"),
310                purpose: Purpose::Storage.into(),
311                aws_iam_role: Some(AwsIamRoleConfig {
312                    role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
313                    ..Default::default()
314                })
315                .into(),
316                ..Default::default()
317            },
318            ctx(),
319        )
320        .await
321        .unwrap();
322        h.create_external_location(
323            CreateExternalLocationRequest {
324                name: name.to_string(),
325                url: url.to_string(),
326                credential_name: format!("{name}-cred"),
327                ..Default::default()
328            },
329            ctx(),
330        )
331        .await
332        .unwrap();
333    }
334
335    #[tokio::test]
336    async fn managed_catalog_with_explicit_root_persists_it() {
337        let h = handler(None, None);
338        make_covering_location(&h, "el", "s3://bucket/cat").await;
339        let cat = h
340            .create_catalog(
341                CreateCatalogRequest {
342                    storage_root: Some("s3://bucket/cat".to_string()),
343                    ..create_req("cat")
344                },
345                ctx(),
346            )
347            .await
348            .unwrap();
349        assert_eq!(cat.storage_root.as_deref(), Some("s3://bucket/cat"));
350        assert_eq!(cat.catalog_type, Some(CatalogType::ManagedCatalog.into()));
351        // The managed storage location is materialized with the catalog id:
352        // <root>/__unitystorage/catalogs/<id>.
353        let id = cat.id.as_deref().expect("catalog should have an id");
354        assert_eq!(
355            cat.storage_location.as_deref(),
356            Some(format!("s3://bucket/cat/__unitystorage/catalogs/{id}").as_str())
357        );
358    }
359
360    #[tokio::test]
361    async fn managed_catalog_without_root_and_no_metastore_default_is_rejected() {
362        let h = handler(None, None);
363        let res = h.create_catalog(create_req("cat"), ctx()).await;
364        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
365    }
366
367    #[tokio::test]
368    async fn managed_catalog_inherits_metastore_default() {
369        let h = handler(Some("s3://bucket/meta"), None);
370        let cat = h.create_catalog(create_req("cat"), ctx()).await.unwrap();
371        // The inherited metastore root is materialized onto the catalog, and the
372        // storage location nests under it with the catalog id.
373        assert_eq!(cat.storage_root.as_deref(), Some("s3://bucket/meta"));
374        let id = cat.id.as_deref().expect("catalog should have an id");
375        assert_eq!(
376            cat.storage_location.as_deref(),
377            Some(format!("s3://bucket/meta/__unitystorage/catalogs/{id}").as_str())
378        );
379    }
380
381    #[tokio::test]
382    async fn explicit_root_takes_precedence_over_metastore_default() {
383        let h = handler(Some("s3://bucket/meta"), None);
384        make_covering_location(&h, "el", "s3://bucket/explicit").await;
385        let cat = h
386            .create_catalog(
387                CreateCatalogRequest {
388                    storage_root: Some("s3://bucket/explicit".to_string()),
389                    ..create_req("cat")
390                },
391                ctx(),
392            )
393            .await
394            .unwrap();
395        assert_eq!(cat.storage_root.as_deref(), Some("s3://bucket/explicit"));
396    }
397
398    #[tokio::test]
399    async fn sharing_catalog_without_root_is_allowed() {
400        let h = handler(None, None);
401        let cat = h
402            .create_catalog(
403                CreateCatalogRequest {
404                    provider_name: Some("prov".to_string()),
405                    share_name: Some("shr".to_string()),
406                    ..create_req("cat")
407                },
408                ctx(),
409            )
410            .await
411            .unwrap();
412        assert!(cat.storage_root.is_none());
413        // A sharing catalog has no managed storage, so no location is materialized.
414        assert!(cat.storage_location.is_none());
415        assert_eq!(
416            cat.catalog_type,
417            Some(CatalogType::DeltasharingCatalog.into())
418        );
419    }
420
421    #[tokio::test]
422    async fn sharing_catalog_with_storage_root_is_rejected() {
423        let h = handler(None, None);
424        let res = h
425            .create_catalog(
426                CreateCatalogRequest {
427                    provider_name: Some("prov".to_string()),
428                    share_name: Some("shr".to_string()),
429                    storage_root: Some("s3://bucket/cat".to_string()),
430                    ..create_req("cat")
431                },
432                ctx(),
433            )
434            .await;
435        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
436    }
437
438    #[tokio::test]
439    async fn provider_without_share_is_rejected() {
440        let h = handler(None, None);
441        let res = h
442            .create_catalog(
443                CreateCatalogRequest {
444                    provider_name: Some("prov".to_string()),
445                    ..create_req("cat")
446                },
447                ctx(),
448            )
449            .await;
450        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
451    }
452
453    #[tokio::test]
454    async fn explicit_root_covered_by_external_location_succeeds() {
455        // A client-supplied root nested under a registered external location is
456        // accepted; the location is materialized under the catalog id.
457        let h = handler(None, None);
458        make_covering_location(&h, "el", "s3://bucket").await;
459        let cat = h
460            .create_catalog(
461                CreateCatalogRequest {
462                    storage_root: Some("s3://bucket/cat".to_string()),
463                    ..create_req("cat")
464                },
465                ctx(),
466            )
467            .await
468            .unwrap();
469        let id = cat.id.as_deref().expect("catalog should have an id");
470        assert_eq!(
471            cat.storage_location.as_deref(),
472            Some(format!("s3://bucket/cat/__unitystorage/catalogs/{id}").as_str())
473        );
474    }
475
476    #[tokio::test]
477    async fn explicit_root_without_external_location_is_rejected() {
478        // No registered external location covers the client-supplied root ⇒ reject.
479        let h = handler(None, None);
480        let res = h
481            .create_catalog(
482                CreateCatalogRequest {
483                    storage_root: Some("s3://bucket/cat".to_string()),
484                    ..create_req("cat")
485                },
486                ctx(),
487            )
488            .await;
489        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
490    }
491
492    #[tokio::test]
493    async fn explicit_root_under_managed_prefix_is_rejected() {
494        // A client root inside a reserved `__unitystorage` region is rejected even
495        // when an external location covers it — the server owns that layout.
496        let h = handler(None, None);
497        make_covering_location(&h, "el", "s3://bucket").await;
498        let res = h
499            .create_catalog(
500                CreateCatalogRequest {
501                    storage_root: Some("s3://bucket/__unitystorage/cat".to_string()),
502                    ..create_req("cat")
503                },
504                ctx(),
505            )
506            .await;
507        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
508    }
509
510    #[tokio::test]
511    async fn metastore_default_root_without_external_location_succeeds() {
512        // The metastore-level default is server config and is exempt from the
513        // external-location coverage requirement — no EL registered, still works.
514        let h = handler(Some("s3://bucket/meta"), None);
515        let cat = h.create_catalog(create_req("cat"), ctx()).await.unwrap();
516        assert_eq!(cat.storage_root.as_deref(), Some("s3://bucket/meta"));
517    }
518
519    #[tokio::test]
520    async fn local_root_outside_allowlist_is_rejected() {
521        // No allowed roots ⇒ deny all file://.
522        let h = handler(None, None);
523        let res = h
524            .create_catalog(
525                CreateCatalogRequest {
526                    storage_root: Some("file:///tmp/not-allowed".to_string()),
527                    ..create_req("cat")
528                },
529                ctx(),
530            )
531            .await;
532        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
533    }
534}