Skip to main content

cloudillo_core/settings/
handler.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Settings management handlers
5
6use axum::{
7	Json,
8	extract::{Path, Query, State},
9	http::StatusCode,
10};
11use serde::Deserialize;
12
13use crate::{
14	extract::{Auth, OptionalRequestId},
15	prelude::*,
16	settings::types::{SettingScope, SettingValue},
17};
18use cloudillo_types::types::ApiResponse;
19
20/// Response for a single setting with metadata
21#[derive(serde::Serialize)]
22pub struct SettingResponse {
23	pub key: String,
24	pub value: SettingValue,
25	pub scope: String,
26	pub permission: String,
27	pub description: String,
28}
29
30/// Query parameters for listing settings
31#[derive(Deserialize, Default)]
32pub struct ListSettingsQuery {
33	/// Comma-separated list of key prefixes (e.g., "file,limits").
34	/// Each prefix is matched as `<prefix>.%` against stored setting names.
35	pub prefix: Option<String>,
36	/// Resolution level — mirrors GET /settings/:name:
37	///   - omitted: full resolution chain (tenant overrides global).
38	///   - "global": raw rows from TnId(0) only.
39	///   - "tenant": raw rows from the caller's (or `tenant=` target's) tenant only.
40	pub level: Option<String>,
41	/// SADM-only: target tenant idTag for cross-tenant reads. Only meaningful with level=tenant.
42	pub tenant: Option<String>,
43}
44
45/// GET /settings - List all settings for authenticated tenant
46/// Returns metadata about available settings and their current values
47/// Supports optional `prefix` query parameter to filter settings by key prefix
48pub async fn list_settings(
49	State(app): State<App>,
50	Auth(auth): Auth,
51	Query(query): Query<ListSettingsQuery>,
52	OptionalRequestId(req_id): OptionalRequestId,
53) -> ClResult<(StatusCode, Json<ApiResponse<Vec<SettingResponse>>>)> {
54	let mut settings_response = Vec::new();
55
56	// `level` requires `prefix`: the no-prefix branch iterates registry
57	// definitions through the normal resolution chain, where "raw at level"
58	// has no clean meaning. Reject explicitly rather than silently ignore.
59	if query.level.is_some() && query.prefix.is_none() {
60		return Err(Error::ValidationError("level requires prefix".into()));
61	}
62
63	if let Some(ref prefix) = query.prefix {
64		// Split the comma-joined prefix list, trim, drop empties.
65		let prefixes: Vec<String> = prefix
66			.split(',')
67			.map(|s| s.trim().to_string())
68			.filter(|s| !s.is_empty())
69			.collect();
70
71		// `tenant=` is meaningless with `level=global` (the global row is shared
72		// across all tenants). Reject explicitly rather than silently ignore, to
73		// match the documented intent on `SettingScopeQuery`.
74		if matches!(query.level.as_deref(), Some("global")) && query.tenant.is_some() {
75			return Err(Error::ValidationError("tenant= is not allowed with level=global".into()));
76		}
77
78		// Resolve target tenant (SADM-only when `tenant=` is supplied).
79		let target_tn_id = resolve_target_tn_id(&app, &auth, query.tenant.as_deref()).await?;
80
81		let rows = match query.level.as_deref() {
82			Some("global") => {
83				// SADM unconditionally for level=global on the list endpoint.
84				// This is stricter than `get_setting`'s per-scope guard but is
85				// simpler and safe because list returns mixed-scope keys —
86				// without per-row scope checks, we can't selectively allow
87				// Tenant-scoped global rows for non-SADM callers.
88				if !crate::abac::is_admin(&auth) {
89					return Err(Error::PermissionDenied);
90				}
91				app.settings.list_by_prefix_at(TnId(0), &prefixes).await?
92			}
93			Some("tenant") => app.settings.list_by_prefix_at(target_tn_id, &prefixes).await?,
94			Some(other) => {
95				return Err(Error::ValidationError(format!("unknown level: {}", other)));
96			}
97			None => app.settings.list_by_prefix(target_tn_id, &prefixes).await?,
98		};
99
100		for (key, value, definition) in rows {
101			settings_response.push(SettingResponse {
102				key,
103				value,
104				scope: format!("{:?}", definition.scope),
105				permission: format!("{:?}", definition.permission),
106				description: definition.description.clone(),
107			});
108		}
109	} else {
110		// No prefix: iterate over all definitions and get their values.
111		// `Ok(None)` from a wildcard-namespace registration is a legitimate
112		// "no value here, no default" answer — silently drop those. But
113		// transient adapter or deserialization errors must NOT be silently
114		// swallowed, so propagate `Err` via `?`.
115		for definition in app.settings_registry.list() {
116			match app.settings.get(auth.tn_id, &definition.key).await {
117				Ok(Some(value)) => settings_response.push(SettingResponse {
118					key: definition.key.clone(),
119					value,
120					scope: format!("{:?}", definition.scope),
121					permission: format!("{:?}", definition.permission),
122					description: definition.description.clone(),
123				}),
124				// `Ok(None)` is a wildcard-namespace key with no stored value;
125				// `SettingNotFound` is an exact-match key with no default and
126				// no row. Both are silently skipped here (matches the previous
127				// behavior). Anything else (transient adapter errors,
128				// deserialization failure) propagates as 500.
129				Ok(None) | Err(Error::SettingNotFound(_)) => {}
130				Err(e) => return Err(e),
131			}
132		}
133	}
134
135	let total = settings_response.len();
136	let response = ApiResponse::with_pagination(settings_response, 0, 100, total)
137		.with_req_id(req_id.unwrap_or_default());
138
139	Ok((StatusCode::OK, Json(response)))
140}
141
142/// Common scope-selection query for GET / PUT / DELETE on a single setting.
143///
144/// `level` semantics differ slightly per handler:
145/// - GET: omitted = full resolution chain (tenant → global → default);
146///   `tenant`/`global` = raw row at that level (404 if absent).
147/// - PUT: omitted = caller's tenant row; `tenant`/`global` = explicit row
148///   selection.
149/// - DELETE: omitted is **rejected** (ambiguous); `tenant`/`global` required.
150///
151/// `tenant` is SADM-only and addresses a different tenant's row by id_tag.
152/// It is meaningless (and ignored) when `level=global`, since the global row
153/// is shared across all tenants.
154#[derive(Deserialize, Default)]
155pub struct SettingScopeQuery {
156	pub level: Option<String>,
157	pub tenant: Option<String>,
158}
159
160/// Resolve the effective tenant id for a settings operation.
161///
162/// When `target` is `None`, the caller acts on their own tenant — return
163/// `auth.tn_id` directly. When `target` is `Some(id_tag)`, the caller is
164/// requesting cross-tenant access; require SADM and look up the target's
165/// `tn_id` via the auth adapter.
166async fn resolve_target_tn_id(
167	app: &App,
168	auth: &cloudillo_types::auth_adapter::AuthCtx,
169	target: Option<&str>,
170) -> ClResult<TnId> {
171	match target {
172		None => Ok(auth.tn_id),
173		Some(id_tag) => {
174			// Acting on behalf of another tenant is SADM-only. We require this
175			// even when `id_tag == auth.id_tag` so the audit trail is honest:
176			// a non-SADM admin should hit "permission denied", not silently
177			// have their explicit `tenant=self` collapse to the implicit path.
178			if !crate::abac::is_admin(auth) {
179				return Err(Error::PermissionDenied);
180			}
181			app.auth_adapter.read_tn_id(id_tag).await.map_err(|_| Error::NotFound)
182		}
183	}
184}
185
186/// GET /settings/:name - Get a specific setting with metadata
187pub async fn get_setting(
188	State(app): State<App>,
189	Auth(auth): Auth,
190	Path(name): Path<String>,
191	Query(query): Query<SettingScopeQuery>,
192	OptionalRequestId(req_id): OptionalRequestId,
193) -> ClResult<(StatusCode, Json<ApiResponse<SettingResponse>>)> {
194	// Get setting definition (supports wildcard patterns like "ui.*")
195	let definition = app.settings_registry.get(&name).ok_or(Error::NotFound)?;
196
197	// Resolve the value at the requested level.
198	// - `tenant`/`global`: raw row at that level, no fallback (404 if unset).
199	// - omitted: full resolution chain (tenant → global → default).
200	//
201	// Read/delete asymmetry at level=global is intentional:
202	// - GET level=global on a Tenant-scoped key is unrestricted (the global
203	//   row is the default everyone resolves through anyway).
204	// - DELETE level=global is SADM-only regardless of scope (clearing the
205	//   global default affects every tenant — see `delete_setting`).
206	// Resolve target tenant if `tenant=` was supplied (SADM-only).
207	let target_tn_id = resolve_target_tn_id(&app, &auth, query.tenant.as_deref()).await?;
208
209	let value = match query.level.as_deref() {
210		Some("global") => {
211			// Reading the raw global row for a Global-scoped key requires SADM:
212			// regular tenant admins must not see (or rely on) cross-tenant
213			// instance state. Tenant-scoped keys at level=global are allowed —
214			// the global row is just the default for everyone. (`tenant=` is
215			// meaningless here; the global row is shared.)
216			if definition.scope == SettingScope::Global
217				&& !auth.roles.iter().any(|r| r.as_ref() == "SADM")
218			{
219				return Err(Error::PermissionDenied);
220			}
221			app.settings.get_raw(TnId(0), &name).await?.ok_or(Error::NotFound)?
222		}
223		Some("tenant") => {
224			app.settings.get_raw(target_tn_id, &name).await?.ok_or(Error::NotFound)?
225		}
226		Some(other) => {
227			return Err(Error::ValidationError(format!("unknown level: {}", other)));
228		}
229		None => app.settings.get(target_tn_id, &name).await?.ok_or(Error::NotFound)?,
230	};
231
232	let response_data = SettingResponse {
233		key: definition.key.clone(),
234		value,
235		scope: format!("{:?}", definition.scope),
236		permission: format!("{:?}", definition.permission),
237		description: definition.description.clone(),
238	};
239
240	let response = ApiResponse::new(response_data).with_req_id(req_id.unwrap_or_default());
241
242	Ok((StatusCode::OK, Json(response)))
243}
244
245/// PUT /settings/:name - Update a setting
246/// Requires appropriate permission level (admin for most, user for some)
247#[derive(Deserialize)]
248pub struct UpdateSettingRequest {
249	pub value: SettingValue,
250}
251
252pub async fn update_setting(
253	State(app): State<App>,
254	Auth(auth): Auth,
255	Path(name): Path<String>,
256	Query(query): Query<SettingScopeQuery>,
257	OptionalRequestId(req_id): OptionalRequestId,
258	Json(req): Json<UpdateSettingRequest>,
259) -> ClResult<(StatusCode, Json<ApiResponse<SettingResponse>>)> {
260	// Get setting definition for validation and permission check
261	let definition = app.settings_registry.get(&name).ok_or(Error::NotFound)?;
262
263	// Check permission
264	if !definition.permission.check(&auth.roles) {
265		warn!("User {} attempted to update setting {} without permission", auth.id_tag, name);
266		return Err(Error::PermissionDenied);
267	}
268
269	// Validate value if validator is set
270	if let Some(ref validator) = definition.validator {
271		validator(&req.value)?;
272	}
273
274	// Resolve the storage row from the explicit `level=` query, mirroring
275	// `delete_setting`. For `level=global` the row is shared and `tenant=` is
276	// meaningless — skip the cross-tenant lookup entirely so a stray
277	// `tenant=` doesn't trigger a SADM-already-required adapter call whose
278	// result is then ignored.
279	let target_tn_id = match query.level.as_deref() {
280		Some("global") => {
281			// Writing the shared global row is SADM-only regardless of
282			// `definition.scope` — the global row is the default every
283			// tenant resolves through.
284			if !auth.roles.iter().any(|r| r.as_ref() == "SADM") {
285				return Err(Error::PermissionDenied);
286			}
287			TnId(0)
288		}
289		Some("tenant") | None => {
290			let acting_tn_id = resolve_target_tn_id(&app, &auth, query.tenant.as_deref()).await?;
291			// Global-scoped keys have no per-tenant override row — same
292			// rationale as `delete_setting`'s guard.
293			if query.level.as_deref() == Some("tenant") && definition.scope == SettingScope::Global
294			{
295				return Err(Error::ValidationError(
296					"level=tenant is not valid for Global-scoped setting; use level=global".into(),
297				));
298			}
299			acting_tn_id
300		}
301		Some(other) => {
302			return Err(Error::ValidationError(format!("unknown level: {}", other)));
303		}
304	};
305
306	// Update the setting using the service
307	app.settings.set(target_tn_id, &name, req.value.clone(), &auth.roles).await?;
308
309	info!(
310		"User {} updated setting {} for tn_id={} (level={})",
311		auth.id_tag,
312		name,
313		target_tn_id.0,
314		query.level.as_deref().unwrap_or("(default)")
315	);
316
317	// Return updated setting
318	let value = app.settings.get(target_tn_id, &name).await?.ok_or(Error::NotFound)?;
319
320	let response_data = SettingResponse {
321		key: definition.key.clone(),
322		value,
323		scope: format!("{:?}", definition.scope),
324		permission: format!("{:?}", definition.permission),
325		description: definition.description.clone(),
326	};
327
328	let response = ApiResponse::new(response_data).with_req_id(req_id.unwrap_or_default());
329
330	Ok((StatusCode::OK, Json(response)))
331}
332
333/// DELETE /settings/:name - Clear a setting at the given level.
334/// Used by the UI's "Reset to default" affordance for tenant overrides.
335///
336/// `level` is **required** here (unlike GET): clearing without an explicit
337/// level is ambiguous (tenant override vs. global default), so an absent
338/// value is rejected with 400.
339pub async fn delete_setting(
340	State(app): State<App>,
341	Auth(auth): Auth,
342	Path(name): Path<String>,
343	Query(query): Query<SettingScopeQuery>,
344) -> ClResult<StatusCode> {
345	let definition = app.settings_registry.get(&name).ok_or(Error::NotFound)?;
346
347	let target_tn_id = match query.level.as_deref() {
348		Some("tenant") => {
349			// Global-scoped keys have no per-tenant override row; clearing at
350			// level=tenant would silently route to TnId(0) inside the service
351			// and look like a successful tenant-level reset. Reject it so the
352			// UI's "Reset to default" flow stays honest.
353			if definition.scope == SettingScope::Global {
354				return Err(Error::ValidationError(
355					"level=tenant is not valid for Global-scoped setting; use level=global".into(),
356				));
357			}
358			resolve_target_tn_id(&app, &auth, query.tenant.as_deref()).await?
359		}
360		Some("global") => {
361			// Clearing the raw global row at level=global requires SADM
362			// regardless of `definition.scope`. The service layer's `clear`
363			// only enforces SADM on the (Global, _) arm; for Tenant-scoped
364			// keys, `(Tenant, 0)` would silently clear the global default
365			// row that every tenant resolves through — a cross-tenant
366			// privilege escalation. Guard unconditionally at the handler.
367			// `tenant=` is meaningless against the shared global row, so
368			// skip the cross-tenant resolution entirely.
369			if !auth.roles.iter().any(|r| r.as_ref() == "SADM") {
370				return Err(Error::PermissionDenied);
371			}
372			TnId(0)
373		}
374		Some(other) => {
375			return Err(Error::ValidationError(format!("unknown level: {}", other)));
376		}
377		None => {
378			return Err(Error::ValidationError("level query parameter is required".into()));
379		}
380	};
381
382	app.settings.clear(target_tn_id, &name, &auth.roles).await?;
383
384	info!("User {} cleared setting {} at tn_id={}", auth.id_tag, name, target_tn_id.0);
385
386	Ok(StatusCode::NO_CONTENT)
387}
388
389// vim: ts=4