Skip to main content

unitycatalog_server/services/
delta_backend.rs

1//! The mangrove [`DeltaBackend`] adapter.
2//!
3//! Implements [`unitycatalog_delta_api::DeltaBackend`] for
4//! [`ServerHandler<RequestContext>`], expressing the narrow backend port over the
5//! server's existing handler traits (`TableHandler`, `StagingTableHandler`,
6//! `TemporaryCredentialHandler`), the `Policy` authorization surface, the resource
7//! store, and the commit coordinator. All Delta *semantics* — the managed-table
8//! contract, the `updateTable` action dispatcher, `loadTable` construction — live
9//! in [`unitycatalog_delta_api`]; this adapter only maps types and errors.
10//!
11//! The impl is on `ServerHandler<RequestContext>` directly (not a wrapper) so the
12//! router's `RequestContext: FromRequestParts<ServerHandler>` extraction is
13//! preserved, exactly as the previous blanket `impl DeltaApiHandler for T` was.
14
15use async_trait::async_trait;
16use buffa::Enumeration;
17
18use unitycatalog_common::models::staging_tables::v1::{CreateStagingTableRequest, StagingTable};
19use unitycatalog_common::models::tables::v1::{
20    Column as UcColumn, CreateTableRequest, DataSourceFormat, DeleteTableRequest, GetTableRequest,
21    Table, TableType,
22};
23use unitycatalog_common::models::temporary_credentials::v1::{
24    GenerateTemporaryPathCredentialsRequest, GenerateTemporaryTableCredentialsRequest,
25    TemporaryCredential, generate_temporary_path_credentials_request::Operation as PathOp,
26    generate_temporary_table_credentials_request::Operation as TableOp,
27    temporary_credential::Credentials,
28};
29use unitycatalog_common::models::{ResourceIdent, ResourceName, ResourceRef};
30use unitycatalog_common::store::Precondition;
31
32use unitycatalog_delta_api::authz::DeltaAction;
33use unitycatalog_delta_api::backend::{
34    BackendResult, CreateTableSpec, CredentialAccess, DeltaBackend, DeltaCapabilities,
35    ResolvedTable, SchemaRef, StagingReservation, TableRef, UpdateTableSpec, VendedCredential,
36    VendedCredentialKind,
37};
38use unitycatalog_delta_api::column::{
39    Column as CrateColumn, ColumnTypeName as CrateColumnTypeName,
40};
41use unitycatalog_delta_api::coordinator::{CommitCoordinator, ProvidesCommitCoordinator};
42use unitycatalog_delta_api::error::DeltaBackendError;
43use unitycatalog_delta_api::models::DeltaTableType;
44
45use crate::api::RequestContext;
46use crate::api::staging_tables::find_staging_table_by_location;
47use crate::api::tables::TableHandler;
48use crate::codegen::staging_tables::StagingTableHandler;
49use crate::codegen::temporary_credentials::TemporaryCredentialHandler;
50use crate::policy::{Permission, Policy, Principal};
51use crate::services::ServerHandler;
52use crate::services::location::StorageLocationUrl;
53use crate::services::object_store::validate_external_storage_location;
54use crate::store::{ResourceStore, ResourceStoreReader};
55use crate::{Error, Result};
56
57// ===================================================================
58// Error mapping
59// ===================================================================
60
61/// Map the server's internal [`Error`] onto the crate's [`DeltaBackendError`].
62///
63/// Reproduces the previous server-side `DeltaError::parts` dispatch exactly (see
64/// the deleted `crates/server/src/rest/routers/delta/models.rs`), so response
65/// status codes and error types are unchanged by the extraction.
66fn to_backend_err(e: Error) -> DeltaBackendError {
67    match &e {
68        Error::NotFound | Error::ResourceStore { .. } => DeltaBackendError::NotFound(e.to_string()),
69        // The wrapped common error carries its own semantics; dispatch on its
70        // machine-readable code so e.g. an "already exists" doesn't surface as 404.
71        Error::Common { source } => match source.error_code() {
72            "RESOURCE_ALREADY_EXISTS" => DeltaBackendError::AlreadyExists(e.to_string()),
73            "INVALID_PARAMETER_VALUE" => DeltaBackendError::InvalidArgument(e.to_string()),
74            "PERMISSION_DENIED" => DeltaBackendError::PermissionDenied(e.to_string()),
75            "COMMIT_VERSION_CONFLICT" => DeltaBackendError::CommitVersionConflict(e.to_string()),
76            // A store CAS mismatch (`Precondition::Version`) surfaces as a generic
77            // conflict; for the Delta write path that is an `assert-etag` failure.
78            "RESOURCE_CONFLICT" => DeltaBackendError::UpdateRequirementConflict(e.to_string()),
79            "RESOURCE_EXHAUSTED" => DeltaBackendError::ResourceExhausted(e.to_string()),
80            _ => DeltaBackendError::NotFoundGeneric(e.to_string()),
81        },
82        Error::NotAllowed => DeltaBackendError::PermissionDenied(e.to_string()),
83        Error::Unauthenticated => DeltaBackendError::Unauthenticated(e.to_string()),
84        Error::AlreadyExists => DeltaBackendError::AlreadyExists(e.to_string()),
85        Error::CommitVersionConflict(m) => DeltaBackendError::CommitVersionConflict(m.clone()),
86        Error::UpdateRequirementConflict(m) => {
87            DeltaBackendError::UpdateRequirementConflict(m.clone())
88        }
89        Error::ResourceExhausted(m) => DeltaBackendError::ResourceExhausted(m.clone()),
90        Error::InvalidArgument(m) => DeltaBackendError::InvalidArgument(m.clone()),
91        Error::InvalidIdentifier(_) | Error::MissingRecipient => {
92            DeltaBackendError::InvalidArgument(e.to_string())
93        }
94        Error::NotImplemented(w) => DeltaBackendError::NotImplemented(w),
95        _ => DeltaBackendError::Internal(e.to_string()),
96    }
97}
98
99/// Parse the version out of an etag string produced by
100/// [`unitycatalog_delta_api::backend::etag_of`] (`etag-<version>`).
101///
102/// Returns `None` for a malformed etag, which the caller treats as an
103/// `assert-etag` conflict.
104fn parse_etag_version(etag: &str) -> Option<u64> {
105    etag.strip_prefix("etag-").and_then(|v| v.parse().ok())
106}
107
108// ===================================================================
109// Column mapping
110// ===================================================================
111
112/// Map a common UC column onto the crate's portable [`Column`](CrateColumn).
113fn uc_column_to_crate(c: UcColumn) -> CrateColumn {
114    CrateColumn {
115        name: c.name,
116        type_text: c.type_text,
117        type_json: c.type_json,
118        position: c.position,
119        type_name: CrateColumnTypeName::from(c.type_name.to_i32()),
120        comment: c.comment,
121        nullable: c.nullable,
122        partition_index: c.partition_index,
123    }
124}
125
126/// Map a crate column back onto the common UC column.
127///
128/// The Delta contract only produces the fields the crate `Column` carries; the
129/// remaining generated fields (`type_precision`, `type_scale`,
130/// `type_interval_type`, `column_id`) are left at their defaults, mirroring the
131/// columns the old contract-derived `CreateTableRequest` persisted.
132fn crate_column_to_uc(c: CrateColumn) -> UcColumn {
133    UcColumn {
134        name: c.name,
135        type_text: c.type_text,
136        type_json: c.type_json,
137        position: c.position,
138        type_name: (c.type_name as i32).into(),
139        comment: c.comment,
140        nullable: c.nullable,
141        partition_index: c.partition_index,
142        ..Default::default()
143    }
144}
145
146// ===================================================================
147// Table / staging / credential mapping
148// ===================================================================
149
150fn to_uc_table_type(t: DeltaTableType) -> TableType {
151    match t {
152        DeltaTableType::Managed => TableType::Managed,
153        DeltaTableType::External => TableType::External,
154    }
155}
156
157/// Map a stored data source format onto the crate's wire format enum. Formats
158/// the wire enum does not carry (`Unspecified`, unknown) map to `None`.
159fn to_delta_format(f: i32) -> Option<unitycatalog_delta_api::models::DeltaDataSourceFormat> {
160    use unitycatalog_delta_api::models::DeltaDataSourceFormat as F;
161    match DataSourceFormat::from_i32(f)? {
162        DataSourceFormat::DELTA => Some(F::Delta),
163        DataSourceFormat::ICEBERG => Some(F::Iceberg),
164        DataSourceFormat::HUDI => Some(F::Hudi),
165        DataSourceFormat::PARQUET => Some(F::Parquet),
166        DataSourceFormat::CSV => Some(F::Csv),
167        DataSourceFormat::JSON => Some(F::Json),
168        DataSourceFormat::ORC => Some(F::Orc),
169        DataSourceFormat::AVRO => Some(F::Avro),
170        DataSourceFormat::TEXT => Some(F::Text),
171        DataSourceFormat::UNITY_CATALOG => Some(F::UnityCatalog),
172        DataSourceFormat::DELTASHARING => Some(F::Deltasharing),
173        DataSourceFormat::DATA_SOURCE_FORMAT_UNSPECIFIED => None,
174    }
175}
176
177/// Map a stored [`Table`] into the crate's portable [`ResolvedTable`].
178///
179/// `version` is the store's per-object version, which drives the etag and the
180/// `assert-etag` compare-and-swap; callers read it via
181/// [`ResourceStoreReader::get_versioned`].
182///
183/// View-like table types (views, metric views, …) map to `table_type: None`,
184/// which the shared handler rejects with the spec's "not a Delta table" 400.
185fn table_to_resolved(table: Table, version: u64) -> ResolvedTable {
186    let table_type = match table.table_type.as_known() {
187        Some(TableType::MANAGED) => Some(DeltaTableType::Managed),
188        Some(TableType::EXTERNAL) => Some(DeltaTableType::External),
189        _ => None,
190    };
191    ResolvedTable {
192        table_id: table.table_id,
193        location: table.storage_location.unwrap_or_default(),
194        table_type,
195        data_source_format: to_delta_format(table.data_source_format.to_i32()),
196        columns: table.columns.into_iter().map(uc_column_to_crate).collect(),
197        properties: table.properties.into_iter().collect(),
198        created_at_ms: table.created_at,
199        updated_at_ms: table.updated_at,
200        version,
201    }
202}
203
204fn staging_to_reservation(st: StagingTable) -> StagingReservation {
205    StagingReservation {
206        table_id: st.id,
207        name: st.name,
208        location: st.staging_location,
209        created_by: st.created_by,
210        stage_committed: st.stage_committed,
211    }
212}
213
214/// Map a vended [`TemporaryCredential`] onto the crate's [`VendedCredential`].
215fn to_vended_credential(creds: &TemporaryCredential, url: String) -> VendedCredential {
216    let kind = match &creds.credentials {
217        Some(Credentials::AwsTempCredentials(aws)) => VendedCredentialKind::S3 {
218            access_key_id: aws.access_key_id.clone(),
219            secret_access_key: aws.secret_access_key.clone(),
220            session_token: (!aws.session_token.is_empty()).then(|| aws.session_token.clone()),
221        },
222        Some(Credentials::AzureUserDelegationSas(az)) => VendedCredentialKind::AzureSas {
223            sas_token: az.sas_token.clone(),
224        },
225        Some(Credentials::GcpOauthToken(gcp)) => VendedCredentialKind::GcsOauth {
226            oauth_token: gcp.oauth_token.clone(),
227        },
228        // R2 reuses the S3-shaped fields.
229        Some(Credentials::R2TempCredentials(r2)) => VendedCredentialKind::S3 {
230            access_key_id: r2.access_key_id.clone(),
231            secret_access_key: r2.secret_access_key.clone(),
232            session_token: (!r2.session_token.is_empty()).then(|| r2.session_token.clone()),
233        },
234        _ => VendedCredentialKind::None,
235    };
236    VendedCredential {
237        url,
238        expiration_time_ms: creds.expiration_time,
239        kind,
240    }
241}
242
243fn to_table_op(access: CredentialAccess) -> i32 {
244    match access {
245        CredentialAccess::Read => TableOp::Read as i32,
246        CredentialAccess::ReadWrite => TableOp::ReadWrite as i32,
247    }
248}
249
250fn to_path_op(access: CredentialAccess) -> i32 {
251    match access {
252        CredentialAccess::Read => PathOp::PathRead as i32,
253        CredentialAccess::ReadWrite => PathOp::PathReadWrite as i32,
254    }
255}
256
257// ===================================================================
258// The adapter
259// ===================================================================
260
261impl ServerHandler<RequestContext> {
262    /// Resolve a staging reservation by uuid via the resource store.
263    async fn get_staging_by_id(&self, table_id: &str) -> Result<StagingTable> {
264        let uuid = uuid::Uuid::parse_str(table_id)
265            .map_err(|_| Error::invalid_argument("table_id is not a valid UUID"))?;
266        let ident = ResourceIdent::StagingTable(ResourceRef::Uuid(uuid));
267        let staging: StagingTable = self.get(&ident).await?.0.try_into()?;
268        Ok(staging)
269    }
270}
271
272#[async_trait]
273impl DeltaBackend<RequestContext> for ServerHandler<RequestContext> {
274    fn capabilities(&self) -> DeltaCapabilities {
275        // The store provides a native, versioned rename, so advertise renameTable.
276        DeltaCapabilities { rename: true }
277    }
278
279    async fn catalog_exists(&self, catalog: &str, _cx: &RequestContext) -> BackendResult<()> {
280        let ident = ResourceIdent::catalog(ResourceName::new([catalog]));
281        self.get(&ident)
282            .await
283            .map_err(Error::from)
284            .map_err(to_backend_err)?;
285        Ok(())
286    }
287
288    async fn resolve_table(
289        &self,
290        table: &TableRef,
291        cx: &RequestContext,
292    ) -> BackendResult<ResolvedTable> {
293        let t = TableHandler::get_table(
294            self,
295            GetTableRequest {
296                full_name: table.full_name(),
297                include_delta_metadata: None,
298                include_browse: None,
299                include_manifest_capabilities: None,
300                ..Default::default()
301            },
302            cx.clone(),
303        )
304        .await
305        .map_err(to_backend_err)?;
306        // Read the store version behind the table's id so the etag reflects the
307        // current row (the `assert-etag` CAS keys on this same version).
308        let version = match t.table_id.as_deref() {
309            Some(id) => match uuid::Uuid::parse_str(id) {
310                Ok(uuid) => {
311                    let ident = ResourceIdent::Table(ResourceRef::Uuid(uuid));
312                    self.get_versioned(&ident)
313                        .await
314                        .map(|(_, _, v)| v)
315                        .map_err(Error::from)
316                        .map_err(to_backend_err)?
317                }
318                Err(_) => 0,
319            },
320            None => 0,
321        };
322        Ok(table_to_resolved(t, version))
323    }
324
325    async fn authorize(&self, action: DeltaAction<'_>, cx: &RequestContext) -> BackendResult<()> {
326        match action {
327            DeltaAction::CreateTable {
328                at,
329                name,
330                table_type,
331            } => {
332                // Authorize CREATE on the target table via the same SecuredAction
333                // the UC-REST createTable uses.
334                let create_action = CreateTableRequest {
335                    name: name.to_string(),
336                    catalog_name: at.catalog.clone(),
337                    schema_name: at.schema.clone(),
338                    table_type: to_uc_table_type(table_type).into(),
339                    data_source_format: DataSourceFormat::Delta.into(),
340                    ..Default::default()
341                };
342                self.check_required(&create_action, cx)
343                    .await
344                    .map_err(to_backend_err)
345            }
346            DeltaAction::WriteTable { table_id, .. } => {
347                let uuid = uuid::Uuid::parse_str(table_id).map_err(|_| {
348                    DeltaBackendError::InvalidArgument("table id is not a valid UUID".into())
349                })?;
350                let ident = ResourceIdent::Table(ResourceRef::Uuid(uuid));
351                self.authorize_checked(&ident, &Permission::Write, cx)
352                    .await
353                    .map_err(to_backend_err)
354            }
355            DeltaAction::AdoptStaging { reservation } => {
356                // The creator-match, in mangrove's identity terms: the caller's
357                // principal name must equal the reservation's `created_by`
358                // (anonymous reservations, `created_by == None`, are adoptable by
359                // any anonymous caller — the pre-crate behavior).
360                let principal = match cx.recipient() {
361                    Principal::User(name) => Some(name.clone()),
362                    Principal::Anonymous => None,
363                };
364                if reservation.created_by.as_deref() != principal.as_deref() {
365                    return Err(DeltaBackendError::PermissionDenied(
366                        "caller is not the creator of the staging table".to_string(),
367                    ));
368                }
369                Ok(())
370            }
371            // Read / delete / rename / credential vending / staging creation are
372            // authorized by the downstream handler traits these operations
373            // delegate to (`TableHandler`, `StagingTableHandler`,
374            // `TemporaryCredentialHandler`), each of which runs `check_required`
375            // itself. Authorizing again here would double-check; matching the
376            // pre-crate behavior, the handler-level hook is a no-op for them.
377            DeltaAction::ReadTable { .. }
378            | DeltaAction::DeleteTable { .. }
379            | DeltaAction::RenameTable { .. }
380            | DeltaAction::VendTableCredential { .. }
381            | DeltaAction::VendPathCredential { .. }
382            | DeltaAction::CreateStaging { .. } => Ok(()),
383            // `DeltaAction` is `#[non_exhaustive]`. Fail closed on an action this
384            // adapter has not been taught: a newly added operation must not slip
385            // through unauthorized until its arm is written.
386            _ => Err(DeltaBackendError::PermissionDenied(
387                "unrecognized Delta action".to_string(),
388            )),
389        }
390    }
391
392    async fn validate_external_location(
393        &self,
394        location: &str,
395        _cx: &RequestContext,
396    ) -> BackendResult<()> {
397        let parsed = StorageLocationUrl::parse(location)
398            .map_err(Error::from)
399            .map_err(to_backend_err)?;
400        validate_external_storage_location(self, &parsed)
401            .await
402            .map_err(to_backend_err)
403    }
404
405    async fn create_table_row(
406        &self,
407        spec: CreateTableSpec,
408        _cx: &RequestContext,
409    ) -> BackendResult<ResolvedTable> {
410        let adopt_ident = spec.adopt_staging.as_ref().map(|reservation| {
411            ResourceIdent::staging_table(ResourceName::new([reservation.name.as_str()]))
412        });
413        let table = Table {
414            name: spec.name,
415            catalog_name: spec.at.catalog,
416            schema_name: spec.at.schema,
417            table_type: to_uc_table_type(spec.table_type).into(),
418            data_source_format: DataSourceFormat::Delta.into(),
419            columns: spec.columns.into_iter().map(crate_column_to_uc).collect(),
420            storage_location: Some(spec.location),
421            comment: spec.comment,
422            properties: spec.properties.into_iter().collect(),
423            table_id: spec.table_id,
424            ..Default::default()
425        };
426        // Persist directly via the store (the request is already validated); the
427        // UC-REST create path re-reads the snapshot for the managed branch, which
428        // the Delta API does not want.
429        //
430        // Managed adoption is a *relabel* (StagingTable → Table at the same id):
431        // both cannot exist at that id, so `replace_atomically` consumes the
432        // reservation and creates the table in one transaction — closing the
433        // orphaned-reservation window. EXTERNAL tables have no reservation and are
434        // a plain create. Take the version the store assigned the new row so the
435        // returned etag matches what a later `loadTable` reads, without assuming a
436        // fixed initial version.
437        let (resource, _, version) = match adopt_ident {
438            Some(ident) => {
439                self.replace_atomically_versioned(&ident, table.into())
440                    .await
441            }
442            None => self.create_versioned(table.into()).await,
443        }
444        .map_err(Error::from)
445        .map_err(to_backend_err)?;
446        let stored: Table = resource
447            .try_into()
448            .map_err(Error::from)
449            .map_err(to_backend_err)?;
450        Ok(table_to_resolved(stored, version))
451    }
452
453    async fn update_table_row(
454        &self,
455        spec: UpdateTableSpec,
456        _cx: &RequestContext,
457    ) -> BackendResult<ResolvedTable> {
458        let uuid = uuid::Uuid::parse_str(&spec.table_id).map_err(|_| {
459            DeltaBackendError::InvalidArgument("table id is not a valid UUID".into())
460        })?;
461        let ident = ResourceIdent::Table(ResourceRef::Uuid(uuid));
462        // Read the row together with its version, so an `assert-etag` translates
463        // into a real compare-and-swap at write time.
464        let (resource, _, version) = self
465            .get_versioned(&ident)
466            .await
467            .map_err(Error::from)
468            .map_err(to_backend_err)?;
469        let mut table: Table = resource
470            .try_into()
471            .map_err(Error::from)
472            .map_err(to_backend_err)?;
473        // assert-etag compare-and-swap: parse the expected etag back to the version
474        // it encodes and pass it as a `Precondition::Version` to the update below,
475        // so the store rejects the write atomically if the row advanced between
476        // this read and the write (closing the read-modify-write race). A malformed
477        // or non-matching etag fails fast here.
478        let precondition = match &spec.expected_etag {
479            Some(expected) => {
480                let expected_version = parse_etag_version(expected).ok_or_else(|| {
481                    DeltaBackendError::UpdateRequirementConflict(
482                        "assert-etag failed: table has been modified".into(),
483                    )
484                })?;
485                if expected_version != version {
486                    return Err(DeltaBackendError::UpdateRequirementConflict(
487                        "assert-etag failed: table has been modified".into(),
488                    ));
489                }
490                Precondition::Version(expected_version)
491            }
492            None => Precondition::Any,
493        };
494        table.columns = spec.columns.into_iter().map(crate_column_to_uc).collect();
495        table.properties = spec.properties.into_iter().collect();
496        // `None` means "leave the stored comment unchanged" (see `UpdateTableSpec`):
497        // the handler only sets it when a set-table-comment action is present.
498        if let Some(comment) = spec.comment {
499            table.comment = Some(comment);
500        }
501        let (updated_resource, _, new_version) = self
502            .update_checked(&ident, table.into(), precondition)
503            .await
504            .map_err(Error::from)
505            .map_err(to_backend_err)?;
506        let updated: Table = updated_resource
507            .try_into()
508            .map_err(Error::from)
509            .map_err(to_backend_err)?;
510        Ok(table_to_resolved(updated, new_version))
511    }
512
513    async fn delete_table(&self, table: &TableRef, cx: &RequestContext) -> BackendResult<()> {
514        TableHandler::delete_table(
515            self,
516            DeleteTableRequest {
517                full_name: table.full_name(),
518                ..Default::default()
519            },
520            cx.clone(),
521        )
522        .await
523        .map_err(to_backend_err)
524    }
525
526    async fn rename_table(
527        &self,
528        from: &TableRef,
529        to_name: &str,
530        _cx: &RequestContext,
531    ) -> BackendResult<()> {
532        // A table's name is 3-part `[catalog, schema, table]`; a rename changes only
533        // the leaf, keeping catalog+schema. `ResourceStore::rename` re-keys the
534        // object and rewrites the leaf name inside its properties in one transaction,
535        // preserving the id, associations, and any secrets.
536        let from_ident = ResourceIdent::table(ResourceName::new([
537            from.catalog.as_str(),
538            from.schema.as_str(),
539            from.table.as_str(),
540        ]));
541        let new_name = ResourceName::new([from.catalog.as_str(), from.schema.as_str(), to_name]);
542        self.rename(&from_ident, &new_name, Precondition::Any)
543            .await
544            .map_err(Error::from)
545            .map_err(to_backend_err)?;
546        Ok(())
547    }
548
549    async fn allocate_staging(
550        &self,
551        at: &SchemaRef,
552        name: &str,
553        cx: &RequestContext,
554    ) -> BackendResult<StagingReservation> {
555        let staging = StagingTableHandler::create_staging_table(
556            self,
557            CreateStagingTableRequest {
558                name: name.to_string(),
559                catalog_name: at.catalog.clone(),
560                schema_name: at.schema.clone(),
561                ..Default::default()
562            },
563            cx.clone(),
564        )
565        .await
566        .map_err(to_backend_err)?;
567        Ok(staging_to_reservation(staging))
568    }
569
570    async fn resolve_staging_by_location(
571        &self,
572        location: &str,
573        _cx: &RequestContext,
574    ) -> BackendResult<StagingReservation> {
575        let staging = find_staging_table_by_location(self, location)
576            .await
577            .map_err(to_backend_err)?;
578        Ok(staging_to_reservation(staging))
579    }
580
581    async fn resolve_staging_by_id(
582        &self,
583        table_id: &str,
584        _cx: &RequestContext,
585    ) -> BackendResult<StagingReservation> {
586        let staging = self
587            .get_staging_by_id(table_id)
588            .await
589            .map_err(to_backend_err)?;
590        Ok(staging_to_reservation(staging))
591    }
592
593    async fn vend_table_credential(
594        &self,
595        table_id: &str,
596        access: CredentialAccess,
597        cx: &RequestContext,
598    ) -> BackendResult<VendedCredential> {
599        let creds = self
600            .generate_temporary_table_credentials(
601                GenerateTemporaryTableCredentialsRequest {
602                    table_id: table_id.to_string(),
603                    operation: to_table_op(access).into(),
604                    ..Default::default()
605                },
606                cx.clone(),
607            )
608            .await
609            .map_err(to_backend_err)?;
610        let url = creds.url.clone();
611        Ok(to_vended_credential(&creds, url))
612    }
613
614    async fn vend_path_credential(
615        &self,
616        location: &str,
617        access: CredentialAccess,
618        cx: &RequestContext,
619    ) -> BackendResult<VendedCredential> {
620        let creds = self
621            .generate_temporary_path_credentials(
622                GenerateTemporaryPathCredentialsRequest {
623                    url: location.to_string(),
624                    operation: to_path_op(access).into(),
625                    dry_run: Some(false),
626                    ..Default::default()
627                },
628                cx.clone(),
629            )
630            .await
631            .map_err(to_backend_err)?;
632        let url = creds.url.clone();
633        Ok(to_vended_credential(&creds, url))
634    }
635
636    fn commit_coordinator(&self) -> &dyn CommitCoordinator {
637        ProvidesCommitCoordinator::commit_coordinator(self)
638    }
639}