Skip to main content

cloudillo_idp/
handler.rs

1//! IDP (Identity Provider) REST endpoints for managing identity registrations
2
3use axum::{
4	body::Bytes,
5	extract::{ConnectInfo, Path, Query, State},
6	http::StatusCode,
7	Json,
8};
9use serde::{Deserialize, Serialize};
10use std::net::SocketAddr;
11
12use cloudillo_core::extract::{Auth, IdTag, OptionalRequestId};
13use cloudillo_core::settings::SettingValue;
14use cloudillo_types::address::parse_address_type;
15use cloudillo_types::identity_provider_adapter::{
16	CreateIdentityOptions, Identity, IdentityStatus, ListIdentityOptions, UpdateIdentityOptions,
17};
18use cloudillo_types::types::{
19	serialize_timestamp_iso, serialize_timestamp_iso_opt, ApiResponse, Timestamp,
20};
21use cloudillo_types::utils::parse_and_validate_identity_id_tag;
22
23use crate::prelude::*;
24
25/// Check if IDP functionality is enabled for a tenant
26async fn check_idp_enabled(app: &App, tn_id: TnId) -> ClResult<()> {
27	match app.settings.get(tn_id, "idp.enabled").await {
28		Ok(SettingValue::Bool(true)) => {
29			debug!(tn_id = tn_id.0, "IDP enabled for tenant");
30			Ok(())
31		}
32		Ok(SettingValue::Bool(false)) => {
33			warn!(tn_id = tn_id.0, "IDP not enabled for tenant");
34			Err(Error::NotFound)
35		}
36		Ok(_) => {
37			warn!(tn_id = tn_id.0, "Invalid idp.enabled setting value");
38			Err(Error::ConfigError("Invalid idp.enabled setting value (expected boolean)".into()))
39		}
40		Err(e) => {
41			warn!(tn_id = tn_id.0, error = ?e, "Failed to check idp.enabled setting");
42			Err(e)
43		}
44	}
45}
46
47/// Authorization result for IDP operations
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum IdpAuthResult {
50	/// Full access as owner
51	Owner,
52	/// Limited access as registrar (only while Pending)
53	Registrar,
54	/// No access
55	Denied,
56}
57
58/// Check if the requesting user has access to an identity
59///
60/// Authorization rules:
61/// - Owner always has full access (permanent)
62/// - Registrar has access only while identity status is Pending
63/// - After activation (Pending → Active), registrar loses control
64fn check_identity_access(identity: &Identity, requester_id_tag: &str) -> IdpAuthResult {
65	// Owner check - owner always has full access
66	if let Some(ref owner) = identity.owner_id_tag {
67		if owner.as_ref() == requester_id_tag {
68			return IdpAuthResult::Owner;
69		}
70	}
71
72	// Registrar check - only valid while Pending
73	if identity.registrar_id_tag.as_ref() == requester_id_tag {
74		if identity.status == IdentityStatus::Pending {
75			return IdpAuthResult::Registrar;
76		}
77		// Registrar loses access after activation
78		debug!(
79			identity = %format!("{}.{}", identity.id_tag_prefix, identity.id_tag_domain),
80			registrar = %identity.registrar_id_tag,
81			status = ?identity.status,
82			"Registrar denied access - identity no longer Pending"
83		);
84	}
85
86	IdpAuthResult::Denied
87}
88
89/// Check if requester can access an identity (view, update, delete)
90fn can_access_identity(identity: &Identity, requester_id_tag: &str) -> bool {
91	matches!(
92		check_identity_access(identity, requester_id_tag),
93		IdpAuthResult::Owner | IdpAuthResult::Registrar
94	)
95}
96
97/// Response structure for identity details
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct IdentityResponse {
101	pub id_tag: String,
102	/// Email address (optional for community-owned identities)
103	#[serde(skip_serializing_if = "Option::is_none")]
104	pub email: Option<String>,
105	pub registrar_id_tag: String,
106	/// Owner id_tag (for community ownership)
107	#[serde(skip_serializing_if = "Option::is_none")]
108	pub owner_id_tag: Option<String>,
109	#[serde(skip_serializing_if = "Option::is_none")]
110	pub address: Option<String>,
111	#[serde(
112		skip_serializing_if = "Option::is_none",
113		serialize_with = "serialize_timestamp_iso_opt"
114	)]
115	pub address_updated_at: Option<Timestamp>,
116	/// Dynamic DNS mode - uses 60s TTL for faster propagation (default: false)
117	pub dyndns: bool,
118	pub status: String,
119	#[serde(serialize_with = "serialize_timestamp_iso")]
120	pub created_at: Timestamp,
121	#[serde(serialize_with = "serialize_timestamp_iso")]
122	pub updated_at: Timestamp,
123	#[serde(serialize_with = "serialize_timestamp_iso")]
124	pub expires_at: Timestamp,
125	/// API key (only returned during creation, never stored or returned in subsequent reads)
126	#[serde(skip_serializing_if = "Option::is_none")]
127	pub api_key: Option<String>,
128}
129
130impl From<Identity> for IdentityResponse {
131	fn from(identity: Identity) -> Self {
132		// Join prefix and domain back into single id_tag field for external API
133		let id_tag = format!("{}.{}", identity.id_tag_prefix, identity.id_tag_domain);
134		Self {
135			id_tag,
136			email: identity.email.map(|e| e.to_string()),
137			registrar_id_tag: identity.registrar_id_tag.to_string(),
138			owner_id_tag: identity.owner_id_tag.map(|o| o.to_string()),
139			address: identity.address.map(|a| a.to_string()),
140			address_updated_at: identity.address_updated_at,
141			dyndns: identity.dyndns,
142			status: identity.status.to_string(),
143			created_at: identity.created_at,
144			updated_at: identity.updated_at,
145			expires_at: identity.expires_at,
146			api_key: None, // Never included in From<Identity>, only set during creation
147		}
148	}
149}
150
151/// Request structure for creating a new identity
152#[derive(Debug, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct CreateIdentityRequest {
155	/// Unique identifier tag for the identity
156	pub id_tag: String,
157	/// Email address for the identity (optional when owner_id_tag is provided)
158	pub email: Option<String>,
159	/// Owner id_tag for community ownership (optional)
160	pub owner_id_tag: Option<String>,
161	/// Initial address (optional)
162	pub address: Option<String>,
163	/// Enable dynamic DNS mode (60s TTL) - defaults to false
164	#[serde(default)]
165	pub dyndns: bool,
166	/// Whether to send activation email (default: true)
167	/// If false, identity is created as Active instead of Pending
168	#[serde(default = "default_true")]
169	pub send_activation_email: bool,
170	/// Whether to create an API key for the identity (default: false)
171	#[serde(default)]
172	pub create_api_key: bool,
173	/// Optional name for the API key
174	pub api_key_name: Option<String>,
175}
176
177fn default_true() -> bool {
178	true
179}
180
181/// Request structure for updating identity address
182#[derive(Debug, Deserialize, Default)]
183pub struct UpdateAddressRequest {
184	/// New address for the identity (optional, leave empty for automatic peer IP)
185	#[serde(default)]
186	pub address: Option<String>,
187	/// If true and address is not provided, use the peer IP address
188	#[serde(default)]
189	pub auto_address: bool,
190}
191
192/// Response structure for address update - only returns the updated address
193#[derive(Debug, Clone, Serialize)]
194pub struct AddressUpdateResponse {
195	pub address: String,
196}
197
198/// Normalize identity path parameter - accepts either full id_tag or just prefix
199/// If prefix-only (no dots), appends the IDP domain
200fn normalize_identity_path(identity_id: &str, idp_domain: &str) -> String {
201	if identity_id.contains('.') {
202		// Full id_tag provided
203		identity_id.to_string()
204	} else {
205		// Prefix only - append IDP domain
206		// idp_domain is the tenant domain (e.g., "home.w9.hu")
207		// identity format: "prefix.domain" (e.g., "test8.home.w9.hu")
208		format!("{}.{}", identity_id, idp_domain)
209	}
210}
211
212/// Query parameters for listing identities
213#[derive(Debug, Deserialize, Default)]
214#[serde(rename_all = "camelCase")]
215pub struct ListIdentitiesQuery {
216	/// Filter by email (partial match)
217	pub email: Option<String>,
218	/// Filter by registrar id_tag
219	pub registrar_id_tag: Option<String>,
220	/// Filter by owner id_tag
221	pub owner_id_tag: Option<String>,
222	/// Filter by status (pending, active, suspended)
223	pub status: Option<String>,
224	/// Limit results
225	pub limit: Option<u32>,
226	/// Offset for pagination
227	pub offset: Option<u32>,
228}
229
230/// GET /api/idp/identities/:id - Get a specific identity by id_tag
231#[axum::debug_handler]
232pub async fn get_identity_by_id(
233	State(app): State<App>,
234	tn_id: TnId,
235	IdTag(idp_domain): IdTag,
236	Path(identity_id): Path<String>,
237	OptionalRequestId(req_id): OptionalRequestId,
238) -> ClResult<(StatusCode, Json<ApiResponse<IdentityResponse>>)> {
239	info!(
240		identity_id = %identity_id,
241		idp_domain = %idp_domain,
242		"GET /api/idp/identities/:id"
243	);
244
245	// Check if IDP is enabled for this tenant
246	check_idp_enabled(&app, tn_id).await?;
247
248	// Verify Identity Provider adapter is available
249	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
250		"Identity Provider not available on this instance".to_string(),
251	))?;
252
253	// Parse and validate identity id_tag against IDP domain
254	let (id_tag_prefix, id_tag_domain) =
255		parse_and_validate_identity_id_tag(&identity_id, &idp_domain)?;
256
257	// Read the identity using split components
258	let identity = idp_adapter
259		.read_identity(&id_tag_prefix, &id_tag_domain)
260		.await?
261		.ok_or(Error::NotFound)?;
262
263	// Check authorization using new helper (owner or registrar while Pending)
264	if !can_access_identity(&identity, &idp_domain) {
265		warn!(
266			identity_id = %identity_id,
267			requested_by = %idp_domain,
268			registrar = %identity.registrar_id_tag,
269			owner = ?identity.owner_id_tag,
270			status = ?identity.status,
271			"Unauthorized access to identity"
272		);
273		return Err(Error::PermissionDenied);
274	}
275
276	let response_data = IdentityResponse::from(identity);
277	let mut response = ApiResponse::new(response_data);
278	if let Some(id) = req_id {
279		response = response.with_req_id(id);
280	}
281
282	Ok((StatusCode::OK, Json(response)))
283}
284
285/// GET /api/idp/identities - List identities
286#[axum::debug_handler]
287pub async fn list_identities(
288	State(app): State<App>,
289	tn_id: TnId,
290	IdTag(idp_domain): IdTag,
291	Query(query_params): Query<ListIdentitiesQuery>,
292	OptionalRequestId(req_id): OptionalRequestId,
293) -> ClResult<(StatusCode, Json<ApiResponse<Vec<IdentityResponse>>>)> {
294	info!(
295		idp_domain = %idp_domain,
296		"GET /api/idp/identities"
297	);
298
299	// Check if IDP is enabled for this tenant
300	check_idp_enabled(&app, tn_id).await?;
301
302	// Verify Identity Provider adapter is available
303	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
304		"Identity Provider not available on this instance".to_string(),
305	))?;
306
307	let opts = ListIdentityOptions {
308		id_tag_domain: idp_domain.to_string(),
309		email: query_params.email.clone(),
310		registrar_id_tag: None,
311		owner_id_tag: query_params.owner_id_tag.clone(),
312		status: query_params.status.as_ref().and_then(|s| s.parse().ok()),
313		expires_after: None,
314		expired_only: false,
315		limit: query_params.limit,
316		offset: query_params.offset,
317	};
318
319	let identities = idp_adapter.list_identities(opts).await?;
320
321	let response_data: Vec<IdentityResponse> =
322		identities.into_iter().map(IdentityResponse::from).collect();
323
324	let total = response_data.len();
325	let offset = query_params.offset.unwrap_or(0) as usize;
326	let limit = query_params.limit.unwrap_or(20) as usize;
327	let mut response = ApiResponse::with_pagination(response_data, offset, limit, total);
328	if let Some(id) = req_id {
329		response = response.with_req_id(id);
330	}
331
332	Ok((StatusCode::OK, Json(response)))
333}
334
335/// POST /api/idp/identities - Create a new identity
336#[axum::debug_handler]
337pub async fn create_identity(
338	State(app): State<App>,
339	tn_id: TnId,
340	IdTag(idp_domain): IdTag,
341	OptionalRequestId(req_id): OptionalRequestId,
342	Json(create_req): Json<CreateIdentityRequest>,
343) -> ClResult<(StatusCode, Json<ApiResponse<IdentityResponse>>)> {
344	info!(
345		identity_id = %create_req.id_tag,
346		idp_domain = %idp_domain,
347		email = ?create_req.email,
348		owner_id_tag = ?create_req.owner_id_tag,
349		"POST /api/idp/identities - Creating new identity"
350	);
351
352	// Check if IDP is enabled for this tenant
353	check_idp_enabled(&app, tn_id).await?;
354
355	// Verify Identity Provider adapter is available
356	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
357		"Identity Provider not available on this instance".to_string(),
358	))?;
359
360	// Validate inputs - id_tag is always required
361	if create_req.id_tag.is_empty() {
362		return Err(Error::ValidationError("id_tag is required".to_string()));
363	}
364
365	// Email is required only if no owner_id_tag is provided
366	if create_req.owner_id_tag.is_none() && create_req.email.as_ref().is_none_or(|e| e.is_empty()) {
367		return Err(Error::ValidationError(
368			"email is required when no owner_id_tag is provided".to_string(),
369		));
370	}
371
372	// Parse and validate identity id_tag against IDP domain
373	let (id_tag_prefix, id_tag_domain) =
374		parse_and_validate_identity_id_tag(&create_req.id_tag, &idp_domain)?;
375
376	// Forbid creation of identities with prefix 'cl-o'
377	if id_tag_prefix == "cl-o" {
378		warn!(
379			id_tag_prefix = %id_tag_prefix,
380			idp_domain = %idp_domain,
381			"Attempted to create identity with forbidden prefix 'cl-o'"
382		);
383		return Err(Error::ValidationError(
384			"Identity prefix 'cl-o' is reserved and cannot be used".to_string(),
385		));
386	}
387
388	// Management API: No quota check needed - IDP owner manages their own capacity
389	// Quotas are only for external registrars using REG tokens (handled in registration.rs)
390
391	// Get renewal interval from settings (in days) and convert to seconds
392	let renewal_interval_days = match app.settings.get(tn_id, "idp.renewal_interval").await {
393		Ok(SettingValue::Int(days)) => days,
394		Ok(_) => {
395			warn!(tn_id = tn_id.0, "Invalid idp.renewal_interval setting value");
396			return Err(Error::ConfigError(
397				"Invalid idp.renewal_interval setting value (expected integer days)".into(),
398			));
399		}
400		Err(e) => {
401			warn!(tn_id = tn_id.0, error = ?e, "Failed to get idp.renewal_interval setting");
402			return Err(e);
403		}
404	};
405
406	let renewal_interval_seconds = renewal_interval_days * 24 * 60 * 60;
407	let expires_at = Timestamp::now().add_seconds(renewal_interval_seconds);
408
409	// Parse address type if address is provided
410	let address_type = if let Some(addr) = &create_req.address {
411		info!(
412			id_tag_prefix = %id_tag_prefix,
413			id_tag_domain = %id_tag_domain,
414			address = %addr,
415			"Creating identity with address"
416		);
417
418		// Parse and log address type
419		match parse_address_type(addr) {
420			Ok(addr_type) => {
421				info!(
422					address = %addr,
423					address_type = ?addr_type,
424					"Parsed address type"
425				);
426				Some(addr_type)
427			}
428			Err(e) => {
429				warn!(
430					address = %addr,
431					error = ?e,
432					"Failed to parse address type"
433				);
434				None
435			}
436		}
437	} else {
438		info!(
439			id_tag_prefix = %id_tag_prefix,
440			id_tag_domain = %id_tag_domain,
441			"Creating identity without address"
442		);
443		None
444	};
445
446	// Create the identity with split id_tag components
447	// Status depends on whether activation email will be sent
448	let initial_status = if create_req.send_activation_email {
449		IdentityStatus::Pending // Will be activated via email
450	} else {
451		IdentityStatus::Active // No email, create as active directly
452	};
453
454	let opts = CreateIdentityOptions {
455		id_tag_prefix: &id_tag_prefix,
456		id_tag_domain: &id_tag_domain,
457		email: create_req.email.as_deref(),
458		registrar_id_tag: &idp_domain,
459		owner_id_tag: create_req.owner_id_tag.as_deref(),
460		status: initial_status,
461		address: create_req.address.as_deref(),
462		address_type,
463		dyndns: create_req.dyndns,
464		lang: None,
465		expires_at: Some(expires_at),
466	};
467
468	info!(
469		id_tag_prefix = %id_tag_prefix,
470		id_tag_domain = %id_tag_domain,
471		"Calling IDP adapter create_identity"
472	);
473
474	let identity = idp_adapter.create_identity(opts).await.map_err(|e| {
475		warn!("Failed to create identity: {}", e);
476		e
477	})?;
478
479	info!(
480		id_tag_prefix = %identity.id_tag_prefix,
481		id_tag_domain = %identity.id_tag_domain,
482		address = ?identity.address,
483		"Identity created successfully"
484	);
485
486	// Create API key for the identity (if requested)
487	let created_key = if create_req.create_api_key {
488		let key_name = create_req.api_key_name.as_deref().unwrap_or("identity-key");
489		let create_key_opts = cloudillo_types::identity_provider_adapter::CreateApiKeyOptions {
490			id_tag_prefix: &id_tag_prefix,
491			id_tag_domain: &id_tag_domain,
492			name: Some(key_name),
493			expires_at: None, // No expiration for identity keys
494		};
495
496		match idp_adapter.create_api_key(create_key_opts).await {
497			Ok(key) => {
498				info!(
499					id_tag_prefix = %id_tag_prefix,
500					id_tag_domain = %id_tag_domain,
501					key_prefix = %key.api_key.key_prefix,
502					"API key created for identity"
503				);
504				Some(key.plaintext_key)
505			}
506			Err(e) => {
507				warn!("Failed to create API key for identity: {}", e);
508				None
509			}
510		}
511	} else {
512		None
513	};
514
515	// Send activation email (if enabled and email provided)
516	if create_req.send_activation_email {
517		if let Some(ref email) = identity.email {
518			if let Err(e) = crate::registration::send_activation_email(
519				&app,
520				tn_id,
521				crate::registration::SendActivationEmailParams {
522					id_tag_prefix: &identity.id_tag_prefix,
523					id_tag_domain: &identity.id_tag_domain,
524					email,
525					lang: None,
526				},
527			)
528			.await
529			{
530				warn!(
531					id_tag_prefix = %id_tag_prefix,
532					id_tag_domain = %id_tag_domain,
533					error = %e,
534					"Failed to send activation email"
535				);
536			}
537		}
538	}
539
540	let mut response_data = IdentityResponse::from(identity);
541	// Include the API key in the response (only shown once!)
542	response_data.api_key = created_key;
543
544	let mut response = ApiResponse::new(response_data);
545	if let Some(id) = req_id {
546		response = response.with_req_id(id);
547	}
548
549	Ok((StatusCode::CREATED, Json(response)))
550}
551
552/// PUT /api/idp/identities/:id/address - Update identity address
553///
554/// Authorization: The authenticated identity (via IDP API key) must match
555/// the identity being updated. This endpoint is designed for self-updates
556/// where each identity uses its own API key to update its address.
557#[allow(clippy::too_many_arguments)]
558#[axum::debug_handler]
559pub async fn update_identity_address(
560	State(app): State<App>,
561	tn_id: TnId,
562	IdTag(idp_domain): IdTag,
563	Auth(auth): Auth,
564	Path(identity_id): Path<String>,
565	ConnectInfo(socket_addr): ConnectInfo<SocketAddr>,
566	OptionalRequestId(req_id): OptionalRequestId,
567	body: Bytes,
568) -> ClResult<(StatusCode, Json<ApiResponse<AddressUpdateResponse>>)> {
569	info!(
570		identity_id = %identity_id,
571		idp_domain = %idp_domain,
572		auth_id_tag = %auth.id_tag,
573		"PUT /api/idp/identities/:id/address - Updating identity address"
574	);
575
576	// Parse request body - accept empty body as auto mode
577	let update_req: UpdateAddressRequest = if body.is_empty() {
578		UpdateAddressRequest::default()
579	} else {
580		serde_json::from_slice(&body)
581			.map_err(|e| Error::ValidationError(format!("Invalid JSON body: {}", e)))?
582	};
583
584	// Check if IDP is enabled for this tenant
585	check_idp_enabled(&app, tn_id).await?;
586
587	// Verify Identity Provider adapter is available
588	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
589		"Identity Provider not available on this instance".to_string(),
590	))?;
591
592	// Normalize path parameter (accept prefix-only or full id_tag)
593	let normalized_id = normalize_identity_path(&identity_id, &idp_domain);
594
595	// Parse and validate identity id_tag against IDP domain
596	let (id_tag_prefix, id_tag_domain) =
597		parse_and_validate_identity_id_tag(&normalized_id, &idp_domain)?;
598
599	// Build the full id_tag for the identity being updated
600	let target_id_tag = format!("{}.{}", id_tag_prefix, id_tag_domain);
601
602	// Authorization: authenticated identity must match the identity being updated
603	if auth.id_tag.as_ref() != target_id_tag {
604		warn!(
605			identity_id = %identity_id,
606			target_id_tag = %target_id_tag,
607			auth_id_tag = %auth.id_tag,
608			"Unauthorized update to identity address - identity mismatch"
609		);
610		return Err(Error::PermissionDenied);
611	}
612
613	// Verify the identity exists
614	let existing = idp_adapter
615		.read_identity(&id_tag_prefix, &id_tag_domain)
616		.await?
617		.ok_or(Error::NotFound)?;
618
619	// Determine the address to use
620	// Empty/missing body or address = use peer IP (auto mode)
621	// Supports: auto_address=true, address="auto", address="", address=null, empty body
622	let address_to_update = if update_req.auto_address {
623		// Explicit auto flag
624		socket_addr.ip().to_string()
625	} else {
626		match update_req.address {
627			Some(addr) if !addr.is_empty() && addr != "auto" => {
628				// Explicit non-empty address provided
629				addr
630			}
631			_ => {
632				// Empty, missing, or "auto" - use peer IP
633				socket_addr.ip().to_string()
634			}
635		}
636	};
637
638	// Check if the address has actually changed - optimization to avoid unnecessary updates
639	if let Some(current_addr) = &existing.address {
640		if current_addr.as_ref() == address_to_update {
641			// Address hasn't changed, return early with current address
642			info!(
643				identity_id = %identity_id,
644				address = %address_to_update,
645				"Address unchanged, skipping update"
646			);
647			let response_data = AddressUpdateResponse { address: address_to_update };
648			let mut response = ApiResponse::new(response_data);
649			if let Some(id) = req_id {
650				response = response.with_req_id(id);
651			}
652			return Ok((StatusCode::OK, Json(response)));
653		}
654	}
655
656	// Parse and validate the address, determining its type
657	let address_type = parse_address_type(&address_to_update)?;
658
659	info!(
660		identity_id = %identity_id,
661		address = %address_to_update,
662		address_type = %address_type,
663		"Address validated and parsed"
664	);
665
666	// Use optimized address-only update for better performance
667	let _updated_identity = idp_adapter
668		.update_identity_address(&id_tag_prefix, &id_tag_domain, &address_to_update, address_type)
669		.await
670		.map_err(|e| {
671			warn!("Failed to update identity address: {}", e);
672			e
673		})?;
674
675	// Return only the address in the response
676	let response_data = AddressUpdateResponse { address: address_to_update };
677	let mut response = ApiResponse::new(response_data);
678	if let Some(id) = req_id {
679		response = response.with_req_id(id);
680	}
681
682	Ok((StatusCode::OK, Json(response)))
683}
684
685/// DELETE /api/idp/identities/:id - Delete an identity
686#[axum::debug_handler]
687pub async fn delete_identity(
688	State(app): State<App>,
689	tn_id: TnId,
690	IdTag(idp_domain): IdTag,
691	Path(identity_id): Path<String>,
692	OptionalRequestId(req_id): OptionalRequestId,
693) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
694	info!(
695		identity_id = %identity_id,
696		idp_domain = %idp_domain,
697		"DELETE /api/idp/identities/:id - Deleting identity"
698	);
699
700	// Check if IDP is enabled for this tenant
701	check_idp_enabled(&app, tn_id).await?;
702
703	// Verify Identity Provider adapter is available
704	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
705		"Identity Provider not available on this instance".to_string(),
706	))?;
707
708	// Parse and validate identity id_tag against IDP domain
709	let (id_tag_prefix, id_tag_domain) =
710		parse_and_validate_identity_id_tag(&identity_id, &idp_domain)?;
711
712	// Get the identity first to check authorization
713	let existing = idp_adapter
714		.read_identity(&id_tag_prefix, &id_tag_domain)
715		.await?
716		.ok_or(Error::NotFound)?;
717
718	// Check authorization using new helper (owner or registrar while Pending)
719	if !can_access_identity(&existing, &idp_domain) {
720		warn!(
721			identity_id = %identity_id,
722			requested_by = %idp_domain,
723			registrar = %existing.registrar_id_tag,
724			owner = ?existing.owner_id_tag,
725			status = ?existing.status,
726			"Unauthorized deletion of identity"
727		);
728		return Err(Error::PermissionDenied);
729	}
730
731	// Delete the identity
732	idp_adapter.delete_identity(&id_tag_prefix, &id_tag_domain).await.map_err(|e| {
733		warn!("Failed to delete identity: {}", e);
734		e
735	})?;
736
737	let mut response = ApiResponse::new(());
738	if let Some(id) = req_id {
739		response = response.with_req_id(id);
740	}
741
742	Ok((StatusCode::OK, Json(response)))
743}
744
745/// Request structure for updating identity settings
746#[derive(Debug, Deserialize)]
747#[serde(rename_all = "camelCase")]
748pub struct UpdateIdentitySettingsRequest {
749	/// Enable dynamic DNS mode (60s TTL instead of 3600s)
750	pub dyndns: Option<bool>,
751}
752
753/// PATCH /api/idp/identities/:id - Update identity settings
754#[axum::debug_handler]
755pub async fn update_identity_settings(
756	State(app): State<App>,
757	tn_id: TnId,
758	IdTag(idp_domain): IdTag,
759	Path(identity_id): Path<String>,
760	OptionalRequestId(req_id): OptionalRequestId,
761	Json(update_req): Json<UpdateIdentitySettingsRequest>,
762) -> ClResult<(StatusCode, Json<ApiResponse<IdentityResponse>>)> {
763	info!(
764		identity_id = %identity_id,
765		idp_domain = %idp_domain,
766		dyndns = ?update_req.dyndns,
767		"PATCH /api/idp/identities/:id - Updating identity settings"
768	);
769
770	// Check if IDP is enabled for this tenant
771	check_idp_enabled(&app, tn_id).await?;
772
773	// Verify Identity Provider adapter is available
774	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
775		"Identity Provider not available on this instance".to_string(),
776	))?;
777
778	// Parse and validate identity id_tag against IDP domain
779	let (id_tag_prefix, id_tag_domain) =
780		parse_and_validate_identity_id_tag(&identity_id, &idp_domain)?;
781
782	// Get the identity first to check authorization
783	let existing = idp_adapter
784		.read_identity(&id_tag_prefix, &id_tag_domain)
785		.await?
786		.ok_or(Error::NotFound)?;
787
788	// Check authorization using new helper (owner or registrar while Pending)
789	if !can_access_identity(&existing, &idp_domain) {
790		warn!(
791			identity_id = %identity_id,
792			requested_by = %idp_domain,
793			registrar = %existing.registrar_id_tag,
794			owner = ?existing.owner_id_tag,
795			status = ?existing.status,
796			"Unauthorized update to identity settings"
797		);
798		return Err(Error::PermissionDenied);
799	}
800
801	// Build update options
802	let update_opts = UpdateIdentityOptions { dyndns: update_req.dyndns, ..Default::default() };
803
804	// Update the identity
805	let updated_identity = idp_adapter
806		.update_identity(&id_tag_prefix, &id_tag_domain, update_opts)
807		.await
808		.map_err(|e| {
809			warn!("Failed to update identity settings: {}", e);
810			e
811		})?;
812
813	let response_data = IdentityResponse::from(updated_identity);
814	let mut response = ApiResponse::new(response_data);
815	if let Some(id) = req_id {
816		response = response.with_req_id(id);
817	}
818
819	Ok((StatusCode::OK, Json(response)))
820}
821
822/// Response structure for IDP public info
823/// This is returned by GET /api/idp/info - a public endpoint for provider selection
824#[derive(Debug, Clone, Serialize, Deserialize)]
825pub struct IdpInfoResponse {
826	/// The provider domain (e.g., "cloudillo.net")
827	pub domain: String,
828	/// Display name of the provider (e.g., "Cloudillo")
829	pub name: String,
830	/// Short info text (pricing, terms, etc.)
831	pub info: String,
832	/// Optional URL for more information
833	#[serde(skip_serializing_if = "Option::is_none")]
834	pub url: Option<String>,
835}
836
837/// GET /api/idp/info - Get public information about this Identity Provider
838///
839/// This endpoint returns public information about the identity provider,
840/// such as its name, pricing info, and a link for more details.
841/// Used by registration UIs to help users choose a provider.
842#[axum::debug_handler]
843pub async fn get_idp_info(
844	State(app): State<App>,
845	tn_id: TnId,
846	OptionalRequestId(req_id): OptionalRequestId,
847) -> ClResult<(StatusCode, Json<ApiResponse<IdpInfoResponse>>)> {
848	info!(tn_id = tn_id.0, "GET /api/idp/info");
849
850	// Check if IDP is enabled for this tenant
851	check_idp_enabled(&app, tn_id).await?;
852
853	// Get the provider domain from the tenant id_tag
854	let domain = app.auth_adapter.read_id_tag(tn_id).await?.to_string();
855
856	// Get the provider name from settings
857	let name = match app.settings.get(tn_id, "idp.name").await {
858		Ok(SettingValue::String(s)) if !s.is_empty() => s,
859		_ => domain.clone(), // Fallback to domain if name not set
860	};
861
862	// Get the provider info text from settings
863	let info = match app.settings.get(tn_id, "idp.info").await {
864		Ok(SettingValue::String(s)) => s,
865		_ => String::new(),
866	};
867
868	// Get the optional URL from settings
869	let url = match app.settings.get(tn_id, "idp.url").await {
870		Ok(SettingValue::String(s)) if !s.is_empty() => Some(s),
871		_ => None,
872	};
873
874	let response_data = IdpInfoResponse { domain, name, info, url };
875
876	let mut response = ApiResponse::new(response_data);
877	if let Some(id) = req_id {
878		response = response.with_req_id(id);
879	}
880
881	Ok((StatusCode::OK, Json(response)))
882}
883
884/// Response structure for identity availability check
885#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct AvailabilityResponse {
887	pub available: bool,
888	pub id_tag: String,
889}
890
891/// Query parameters for checking identity availability
892#[derive(Debug, Deserialize)]
893#[serde(rename_all = "camelCase")]
894pub struct CheckAvailabilityQuery {
895	/// The identity id_tag to check (e.g., "alice.cloudillo.net")
896	pub id_tag: String,
897}
898
899/// GET /api/idp/check-availability - Check if an identity id_tag is available
900///
901/// This endpoint checks if an identity is available for registration within the
902/// authenticated tenant's domain. The identity must belong to the same domain as
903/// the authenticated tenant.
904#[axum::debug_handler]
905pub async fn check_identity_availability(
906	State(app): State<App>,
907	tn_id: TnId,
908	IdTag(my_id_tag): IdTag,
909	Query(query): Query<CheckAvailabilityQuery>,
910	OptionalRequestId(req_id): OptionalRequestId,
911) -> ClResult<(StatusCode, Json<ApiResponse<AvailabilityResponse>>)> {
912	let id_tag = query.id_tag.trim().to_lowercase();
913
914	info!(
915		id_tag = %id_tag,
916		registrar_id_tag = %my_id_tag,
917		"GET /api/idp/check-availability - Checking identity availability"
918	);
919
920	// Check if IDP is enabled for this tenant
921	check_idp_enabled(&app, tn_id).await?;
922
923	// Verify Identity Provider adapter is available
924	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
925		"Identity Provider not available on this instance".to_string(),
926	))?;
927
928	// Validate id_tag format - must contain at least one dot
929	if !id_tag.contains('.') {
930		return Err(Error::ValidationError(
931			"Identity id_tag must be in format 'prefix.domain' (e.g., 'alice.cloudillo.net')"
932				.to_string(),
933		));
934	}
935
936	// Split at the FIRST dot: "alice.cloudillo.net" -> prefix: "alice", domain: "cloudillo.net"
937	if let Some(first_dot_pos) = id_tag.find('.') {
938		let id_tag_prefix = &id_tag[..first_dot_pos];
939		let id_tag_domain = &id_tag[first_dot_pos + 1..];
940
941		// Validate prefix is not empty
942		if id_tag_prefix.is_empty() {
943			return Err(Error::ValidationError(
944				"Identity prefix cannot be empty (id_tag must be in format 'prefix.domain')"
945					.to_string(),
946			));
947		}
948
949		// Forbid 'cl-o' prefix (reserved)
950		if id_tag_prefix == "cl-o" {
951			warn!(
952				id_tag_prefix = %id_tag_prefix,
953				"Attempted to check availability for forbidden prefix 'cl-o'"
954			);
955			return Err(Error::ValidationError(
956				"Identity prefix 'cl-o' is reserved and cannot be used".to_string(),
957			));
958		}
959
960		// Validate domain is not empty
961		if id_tag_domain.is_empty() {
962			return Err(Error::ValidationError(
963				"Identity domain cannot be empty (id_tag must be in format 'prefix.domain')"
964					.to_string(),
965			));
966		}
967
968		// Validate that the requested identity domain matches the registrar's domain
969		if id_tag_domain != my_id_tag.as_ref() {
970			warn!(
971				requested_domain = %id_tag_domain,
972				registrar_domain = %my_id_tag,
973				"Domain mismatch in availability check"
974			);
975			return Err(Error::PermissionDenied);
976		}
977
978		debug!(
979			id_tag = %id_tag,
980			prefix = %id_tag_prefix,
981			domain = %id_tag_domain,
982			"Parsed identity id_tag for availability check"
983		);
984
985		// Check if the identity exists
986		let identity_exists =
987			idp_adapter.read_identity(id_tag_prefix, id_tag_domain).await?.is_some();
988
989		let response_data =
990			AvailabilityResponse { available: !identity_exists, id_tag: id_tag.clone() };
991
992		let mut response = ApiResponse::new(response_data);
993		if let Some(id) = req_id {
994			response = response.with_req_id(id);
995		}
996
997		Ok((StatusCode::OK, Json(response)))
998	} else {
999		Err(Error::ValidationError(
1000			"Identity id_tag must contain at least one dot separator".to_string(),
1001		))
1002	}
1003}
1004
1005/// Request structure for identity activation
1006#[derive(Debug, Deserialize)]
1007#[serde(rename_all = "camelCase")]
1008pub struct ActivateIdentityRequest {
1009	/// The activation reference ID
1010	pub ref_id: String,
1011}
1012
1013/// POST /api/idp/activate - Activate an identity using a ref token
1014///
1015/// This endpoint activates a pending identity by consuming an activation ref.
1016/// After activation:
1017/// - Identity status changes from Pending to Active
1018/// - Registrar loses control (only owner can manage)
1019#[axum::debug_handler]
1020pub async fn activate_identity(
1021	State(app): State<App>,
1022	tn_id: TnId,
1023	OptionalRequestId(req_id): OptionalRequestId,
1024	Json(activate_req): Json<ActivateIdentityRequest>,
1025) -> ClResult<(StatusCode, Json<ApiResponse<IdentityResponse>>)> {
1026	info!(
1027		ref_id = %activate_req.ref_id,
1028		"POST /api/idp/activate - Activating identity"
1029	);
1030
1031	// Check if IDP is enabled for this tenant
1032	check_idp_enabled(&app, tn_id).await?;
1033
1034	// Verify Identity Provider adapter is available
1035	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
1036		"Identity Provider not available on this instance".to_string(),
1037	))?;
1038
1039	// Use and validate the activation ref
1040	let (_ref_tn_id, _ref_id_tag, ref_data) = app
1041		.meta_adapter
1042		.use_ref(&activate_req.ref_id, &["idp.activation"])
1043		.await
1044		.map_err(|e| {
1045			warn!(ref_id = %activate_req.ref_id, error = ?e, "Invalid activation ref");
1046			e
1047		})?;
1048
1049	// Get the identity id_tag from the ref's resource_id
1050	let identity_id = ref_data
1051		.resource_id
1052		.ok_or_else(|| Error::Internal("Activation ref missing resource_id".to_string()))?
1053		.to_string();
1054
1055	// Parse identity id_tag into prefix and domain
1056	let (id_tag_prefix, id_tag_domain) = if let Some(dot_pos) = identity_id.find('.') {
1057		(identity_id[..dot_pos].to_string(), identity_id[dot_pos + 1..].to_string())
1058	} else {
1059		return Err(Error::ValidationError("Invalid identity id_tag format".to_string()));
1060	};
1061
1062	// Get the identity
1063	let existing = idp_adapter
1064		.read_identity(&id_tag_prefix, &id_tag_domain)
1065		.await?
1066		.ok_or(Error::NotFound)?;
1067
1068	// Verify identity is in Pending status
1069	if existing.status != IdentityStatus::Pending {
1070		warn!(
1071			identity_id = %identity_id,
1072			status = ?existing.status,
1073			"Cannot activate identity - not in Pending status"
1074		);
1075		return Err(Error::ValidationError(format!(
1076			"Identity is not in Pending status (current: {})",
1077			existing.status
1078		)));
1079	}
1080
1081	// Update identity status to Active
1082	let update_opts =
1083		UpdateIdentityOptions { status: Some(IdentityStatus::Active), ..Default::default() };
1084
1085	let updated_identity = idp_adapter
1086		.update_identity(&id_tag_prefix, &id_tag_domain, update_opts)
1087		.await
1088		.map_err(|e| {
1089			warn!(identity_id = %identity_id, error = ?e, "Failed to activate identity");
1090			e
1091		})?;
1092
1093	info!(
1094		identity_id = %identity_id,
1095		registrar = %updated_identity.registrar_id_tag,
1096		owner = ?updated_identity.owner_id_tag,
1097		"Identity activated successfully - registrar access revoked"
1098	);
1099
1100	let response_data = IdentityResponse::from(updated_identity);
1101	let mut response = ApiResponse::new(response_data);
1102	if let Some(id) = req_id {
1103		response = response.with_req_id(id);
1104	}
1105
1106	Ok((StatusCode::OK, Json(response)))
1107}
1108
1109// vim: ts=4