Skip to main content

unitycatalog_client/
temporary_credentials.rs

1use olai_http::CloudClient;
2use reqwest::IntoUrl;
3use unitycatalog_common::models::temporary_credentials::v1::TemporaryCredential;
4use unitycatalog_common::{
5    model_versions::v1::GetModelVersionRequest,
6    models::temporary_credentials::v1::{
7        GenerateTemporaryModelVersionCredentialsRequest, GenerateTemporaryPathCredentialsRequest,
8        GenerateTemporaryTableCredentialsRequest, GenerateTemporaryVolumeCredentialsRequest,
9        generate_temporary_model_version_credentials_request::Operation as MvOperation,
10        generate_temporary_path_credentials_request::Operation as PthOperation,
11        generate_temporary_table_credentials_request::Operation as TblOperation,
12        generate_temporary_volume_credentials_request::Operation as VolOperation,
13    },
14    tables::v1::GetTableRequest,
15    volumes::v1::GetVolumeRequest,
16};
17use url::Url;
18use uuid::Uuid;
19
20use crate::Result;
21use crate::codegen::tables::TableServiceClient;
22pub(super) use crate::codegen::temporary_credentials::TemporaryCredentialClient as TemporaryCredentialClientBase;
23use crate::codegen::volumes::client::VolumeServiceClient;
24
25/// A reference to a table in unity catalog.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TableReference {
28    /// The unique identifier of the table.
29    Id(Uuid),
30    /// The fully qualified name of the table.
31    Name(String),
32}
33
34impl From<String> for TableReference {
35    fn from(name: String) -> Self {
36        TableReference::Name(name)
37    }
38}
39
40impl From<&str> for TableReference {
41    fn from(name: &str) -> Self {
42        TableReference::Name(name.to_string())
43    }
44}
45
46impl From<Uuid> for TableReference {
47    fn from(id: Uuid) -> Self {
48        TableReference::Id(id)
49    }
50}
51
52/// A reference to a volume in unity catalog.
53///
54/// Use [`VolumeReference::Name`] for the three-level
55/// `<catalog>.<schema>.<volume>` form and [`VolumeReference::Id`] when you
56/// already hold the volume's UUID. The client resolves names to IDs
57/// transparently on first use.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum VolumeReference {
60    /// The unique identifier of the volume.
61    Id(Uuid),
62    /// The fully qualified `<catalog>.<schema>.<volume>` name.
63    Name(String),
64}
65
66impl From<String> for VolumeReference {
67    fn from(name: String) -> Self {
68        VolumeReference::Name(name)
69    }
70}
71
72impl From<&str> for VolumeReference {
73    fn from(name: &str) -> Self {
74        VolumeReference::Name(name.to_string())
75    }
76}
77
78impl From<Uuid> for VolumeReference {
79    fn from(id: Uuid) -> Self {
80        VolumeReference::Id(id)
81    }
82}
83
84/// The kind of access requested for a table.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum TableOperation {
87    /// Read-only access.
88    Read,
89    /// Read and write access.
90    ReadWrite,
91}
92
93impl From<TableOperation> for i32 {
94    fn from(operation: TableOperation) -> Self {
95        match operation {
96            TableOperation::Read => TblOperation::Read as i32,
97            TableOperation::ReadWrite => TblOperation::ReadWrite as i32,
98        }
99    }
100}
101
102impl From<TableOperation> for TblOperation {
103    fn from(operation: TableOperation) -> Self {
104        match operation {
105            TableOperation::Read => TblOperation::Read,
106            TableOperation::ReadWrite => TblOperation::ReadWrite,
107        }
108    }
109}
110
111/// The kind of access requested for a storage path.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum PathOperation {
114    /// Read-only access.
115    Read,
116    /// Read and write access.
117    ReadWrite,
118    /// Access for creating a new table at the path.
119    CreateTable,
120}
121
122impl From<PathOperation> for i32 {
123    fn from(operation: PathOperation) -> Self {
124        match operation {
125            PathOperation::Read => PthOperation::PathRead as i32,
126            PathOperation::ReadWrite => PthOperation::PathReadWrite as i32,
127            PathOperation::CreateTable => PthOperation::PathCreateTable as i32,
128        }
129    }
130}
131
132impl From<PathOperation> for PthOperation {
133    fn from(operation: PathOperation) -> Self {
134        match operation {
135            PathOperation::Read => PthOperation::PathRead,
136            PathOperation::ReadWrite => PthOperation::PathReadWrite,
137            PathOperation::CreateTable => PthOperation::PathCreateTable,
138        }
139    }
140}
141
142/// The kind of access requested for a volume.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum VolumeOperation {
145    /// Read-only access.
146    Read,
147    /// Read and write access.
148    ReadWrite,
149}
150
151impl From<VolumeOperation> for i32 {
152    fn from(operation: VolumeOperation) -> Self {
153        match operation {
154            VolumeOperation::Read => VolOperation::ReadVolume as i32,
155            VolumeOperation::ReadWrite => VolOperation::WriteVolume as i32,
156        }
157    }
158}
159
160impl From<VolumeOperation> for VolOperation {
161    fn from(operation: VolumeOperation) -> Self {
162        match operation {
163            VolumeOperation::Read => VolOperation::ReadVolume,
164            VolumeOperation::ReadWrite => VolOperation::WriteVolume,
165        }
166    }
167}
168
169/// The kind of access requested for a model version.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum ModelVersionOperation {
172    /// Read-only access.
173    Read,
174    /// Read and write access.
175    ReadWrite,
176}
177
178impl From<ModelVersionOperation> for i32 {
179    fn from(operation: ModelVersionOperation) -> Self {
180        match operation {
181            ModelVersionOperation::Read => MvOperation::ReadModelVersion as i32,
182            ModelVersionOperation::ReadWrite => MvOperation::ReadWriteModelVersion as i32,
183        }
184    }
185}
186
187impl From<ModelVersionOperation> for MvOperation {
188    fn from(operation: ModelVersionOperation) -> Self {
189        match operation {
190            ModelVersionOperation::Read => MvOperation::ReadModelVersion,
191            ModelVersionOperation::ReadWrite => MvOperation::ReadWriteModelVersion,
192        }
193    }
194}
195
196/// Client for vending temporary storage credentials for tables, volumes, and paths.
197///
198/// Wraps the generated low-level client with name → UUID resolution: callers may
199/// pass a fully qualified name or a UUID, and the client issues the lookup needed
200/// to obtain the ID the credential endpoint requires. Prefer
201/// [`UnityCatalogClient::temporary_credentials`](crate::UnityCatalogClient::temporary_credentials).
202#[derive(Clone)]
203pub struct TemporaryCredentialClient {
204    client: TemporaryCredentialClientBase,
205}
206
207impl TemporaryCredentialClient {
208    /// Creates a client from a [`CloudClient`] (carrying auth) and a base URL.
209    ///
210    /// The base URL is normalized to end in `/` so it joins cleanly with the
211    /// relative endpoint paths.
212    pub fn new_with_url(client: CloudClient, mut base_url: Url) -> Self {
213        if !base_url.path().ends_with('/') {
214            base_url.set_path(&format!("{}/", base_url.path()));
215        }
216        Self {
217            client: TemporaryCredentialClientBase::new(client, base_url),
218        }
219    }
220
221    /// Creates a client wrapping an existing generated low-level client.
222    pub fn new(client: TemporaryCredentialClientBase) -> Self {
223        Self { client }
224    }
225
226    /// POST a credential request and deserialize the [`TemporaryCredential`]
227    /// response, tolerating servers that emit the inactive `credentials` oneof
228    /// siblings as explicit `null`s.
229    ///
230    /// The wire `credentials` field is a protobuf oneof (`aws_temp_credentials`,
231    /// `azure_user_delegation_sas`, …). Our pbjson-generated deserializer maps
232    /// every oneof key into a single slot and rejects the *second* oneof key it
233    /// sees with a `duplicate field` error — even when that key's value is
234    /// `null`. Some servers (e.g. the OSS reference) serialize all oneof
235    /// variants, with the inactive ones set to `null`, which trips that check.
236    /// We strip those null siblings before handing the body to the generated
237    /// deserializer, so only the active variant remains. We replicate the
238    /// generated client's request/error handling so behaviour is otherwise
239    /// identical.
240    async fn post_credential<R: serde::Serialize>(
241        &self,
242        path: &str,
243        request: &R,
244    ) -> Result<TemporaryCredential> {
245        let url = self.client.base_url.join(path)?;
246        let response = self
247            .client
248            .client
249            .post(url)
250            .json(request)
251            .send_raw()
252            .await
253            .map_err(crate::Error::from_api_send)?;
254        let bytes = response.bytes().await?;
255
256        // Drop any `credentials` oneof member whose value is null, so the
257        // generated oneof deserializer sees at most one of them. The generated
258        // field matcher accepts both snake_case and camelCase, so match either.
259        let mut value: serde_json::Value = serde_json::from_slice(&bytes)?;
260        if let Some(obj) = value.as_object_mut() {
261            const ONEOF_KEYS: [&str; 10] = [
262                "aws_temp_credentials",
263                "awsTempCredentials",
264                "azure_user_delegation_sas",
265                "azureUserDelegationSas",
266                "azure_aad",
267                "azureAad",
268                "gcp_oauth_token",
269                "gcpOauthToken",
270                "r2_temp_credentials",
271                "r2TempCredentials",
272            ];
273            obj.retain(|key, v| !(v.is_null() && ONEOF_KEYS.contains(&key.as_str())));
274        }
275        Ok(serde_json::from_value(value)?)
276    }
277
278    /// Gets a temporary credential for reading or writing to a table.
279    ///
280    /// # Arguments
281    ///
282    /// * `table`: The table to get a temporary credential for.
283    /// * `operation`: The operation to perform on the table.
284    ///
285    /// Returns a tuple of the temporary credential and the resolved table ID.
286    pub async fn temporary_table_credential(
287        &self,
288        table: impl Into<TableReference>,
289        operation: TableOperation,
290    ) -> Result<(TemporaryCredential, Uuid)> {
291        // Resolve a name to an id, capturing the table's storage location so we
292        // can backfill it onto the credential if the server omits the `url`.
293        let (table_id, storage_location) = match table.into() {
294            TableReference::Id(id) => (id.as_hyphenated().to_string(), None),
295            TableReference::Name(name) => {
296                let table_client = TableServiceClient::new(
297                    self.client.client.clone(),
298                    self.client.base_url.clone(),
299                );
300                let table_info = table_client
301                    .get_table(&GetTableRequest {
302                        full_name: name,
303                        include_browse: Some(false),
304                        include_delta_metadata: Some(false),
305                        include_manifest_capabilities: Some(false),
306                        ..Default::default()
307                    })
308                    .await?;
309                (
310                    table_info.table_id.clone().unwrap_or_default(),
311                    table_info.storage_location.clone(),
312                )
313            }
314        };
315        let uuid =
316            Uuid::parse_str(&table_id).map_err(unitycatalog_common::Error::InvalidIdentifier)?;
317        let mut credential = self
318            .post_credential(
319                "temporary-table-credentials",
320                &GenerateTemporaryTableCredentialsRequest {
321                    table_id,
322                    operation: TblOperation::from(operation).into(),
323                    ..Default::default()
324                },
325            )
326            .await?;
327        backfill_credential_url(&mut credential, storage_location);
328        Ok((credential, uuid))
329    }
330
331    /// Gets a temporary credential for reading or writing to a storage path.
332    ///
333    /// Pass `dry_run` to validate access without vending a usable credential.
334    /// Returns a tuple of the temporary credential and the parsed path URL.
335    pub async fn temporary_path_credential(
336        &self,
337        path: impl IntoUrl,
338        operation: PathOperation,
339        dry_run: impl Into<Option<bool>>,
340    ) -> Result<(TemporaryCredential, Url)> {
341        let url = path.into_url()?;
342        Ok((
343            self.post_credential(
344                "temporary-path-credentials",
345                &GenerateTemporaryPathCredentialsRequest {
346                    url: url.to_string(),
347                    operation: PthOperation::from(operation).into(),
348                    dry_run: dry_run.into(),
349                    ..Default::default()
350                },
351            )
352            .await?,
353            url,
354        ))
355    }
356
357    /// Gets a temporary credential for reading or writing to a volume.
358    ///
359    /// # Arguments
360    ///
361    /// * `volume`: The volume to get a temporary credential for. May be either
362    ///   a [`VolumeReference::Id`] (UUID, preferred when known) or a
363    ///   [`VolumeReference::Name`] in three-level dotted form
364    ///   (`catalog.schema.volume`). Names are resolved to IDs by issuing a
365    ///   `GetVolume` request.
366    /// * `operation`: Whether the credentials should grant read-only or
367    ///   read-write access.
368    ///
369    /// Returns a tuple of the temporary credential and the resolved volume ID.
370    ///
371    /// The Unity Catalog metastore must have `external_access_enabled = true`
372    /// and the caller must hold `EXTERNAL_USE_SCHEMA` on the parent schema.
373    pub async fn temporary_volume_credential(
374        &self,
375        volume: impl Into<VolumeReference>,
376        operation: VolumeOperation,
377    ) -> Result<(TemporaryCredential, Uuid)> {
378        let (volume_id, storage_location) = match volume.into() {
379            VolumeReference::Id(id) => (id.as_hyphenated().to_string(), None),
380            VolumeReference::Name(name) => {
381                let volume_client = VolumeServiceClient::new(
382                    self.client.client.clone(),
383                    self.client.base_url.clone(),
384                );
385                let info = volume_client
386                    .get_volume(&GetVolumeRequest {
387                        name,
388                        include_browse: Some(false),
389                        ..Default::default()
390                    })
391                    .await?;
392                (info.volume_id, Some(info.storage_location))
393            }
394        };
395        let uuid =
396            Uuid::parse_str(&volume_id).map_err(unitycatalog_common::Error::InvalidIdentifier)?;
397        let mut credential = self
398            .post_credential(
399                "temporary-volume-credentials",
400                &GenerateTemporaryVolumeCredentialsRequest {
401                    volume_id,
402                    operation: VolOperation::from(operation).into(),
403                    ..Default::default()
404                },
405            )
406            .await?;
407        backfill_credential_url(&mut credential, storage_location);
408        Ok((credential, uuid))
409    }
410
411    /// Gets a temporary credential for reading or writing to a model version.
412    ///
413    /// # Arguments
414    ///
415    /// * `full_name`: The three-level (`catalog.schema.model`) name of the parent
416    ///   registered model.
417    /// * `version`: The integer version number of the model version.
418    /// * `operation`: Whether the credentials should grant read-only or read-write
419    ///   access.
420    ///
421    /// The Unity Catalog metastore must have `external_access_enabled = true` and
422    /// the caller must hold `EXTERNAL_USE_SCHEMA` on the parent schema.
423    pub async fn temporary_model_version_credential(
424        &self,
425        full_name: impl Into<String>,
426        version: i64,
427        operation: ModelVersionOperation,
428    ) -> Result<TemporaryCredential> {
429        let full_name = full_name.into();
430        let [catalog_name, schema_name, model_name] =
431            <[String; 3]>::try_from(full_name.split('.').map(str::to_string).collect::<Vec<_>>())
432                .map_err(|_| {
433                unitycatalog_common::Error::invalid_argument(
434                    "full_name must be a three-level catalog.schema.model name",
435                )
436            })?;
437
438        // Resolve the version's storage location so we can backfill the credential
439        // `url` if the server omits it (mirroring the table/volume paths).
440        let mv_client = crate::codegen::model_versions::ModelVersionServiceClient::new(
441            self.client.client.clone(),
442            self.client.base_url.clone(),
443        );
444        let storage_location = mv_client
445            .get_model_version(&GetModelVersionRequest {
446                full_name: full_name.clone(),
447                version,
448                ..Default::default()
449            })
450            .await
451            .ok()
452            .and_then(|mv| mv.storage_location);
453
454        let mut credential = self
455            .post_credential(
456                "temporary-model-version-credentials",
457                &GenerateTemporaryModelVersionCredentialsRequest {
458                    catalog_name,
459                    schema_name,
460                    model_name,
461                    version,
462                    operation: MvOperation::from(operation).into(),
463                    ..Default::default()
464                },
465            )
466            .await?;
467        backfill_credential_url(&mut credential, storage_location);
468        Ok(credential)
469    }
470}
471
472/// Backfill a credential's `url` from a known storage location when the server
473/// left it empty.
474///
475/// The `url` field of [`TemporaryCredential`] ("the storage path accessible by
476/// the temporary credential") is required by downstream object-store wiring, but
477/// some servers (e.g. the OSS reference) omit it for table/volume credentials and
478/// only echo it for path credentials. When we already know the securable's
479/// storage location we fill it in so the store can be built.
480fn backfill_credential_url(credential: &mut TemporaryCredential, storage_location: Option<String>) {
481    if credential.url.is_empty()
482        && let Some(location) = storage_location
483        && !location.is_empty()
484    {
485        credential.url = location;
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    /// A malformed server-supplied table id must surface as an error, not panic
494    /// (the credential path previously called `Uuid::parse_str(..).unwrap()`).
495    #[test]
496    fn malformed_table_id_is_error_not_panic() {
497        let result =
498            Uuid::parse_str("not-a-uuid").map_err(unitycatalog_common::Error::InvalidIdentifier);
499        let err: crate::Error = result.unwrap_err().into();
500        assert!(matches!(err, crate::Error::Common { .. }));
501    }
502}