Skip to main content

unitycatalog_client/
temporary_credentials.rs

1use crate::Transport;
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 the per-target [`Transport`] (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: Transport, 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 response = crate::error::check_api_response(response).await?;
255        let bytes = response.bytes().await?;
256
257        // Drop any `credentials` oneof member whose value is null, so the
258        // generated oneof deserializer sees at most one of them. The generated
259        // field matcher accepts both snake_case and camelCase, so match either.
260        let mut value: serde_json::Value = serde_json::from_slice(&bytes)?;
261        if let Some(obj) = value.as_object_mut() {
262            const ONEOF_KEYS: [&str; 10] = [
263                "aws_temp_credentials",
264                "awsTempCredentials",
265                "azure_user_delegation_sas",
266                "azureUserDelegationSas",
267                "azure_aad",
268                "azureAad",
269                "gcp_oauth_token",
270                "gcpOauthToken",
271                "r2_temp_credentials",
272                "r2TempCredentials",
273            ];
274            obj.retain(|key, v| !(v.is_null() && ONEOF_KEYS.contains(&key.as_str())));
275        }
276        Ok(serde_json::from_value(value)?)
277    }
278
279    /// Gets a temporary credential for reading or writing to a table.
280    ///
281    /// # Arguments
282    ///
283    /// * `table`: The table to get a temporary credential for.
284    /// * `operation`: The operation to perform on the table.
285    ///
286    /// Returns a tuple of the temporary credential and the resolved table ID.
287    pub async fn temporary_table_credential(
288        &self,
289        table: impl Into<TableReference>,
290        operation: TableOperation,
291    ) -> Result<(TemporaryCredential, Uuid)> {
292        // Resolve a name to an id, capturing the table's storage location so we
293        // can backfill it onto the credential if the server omits the `url`.
294        let (table_id, storage_location) = match table.into() {
295            TableReference::Id(id) => (id.as_hyphenated().to_string(), None),
296            TableReference::Name(name) => {
297                let table_client = TableServiceClient::new(
298                    self.client.client.clone(),
299                    self.client.base_url.clone(),
300                );
301                let table_info = table_client
302                    .get_table(&GetTableRequest {
303                        full_name: name,
304                        include_browse: Some(false),
305                        include_delta_metadata: Some(false),
306                        include_manifest_capabilities: Some(false),
307                        ..Default::default()
308                    })
309                    .await?;
310                (
311                    table_info.table_id.clone().unwrap_or_default(),
312                    table_info.storage_location.clone(),
313                )
314            }
315        };
316        let uuid =
317            Uuid::parse_str(&table_id).map_err(unitycatalog_common::Error::InvalidIdentifier)?;
318        let mut credential = self
319            .post_credential(
320                "temporary-table-credentials",
321                &GenerateTemporaryTableCredentialsRequest {
322                    table_id,
323                    operation: TblOperation::from(operation).into(),
324                    ..Default::default()
325                },
326            )
327            .await?;
328        backfill_credential_url(&mut credential, storage_location);
329        Ok((credential, uuid))
330    }
331
332    /// Gets a temporary credential for reading or writing to a storage path.
333    ///
334    /// Pass `dry_run` to validate access without vending a usable credential.
335    /// Returns a tuple of the temporary credential and the parsed path URL.
336    pub async fn temporary_path_credential(
337        &self,
338        path: impl IntoUrl,
339        operation: PathOperation,
340        dry_run: impl Into<Option<bool>>,
341    ) -> Result<(TemporaryCredential, Url)> {
342        let url = path.into_url()?;
343        Ok((
344            self.post_credential(
345                "temporary-path-credentials",
346                &GenerateTemporaryPathCredentialsRequest {
347                    url: url.to_string(),
348                    operation: PthOperation::from(operation).into(),
349                    dry_run: dry_run.into(),
350                    ..Default::default()
351                },
352            )
353            .await?,
354            url,
355        ))
356    }
357
358    /// Gets a temporary credential for reading or writing to a volume.
359    ///
360    /// # Arguments
361    ///
362    /// * `volume`: The volume to get a temporary credential for. May be either
363    ///   a [`VolumeReference::Id`] (UUID, preferred when known) or a
364    ///   [`VolumeReference::Name`] in three-level dotted form
365    ///   (`catalog.schema.volume`). Names are resolved to IDs by issuing a
366    ///   `GetVolume` request.
367    /// * `operation`: Whether the credentials should grant read-only or
368    ///   read-write access.
369    ///
370    /// Returns a tuple of the temporary credential and the resolved volume ID.
371    ///
372    /// The Unity Catalog metastore must have `external_access_enabled = true`
373    /// and the caller must hold `EXTERNAL_USE_SCHEMA` on the parent schema.
374    pub async fn temporary_volume_credential(
375        &self,
376        volume: impl Into<VolumeReference>,
377        operation: VolumeOperation,
378    ) -> Result<(TemporaryCredential, Uuid)> {
379        let (volume_id, storage_location) = match volume.into() {
380            VolumeReference::Id(id) => (id.as_hyphenated().to_string(), None),
381            VolumeReference::Name(name) => {
382                let volume_client = VolumeServiceClient::new(
383                    self.client.client.clone(),
384                    self.client.base_url.clone(),
385                );
386                let info = volume_client
387                    .get_volume(&GetVolumeRequest {
388                        name,
389                        include_browse: Some(false),
390                        ..Default::default()
391                    })
392                    .await?;
393                (info.volume_id, Some(info.storage_location))
394            }
395        };
396        let uuid =
397            Uuid::parse_str(&volume_id).map_err(unitycatalog_common::Error::InvalidIdentifier)?;
398        let mut credential = self
399            .post_credential(
400                "temporary-volume-credentials",
401                &GenerateTemporaryVolumeCredentialsRequest {
402                    volume_id,
403                    operation: VolOperation::from(operation).into(),
404                    ..Default::default()
405                },
406            )
407            .await?;
408        backfill_credential_url(&mut credential, storage_location);
409        Ok((credential, uuid))
410    }
411
412    /// Gets a temporary credential for reading or writing to a model version.
413    ///
414    /// # Arguments
415    ///
416    /// * `full_name`: The three-level (`catalog.schema.model`) name of the parent
417    ///   registered model.
418    /// * `version`: The integer version number of the model version.
419    /// * `operation`: Whether the credentials should grant read-only or read-write
420    ///   access.
421    ///
422    /// The Unity Catalog metastore must have `external_access_enabled = true` and
423    /// the caller must hold `EXTERNAL_USE_SCHEMA` on the parent schema.
424    pub async fn temporary_model_version_credential(
425        &self,
426        full_name: impl Into<String>,
427        version: i64,
428        operation: ModelVersionOperation,
429    ) -> Result<TemporaryCredential> {
430        let full_name = full_name.into();
431        let [catalog_name, schema_name, model_name] =
432            <[String; 3]>::try_from(full_name.split('.').map(str::to_string).collect::<Vec<_>>())
433                .map_err(|_| {
434                unitycatalog_common::Error::invalid_argument(
435                    "full_name must be a three-level catalog.schema.model name",
436                )
437            })?;
438
439        // Resolve the version's storage location so we can backfill the credential
440        // `url` if the server omits it (mirroring the table/volume paths).
441        let mv_client = crate::codegen::model_versions::ModelVersionServiceClient::new(
442            self.client.client.clone(),
443            self.client.base_url.clone(),
444        );
445        let storage_location = mv_client
446            .get_model_version(&GetModelVersionRequest {
447                full_name: full_name.clone(),
448                version,
449                ..Default::default()
450            })
451            .await
452            .ok()
453            .and_then(|mv| mv.storage_location);
454
455        let mut credential = self
456            .post_credential(
457                "temporary-model-version-credentials",
458                &GenerateTemporaryModelVersionCredentialsRequest {
459                    catalog_name,
460                    schema_name,
461                    model_name,
462                    version,
463                    operation: MvOperation::from(operation).into(),
464                    ..Default::default()
465                },
466            )
467            .await?;
468        backfill_credential_url(&mut credential, storage_location);
469        Ok(credential)
470    }
471}
472
473/// Backfill a credential's `url` from a known storage location when the server
474/// left it empty.
475///
476/// The `url` field of [`TemporaryCredential`] ("the storage path accessible by
477/// the temporary credential") is required by downstream object-store wiring, but
478/// some servers (e.g. the OSS reference) omit it for table/volume credentials and
479/// only echo it for path credentials. When we already know the securable's
480/// storage location we fill it in so the store can be built.
481fn backfill_credential_url(credential: &mut TemporaryCredential, storage_location: Option<String>) {
482    if credential.url.is_empty()
483        && let Some(location) = storage_location
484        && !location.is_empty()
485    {
486        credential.url = location;
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    /// A malformed server-supplied table id must surface as an error, not panic
495    /// (the credential path previously called `Uuid::parse_str(..).unwrap()`).
496    #[test]
497    fn malformed_table_id_is_error_not_panic() {
498        let result =
499            Uuid::parse_str("not-a-uuid").map_err(unitycatalog_common::Error::InvalidIdentifier);
500        let err: crate::Error = result.unwrap_err().into();
501        assert!(matches!(err, crate::Error::Common { .. }));
502    }
503}