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