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