Skip to main content

rainier_auth/
gate.rs

1//! Authorization — the [`Gate`] and its abilities.
2//!
3//! Authentication asks *who are you*; authorization asks *may you do this*.
4//! Laravel splits them the same way, and the split matters: a guard is about
5//! identity and a gate is about policy, and mixing them produces controllers
6//! that check both by hand and get one of them wrong.
7//!
8//! ```
9//! # use rainier_auth::{Authenticatable, Gate};
10//! # #[derive(Clone)] struct User { id: u64, admin: bool }
11//! # impl Authenticatable for User {
12//! #     fn auth_identifier(&self) -> String { self.id.to_string() }
13//! #     fn auth_password_hash(&self) -> Option<&str> { None }
14//! # }
15//! # #[derive(Clone)] struct Post { author_id: u64 }
16//! # #[tokio::main] async fn main() -> rainier_support::Result<()> {
17//! let gate = Gate::<User>::new()
18//!     .define("posts.update", |user: &User, post: Option<&Post>| {
19//!         user.admin || post.is_some_and(|p| p.author_id == user.id)
20//!     });
21//!
22//! let author = User { id: 1, admin: false };
23//! let post = Post { author_id: 1 };
24//!
25//! assert!(gate.allows("posts.update", &author, Some(&post)));
26//! gate.authorize("posts.update", &author, Some(&post))?;
27//! # Ok(()) }
28//! ```
29
30use std::any::Any;
31use std::collections::HashMap;
32use std::sync::Arc;
33
34use rainier_support::{Error, Result};
35
36/// Anything a gate can authorize.
37///
38/// Not [`Authenticatable`](crate::Authenticatable) — deliberately. A gate
39/// answers *may this actor do this*, and in a service with machine callers the
40/// actor is often not a person:
41///
42/// | The actor | Where it comes from |
43/// |---|---|
44/// | a user | a session or a bearer token |
45/// | an API client | the client-credentials grant, where there is no user at all |
46/// | a cloud principal | an assumed IAM role, an STS identity |
47/// | a service account | a Kubernetes token, a signed internal request |
48///
49/// Requiring an actor to be authenticatable made two of those unrepresentable:
50/// an API client has no password hash and no session, and inventing one so it
51/// could be authorized is the kind of shape that ends in a machine identity
52/// accidentally being able to log in.
53///
54/// There is nothing to implement. This is a blanket alias over the bounds a
55/// gate genuinely needs — `Send + Sync + 'static` — so every existing
56/// `Gate<User>` keeps working untouched and `Gate<ApiClient>` starts working
57/// without `ApiClient` having to pretend to be a person.
58pub trait Actor: Send + Sync + 'static {}
59
60impl<T: Send + Sync + 'static> Actor for T {}
61
62/// A check registered against an ability name.
63type Ability<A> = Arc<dyn Fn(&A, Option<&dyn Any>) -> bool + Send + Sync>;
64
65/// A check that runs before every ability and may short-circuit it.
66type BeforeCheck<A> = Arc<dyn Fn(&A, &str) -> Option<bool> + Send + Sync>;
67
68/// A registry of authorization abilities.
69///
70/// Checks are **synchronous**. An authorization decision that needs a database
71/// round-trip is a sign the subject should have been loaded already — the
72/// controller has the record in hand by the time it authorizes, and re-fetching
73/// it inside the check would double every query.
74pub struct Gate<A: Actor> {
75    abilities: HashMap<String, Ability<A>>,
76    /// Consulted before any ability; a `Some` short-circuits the decision.
77    before: Vec<BeforeCheck<A>>,
78}
79
80impl<A: Actor> Default for Gate<A> {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl<A: Actor> Gate<A> {
87    /// A gate with no abilities. Everything is denied until defined.
88    pub fn new() -> Self {
89        Self { abilities: HashMap::new(), before: Vec::new() }
90    }
91
92    /// Define an ability taking the actor and an optional subject.
93    pub fn define<S, F>(mut self, ability: impl Into<String>, check: F) -> Self
94    where
95        S: 'static,
96        F: Fn(&A, Option<&S>) -> bool + Send + Sync + 'static,
97    {
98        let erased: Ability<A> = Arc::new(move |user, subject| {
99            match subject {
100                // A subject of the wrong type is a programming error, not a
101                // permission: deny, and let the caller notice.
102                Some(subject) => match subject.downcast_ref::<S>() {
103                    Some(typed) => check(user, Some(typed)),
104                    None => false,
105                },
106                None => check(user, None),
107            }
108        });
109        self.abilities.insert(ability.into(), erased);
110        self
111    }
112
113    /// Define an ability that ignores its subject.
114    pub fn define_simple<F>(self, ability: impl Into<String>, check: F) -> Self
115    where
116        F: Fn(&A) -> bool + Send + Sync + 'static,
117    {
118        self.define::<(), _>(ability, move |user, _| check(user))
119    }
120
121    /// Register a check that runs before every ability.
122    ///
123    /// Returning `Some(true)` grants regardless of the ability — the "an admin
124    /// may do anything" rule, in one place instead of at the top of every
125    /// check. `None` defers.
126    pub fn before<F>(mut self, check: F) -> Self
127    where
128        F: Fn(&A, &str) -> Option<bool> + Send + Sync + 'static,
129    {
130        self.before.push(Arc::new(check));
131        self
132    }
133
134    /// Whether `actor` may perform `ability` on `subject`.
135    ///
136    /// An **undefined** ability is denied. Defaulting to "allow" would mean a
137    /// typo in an ability name silently opens a hole.
138    pub fn allows<S: 'static>(&self, ability: &str, actor: &A, subject: Option<&S>) -> bool {
139        for before in &self.before {
140            if let Some(decision) = before(actor, ability) {
141                return decision;
142            }
143        }
144
145        match self.abilities.get(ability) {
146            Some(check) => check(actor, subject.map(|s| s as &dyn Any)),
147            None => false,
148        }
149    }
150
151    /// The inverse of [`allows`](Self::allows).
152    pub fn denies<S: 'static>(&self, ability: &str, actor: &A, subject: Option<&S>) -> bool {
153        !self.allows(ability, actor, subject)
154    }
155
156    /// Whether `actor` may perform `ability`, with no subject.
157    pub fn allows_any(&self, ability: &str, actor: &A) -> bool {
158        self.allows::<()>(ability, actor, None)
159    }
160
161    /// Grant, or fail with a `403`.
162    pub fn authorize<S: 'static>(
163        &self,
164        ability: &str,
165        actor: &A,
166        subject: Option<&S>,
167    ) -> Result<()> {
168        if self.allows(ability, actor, subject) {
169            return Ok(());
170        }
171        Err(Error::unauthorized("This action is unauthorized."))
172    }
173
174    /// Whether `ability` has been defined.
175    pub fn has(&self, ability: &str) -> bool {
176        self.abilities.contains_key(ability)
177    }
178
179    /// Every defined ability, sorted.
180    pub fn abilities(&self) -> Vec<&str> {
181        let mut names: Vec<&str> = self.abilities.keys().map(String::as_str).collect();
182        names.sort();
183        names
184    }
185}
186
187impl<A: Actor> std::fmt::Debug for Gate<A> {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("Gate")
190            .field("abilities", &self.abilities())
191            .field("before", &self.before.len())
192            .finish()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[derive(Debug, Clone)]
201    struct User {
202        id: u64,
203        admin: bool,
204        banned: bool,
205    }
206
207    #[derive(Debug)]
208    struct Post {
209        author_id: u64,
210    }
211
212    #[derive(Debug)]
213    struct Comment;
214
215    fn user(id: u64) -> User {
216        User { id, admin: false, banned: false }
217    }
218
219    fn admin() -> User {
220        User { id: 99, admin: true, banned: false }
221    }
222
223    fn gate() -> Gate<User> {
224        Gate::new()
225            .define("posts.update", |user: &User, post: Option<&Post>| {
226                post.is_some_and(|post| post.author_id == user.id)
227            })
228            .define_simple("posts.create", |user: &User| !user.banned)
229    }
230
231    #[test]
232    fn an_ability_decides_from_the_user_and_subject() {
233        let gate = gate();
234        let post = Post { author_id: 1 };
235
236        assert!(gate.allows("posts.update", &user(1), Some(&post)));
237        assert!(gate.denies("posts.update", &user(2), Some(&post)));
238    }
239
240    #[test]
241    fn an_undefined_ability_is_denied() {
242        // A typo in an ability name must fail closed, not open.
243        let gate = gate();
244        assert!(gate.denies::<Post>("posts.publish", &admin(), None));
245        assert!(!gate.has("posts.publish"));
246    }
247
248    #[test]
249    fn an_ability_with_no_subject() {
250        let gate = gate();
251        assert!(gate.allows_any("posts.create", &user(1)));
252
253        let banned = User { banned: true, ..user(1) };
254        assert!(!gate.allows_any("posts.create", &banned));
255    }
256
257    #[test]
258    fn a_subject_of_the_wrong_type_is_denied_rather_than_ignored() {
259        let gate = gate();
260        // `posts.update` expects a `Post`; passing a `Comment` is a bug, and
261        // silently treating it as "no subject" would grant or deny by accident.
262        assert!(gate.denies("posts.update", &user(1), Some(&Comment)));
263    }
264
265    #[test]
266    fn a_before_check_short_circuits_everything() {
267        let gate = gate().before(|user: &User, _ability: &str| user.admin.then_some(true));
268
269        let someone_elses = Post { author_id: 1 };
270        assert!(gate.allows("posts.update", &admin(), Some(&someone_elses)));
271        assert!(gate.allows_any("posts.anything.undefined", &admin()));
272    }
273
274    #[test]
275    fn a_before_check_can_also_deny_outright() {
276        let gate = gate().before(|user: &User, _: &str| user.banned.then_some(false));
277
278        let banned = User { banned: true, ..user(1) };
279        let own_post = Post { author_id: 1 };
280        assert!(gate.denies("posts.update", &banned, Some(&own_post)));
281    }
282
283    #[test]
284    fn a_before_check_returning_none_defers_to_the_ability() {
285        let gate = gate().before(|_: &User, _: &str| None);
286        let post = Post { author_id: 1 };
287
288        assert!(gate.allows("posts.update", &user(1), Some(&post)));
289        assert!(gate.denies("posts.update", &user(2), Some(&post)));
290    }
291
292    #[test]
293    fn authorize_turns_a_denial_into_a_403() {
294        let gate = gate();
295        let post = Post { author_id: 1 };
296
297        assert!(gate.authorize("posts.update", &user(1), Some(&post)).is_ok());
298
299        let err = gate.authorize("posts.update", &user(2), Some(&post)).unwrap_err();
300        assert_eq!(err.status(), 403);
301        assert_eq!(err.message(), "This action is unauthorized.");
302    }
303
304    #[test]
305    fn defined_abilities_can_be_listed() {
306        assert_eq!(gate().abilities(), vec!["posts.create", "posts.update"]);
307        assert!(gate().has("posts.update"));
308    }
309
310    /// A machine caller: no password, no session, no person behind it.
311    #[derive(Debug)]
312    struct ApiClient {
313        id: String,
314        scopes: Vec<&'static str>,
315    }
316
317    /// An assumed cloud role, which is not even an account this service owns.
318    #[derive(Debug)]
319    struct StsPrincipal {
320        arn: String,
321    }
322
323    #[test]
324    fn a_gate_authorizes_an_api_client() {
325        // The client-credentials grant: there is no user at all, and inventing
326        // one so the gate would accept it is how a machine identity ends up
327        // able to log in.
328        let gate = Gate::<ApiClient>::new().define_simple("posts.read", |client: &ApiClient| {
329            client.scopes.contains(&"posts:read")
330        });
331
332        let reader = ApiClient { id: "svc-reports".into(), scopes: vec!["posts:read"] };
333        let writer = ApiClient { id: "svc-import".into(), scopes: vec!["posts:write"] };
334
335        assert!(gate.allows_any("posts.read", &reader));
336        assert!(gate.denies::<()>("posts.read", &writer, None));
337        assert_eq!(reader.id, "svc-reports");
338    }
339
340    #[test]
341    fn a_gate_authorizes_a_cloud_principal() {
342        let gate = Gate::<StsPrincipal>::new().define_simple("admin.enter", |principal| {
343            principal.arn.starts_with("arn:aws:sts::123456789012:assumed-role/Deployer/")
344        });
345
346        let deployer =
347            StsPrincipal { arn: "arn:aws:sts::123456789012:assumed-role/Deployer/ci".into() };
348        let stranger =
349            StsPrincipal { arn: "arn:aws:sts::999999999999:assumed-role/Other/x".into() };
350
351        assert!(gate.allows_any("admin.enter", &deployer));
352        assert!(!gate.allows_any("admin.enter", &stranger));
353    }
354
355    #[test]
356    fn an_undefined_ability_is_denied_for_a_machine_too() {
357        // The property that matters most, restated for the actor type nobody
358        // was thinking about when it was written.
359        let gate = Gate::<ApiClient>::new();
360        let client = ApiClient { id: "svc".into(), scopes: vec!["everything"] };
361
362        assert!(!gate.allows_any("posts.read", &client));
363    }
364}