Skip to main content

unitycatalog_server/api/
schemas.rs

1use itertools::Itertools;
2
3use unitycatalog_common::models::ObjectLabel;
4use unitycatalog_common::models::schemas::v1::*;
5use unitycatalog_common::models::{ResourceIdent, ResourceName, ResourceRef};
6
7use super::{RequestContext, SecuredAction};
8pub use crate::codegen::schemas::SchemaHandler;
9use crate::policy::{Permission, Policy, process_resources};
10use crate::services::ProvidesLocalStoragePolicy;
11use crate::services::location::StorageLocationUrl;
12use crate::store::ResourceStore;
13use crate::{Error, Result};
14
15#[async_trait::async_trait]
16impl<T: ResourceStore + Policy<RequestContext> + ProvidesLocalStoragePolicy>
17    SchemaHandler<RequestContext> for T
18{
19    #[tracing::instrument(skip(self, context), fields(resource_name))]
20    async fn create_schema(
21        &self,
22        request: CreateSchemaRequest,
23        context: RequestContext,
24    ) -> Result<Schema> {
25        tracing::Span::current().record("resource_name", &request.name);
26        self.check_required(&request, &context).await?;
27
28        // When the schema is created with its own storage root, echo the root
29        // back on the schema (`storage_root`) and materialize a managed storage
30        // location that embeds the schema id (`storage_location` =
31        // `<root>/__unitystorage/schemas/<schema_id>`), mirroring the reference's
32        // `SchemaRepository`/`SchemaInfo`. The location is built from the schema's
33        // own root, independent of the parent catalog. The id is pre-allocated so
34        // the store persists the row under it (else a v7 is minted). When no root
35        // is provided, both fields stay empty and managed securables fall back to
36        // the parent catalog's storage location.
37        let schema_id = uuid::Uuid::now_v7().hyphenated().to_string();
38        let storage_root = request.storage_root.filter(|s| !s.is_empty());
39        let storage_location = match storage_root.as_deref() {
40            // A client-supplied schema root must pass the local-storage policy, lie
41            // outside any reserved `__unitystorage` region, and be covered by a
42            // registered external location — mirroring the reference's
43            // `SchemaService` `AuthorizeExpression`.
44            // TODO(auth): also authorize CREATE_MANAGED_STORAGE/OWNER on the
45            // covering external location once the policy layer exists
46            // (feedback_auth_pattern).
47            Some(root) => {
48                let url = StorageLocationUrl::parse(root)?;
49                crate::services::object_store::validate_managed_storage_root(self, &url).await?;
50                Some(super::staging_tables::schema_location(root, &schema_id))
51            }
52            None => None,
53        };
54
55        let resource = Schema {
56            schema_id: Some(schema_id),
57            full_name: format!("{}.{}", request.catalog_name, request.name),
58            name: request.name,
59            catalog_name: request.catalog_name,
60            comment: request.comment,
61            properties: request.properties,
62            storage_root,
63            storage_location,
64            ..Default::default()
65        };
66        // TODO:
67        // - update the schema with the current actor as owner
68        // - create updated_* relations
69        Ok(self.create(resource.into()).await?.0.try_into()?)
70    }
71
72    #[tracing::instrument(skip(self, context), fields(resource_name))]
73    async fn delete_schema(
74        &self,
75        request: DeleteSchemaRequest,
76        context: RequestContext,
77    ) -> Result<()> {
78        tracing::Span::current().record("resource_name", &request.full_name);
79        self.check_required(&request, &context).await?;
80        Ok(self.delete(&request.resource()).await?)
81    }
82
83    #[tracing::instrument(skip(self, context))]
84    async fn list_schemas(
85        &self,
86        request: ListSchemasRequest,
87        context: RequestContext,
88    ) -> Result<ListSchemasResponse> {
89        self.check_required(&request, &context).await?;
90        let (mut resources, next_page_token) = self
91            .list(
92                &ObjectLabel::Schema,
93                Some(&ResourceName::new([&request.catalog_name])),
94                request.max_results.map(|v| v as usize),
95                request.page_token,
96            )
97            .await?;
98        process_resources(self, &context, &Permission::Read, &mut resources).await?;
99        Ok(ListSchemasResponse {
100            schemas: resources.into_iter().map(|r| r.try_into()).try_collect()?,
101            next_page_token,
102            ..Default::default()
103        })
104    }
105
106    #[tracing::instrument(skip(self, context), fields(resource_name))]
107    async fn get_schema(
108        &self,
109        request: GetSchemaRequest,
110        context: RequestContext,
111    ) -> Result<Schema> {
112        tracing::Span::current().record("resource_name", &request.full_name);
113        self.check_required(&request, &context).await?;
114        Ok(self.get(&request.resource()).await?.0.try_into()?)
115    }
116
117    #[tracing::instrument(skip(self, context), fields(resource_name))]
118    async fn update_schema(
119        &self,
120        request: UpdateSchemaRequest,
121        context: RequestContext,
122    ) -> Result<Schema> {
123        tracing::Span::current().record("resource_name", &request.full_name);
124        self.check_required(&request, &context).await?;
125        let ident = request.resource();
126        let name = ResourceName::from_naive_str_split(request.full_name);
127        let [catalog_name, schema_name] = name.as_ref() else {
128            return Err(Error::invalid_argument(
129                "Invalid schema name - expected <catalog_name>.<schema_name>",
130            ));
131        };
132        let new_name = request.new_name.unwrap_or(schema_name.to_owned());
133        let resource = Schema {
134            name: new_name.clone(),
135            comment: request.comment,
136            properties: request.properties,
137            catalog_name: catalog_name.to_owned(),
138            full_name: format!("{}.{}", catalog_name, new_name),
139            ..Default::default()
140        };
141        // TODO:
142        // - add update_* relations
143        // - update owner if necessary
144        Ok(self.update(&ident, resource.into()).await?.0.try_into()?)
145    }
146}
147
148impl SecuredAction for CreateSchemaRequest {
149    fn resource(&self) -> ResourceIdent {
150        ResourceIdent::schema(ResourceName::new([
151            self.catalog_name.as_str(),
152            self.name.as_str(),
153        ]))
154    }
155
156    fn permission(&self) -> &'static Permission {
157        &Permission::Create
158    }
159}
160
161impl SecuredAction for ListSchemasRequest {
162    fn resource(&self) -> ResourceIdent {
163        ResourceIdent::schema(ResourceRef::Undefined)
164    }
165
166    fn permission(&self) -> &'static Permission {
167        &Permission::Read
168    }
169}
170
171impl SecuredAction for GetSchemaRequest {
172    fn resource(&self) -> ResourceIdent {
173        ResourceIdent::schema(ResourceName::from_naive_str_split(self.full_name.as_str()))
174    }
175
176    fn permission(&self) -> &'static Permission {
177        &Permission::Read
178    }
179}
180
181impl SecuredAction for UpdateSchemaRequest {
182    fn resource(&self) -> ResourceIdent {
183        ResourceIdent::schema(ResourceName::from_naive_str_split(self.full_name.as_str()))
184    }
185
186    fn permission(&self) -> &'static Permission {
187        &Permission::Manage
188    }
189}
190
191impl SecuredAction for DeleteSchemaRequest {
192    fn resource(&self) -> ResourceIdent {
193        ResourceIdent::schema(ResourceName::from_naive_str_split(self.full_name.as_str()))
194    }
195
196    fn permission(&self) -> &'static Permission {
197        &Permission::Manage
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use std::sync::Arc;
204
205    use unitycatalog_common::models::credentials::v1::{
206        AwsIamRoleConfig, CreateCredentialRequest, Purpose,
207    };
208    use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
209    use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
210
211    use super::*;
212    use crate::api::{CredentialHandler, ExternalLocationHandler};
213    use crate::memory::InMemoryResourceStore;
214    use crate::policy::{ConstantPolicy, Principal};
215    use crate::services::ServerHandler;
216
217    fn handler() -> ServerHandler<RequestContext> {
218        let encryptor =
219            EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
220        let store = Arc::new(InMemoryResourceStore::new(encryptor));
221        let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
222        ServerHandler::try_new_tokio(policy, store).unwrap()
223    }
224
225    fn ctx() -> RequestContext {
226        RequestContext {
227            recipient: Principal::anonymous(),
228        }
229    }
230
231    /// Register a credential + external location at `url` so a managed schema
232    /// `storage_root` under it passes the coverage check. (`create_schema` does
233    /// not look up the parent catalog, so no catalog needs to exist.)
234    async fn make_covering_location(h: &ServerHandler<RequestContext>, name: &str, url: &str) {
235        h.create_credential(
236            CreateCredentialRequest {
237                name: format!("{name}-cred"),
238                purpose: Purpose::Storage.into(),
239                aws_iam_role: Some(AwsIamRoleConfig {
240                    role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
241                    ..Default::default()
242                })
243                .into(),
244                ..Default::default()
245            },
246            ctx(),
247        )
248        .await
249        .unwrap();
250        h.create_external_location(
251            CreateExternalLocationRequest {
252                name: name.to_string(),
253                url: url.to_string(),
254                credential_name: format!("{name}-cred"),
255                ..Default::default()
256            },
257            ctx(),
258        )
259        .await
260        .unwrap();
261    }
262
263    fn create_req(catalog: &str, name: &str, storage_root: Option<&str>) -> CreateSchemaRequest {
264        CreateSchemaRequest {
265            name: name.to_string(),
266            catalog_name: catalog.to_string(),
267            storage_root: storage_root.map(str::to_string),
268            ..Default::default()
269        }
270    }
271
272    #[tokio::test]
273    async fn schema_without_root_has_no_storage_fields() {
274        // No storage_root ⇒ no coverage check, no materialized location; managed
275        // securables fall back to the catalog at table-create time.
276        let h = handler();
277        let schema = h
278            .create_schema(create_req("cat", "sch", None), ctx())
279            .await
280            .unwrap();
281        assert!(schema.storage_root.is_none());
282        assert!(schema.storage_location.is_none());
283    }
284
285    #[tokio::test]
286    async fn schema_with_covered_root_succeeds() {
287        // A client-supplied root under a registered external location is accepted;
288        // the location is materialized under the schema id.
289        let h = handler();
290        make_covering_location(&h, "el", "s3://bucket").await;
291        let schema = h
292            .create_schema(create_req("cat", "sch", Some("s3://bucket/sch")), ctx())
293            .await
294            .unwrap();
295        assert_eq!(schema.storage_root.as_deref(), Some("s3://bucket/sch"));
296        let id = schema.schema_id.as_deref().expect("schema id");
297        assert_eq!(
298            schema.storage_location.as_deref(),
299            Some(format!("s3://bucket/sch/__unitystorage/schemas/{id}").as_str())
300        );
301    }
302
303    #[tokio::test]
304    async fn schema_with_uncovered_root_is_rejected() {
305        // No registered external location covers the root ⇒ reject.
306        let h = handler();
307        let res = h
308            .create_schema(create_req("cat", "sch", Some("s3://bucket/sch")), ctx())
309            .await;
310        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
311    }
312
313    #[tokio::test]
314    async fn schema_root_under_managed_prefix_is_rejected() {
315        // A root inside a reserved `__unitystorage` region is rejected even when an
316        // external location covers it.
317        let h = handler();
318        make_covering_location(&h, "el", "s3://bucket").await;
319        let res = h
320            .create_schema(
321                create_req("cat", "sch", Some("s3://bucket/__unitystorage/sch")),
322                ctx(),
323            )
324            .await;
325        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
326    }
327}