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 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 let storage_root = if catalog_type == CatalogType::ManagedCatalog {
67 match request.storage_root.filter(|s| !s.is_empty()) {
68 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 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 self.local_storage_policy()
99 .check(&StorageLocationUrl::parse(&root)?)?;
100 Some(root)
101 }
102 }
103 } else {
104 None
105 };
106
107 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 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 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 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 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 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 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 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 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 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 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 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 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}