unitycatalog_server/policy/mod.rs
1//! Authorization for the Unity Catalog server.
2//!
3//! Every request that touches a resource is gated by a [`Policy`]: given a
4//! resource, the [`Permission`] the operation requires, and a caller-supplied
5//! context, it returns a [`Decision`] of [`Allow`](Decision::Allow) or
6//! [`Deny`](Decision::Deny). The trait is generic over the context type `Cx` so
7//! the identity model is not baked in — a server can authorize on a
8//! [`Principal`], a request-scoped struct, or `()` for an unauthenticated
9//! allow-all deployment.
10//!
11//! Request types don't pass a bare resource/permission pair; they implement
12//! [`SecuredAction`], which pins each operation to the exact permission it needs
13//! at compile time. Handlers call [`Policy::check_required`] with the request
14//! itself, so the required permission can never be mismatched by hand.
15//!
16//! # Provided implementations
17//!
18//! [`ConstantPolicy`] is the only implementation shipped today. It returns a
19//! fixed decision regardless of input; [`ConstantPolicy::default`] allows
20//! everything, which suits development and deployments where a trusted proxy
21//! enforces access control upstream. Real per-resource RBAC is a future
22//! implementation of this same trait.
23//!
24//! # Composing a policy into a handler
25//!
26//! A type that holds a policy behind an [`Arc`] implements [`ProvidesPolicy`],
27//! and a blanket impl then makes that type act as a [`Policy`] by delegation, so
28//! handlers can call [`check_required`](Policy::check_required) on `self`
29//! directly. List endpoints use [`process_resources`] (or [`filter_authorized`]
30//! for a `dyn`-typed policy) to drop entries the caller may not see.
31//!
32//! # Examples
33//!
34//! ```
35//! use unitycatalog_server::policy::{ConstantPolicy, Decision, Permission, Policy};
36//! use unitycatalog_common::models::{ResourceIdent, resource_name};
37//!
38//! # async fn run() -> unitycatalog_server::Result<()> {
39//! // An allow-all policy authorized against the unit context.
40//! let policy = ConstantPolicy::default();
41//! let resource = ResourceIdent::catalog(resource_name!("main"));
42//!
43//! let decision = policy.authorize(&resource, &Permission::Read, &()).await?;
44//! assert_eq!(decision, Decision::Allow);
45//! # Ok(())
46//! # }
47//! ```
48
49use std::sync::Arc;
50
51use strum::AsRefStr;
52use unitycatalog_common::models::{ResourceExt, ResourceIdent};
53
54pub use self::constant::*;
55use crate::api::SecuredAction;
56use crate::{Error, Result};
57
58mod constant;
59
60/// The identity a request is made on behalf of.
61///
62/// This is one possible authorization context (`Cx`) for a [`Policy`]; a server
63/// that extracts a username from a reverse-proxy header would authorize against
64/// a `Principal`.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum Principal {
67 /// No authenticated identity — used when no credentials are present, e.g.
68 /// behind a proxy that does not forward an identity header.
69 Anonymous,
70 /// A named user identity, typically the value of a forwarded-user header.
71 User(String),
72}
73
74impl Principal {
75 /// Returns an [`Anonymous`](Principal::Anonymous) principal.
76 pub fn anonymous() -> Self {
77 Self::Anonymous
78 }
79
80 /// Returns a [`User`](Principal::User) principal with the given name.
81 pub fn user(name: impl Into<String>) -> Self {
82 Self::User(name.into())
83 }
84}
85
86/// The access level an operation requires on a resource.
87///
88/// Mirrors the Unity Catalog privilege model. A request type reports the
89/// permission it needs through [`SecuredAction::permission`], so the mapping
90/// from operation to permission is fixed rather than chosen at each call site.
91///
92/// Serialized as `snake_case`; parsing is ASCII-case-insensitive.
93#[derive(Debug, Clone, AsRefStr, PartialEq, Eq, strum::EnumString)]
94#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
95pub enum Permission {
96 /// List and read a resource (e.g. `get`/`list` operations).
97 Read,
98 /// Modify data within an existing resource.
99 Write,
100 /// Update or delete a resource's metadata.
101 Manage,
102 /// Create new resources.
103 Create,
104 /// Use a resource as part of another operation, e.g. attaching a credential
105 /// (mirrors `USE_CATALOG` / `USE_SCHEMA`).
106 Use,
107 /// Discover that a resource exists without reading its contents (`BROWSE`).
108 Browse,
109 /// Query data from a table or volume (`SELECT`).
110 Select,
111}
112
113impl From<Permission> for String {
114 fn from(val: Permission) -> Self {
115 val.as_ref().to_string()
116 }
117}
118
119/// Decision made by a policy.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Decision {
122 /// Allow the action.
123 Allow,
124 /// Deny the action.
125 Deny,
126}
127
128/// Access-control decision point for the server.
129///
130/// Implementations decide whether a caller (described by the context `Cx`) may
131/// perform an operation on a resource. [`authorize`](Self::authorize) is the only
132/// required method; every other method has a default that delegates to it. The
133/// trait is `async` because real backends (external RBAC, database lookups) are
134/// I/O bound.
135///
136/// `Cx` is the authorization context — the identity or request state the policy
137/// evaluates against, e.g. [`Principal`] or `()`.
138#[async_trait::async_trait]
139pub trait Policy<Cx: Send + Sync + 'static>: Send + Sync + 'static {
140 /// Authorizes a [`SecuredAction`], reading the resource and required
141 /// permission from the action itself.
142 ///
143 /// Convenience over [`authorize`](Self::authorize) that removes the chance of
144 /// passing a permission that doesn't match the operation.
145 async fn check(&self, obj: &dyn SecuredAction, context: &Cx) -> Result<Decision> {
146 self.authorize(&obj.resource(), obj.permission(), context)
147 .await
148 }
149
150 /// Like [`check`](Self::check), but maps a [`Deny`](Decision::Deny) to
151 /// [`Error::NotAllowed`] so a handler can propagate it with `?`.
152 async fn check_required(&self, obj: &dyn SecuredAction, context: &Cx) -> Result<()> {
153 match self.check(obj, context).await? {
154 Decision::Allow => Ok(()),
155 Decision::Deny => Err(Error::NotAllowed),
156 }
157 }
158
159 /// Decides whether `context` may exercise `permission` on `resource`.
160 ///
161 /// Returns [`Decision::Allow`] if the permission is granted and
162 /// [`Decision::Deny`] otherwise. This is the one method an implementation
163 /// must provide.
164 async fn authorize(
165 &self,
166 resource: &ResourceIdent,
167 permission: &Permission,
168 context: &Cx,
169 ) -> Result<Decision>;
170
171 /// Authorizes the same `permission` against many resources, returning one
172 /// [`Decision`] per resource in the same order.
173 ///
174 /// The default calls [`authorize`](Self::authorize) sequentially;
175 /// implementations backed by a batchable service should override it to issue
176 /// a single bulk lookup. Used by [`process_resources`] to filter list
177 /// results.
178 async fn authorize_many(
179 &self,
180 resources: &[ResourceIdent],
181 permission: &Permission,
182 context: &Cx,
183 ) -> Result<Vec<Decision>> {
184 let mut decisions = Vec::with_capacity(resources.len());
185 for resource in resources {
186 decisions.push(self.authorize(resource, permission, context).await?);
187 }
188 Ok(decisions)
189 }
190
191 /// Like [`authorize`](Self::authorize), but maps a [`Deny`](Decision::Deny)
192 /// to [`Error::NotAllowed`].
193 async fn authorize_checked(
194 &self,
195 resource: &ResourceIdent,
196 permission: &Permission,
197 context: &Cx,
198 ) -> Result<()> {
199 match self.authorize(resource, permission, context).await? {
200 Decision::Allow => Ok(()),
201 Decision::Deny => Err(Error::NotAllowed),
202 }
203 }
204}
205
206/// Types that own a [`Policy`] and want to act as one.
207///
208/// A blanket impl makes any `ProvidesPolicy<Cx>` implement `Policy<Cx>` by
209/// delegating every method to [`policy`](Self::policy), so a composed handler
210/// can call [`check_required`](Policy::check_required) on `self` without reaching
211/// into the wrapped policy explicitly.
212pub trait ProvidesPolicy<Cx: Send + Sync + 'static>: Send + Sync + 'static {
213 /// Returns the policy this type delegates authorization to.
214 fn policy(&self) -> &Arc<dyn Policy<Cx>>;
215}
216
217#[async_trait::async_trait]
218impl<T: Policy<Cx>, Cx: Send + Sync + 'static> Policy<Cx> for Arc<T> {
219 async fn authorize(
220 &self,
221 resource: &ResourceIdent,
222 permission: &Permission,
223 context: &Cx,
224 ) -> Result<Decision> {
225 T::authorize(self, resource, permission, context).await
226 }
227
228 async fn authorize_many(
229 &self,
230 resources: &[ResourceIdent],
231 permission: &Permission,
232 context: &Cx,
233 ) -> Result<Vec<Decision>> {
234 T::authorize_many(self, resources, permission, context).await
235 }
236}
237
238/// Filters a `Vec` of resources in place, keeping only those the context is
239/// allowed to access.
240///
241/// Batches the check through [`Policy::authorize_many`] and then retains each
242/// resource whose decision is [`Allow`](Decision::Allow), preserving order. List
243/// endpoints call this before returning results so callers never see resources
244/// they lack `permission` on. For a policy held behind a trait object, use
245/// [`filter_authorized`] instead.
246pub async fn process_resources<
247 T: Policy<Cx> + Sized,
248 Cx: Send + Sync + 'static,
249 R: ResourceExt + Send,
250>(
251 handler: &T,
252 context: &Cx,
253 permission: &Permission,
254 resources: &mut Vec<R>,
255) -> Result<()> {
256 filter_authorized(handler, context, permission, resources).await
257}
258
259/// [`process_resources`] for a `dyn`-typed policy.
260///
261/// Identical filtering behavior, but takes `&dyn Policy<Cx>` so it can be used
262/// with an `Arc<dyn Policy<Cx>>` (which does not satisfy the `Sized` bound on
263/// [`process_resources`]). Handler patterns that hold the policy behind a trait
264/// object — e.g. proxy/decorator handlers — use this.
265pub async fn filter_authorized<Cx: Send + Sync + 'static, R: ResourceExt + Send>(
266 policy: &dyn Policy<Cx>,
267 context: &Cx,
268 permission: &Permission,
269 resources: &mut Vec<R>,
270) -> Result<()> {
271 let res = resources.iter().map(|r| r.into()).collect::<Vec<_>>();
272 let decisions = policy.authorize_many(&res, permission, context).await?;
273 // `decisions[i]` corresponds to `resources[i]`; pair them in forward order.
274 let mut allow = decisions.into_iter().map(|d| d == Decision::Allow);
275 resources.retain(|_| allow.next().unwrap_or(false));
276 Ok(())
277}
278
279#[cfg(test)]
280mod test {
281 use super::*;
282 use unitycatalog_common::models::{ResourceName, ResourceRef, resource_name};
283
284 /// Minimal resource carrying a share name, for exercising [`filter_authorized`].
285 #[derive(Debug, Clone, PartialEq)]
286 struct TestShare(&'static str);
287
288 impl ResourceExt for TestShare {
289 fn resource_name(&self) -> ResourceName {
290 ResourceName::new([self.0])
291 }
292 fn resource_ref(&self) -> ResourceRef {
293 ResourceRef::Name(self.resource_name())
294 }
295 fn resource_ident(&self) -> ResourceIdent {
296 ResourceIdent::share(self.resource_name())
297 }
298 }
299
300 /// Policy that allows only resources whose ident matches one of `allow`,
301 /// and denies everything else — i.e. a non-uniform per-resource decision.
302 struct AllowListPolicy {
303 allow: Vec<ResourceIdent>,
304 }
305
306 #[async_trait::async_trait]
307 impl Policy<()> for AllowListPolicy {
308 async fn authorize(
309 &self,
310 resource: &ResourceIdent,
311 _permission: &Permission,
312 _context: &(),
313 ) -> Result<Decision> {
314 Ok(if self.allow.contains(resource) {
315 Decision::Allow
316 } else {
317 Decision::Deny
318 })
319 }
320 }
321
322 /// Regression test for the reversed decision mapping bug: a non-uniform
323 /// policy must filter the *correct* resources, in their original order.
324 /// Before the fix, decisions were consumed back-to-front via `pop()`, so
325 /// resource `i` was matched against decision `n-1-i`.
326 #[tokio::test]
327 async fn filter_authorized_pairs_decision_with_correct_resource() {
328 let policy = AllowListPolicy {
329 allow: vec![
330 ResourceIdent::share(resource_name!("a")),
331 ResourceIdent::share(resource_name!("c")),
332 ],
333 };
334
335 // Asymmetric layout so a reversed mapping yields a different result:
336 // allowed at indices 0 and 2, denied at 1 and 3.
337 let mut resources = vec![
338 TestShare("a"),
339 TestShare("b"),
340 TestShare("c"),
341 TestShare("d"),
342 ];
343
344 filter_authorized(&policy, &(), &Permission::Read, &mut resources)
345 .await
346 .unwrap();
347
348 assert_eq!(resources, vec![TestShare("a"), TestShare("c")]);
349 }
350}