1use std::sync::Arc;
28
29use axum::{
30 extract::{Request, State},
31 http::StatusCode,
32 middleware::{from_fn_with_state, Next},
33 response::{IntoResponse, Response},
34 routing::MethodRouter,
35 Json,
36};
37use backbone_auth::middleware::AuthContext;
38use backbone_auth::{PermissionRequired, PermissionWaiver, PERMISSION_REQUIRED};
39
40use crate::http::ApiResponse;
41use crate::violation::Violation;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Needs {
46 pub permission: String,
48 pub alternates: Vec<String>,
50}
51
52pub fn permission_refusal<T>(refused: &PermissionRequired) -> (StatusCode, Json<ApiResponse<T>>) {
55 let message = refused.to_string();
56 let violation = Violation::new("", PERMISSION_REQUIRED, message.clone())
57 .with_param("permission", refused.permission.clone());
58 (
59 StatusCode::FORBIDDEN,
60 Json(ApiResponse::rejected(message, vec![violation])),
61 )
62}
63
64pub fn unauthenticated_refusal<T>() -> (StatusCode, Json<ApiResponse<T>>) {
66 let message = "Authentication required";
67 let violation = Violation::new("", "authentication_required", message);
68 (
69 StatusCode::UNAUTHORIZED,
70 Json(ApiResponse::rejected(message, vec![violation])),
71 )
72}
73
74fn refusal(req: &Request, needs: &[Needs]) -> Option<Response> {
76 if req.extensions().get::<PermissionWaiver>().is_some() {
77 return None;
78 }
79 let Some(ctx) = req.extensions().get::<AuthContext>() else {
80 return Some(unauthenticated_refusal::<()>().into_response());
81 };
82 for need in needs {
83 let alternates: Vec<&str> = need.alternates.iter().map(String::as_str).collect();
84 if let Err(refused) = ctx.require_with(&need.permission, &alternates) {
85 tracing::debug!(target: "backbone::auth", user = %ctx.user_id, permission = %refused.permission, "permission refused");
86 return Some(permission_refusal::<()>(&refused).into_response());
87 }
88 }
89 None
90}
91
92async fn enforce(State(needs): State<Arc<Vec<Needs>>>, req: Request, next: Next) -> Response {
93 match refusal(&req, &needs) {
94 Some(refused) => refused,
95 None => next.run(req).await,
96 }
97}
98
99pub fn guarded<S>(route: MethodRouter<S>, permission: impl Into<String>) -> MethodRouter<S>
101where
102 S: Clone + Send + Sync + 'static,
103{
104 guarded_needs(
105 route,
106 vec![Needs {
107 permission: permission.into(),
108 alternates: Vec::new(),
109 }],
110 )
111}
112
113pub fn guarded_all<S>(route: MethodRouter<S>, permissions: &[&str]) -> MethodRouter<S>
116where
117 S: Clone + Send + Sync + 'static,
118{
119 guarded_needs(
120 route,
121 permissions
122 .iter()
123 .map(|p| Needs {
124 permission: (*p).to_string(),
125 alternates: Vec::new(),
126 })
127 .collect(),
128 )
129}
130
131pub fn guarded_needs<S>(route: MethodRouter<S>, needs: Vec<Needs>) -> MethodRouter<S>
134where
135 S: Clone + Send + Sync + 'static,
136{
137 route.route_layer(from_fn_with_state(Arc::new(needs), enforce))
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142pub enum Gate {
143 Read,
145 Create,
147 Update,
149 Delete,
151 Restore,
153 Purge,
155 CreateUpdate,
157}
158
159impl Gate {
160 pub fn action(&self) -> &'static str {
162 match self {
163 Gate::Read => "read",
164 Gate::Create => "create",
165 Gate::Update => "update",
166 Gate::Delete => "delete",
167 Gate::Restore => "restore",
168 Gate::Purge => "purge",
169 Gate::CreateUpdate => "create",
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct CrudPermissions {
178 entity: String,
179 module: Option<String>,
180 overrides: Vec<(Gate, String)>,
181 public: Vec<Gate>,
182}
183
184impl CrudPermissions {
185 pub fn new(entity: impl Into<String>) -> Self {
187 Self {
188 entity: entity.into(),
189 module: None,
190 overrides: Vec::new(),
191 public: Vec::new(),
192 }
193 }
194
195 pub fn in_module(mut self, module: impl Into<String>) -> Self {
198 self.module = Some(module.into());
199 self
200 }
201
202 pub fn requiring(mut self, gate: Gate, permission: impl Into<String>) -> Self {
204 self.overrides.retain(|(g, _)| *g != gate);
205 self.overrides.push((gate, permission.into()));
206 self
207 }
208
209 pub fn public(mut self, gate: Gate) -> Self {
215 let gate = if gate == Gate::CreateUpdate {
216 Gate::Create
217 } else {
218 gate
219 };
220 if !self.public.contains(&gate) {
221 self.public.push(gate);
222 }
223 self
224 }
225
226 pub fn is_public(&self, gate: Gate) -> bool {
229 match gate {
230 Gate::CreateUpdate => {
231 self.public.contains(&Gate::Create) && self.public.contains(&Gate::Update)
232 }
233 g => self.public.contains(&g),
234 }
235 }
236
237 pub fn permission(&self, gate: Gate) -> String {
239 let gate = if gate == Gate::CreateUpdate {
240 Gate::Create
241 } else {
242 gate
243 };
244 self.overrides
245 .iter()
246 .find(|(g, _)| *g == gate)
247 .map(|(_, p)| p.clone())
248 .unwrap_or_else(|| format!("{}:{}", self.entity, gate.action()))
249 }
250
251 fn one(&self, gate: Gate) -> Needs {
252 let overridden = self.overrides.iter().any(|(g, _)| *g == gate);
253 let alternates = match (&self.module, overridden) {
254 (Some(module), false) => vec![format!("{module}:{}", gate.action())],
255 _ => Vec::new(),
256 };
257 Needs {
258 permission: self.permission(gate),
259 alternates,
260 }
261 }
262
263 pub fn needs(&self, gate: Gate) -> Vec<Needs> {
266 match gate {
267 Gate::CreateUpdate => [Gate::Create, Gate::Update]
268 .into_iter()
269 .filter(|g| !self.public.contains(g))
270 .map(|g| self.one(g))
271 .collect(),
272 g if self.public.contains(&g) => Vec::new(),
273 g => vec![self.one(g)],
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use axum::{body::Body, http::Request as HttpRequest, routing::get};
282 use tower::ServiceExt;
283
284 fn app(route: MethodRouter<()>) -> axum::Router {
285 axum::Router::new().route("/", route)
286 }
287
288 async fn call(
289 app: axum::Router,
290 ctx: Option<AuthContext>,
291 waiver: bool,
292 ) -> (StatusCode, serde_json::Value) {
293 let mut req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
294 if let Some(c) = ctx {
295 req.extensions_mut().insert(c);
296 }
297 if waiver {
298 req.extensions_mut().insert(PermissionWaiver);
299 }
300 let res = app.oneshot(req).await.unwrap();
301 let status = res.status();
302 let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
303 .await
304 .unwrap();
305 (
306 status,
307 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
308 )
309 }
310
311 fn holding(perms: &[&str]) -> AuthContext {
312 AuthContext {
313 user_id: "u".into(),
314 roles: vec![],
315 permissions: perms.iter().map(|s| s.to_string()).collect(),
316 }
317 }
318
319 #[tokio::test]
320 async fn a_holder_passes_and_a_caller_without_it_is_403_with_the_permission_named() {
321 let route = || guarded(get(|| async { "ok" }), "ticket:read");
322 let (status, _) = call(app(route()), Some(holding(&["ticket:read"])), false).await;
323 assert_eq!(status, StatusCode::OK);
324
325 let (status, body) = call(app(route()), Some(holding(&[])), false).await;
326 assert_eq!(
327 status,
328 StatusCode::FORBIDDEN,
329 "no permission, no resolver: fail closed"
330 );
331 assert_eq!(body["violations"][0]["code"], "permission_required");
332 assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
333 assert_eq!(body["success"], false);
334
335 let (status, body) = call(app(route()), Some(holding(&["ticket:update"])), false).await;
336 assert_eq!(status, StatusCode::FORBIDDEN);
337 assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
338 }
339
340 #[tokio::test]
341 async fn a_request_with_no_auth_context_is_401() {
342 let (status, body) = call(
343 app(guarded(get(|| async { "ok" }), "ticket:read")),
344 None,
345 false,
346 )
347 .await;
348 assert_eq!(status, StatusCode::UNAUTHORIZED);
349 assert_eq!(body["violations"][0]["code"], "authentication_required");
350 }
351
352 #[tokio::test]
353 async fn wildcards_grant_and_the_waiver_admits_a_request_a_narrower_rule_already_admitted() {
354 let route = || guarded(get(|| async { "ok" }), "ticket:read");
355 for held in ["ticket:*", "*:read", "*:*"] {
356 let (status, _) = call(app(route()), Some(holding(&[held])), false).await;
357 assert_eq!(status, StatusCode::OK, "{held}");
358 }
359 let (status, _) = call(app(route()), Some(holding(&[])), true).await;
360 assert_eq!(status, StatusCode::OK, "waived");
361 let (status, _) = call(app(route()), None, true).await;
362 assert_eq!(
363 status,
364 StatusCode::OK,
365 "a waiver stands for the whole check"
366 );
367 }
368
369 #[tokio::test]
370 async fn the_check_runs_before_the_handler() {
371 async fn must_not_run() -> &'static str {
372 panic!("the handler must not run for a refused caller")
373 }
374 let route = guarded(get(must_not_run), "ticket:read");
375 let (status, _) = call(app(route), Some(holding(&[])), false).await;
376 assert_eq!(status, StatusCode::FORBIDDEN);
377 }
378
379 #[tokio::test]
380 async fn guarded_all_needs_every_permission() {
381 let route = || guarded_all(get(|| async { "ok" }), &["ticket:create", "ticket:update"]);
382 let (status, body) = call(app(route()), Some(holding(&["ticket:create"])), false).await;
383 assert_eq!(status, StatusCode::FORBIDDEN);
384 assert_eq!(
385 body["violations"][0]["params"]["permission"],
386 "ticket:update"
387 );
388 let (status, _) = call(
389 app(route()),
390 Some(holding(&["ticket:create", "ticket:update"])),
391 false,
392 )
393 .await;
394 assert_eq!(status, StatusCode::OK);
395 }
396
397 #[test]
398 fn crud_permissions_default_to_entity_colon_action_and_honour_a_rename() {
399 let p = CrudPermissions::new("payroll_entry").requiring(Gate::Read, "payroll:view");
400 assert_eq!(p.permission(Gate::Read), "payroll:view");
401 assert_eq!(p.permission(Gate::Purge), "payroll_entry:purge");
402 assert_eq!(p.permission(Gate::CreateUpdate), "payroll_entry:create");
403 assert_eq!(
404 p.needs(Gate::CreateUpdate)
405 .iter()
406 .map(|n| n.permission.as_str())
407 .collect::<Vec<_>>(),
408 vec!["payroll_entry:create", "payroll_entry:update"]
409 );
410 let p = CrudPermissions::new("salary_slip").in_module("payroll");
411 assert_eq!(
412 p.needs(Gate::Read)[0].alternates,
413 vec!["payroll:read".to_string()]
414 );
415 let p = p.requiring(Gate::Read, "payroll:view");
416 assert!(
417 p.needs(Gate::Read)[0].alternates.is_empty(),
418 "a renamed permission has no legacy alternates"
419 );
420 }
421
422 #[test]
423 fn a_public_gate_needs_nothing_and_an_upsert_needs_only_its_non_public_half() {
424 let p = CrudPermissions::new("label").public(Gate::Read);
425 assert!(p.is_public(Gate::Read) && !p.is_public(Gate::Create));
426 assert!(p.needs(Gate::Read).is_empty());
427 assert_eq!(p.needs(Gate::Create)[0].permission, "label:create");
428
429 let p = CrudPermissions::new("label").public(Gate::Create);
430 assert!(!p.is_public(Gate::CreateUpdate), "an upsert also updates");
431 assert_eq!(
432 p.needs(Gate::CreateUpdate)
433 .iter()
434 .map(|n| n.permission.as_str())
435 .collect::<Vec<_>>(),
436 vec!["label:update"]
437 );
438 let p = p.public(Gate::Update);
439 assert!(p.is_public(Gate::CreateUpdate));
440 assert!(p.needs(Gate::CreateUpdate).is_empty());
441 }
442}