Skip to main content

cloudillo_core/
middleware.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Custom middlewares
5
6use crate::extract::RequestId;
7use crate::extract::{Auth, IdTag};
8use crate::prelude::*;
9use axum::{
10	body::Body,
11	extract::State,
12	http::{Request, header, response::Response},
13	middleware::Next,
14};
15use cloudillo_types::auth_adapter::AuthCtx;
16use cloudillo_types::types::TokenScope;
17use std::pin::Pin;
18
19/// Tenant API key prefix (validated by auth adapter)
20const TENANT_API_KEY_PREFIX: &str = "cl_";
21
22/// IDP API key prefix (validated by identity provider adapter)
23const IDP_API_KEY_PREFIX: &str = "idp_";
24
25/// API key type for routing to correct validation adapter
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ApiKeyType {
28	/// Tenant API key (cl_ prefix) - validated by auth adapter
29	Tenant,
30	/// IDP API key (idp_ prefix) - validated by identity provider adapter
31	Idp,
32}
33
34/// Check if a token is an API key and return its type
35fn get_api_key_type(token: &str) -> Option<ApiKeyType> {
36	if token.starts_with(TENANT_API_KEY_PREFIX) {
37		Some(ApiKeyType::Tenant)
38	} else if token.starts_with(IDP_API_KEY_PREFIX) {
39		Some(ApiKeyType::Idp)
40	} else {
41		None
42	}
43}
44
45// Type aliases for permission check middleware components
46pub type PermissionCheckInput =
47	(State<App>, Auth, axum::extract::Path<String>, Request<Body>, Next);
48pub type PermissionCheckOutput =
49	Pin<Box<dyn Future<Output = Result<axum::response::Response, Error>> + Send>>;
50
51/// Wrapper struct for permission check middleware factories
52///
53/// This struct wraps a closure that implements the permission check middleware pattern.
54/// It takes a static permission action string and returns a middleware factory function.
55#[derive(Clone)]
56pub struct PermissionCheckFactory<F>
57where
58	F: Fn(
59			State<App>,
60			Auth,
61			axum::extract::Path<String>,
62			Request<Body>,
63			Next,
64		) -> PermissionCheckOutput
65		+ Clone
66		+ Send
67		+ Sync,
68{
69	handler: F,
70}
71
72impl<F> PermissionCheckFactory<F>
73where
74	F: Fn(
75			State<App>,
76			Auth,
77			axum::extract::Path<String>,
78			Request<Body>,
79			Next,
80		) -> PermissionCheckOutput
81		+ Clone
82		+ Send
83		+ Sync,
84{
85	pub fn new(handler: F) -> Self {
86		Self { handler }
87	}
88
89	pub fn call(
90		&self,
91		state: State<App>,
92		auth: Auth,
93		path: axum::extract::Path<String>,
94		req: Request<Body>,
95		next: Next,
96	) -> PermissionCheckOutput {
97		(self.handler)(state, auth, path, req, next)
98	}
99}
100
101/// Extract token from query parameters
102fn extract_token_from_query(query: &str) -> Option<String> {
103	for param in query.split('&') {
104		if param.starts_with("token=") {
105			let token = param.strip_prefix("token=")?;
106			if !token.is_empty() {
107				// For JWT tokens, just use as-is (they don't contain special chars that need decoding)
108				// URL decoding is typically only needed for form-encoded data
109				return Some(token.to_string());
110			}
111		}
112	}
113	None
114}
115
116/// Owner/leader gate. Must be layered *after* `require_auth`, which installs `Auth`.
117///
118/// *Delegated* credentials — share links and apkg-publish tokens — are rejected
119/// before the role check: tenant API keys are minted with the *full* owner role set
120/// regardless of their `scopes` column, so a role test alone would let one through.
121///
122/// Capability scopes (`carddav:*` / `caldav:*`) are deliberately not rejected here —
123/// `crate::scope::scope_permits` already constrained them fail-closed in
124/// `require_auth`, and a second rejection would 403 a DAV key on its own routes.
125pub async fn require_leader(
126	Auth(auth_ctx): Auth,
127	req: Request<Body>,
128	next: Next,
129) -> ClResult<Response<Body>> {
130	if auth_ctx.scope.as_deref().and_then(TokenScope::parse).is_some() {
131		warn!(
132			subject = %auth_ctx.id_tag,
133			scope = ?auth_ctx.scope,
134			"Owner/leader permission denied - delegated token"
135		);
136		return Err(Error::PermissionDenied);
137	}
138	if !crate::roles::is_leader(&auth_ctx.roles) {
139		warn!(
140			subject = %auth_ctx.id_tag,
141			roles = ?auth_ctx.roles,
142			"Owner/leader permission denied"
143		);
144		return Err(Error::PermissionDenied);
145	}
146	Ok(next.run(req).await)
147}
148
149/// Whether [`authenticate`] applies [`crate::scope::scope_permits`].
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum ScopeGate {
152	/// Normal protected-API behaviour.
153	Enforce,
154	/// The scope-agnostic tier — see [`require_auth_public_data`].
155	Skip,
156}
157
158/// Full credential validation for the protected API surface.
159///
160/// Validates all three credential families (`cl_` tenant API key, `idp_` key,
161/// JWT), resolves the tenant, and — under [`ScopeGate::Enforce`] — applies
162/// [`crate::scope::scope_permits`]. On success `Auth` is installed in the
163/// request extensions.
164///
165/// The [`ScopeGate`] is the **only** difference between [`require_auth`] and
166/// [`require_auth_public_data`]; both delegate here so the validation itself can
167/// never drift between them. Keep both wrappers one-line delegations — inlining
168/// either body is what would silently drop token validation from one tier.
169async fn authenticate(
170	state: App,
171	mut req: Request<Body>,
172	next: Next,
173	gate: ScopeGate,
174) -> ClResult<Response<Body>> {
175	// Extract IdTag from request extensions (inserted by webserver)
176	let id_tag = req
177		.extensions()
178		.get::<IdTag>()
179		.ok_or_else(|| {
180			warn!("IdTag not found in request extensions");
181			Error::PermissionDenied
182		})?
183		.clone();
184
185	// Convert IdTag to TnId via database lookup
186	let tn_id = state.auth_adapter.read_tn_id(&id_tag.0).await.map_err(|_| {
187		warn!("Failed to resolve tenant ID for id_tag: {}", id_tag.0);
188		Error::PermissionDenied
189	})?;
190
191	// Try to get token from Authorization header first
192	let token = if let Some(auth_header) =
193		req.headers().get("Authorization").and_then(|h| h.to_str().ok())
194	{
195		if let Some(token) = auth_header.strip_prefix("Bearer ") {
196			token.trim().to_string()
197		} else {
198			warn!("Authorization header present but doesn't start with 'Bearer ': {}", auth_header);
199			return Err(Error::PermissionDenied);
200		}
201	} else {
202		// Fallback: try to get token from query parameter (for WebSocket)
203		let query_token = extract_token_from_query(req.uri().query().unwrap_or(""));
204		if query_token.is_none() {
205			warn!("No Authorization header and no token query parameter found");
206		}
207		query_token.ok_or(Error::PermissionDenied)?
208	};
209
210	// Validate token based on type
211	let claims = match get_api_key_type(&token) {
212		Some(ApiKeyType::Tenant) => {
213			// Validate tenant API key (cl_ prefix)
214			let validation = state.auth_adapter.validate_api_key(&token).await.map_err(|e| {
215				warn!("Tenant API key validation failed: {:?}", e);
216				Error::PermissionDenied
217			})?;
218
219			// Verify API key belongs to requested tenant
220			if validation.tn_id != tn_id {
221				warn!(
222					"API key tenant mismatch: key belongs to {:?} but request is for {:?}",
223					validation.tn_id, tn_id
224				);
225				return Err(Error::PermissionDenied);
226			}
227
228			AuthCtx {
229				tn_id: validation.tn_id,
230				id_tag: validation.id_tag,
231				roles: validation.roles.map(|r| crate::roles::parse_roles(&r)).unwrap_or_default(),
232				scope: validation.scopes,
233				anonymous: false,
234			}
235		}
236		Some(ApiKeyType::Idp) => {
237			// Validate IDP API key (idp_ prefix)
238			let idp_adapter = state.idp_adapter.as_ref().ok_or_else(|| {
239				warn!("IDP API key used but Identity Provider not available");
240				Error::ServiceUnavailable("Identity Provider not available".to_string())
241			})?;
242
243			let auth_id_tag = idp_adapter
244				.verify_api_key(&token)
245				.await
246				.map_err(|e| {
247					warn!("IDP API key validation error: {:?}", e);
248					Error::PermissionDenied
249				})?
250				.ok_or_else(|| {
251					warn!("IDP API key validation failed: key not found or expired");
252					Error::PermissionDenied
253				})?;
254
255			AuthCtx {
256				tn_id, // From request host lookup
257				id_tag: auth_id_tag.into(),
258				roles: Box::new([]), // IDP keys don't have roles
259				scope: None,
260				anonymous: false,
261			}
262		}
263		None => {
264			// Validate JWT token (existing flow)
265			state.auth_adapter.validate_access_token(tn_id, &id_tag.0, &token).await?
266		}
267	};
268
269	// Enforce scope restrictions centrally and fail-closed: a scope string the
270	// matcher doesn't recognise grants nothing anywhere (see `crate::scope`).
271	if gate == ScopeGate::Enforce
272		&& !crate::scope::scope_permits(claims.scope.as_deref(), req.method(), req.uri().path())
273	{
274		warn!(
275			scope = ?claims.scope,
276			path = %req.uri().path(),
277			"Scoped token denied access to non-matching endpoint"
278		);
279		return Err(Error::PermissionDenied);
280	}
281
282	req.extensions_mut().insert(Auth(claims));
283
284	Ok(next.run(req).await)
285}
286
287pub async fn require_auth(
288	State(state): State<App>,
289	req: Request<Body>,
290	next: Next,
291) -> ClResult<Response<Body>> {
292	authenticate(state, req, next, ScopeGate::Enforce).await
293}
294
295/// Like [`require_auth`], but **skips the scope gate**. It relaxes *scope*, never
296/// *validity*: an expired, forged, wrong-tenant or unparseable token is rejected here
297/// exactly as `require_auth` rejects it.
298///
299/// # The admission rule — read before mounting anything here
300///
301/// > **Mount a route under this layer only if every field of its response is already
302/// > obtainable without authentication elsewhere.**
303///
304/// That rule is what makes the admission safe, and the admission is total: *any* valid
305/// credential reaches this tier with **no** scope filtering. Exhaustively —
306///
307/// - every `file:{file_id}:{R|C|W}` share-link token,
308/// - every `apkg:publish` token,
309/// - every `carddav:*` / `caldav:*` capability key,
310/// - every scope string [`crate::scope::scope_permits`] does **not** recognise — the
311///   case it exists to fail closed on, since tenant API keys are minted with the full
312///   owner role set regardless of their `scopes` column,
313/// - and every scope family added in future, admitted the day it is added with no diff
314///   touching this file.
315///
316/// The share-link token is the worked example because it is the most adversarial: a
317/// credential handed to an untrusted third party for one document, which
318/// `scope_permits` otherwise confines to `/api/files/**`, `/api/search` and the
319/// CRDT/RTDB sockets. Mounting a route here hands that third party the route, with no
320/// ABAC behind it unless the compose site adds one.
321///
322/// It exists because [`crate::scope::scope_permits`] is a central path list, and growing
323/// it is how a scope quietly widens: the list drifts away from the routes it governs and
324/// no reviewer of a route change ever sees it. Expressing the relaxation as a mount puts
325/// the decision in the diff that adds the route, and leaves `scope.rs` untouched — so
326/// every other `/api/profiles/*` route stays denied to a file-scoped token.
327///
328/// The mounted set is pinned by
329/// `crate::routes::protected::tests::public_data_tier_holds_exactly_the_admitted_tables`
330/// in the `cloudillo` crate. Currently: `GET /api/profiles/batch` — the reduced 4-field
331/// profile projection, which is `GET /api/me` minus `keys`.
332pub async fn require_auth_public_data(
333	State(state): State<App>,
334	req: Request<Body>,
335	next: Next,
336) -> ClResult<Response<Body>> {
337	authenticate(state, req, next, ScopeGate::Skip).await
338}
339
340pub async fn optional_auth(
341	State(state): State<App>,
342	mut req: Request<Body>,
343	next: Next,
344) -> ClResult<Response<Body>> {
345	// Try to extract IdTag (optional for this middleware)
346	let id_tag = req.extensions().get::<IdTag>().cloned();
347
348	// Try to get token from Authorization header first
349	let token = if let Some(auth_header) =
350		req.headers().get(header::AUTHORIZATION).and_then(|h| h.to_str().ok())
351	{
352		auth_header.strip_prefix("Bearer ").map(|token| token.trim().to_string())
353	} else if req.uri().path().starts_with("/ws/") || req.uri().path().starts_with("/api/files/") {
354		// Fallback: try to get token from query parameter (for WebSocket and file endpoints)
355		let query = req.uri().query().unwrap_or("");
356		extract_token_from_query(query)
357	} else {
358		None
359	};
360
361	// Only validate if both id_tag and token are present
362	if let (Some(id_tag), Some(ref token)) = (id_tag, token) {
363		// Try to get tn_id
364		match state.auth_adapter.read_tn_id(&id_tag.0).await {
365			Ok(tn_id) => {
366				// Try to validate token based on type
367				let claims_result: Result<Result<AuthCtx, Error>, Error> =
368					match get_api_key_type(token) {
369						Some(ApiKeyType::Tenant) => {
370							// Validate tenant API key (cl_ prefix)
371							state.auth_adapter.validate_api_key(token).await.map(|validation| {
372								// Verify API key belongs to requested tenant
373								if validation.tn_id != tn_id {
374									return Err(Error::PermissionDenied);
375								}
376								Ok(AuthCtx {
377									tn_id: validation.tn_id,
378									id_tag: validation.id_tag,
379									roles: validation
380										.roles
381										.map(|r| crate::roles::parse_roles(&r))
382										.unwrap_or_default(),
383									scope: validation.scopes,
384									anonymous: false,
385								})
386							})
387						}
388						Some(ApiKeyType::Idp) => {
389							// Validate IDP API key (idp_ prefix)
390							if let Some(idp_adapter) = state.idp_adapter.as_ref() {
391								match idp_adapter.verify_api_key(token).await {
392									Ok(Some(auth_id_tag)) => Ok(Ok(AuthCtx {
393										tn_id,
394										id_tag: auth_id_tag.into(),
395										roles: Box::new([]),
396										scope: None,
397										anonymous: false,
398									})),
399									Ok(None) => {
400										warn!(
401											"IDP API key validation failed: key not found or expired"
402										);
403										Err(Error::PermissionDenied)
404									}
405									Err(e) => {
406										warn!("IDP API key validation error: {:?}", e);
407										Err(Error::PermissionDenied)
408									}
409								}
410							} else {
411								warn!("IDP API key used but Identity Provider not available");
412								Err(Error::ServiceUnavailable(
413									"Identity Provider not available".to_string(),
414								))
415							}
416						}
417						None => {
418							// Validate JWT token
419							state
420								.auth_adapter
421								.validate_access_token(tn_id, &id_tag.0, token)
422								.await
423								.map(Ok)
424						}
425					};
426
427				match claims_result {
428					Ok(Ok(claims)) => {
429						// Same fail-closed decision as `require_auth`, but a denial
430						// here degrades to unauthenticated rather than 403.
431						let allowed = crate::scope::scope_permits(
432							claims.scope.as_deref(),
433							req.method(),
434							req.uri().path(),
435						);
436						if allowed {
437							req.extensions_mut().insert(Auth(claims));
438						} else {
439							warn!(
440								scope = ?claims.scope,
441								path = %req.uri().path(),
442								"Scoped token denied access in optional_auth, treating as unauthenticated"
443							);
444						}
445					}
446					Ok(Err(e)) => {
447						warn!("Token validation failed (tenant mismatch): {:?}", e);
448					}
449					Err(e) => {
450						warn!("Token validation failed: {:?}", e);
451					}
452				}
453			}
454			Err(e) => {
455				warn!("Failed to resolve tenant ID: {:?}", e);
456			}
457		}
458	}
459
460	Ok(next.run(req).await)
461}
462
463/// Add or generate request ID, attach a `request` span carrying its short
464/// form, and store the full id in extensions. The custom log formatter
465/// (`crate::log::CloudilloFormat`) uses the `request` span's `id` field to
466/// prefix every event line with `REQ:<short>`.
467///
468/// If the outer transport layer (see `cloudillo::webserver::create_https_server`)
469/// has already inserted a `RequestId` extension and entered the `request` span,
470/// `RequestId::install` returns a span that just re-uses the existing id.
471pub async fn request_id_middleware(mut req: Request<Body>, next: Next) -> Response<Body> {
472	let span = RequestId::install(&mut req);
473	let request_id = req.extensions().get::<RequestId>().map(|r| r.0.clone()).unwrap_or_default();
474
475	let mut response = {
476		use tracing::Instrument;
477		next.run(req).instrument(span).await
478	};
479
480	if let Ok(header_value) = request_id.parse() {
481		response.headers_mut().insert("X-Request-ID", header_value);
482	}
483	response
484}
485
486#[cfg(test)]
487mod tests {
488	use super::*;
489	use axum::{Router, http::StatusCode, middleware, routing::get};
490	use tower::ServiceExt;
491
492	fn auth_ctx(roles: &[&str], scope: Option<&str>) -> AuthCtx {
493		AuthCtx {
494			tn_id: TnId(1),
495			id_tag: "alice.example.com".into(),
496			roles: roles.iter().map(|r| Box::from(*r)).collect(),
497			scope: scope.map(Box::from),
498			anonymous: false,
499		}
500	}
501
502	/// Drives `require_leader` without `require_auth` or `App` state — `Auth`
503	/// reads straight from request extensions (see `crate::extract`).
504	async fn run_require_leader(auth: Option<AuthCtx>) -> StatusCode {
505		let app: Router = Router::new()
506			.route("/x", get(|| async { "ok" }))
507			.layer(middleware::from_fn(require_leader));
508
509		let mut req = Request::builder().uri("/x").body(Body::empty()).expect("build request");
510		if let Some(ctx) = auth {
511			req.extensions_mut().insert(Auth(ctx));
512		}
513
514		app.oneshot(req).await.expect("router responds").status()
515	}
516
517	#[tokio::test]
518	async fn require_leader_allows_leader() {
519		assert_eq!(run_require_leader(Some(auth_ctx(&["leader"], None))).await, StatusCode::OK);
520	}
521
522	#[tokio::test]
523	async fn require_leader_denies_non_leader_roles() {
524		assert_eq!(
525			run_require_leader(Some(auth_ctx(&["contributor"], None))).await,
526			StatusCode::FORBIDDEN
527		);
528	}
529
530	#[tokio::test]
531	async fn require_leader_denies_role_less_principal() {
532		// The federated stranger: authenticated, but carries no roles.
533		assert_eq!(run_require_leader(Some(auth_ctx(&[], None))).await, StatusCode::FORBIDDEN);
534	}
535
536	#[tokio::test]
537	async fn require_leader_denies_delegated_token_with_leader_roles() {
538		// Tenant API keys carry the full owner role set regardless of scope, so a
539		// delegated (share-link) token must be rejected on its scope, not its roles.
540		assert_eq!(
541			run_require_leader(Some(auth_ctx(&["leader"], Some("file:f1~abc:W")))).await,
542			StatusCode::FORBIDDEN
543		);
544	}
545
546	#[tokio::test]
547	async fn require_leader_allows_capability_token_with_leader_roles() {
548		// A capability scope is not a delegation, so it passes on its roles;
549		// `crate::scope::scope_permits` is what confines it to its own routes.
550		assert_eq!(
551			run_require_leader(Some(auth_ctx(&["leader"], Some("carddav:read")))).await,
552			StatusCode::OK
553		);
554	}
555
556	#[tokio::test]
557	async fn require_leader_denies_missing_auth() {
558		// Fail closed when the `Auth` extension is absent entirely.
559		assert_eq!(run_require_leader(None).await, StatusCode::FORBIDDEN);
560	}
561
562	/// Driving the real middleware needs a full `App` (adapters, DB) that no unit test
563	/// here can build, so pin the half that can be pinned: *why* the permissive tier is
564	/// needed at all.
565	#[test]
566	fn file_scope_needs_the_permissive_tier_for_the_batch_route() {
567		use crate::scope::scope_permits;
568		use axum::http::Method;
569
570		let s = Some("file:f1~abc:R");
571
572		// The whole reason `require_auth_public_data` exists: under `require_auth`
573		// this request is a 403.
574		assert!(!scope_permits(s, &Method::GET, "/api/profiles/batch"));
575
576		// ...and `scope.rs` is deliberately untouched, so every other profile route
577		// stays denied to a file-scoped token exactly as before.
578		assert!(!scope_permits(s, &Method::GET, "/api/profiles/alice.example.com"));
579		assert!(!scope_permits(s, &Method::PATCH, "/api/profiles/alice.example.com"));
580		assert!(!scope_permits(s, &Method::POST, "/api/profiles/alice.example.com/refresh"));
581		assert!(!scope_permits(s, &Method::PUT, "/api/profiles/alice.example.com"));
582		assert!(!scope_permits(s, &Method::GET, "/api/profiles"));
583	}
584}
585
586// vim: ts=4