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}
182
183impl CrudPermissions {
184 pub fn new(entity: impl Into<String>) -> Self {
186 Self {
187 entity: entity.into(),
188 module: None,
189 overrides: Vec::new(),
190 }
191 }
192
193 pub fn in_module(mut self, module: impl Into<String>) -> Self {
196 self.module = Some(module.into());
197 self
198 }
199
200 pub fn requiring(mut self, gate: Gate, permission: impl Into<String>) -> Self {
202 self.overrides.retain(|(g, _)| *g != gate);
203 self.overrides.push((gate, permission.into()));
204 self
205 }
206
207 pub fn permission(&self, gate: Gate) -> String {
209 let gate = if gate == Gate::CreateUpdate {
210 Gate::Create
211 } else {
212 gate
213 };
214 self.overrides
215 .iter()
216 .find(|(g, _)| *g == gate)
217 .map(|(_, p)| p.clone())
218 .unwrap_or_else(|| format!("{}:{}", self.entity, gate.action()))
219 }
220
221 fn one(&self, gate: Gate) -> Needs {
222 let overridden = self.overrides.iter().any(|(g, _)| *g == gate);
223 let alternates = match (&self.module, overridden) {
224 (Some(module), false) => vec![format!("{module}:{}", gate.action())],
225 _ => Vec::new(),
226 };
227 Needs {
228 permission: self.permission(gate),
229 alternates,
230 }
231 }
232
233 pub fn needs(&self, gate: Gate) -> Vec<Needs> {
235 match gate {
236 Gate::CreateUpdate => vec![self.one(Gate::Create), self.one(Gate::Update)],
237 g => vec![self.one(g)],
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use axum::{body::Body, http::Request as HttpRequest, routing::get};
246 use tower::ServiceExt;
247
248 fn app(route: MethodRouter<()>) -> axum::Router {
249 axum::Router::new().route("/", route)
250 }
251
252 async fn call(
253 app: axum::Router,
254 ctx: Option<AuthContext>,
255 waiver: bool,
256 ) -> (StatusCode, serde_json::Value) {
257 let mut req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
258 if let Some(c) = ctx {
259 req.extensions_mut().insert(c);
260 }
261 if waiver {
262 req.extensions_mut().insert(PermissionWaiver);
263 }
264 let res = app.oneshot(req).await.unwrap();
265 let status = res.status();
266 let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
267 .await
268 .unwrap();
269 (
270 status,
271 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
272 )
273 }
274
275 fn holding(perms: &[&str]) -> AuthContext {
276 AuthContext {
277 user_id: "u".into(),
278 roles: vec![],
279 permissions: perms.iter().map(|s| s.to_string()).collect(),
280 }
281 }
282
283 #[tokio::test]
284 async fn a_holder_passes_and_a_caller_without_it_is_403_with_the_permission_named() {
285 let route = || guarded(get(|| async { "ok" }), "ticket:read");
286 let (status, _) = call(app(route()), Some(holding(&["ticket:read"])), false).await;
287 assert_eq!(status, StatusCode::OK);
288
289 let (status, body) = call(app(route()), Some(holding(&[])), false).await;
290 assert_eq!(
291 status,
292 StatusCode::FORBIDDEN,
293 "no permission, no resolver: fail closed"
294 );
295 assert_eq!(body["violations"][0]["code"], "permission_required");
296 assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
297 assert_eq!(body["success"], false);
298
299 let (status, body) = call(app(route()), Some(holding(&["ticket:update"])), false).await;
300 assert_eq!(status, StatusCode::FORBIDDEN);
301 assert_eq!(body["violations"][0]["params"]["permission"], "ticket:read");
302 }
303
304 #[tokio::test]
305 async fn a_request_with_no_auth_context_is_401() {
306 let (status, body) = call(
307 app(guarded(get(|| async { "ok" }), "ticket:read")),
308 None,
309 false,
310 )
311 .await;
312 assert_eq!(status, StatusCode::UNAUTHORIZED);
313 assert_eq!(body["violations"][0]["code"], "authentication_required");
314 }
315
316 #[tokio::test]
317 async fn wildcards_grant_and_the_waiver_admits_a_request_a_narrower_rule_already_admitted() {
318 let route = || guarded(get(|| async { "ok" }), "ticket:read");
319 for held in ["ticket:*", "*:read", "*:*"] {
320 let (status, _) = call(app(route()), Some(holding(&[held])), false).await;
321 assert_eq!(status, StatusCode::OK, "{held}");
322 }
323 let (status, _) = call(app(route()), Some(holding(&[])), true).await;
324 assert_eq!(status, StatusCode::OK, "waived");
325 let (status, _) = call(app(route()), None, true).await;
326 assert_eq!(
327 status,
328 StatusCode::OK,
329 "a waiver stands for the whole check"
330 );
331 }
332
333 #[tokio::test]
334 async fn the_check_runs_before_the_handler() {
335 async fn must_not_run() -> &'static str {
336 panic!("the handler must not run for a refused caller")
337 }
338 let route = guarded(get(must_not_run), "ticket:read");
339 let (status, _) = call(app(route), Some(holding(&[])), false).await;
340 assert_eq!(status, StatusCode::FORBIDDEN);
341 }
342
343 #[tokio::test]
344 async fn guarded_all_needs_every_permission() {
345 let route = || guarded_all(get(|| async { "ok" }), &["ticket:create", "ticket:update"]);
346 let (status, body) = call(app(route()), Some(holding(&["ticket:create"])), false).await;
347 assert_eq!(status, StatusCode::FORBIDDEN);
348 assert_eq!(
349 body["violations"][0]["params"]["permission"],
350 "ticket:update"
351 );
352 let (status, _) = call(
353 app(route()),
354 Some(holding(&["ticket:create", "ticket:update"])),
355 false,
356 )
357 .await;
358 assert_eq!(status, StatusCode::OK);
359 }
360
361 #[test]
362 fn crud_permissions_default_to_entity_colon_action_and_honour_a_rename() {
363 let p = CrudPermissions::new("payroll_entry").requiring(Gate::Read, "payroll:view");
364 assert_eq!(p.permission(Gate::Read), "payroll:view");
365 assert_eq!(p.permission(Gate::Purge), "payroll_entry:purge");
366 assert_eq!(p.permission(Gate::CreateUpdate), "payroll_entry:create");
367 assert_eq!(
368 p.needs(Gate::CreateUpdate)
369 .iter()
370 .map(|n| n.permission.as_str())
371 .collect::<Vec<_>>(),
372 vec!["payroll_entry:create", "payroll_entry:update"]
373 );
374 let p = CrudPermissions::new("salary_slip").in_module("payroll");
375 assert_eq!(
376 p.needs(Gate::Read)[0].alternates,
377 vec!["payroll:read".to_string()]
378 );
379 let p = p.requiring(Gate::Read, "payroll:view");
380 assert!(
381 p.needs(Gate::Read)[0].alternates.is_empty(),
382 "a renamed permission has no legacy alternates"
383 );
384 }
385}