Skip to main content

unitycatalog_server/api/
credentials.rs

1use itertools::Itertools;
2
3use unitycatalog_common::models::credentials::v1::{
4    CreateCredentialRequest, Credential, DeleteCredentialRequest, GetCredentialRequest,
5    ListCredentialsRequest, ListCredentialsResponse, UpdateCredentialRequest,
6};
7use unitycatalog_common::models::{ObjectLabel, ResourceIdent, ResourceName, ResourceRef};
8
9use super::{RequestContext, SecuredAction};
10pub use crate::codegen::credentials::CredentialHandler;
11use crate::policy::{Permission, Policy, process_resources};
12use crate::store::ResourceStore;
13use crate::{Error, Result};
14
15#[async_trait::async_trait]
16pub trait CredentialHandlerExt: Send + Sync + 'static {
17    /// Get a credential without checking permissions.
18    ///
19    /// This is used internally when access to a resource is already checked
20    /// and we need to create internal stores or vended credentials for the resource.
21    ///
22    // TODO: this could also be done by a server recipient / context type
23    async fn get_credential_internal(&self, request: GetCredentialRequest) -> Result<Credential>;
24}
25
26/// Whether a credential carries at least one secret configuration.
27///
28/// The secret material lives directly on the `Credential`'s sensitive fields
29/// (sealed inline on the object row by the store's managed layer); a create/update
30/// must supply exactly one.
31fn has_secret(cred: &Credential) -> bool {
32    cred.azure_service_principal.is_set()
33        || cred.azure_managed_identity.is_set()
34        || cred.azure_storage_key.is_set()
35        || cred.aws_iam_role.is_set()
36        || cred.databricks_gcp_service_account.is_set()
37}
38
39#[async_trait::async_trait]
40impl<T: ResourceStore + Policy<RequestContext>> CredentialHandler<RequestContext> for T {
41    #[tracing::instrument(skip(self, context))]
42    async fn list_credentials(
43        &self,
44        request: ListCredentialsRequest,
45        context: RequestContext,
46    ) -> Result<ListCredentialsResponse> {
47        self.check_required(&request, &context).await?;
48        let (mut resources, next_page_token) = self
49            .list(
50                &ObjectLabel::Credential,
51                None,
52                request.max_results.map(|v| v as usize),
53                request.page_token,
54            )
55            .await?;
56        process_resources(self, &context, &Permission::Read, &mut resources).await?;
57        Ok(ListCredentialsResponse {
58            credentials: resources.into_iter().map(|r| r.try_into()).try_collect()?,
59            next_page_token,
60            ..Default::default()
61        })
62    }
63    #[tracing::instrument(skip(self, context), fields(resource_name))]
64    async fn create_credential(
65        &self,
66        request: CreateCredentialRequest,
67        context: RequestContext,
68    ) -> Result<Credential> {
69        tracing::Span::current().record("resource_name", &request.name);
70        self.check_required(&request, &context).await?;
71        // The secret configs ride the Credential itself; the store's managed layer
72        // strips them out of the searchable properties and seals them into the
73        // object's inline encrypted blob, atomically with the row.
74        let cred = Credential {
75            name: request.name.clone(),
76            full_name: request.name,
77            comment: request.comment,
78            purpose: request.purpose,
79            read_only: request.read_only.unwrap_or(false),
80            used_for_managed_storage: false,
81            id: None,
82            created_at: None,
83            updated_at: None,
84            azure_managed_identity: request.azure_managed_identity,
85            azure_service_principal: request.azure_service_principal,
86            azure_storage_key: request.azure_storage_key,
87            aws_iam_role: request.aws_iam_role,
88            databricks_gcp_service_account: request.databricks_gcp_service_account,
89            owner: None,
90            created_by: None,
91            updated_by: None,
92            ..Default::default()
93        };
94        if !has_secret(&cred) {
95            return Err(Error::invalid_argument("No credentials provided"));
96        }
97        // The create response is redacted (the store never returns sealed fields on
98        // an ordinary read), which matches the public API contract.
99        Ok(self.create(cred.into()).await?.0.try_into()?)
100    }
101
102    #[tracing::instrument(skip(self, context), fields(resource_name))]
103    async fn get_credential(
104        &self,
105        request: GetCredentialRequest,
106        context: RequestContext,
107    ) -> Result<Credential> {
108        tracing::Span::current().record("resource_name", &request.name);
109        self.check_required(&request, &context).await?;
110        self.get_credential_internal(request).await
111    }
112
113    #[tracing::instrument(skip(self, context), fields(resource_name))]
114    async fn update_credential(
115        &self,
116        request: UpdateCredentialRequest,
117        context: RequestContext,
118    ) -> Result<Credential> {
119        tracing::Span::current().record("resource_name", &request.name);
120        self.check_required(&request, &context).await?;
121        // `purpose` is immutable on update — carry it over from the stored row. A
122        // plain (redacted) get suffices: purpose is a non-sensitive Data field.
123        let ident = ResourceIdent::credential(ResourceName::new([request.name.as_str()]));
124        let curr: Credential = self.get(&ident).await?.0.try_into()?;
125        let cred = Credential {
126            name: request.name.clone(),
127            full_name: request.name,
128            comment: request.comment,
129            purpose: curr.purpose,
130            read_only: request.read_only.unwrap_or(false),
131            used_for_managed_storage: false,
132            id: None,
133            created_at: None,
134            updated_at: None,
135            azure_managed_identity: request.azure_managed_identity,
136            azure_service_principal: request.azure_service_principal,
137            azure_storage_key: request.azure_storage_key,
138            aws_iam_role: request.aws_iam_role,
139            databricks_gcp_service_account: request.databricks_gcp_service_account,
140            owner: None,
141            created_by: None,
142            updated_by: None,
143            ..Default::default()
144        };
145        if !has_secret(&cred) {
146            return Err(Error::invalid_argument("No credentials provided"));
147        }
148        Ok(self.update(&ident, cred.into()).await?.0.try_into()?)
149    }
150
151    #[tracing::instrument(skip(self, context), fields(resource_name))]
152    async fn delete_credential(
153        &self,
154        request: DeleteCredentialRequest,
155        context: RequestContext,
156    ) -> Result<()> {
157        tracing::Span::current().record("resource_name", &request.name);
158        self.check_required(&request, &context).await?;
159        // The sealed secret blob rides the object row, so deleting the credential
160        // object drops it atomically — no separate secret cleanup.
161        Ok(self.delete(&request.resource()).await?)
162    }
163}
164
165#[async_trait::async_trait]
166impl<T: ResourceStore + Policy<RequestContext>> CredentialHandlerExt for T {
167    async fn get_credential_internal(&self, request: GetCredentialRequest) -> Result<Credential> {
168        // `get_with_secrets` decrypts the inline sealed fields back into the object's
169        // properties, so the Object -> Credential conversion hydrates the secret
170        // config directly — no manual field routing.
171        Ok(self
172            .get_with_secrets(&request.resource())
173            .await?
174            .0
175            .try_into()?)
176    }
177}
178
179impl SecuredAction for CreateCredentialRequest {
180    fn resource(&self) -> ResourceIdent {
181        ResourceIdent::credential(ResourceName::new([self.name.as_str()]))
182    }
183
184    fn permission(&self) -> &'static Permission {
185        &Permission::Create
186    }
187}
188
189impl SecuredAction for ListCredentialsRequest {
190    fn resource(&self) -> ResourceIdent {
191        ResourceIdent::credential(ResourceRef::Undefined)
192    }
193
194    fn permission(&self) -> &'static Permission {
195        &Permission::Read
196    }
197}
198
199impl SecuredAction for GetCredentialRequest {
200    fn resource(&self) -> ResourceIdent {
201        ResourceIdent::credential(ResourceName::new([self.name.as_str()]))
202    }
203
204    fn permission(&self) -> &'static Permission {
205        &Permission::Read
206    }
207}
208
209impl SecuredAction for UpdateCredentialRequest {
210    fn resource(&self) -> ResourceIdent {
211        ResourceIdent::credential(ResourceName::new([self.name.as_str()]))
212    }
213
214    fn permission(&self) -> &'static Permission {
215        &Permission::Manage
216    }
217}
218
219impl SecuredAction for DeleteCredentialRequest {
220    fn resource(&self) -> ResourceIdent {
221        ResourceIdent::credential(ResourceName::new([self.name.as_str()]))
222    }
223
224    fn permission(&self) -> &'static Permission {
225        &Permission::Manage
226    }
227}