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 self.local_storage_policy()
34 .check(&StorageLocationUrl::parse(&request.url)?)?;
35
36 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 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 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 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 if url != current.url {
133 self.local_storage_policy()
135 .check(&StorageLocationUrl::parse(&url)?)?;
136 check_no_overlap(self, &url, ¤t.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 Ok(self
155 .update(&curr_ident, current.into())
156 .await?
157 .0
158 .try_into()?)
159 }
160}
161
162async 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 #[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 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 #[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 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 let res = create_location(&h, "bad", "file:///etc/uc").await;
344 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
345
346 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 create_location(&h, "base", "s3://bucket/data")
359 .await
360 .unwrap();
361
362 let nested = create_location(&h, "nested", "s3://bucket/data/sub").await;
364 assert!(
365 matches!(nested, Err(Error::InvalidArgument(_))),
366 "{nested:?}"
367 );
368
369 let parent = create_location(&h, "parent", "s3://bucket").await;
371 assert!(
372 matches!(parent, Err(Error::InvalidArgument(_))),
373 "{parent:?}"
374 );
375
376 create_location(&h, "sibling", "s3://bucket/data-secret")
378 .await
379 .unwrap();
380
381 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 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 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}