cloudillo-core 0.8.13

Core infrastructure for the Cloudillo platform: middleware, extractors, scheduler, rate limiting, and access control
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// SPDX-FileCopyrightText: Szilárd Hajba
// SPDX-License-Identifier: LGPL-3.0-or-later

//! Custom middlewares

use crate::extract::RequestId;
use crate::extract::{Auth, IdTag};
use crate::prelude::*;
use axum::{
	body::Body,
	extract::State,
	http::{Method, Request, header, response::Response},
	middleware::Next,
};
use cloudillo_types::auth_adapter::AuthCtx;
use std::pin::Pin;

/// Tenant API key prefix (validated by auth adapter)
const TENANT_API_KEY_PREFIX: &str = "cl_";

/// IDP API key prefix (validated by identity provider adapter)
const IDP_API_KEY_PREFIX: &str = "idp_";

/// API key type for routing to correct validation adapter
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiKeyType {
	/// Tenant API key (cl_ prefix) - validated by auth adapter
	Tenant,
	/// IDP API key (idp_ prefix) - validated by identity provider adapter
	Idp,
}

/// Check if a token is an API key and return its type
fn get_api_key_type(token: &str) -> Option<ApiKeyType> {
	if token.starts_with(TENANT_API_KEY_PREFIX) {
		Some(ApiKeyType::Tenant)
	} else if token.starts_with(IDP_API_KEY_PREFIX) {
		Some(ApiKeyType::Idp)
	} else {
		None
	}
}

// Type aliases for permission check middleware components
pub type PermissionCheckInput =
	(State<App>, Auth, axum::extract::Path<String>, Request<Body>, Next);
pub type PermissionCheckOutput =
	Pin<Box<dyn Future<Output = Result<axum::response::Response, Error>> + Send>>;

/// Wrapper struct for permission check middleware factories
///
/// This struct wraps a closure that implements the permission check middleware pattern.
/// It takes a static permission action string and returns a middleware factory function.
#[derive(Clone)]
pub struct PermissionCheckFactory<F>
where
	F: Fn(
			State<App>,
			Auth,
			axum::extract::Path<String>,
			Request<Body>,
			Next,
		) -> PermissionCheckOutput
		+ Clone
		+ Send
		+ Sync,
{
	handler: F,
}

impl<F> PermissionCheckFactory<F>
where
	F: Fn(
			State<App>,
			Auth,
			axum::extract::Path<String>,
			Request<Body>,
			Next,
		) -> PermissionCheckOutput
		+ Clone
		+ Send
		+ Sync,
{
	pub fn new(handler: F) -> Self {
		Self { handler }
	}

	pub fn call(
		&self,
		state: State<App>,
		auth: Auth,
		path: axum::extract::Path<String>,
		req: Request<Body>,
		next: Next,
	) -> PermissionCheckOutput {
		(self.handler)(state, auth, path, req, next)
	}
}

/// Extract token from query parameters
fn extract_token_from_query(query: &str) -> Option<String> {
	for param in query.split('&') {
		if param.starts_with("token=") {
			let token = param.strip_prefix("token=")?;
			if !token.is_empty() {
				// For JWT tokens, just use as-is (they don't contain special chars that need decoding)
				// URL decoding is typically only needed for form-encoded data
				return Some(token.to_string());
			}
		}
	}
	None
}

pub async fn require_auth(
	State(state): State<App>,
	mut req: Request<Body>,
	next: Next,
) -> ClResult<Response<Body>> {
	// Extract IdTag from request extensions (inserted by webserver)
	let id_tag = req
		.extensions()
		.get::<IdTag>()
		.ok_or_else(|| {
			warn!("IdTag not found in request extensions");
			Error::PermissionDenied
		})?
		.clone();

	// Convert IdTag to TnId via database lookup
	let tn_id = state.auth_adapter.read_tn_id(&id_tag.0).await.map_err(|_| {
		warn!("Failed to resolve tenant ID for id_tag: {}", id_tag.0);
		Error::PermissionDenied
	})?;

	// Try to get token from Authorization header first
	let token = if let Some(auth_header) =
		req.headers().get("Authorization").and_then(|h| h.to_str().ok())
	{
		if let Some(token) = auth_header.strip_prefix("Bearer ") {
			token.trim().to_string()
		} else {
			warn!("Authorization header present but doesn't start with 'Bearer ': {}", auth_header);
			return Err(Error::PermissionDenied);
		}
	} else {
		// Fallback: try to get token from query parameter (for WebSocket)
		let query_token = extract_token_from_query(req.uri().query().unwrap_or(""));
		if query_token.is_none() {
			warn!("No Authorization header and no token query parameter found");
		}
		query_token.ok_or(Error::PermissionDenied)?
	};

	// Validate token based on type
	let claims = match get_api_key_type(&token) {
		Some(ApiKeyType::Tenant) => {
			// Validate tenant API key (cl_ prefix)
			let validation = state.auth_adapter.validate_api_key(&token).await.map_err(|e| {
				warn!("Tenant API key validation failed: {:?}", e);
				Error::PermissionDenied
			})?;

			// Verify API key belongs to requested tenant
			if validation.tn_id != tn_id {
				warn!(
					"API key tenant mismatch: key belongs to {:?} but request is for {:?}",
					validation.tn_id, tn_id
				);
				return Err(Error::PermissionDenied);
			}

			AuthCtx {
				tn_id: validation.tn_id,
				id_tag: validation.id_tag,
				roles: validation
					.roles
					.map(|r| r.split(',').map(Box::from).collect())
					.unwrap_or_default(),
				scope: validation.scopes,
			}
		}
		Some(ApiKeyType::Idp) => {
			// Validate IDP API key (idp_ prefix)
			let idp_adapter = state.idp_adapter.as_ref().ok_or_else(|| {
				warn!("IDP API key used but Identity Provider not available");
				Error::ServiceUnavailable("Identity Provider not available".to_string())
			})?;

			let auth_id_tag = idp_adapter
				.verify_api_key(&token)
				.await
				.map_err(|e| {
					warn!("IDP API key validation error: {:?}", e);
					Error::PermissionDenied
				})?
				.ok_or_else(|| {
					warn!("IDP API key validation failed: key not found or expired");
					Error::PermissionDenied
				})?;

			AuthCtx {
				tn_id, // From request host lookup
				id_tag: auth_id_tag.into(),
				roles: Box::new([]), // IDP keys don't have roles
				scope: None,
			}
		}
		None => {
			// Validate JWT token (existing flow)
			state.auth_adapter.validate_access_token(tn_id, &token).await?
		}
	};

	// Enforce scope restrictions: scoped tokens can only access matching endpoints
	if let Some(ref scope) = claims.scope
		&& let Some(token_scope) = cloudillo_types::types::TokenScope::parse(scope)
	{
		let path = req.uri().path();
		let method = req.method().clone();
		let allowed = match token_scope {
			cloudillo_types::types::TokenScope::File { .. } => {
				path.starts_with("/api/files/")
					|| path == "/api/files"
					|| path.starts_with("/ws/rtdb/")
					|| path.starts_with("/ws/crdt/")
					|| path == "/api/auth/access-token"
			}
			cloudillo_types::types::TokenScope::ApkgPublish => {
				path.starts_with("/api/files/apkg/")
					|| (path == "/api/actions" && method == Method::POST)
					|| path.starts_with("/api/apps")
			}
		};
		if !allowed {
			warn!(scope = %scope, path = %path, "Scoped token denied access to non-matching endpoint");
			return Err(Error::PermissionDenied);
		}
	}

	req.extensions_mut().insert(Auth(claims));

	Ok(next.run(req).await)
}

pub async fn optional_auth(
	State(state): State<App>,
	mut req: Request<Body>,
	next: Next,
) -> ClResult<Response<Body>> {
	// Try to extract IdTag (optional for this middleware)
	let id_tag = req.extensions().get::<IdTag>().cloned();

	// Try to get token from Authorization header first
	let token = if let Some(auth_header) =
		req.headers().get(header::AUTHORIZATION).and_then(|h| h.to_str().ok())
	{
		auth_header.strip_prefix("Bearer ").map(|token| token.trim().to_string())
	} else if req.uri().path().starts_with("/ws/") || req.uri().path().starts_with("/api/files/") {
		// Fallback: try to get token from query parameter (for WebSocket and file endpoints)
		let query = req.uri().query().unwrap_or("");
		extract_token_from_query(query)
	} else {
		None
	};

	// Only validate if both id_tag and token are present
	if let (Some(id_tag), Some(ref token)) = (id_tag, token) {
		// Try to get tn_id
		match state.auth_adapter.read_tn_id(&id_tag.0).await {
			Ok(tn_id) => {
				// Try to validate token based on type
				let claims_result: Result<Result<AuthCtx, Error>, Error> =
					match get_api_key_type(token) {
						Some(ApiKeyType::Tenant) => {
							// Validate tenant API key (cl_ prefix)
							state.auth_adapter.validate_api_key(token).await.map(|validation| {
								// Verify API key belongs to requested tenant
								if validation.tn_id != tn_id {
									return Err(Error::PermissionDenied);
								}
								Ok(AuthCtx {
									tn_id: validation.tn_id,
									id_tag: validation.id_tag,
									roles: validation
										.roles
										.map(|r| r.split(',').map(Box::from).collect())
										.unwrap_or_default(),
									scope: validation.scopes,
								})
							})
						}
						Some(ApiKeyType::Idp) => {
							// Validate IDP API key (idp_ prefix)
							if let Some(idp_adapter) = state.idp_adapter.as_ref() {
								match idp_adapter.verify_api_key(token).await {
									Ok(Some(auth_id_tag)) => Ok(Ok(AuthCtx {
										tn_id,
										id_tag: auth_id_tag.into(),
										roles: Box::new([]),
										scope: None,
									})),
									Ok(None) => {
										warn!(
											"IDP API key validation failed: key not found or expired"
										);
										Err(Error::PermissionDenied)
									}
									Err(e) => {
										warn!("IDP API key validation error: {:?}", e);
										Err(Error::PermissionDenied)
									}
								}
							} else {
								warn!("IDP API key used but Identity Provider not available");
								Err(Error::ServiceUnavailable(
									"Identity Provider not available".to_string(),
								))
							}
						}
						None => {
							// Validate JWT token
							state.auth_adapter.validate_access_token(tn_id, token).await.map(Ok)
						}
					};

				match claims_result {
					Ok(Ok(claims)) => {
						// Enforce scope restrictions: scoped tokens can only access matching endpoints
						let scope_allowed = if let Some(ref scope) = claims.scope {
							if let Some(token_scope) =
								cloudillo_types::types::TokenScope::parse(scope)
							{
								let path = req.uri().path();
								let method = req.method().clone();
								match token_scope {
									cloudillo_types::types::TokenScope::File { .. } => {
										path.starts_with("/api/files/")
											|| path == "/api/files" || path.starts_with("/ws/rtdb/")
											|| path.starts_with("/ws/crdt/") || path
											== "/api/auth/access-token"
									}
									// ApkgPublish scope: intentionally restrictive allowlist.
									// Only permits the exact endpoints needed for app publishing
									// to limit blast radius of a compromised scoped token.
									cloudillo_types::types::TokenScope::ApkgPublish => {
										path.starts_with("/api/files/apkg/")
											|| (path == "/api/actions" && method == Method::POST)
											|| path.starts_with("/api/apps")
									}
								}
							} else {
								false
							}
						} else {
							true
						};
						if scope_allowed {
							req.extensions_mut().insert(Auth(claims));
						} else {
							warn!(
								"Scoped token denied access in optional_auth, treating as unauthenticated"
							);
						}
					}
					Ok(Err(e)) => {
						warn!("Token validation failed (tenant mismatch): {:?}", e);
					}
					Err(e) => {
						warn!("Token validation failed: {:?}", e);
					}
				}
			}
			Err(e) => {
				warn!("Failed to resolve tenant ID: {:?}", e);
			}
		}
	}

	Ok(next.run(req).await)
}

/// Add or generate request ID, attach a `request` span carrying its short
/// form, and store the full id in extensions. The custom log formatter
/// (`crate::log::CloudilloFormat`) uses the `request` span's `id` field to
/// prefix every event line with `REQ:<short>`.
///
/// If the outer transport layer (see `cloudillo::webserver::create_https_server`)
/// has already inserted a `RequestId` extension and entered the `request` span,
/// `RequestId::install` returns a span that just re-uses the existing id.
pub async fn request_id_middleware(mut req: Request<Body>, next: Next) -> Response<Body> {
	let span = RequestId::install(&mut req);
	let request_id = req.extensions().get::<RequestId>().map(|r| r.0.clone()).unwrap_or_default();

	let mut response = {
		use tracing::Instrument;
		next.run(req).instrument(span).await
	};

	if let Ok(header_value) = request_id.parse() {
		response.headers_mut().insert("X-Request-ID", header_value);
	}
	response
}

// vim: ts=4