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