Skip to main content

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}