Skip to main content

cloudillo_idp/
registration.rs

1//! IDP Registration business logic
2//!
3//! Contains the core logic for processing identity registration requests,
4//! extracted from the action hook handlers for better separation of concerns.
5
6use cloudillo_types::address::parse_address_type;
7use cloudillo_types::identity_provider_adapter::IdentityStatus;
8use cloudillo_types::utils::parse_and_validate_identity_id_tag;
9
10use crate::prelude::*;
11
12/// Content structure for IDP:REG actions
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct IdpRegContent {
16	pub id_tag: String,
17	/// Email address (optional when owner_id_tag is provided)
18	#[serde(skip_serializing_if = "Option::is_none")]
19	pub email: Option<String>,
20	/// ID tag of the owner who will control this identity (optional)
21	#[serde(skip_serializing_if = "Option::is_none")]
22	pub owner_id_tag: Option<String>,
23	/// Role of the token issuer: "registrar" (default) or "owner"
24	/// When "owner", the token issuer becomes the owner_id_tag
25	#[serde(skip_serializing_if = "Option::is_none")]
26	pub issuer: Option<String>,
27	/// Optional address for the identity. Use "auto" to use the client's IP address
28	#[serde(skip_serializing_if = "Option::is_none")]
29	pub address: Option<String>,
30	/// Preferred language for emails and notifications (e.g., "hu", "de")
31	#[serde(skip_serializing_if = "Option::is_none")]
32	pub lang: Option<String>,
33}
34
35/// Response structure for IDP:REG registration
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct IdpRegResponse {
39	pub success: bool,
40	pub message: String,
41	pub identity_status: String,
42	pub activation_ref: Option<String>,
43	pub api_key: Option<String>,
44}
45
46/// Input parameters for processing an identity registration
47pub struct ProcessRegistrationParams<'a> {
48	pub reg_content: IdpRegContent,
49	pub issuer: &'a str,
50	pub audience: &'a str,
51	pub tenant_id: i64,
52	pub client_address: Option<&'a str>,
53}
54
55/// Result of a successful registration
56pub struct RegistrationResult {
57	pub identity_id: String,
58	pub activation_ref: String,
59	pub api_key_prefix: String,
60	pub plaintext_key: String,
61	pub response: IdpRegResponse,
62}
63
64/// Parameters for sending activation email
65pub struct SendActivationEmailParams<'a> {
66	pub id_tag_prefix: &'a str,
67	pub id_tag_domain: &'a str,
68	pub email: &'a str,
69	pub lang: Option<String>,
70}
71
72/// Send activation email for a newly created identity
73///
74/// Creates an activation reference and schedules the email task.
75/// Returns the activation reference string on success.
76pub async fn send_activation_email(
77	app: &App,
78	tn_id: TnId,
79	params: SendActivationEmailParams<'_>,
80) -> ClResult<String> {
81	let identity_id = format!("{}.{}", params.id_tag_prefix, params.id_tag_domain);
82	let idp_domain = params.id_tag_domain;
83
84	// Create activation reference (24 hours validity)
85	let expires_at_ref = Some(Timestamp::now().add_seconds(24 * 60 * 60));
86	let (activation_ref, activation_link) = cloudillo_ref::service::create_ref_internal(
87		app,
88		tn_id,
89		cloudillo_ref::service::CreateRefInternalParams {
90			id_tag: idp_domain,
91			typ: "idp.activation",
92			description: Some("Identity provider activation"),
93			expires_at: expires_at_ref,
94			path_prefix: "/idp/activate",
95			resource_id: Some(&identity_id),
96			count: None,
97		},
98	)
99	.await?;
100
101	// Schedule email task
102	let template_vars = serde_json::json!({
103		"identity_tag": identity_id,
104		"activation_link": activation_link,
105		"identity_provider": idp_domain,
106		"expire_hours": 24,
107	});
108
109	let email_task_key = format!("email:idp-activation:{}:{}", tn_id.0, identity_id);
110	match cloudillo_email::EmailModule::schedule_email_task_with_key(
111		&app.scheduler,
112		&app.settings,
113		tn_id,
114		cloudillo_email::EmailTaskParams {
115			to: params.email.to_string(),
116			subject: None,
117			template_name: "idp-activation".to_string(),
118			template_vars,
119			lang: params.lang,
120			custom_key: Some(email_task_key),
121			from_name_override: Some(format!("{} Identity Provider", idp_domain.to_uppercase())),
122		},
123	)
124	.await
125	{
126		Ok(_) => {
127			info!(
128				id_tag_prefix = %params.id_tag_prefix,
129				id_tag_domain = %params.id_tag_domain,
130				email = %params.email,
131				"Activation email scheduled successfully"
132			);
133		}
134		Err(e) => {
135			warn!(
136				id_tag_prefix = %params.id_tag_prefix,
137				id_tag_domain = %params.id_tag_domain,
138				email = %params.email,
139				error = %e,
140				"Failed to schedule activation email"
141			);
142		}
143	}
144
145	Ok(activation_ref)
146}
147
148/// Process an identity registration request
149///
150/// This function contains the core business logic for IDP:REG processing:
151/// 1. Validates the registration request content
152/// 2. Checks registrar quota
153/// 3. Verifies the identity doesn't already exist
154/// 4. Creates a new identity with Pending status
155/// 5. Generates an API key for address updates
156/// 6. Creates an activation reference (24-hour single-use token)
157/// 7. Sends activation email (or logs in development)
158/// 8. Updates quota counts
159pub async fn process_registration(
160	app: &App,
161	params: ProcessRegistrationParams<'_>,
162) -> ClResult<RegistrationResult> {
163	let reg_content = params.reg_content;
164	let registrar_id_tag = params.issuer;
165	let target_idp = params.audience;
166
167	info!(
168		id_tag = %reg_content.id_tag,
169		issuer = %registrar_id_tag,
170		audience = %target_idp,
171		"Processing IDP registration request"
172	);
173
174	// Validate id_tag is present
175	if reg_content.id_tag.is_empty() {
176		warn!(
177			id_tag = %reg_content.id_tag,
178			"IDP:REG content has empty id_tag"
179		);
180		return Err(Error::ValidationError("IDP:REG content missing id_tag".into()));
181	}
182
183	// Determine issuer role - defaults to "registrar" if not specified
184	let issuer_role = reg_content.issuer.as_deref().unwrap_or("registrar");
185
186	// Validate issuer role
187	if issuer_role != "registrar" && issuer_role != "owner" {
188		warn!(
189			issuer_role = %issuer_role,
190			"IDP:REG content has invalid issuer role"
191		);
192		return Err(Error::ValidationError(format!(
193			"Invalid issuer role '{}': must be 'registrar' or 'owner'",
194			issuer_role
195		)));
196	}
197
198	// Determine owner_id_tag based on issuer role
199	// When issuer="owner", the token issuer becomes the owner
200	// When issuer="registrar", use explicit owner_id_tag if provided
201	let owner_id_tag: Option<&str> = match issuer_role {
202		"owner" => {
203			// Token issuer is the owner
204			Some(registrar_id_tag)
205		}
206		"registrar" => {
207			// Use explicit owner_id_tag from content if provided
208			reg_content.owner_id_tag.as_deref()
209		}
210		_ => None, // Already validated above, won't reach here
211	};
212
213	// Email validation: required only if no owner_id_tag
214	if owner_id_tag.is_none() && reg_content.email.as_ref().is_none_or(|e| e.is_empty()) {
215		warn!(
216			id_tag = %reg_content.id_tag,
217			"IDP:REG content missing email (required when no owner specified)"
218		);
219		return Err(Error::ValidationError(
220			"IDP:REG content missing email (required when no owner_id_tag is provided)".into(),
221		));
222	}
223
224	info!(
225		id_tag = %reg_content.id_tag,
226		issuer_role = %issuer_role,
227		owner_id_tag = ?owner_id_tag,
228		email = ?reg_content.email,
229		"IDP:REG - Parsed ownership model"
230	);
231
232	// Verify Identity Provider adapter is available
233	let idp_adapter = app.idp_adapter.as_ref().ok_or_else(|| {
234		warn!("IDP:REG hook triggered but Identity Provider adapter not available");
235		Error::ServiceUnavailable("Identity Provider not available on this instance".to_string())
236	})?;
237
238	// Parse and validate identity id_tag against the TARGET domain (audience), not the issuer
239	// In federated identity, any IDP can register identities on any server,
240	// but the identity's domain suffix must match the target server
241	let (id_tag_prefix, id_tag_domain) =
242		parse_and_validate_identity_id_tag(&reg_content.id_tag, target_idp).map_err(|e| {
243			warn!(
244				error = %e,
245				id_tag = %reg_content.id_tag,
246				target_idp = %target_idp,
247				registrar = %registrar_id_tag,
248				"Failed to parse/validate identity id_tag against target IdP domain"
249			);
250			e
251		})?;
252
253	// Determine the address to use - handle "auto" special value
254	let address = match &reg_content.address {
255		Some(addr) if addr == "auto" => {
256			// Use client IP address from context
257			params.client_address
258		}
259		Some(addr) => Some(addr.as_str()),
260		None => None,
261	};
262
263	info!(
264		id_tag = %reg_content.id_tag,
265		address = ?address,
266		client_address = ?params.client_address,
267		"Resolved address for identity (auto = client IP)"
268	);
269
270	// Parse address type from resolved address
271	let address_type = if let Some(addr_str) = address {
272		match parse_address_type(addr_str) {
273			Ok(addr_type) => {
274				info!(
275					address = %addr_str,
276					address_type = ?addr_type,
277					"IDP:REG - Parsed address type from resolved address"
278				);
279				Some(addr_type)
280			}
281			Err(e) => {
282				warn!(
283					address = %addr_str,
284					error = ?e,
285					"IDP:REG - Failed to parse address type"
286				);
287				None
288			}
289		}
290	} else {
291		None
292	};
293
294	// Check registrar quota
295	let quota = idp_adapter.get_quota(registrar_id_tag).await.ok();
296	if let Some(quota) = quota {
297		if quota.current_identities >= quota.max_identities {
298			warn!(
299				registrar = %registrar_id_tag,
300				current = quota.current_identities,
301				max = quota.max_identities,
302				"Registrar quota exceeded"
303			);
304
305			let response = IdpRegResponse {
306				success: false,
307				message: "Registrar quota exceeded".to_string(),
308				identity_status: "quota_exceeded".to_string(),
309				activation_ref: None,
310				api_key: None,
311			};
312
313			return Ok(RegistrationResult {
314				identity_id: String::new(),
315				activation_ref: String::new(),
316				api_key_prefix: String::new(),
317				plaintext_key: String::new(),
318				response,
319			});
320		}
321	}
322
323	// Create the identity with Pending status
324	let expires_at = Timestamp::now().add_seconds(24 * 60 * 60);
325	let create_opts = cloudillo_types::identity_provider_adapter::CreateIdentityOptions {
326		id_tag_prefix: &id_tag_prefix,
327		id_tag_domain: &id_tag_domain,
328		email: reg_content.email.as_deref(),
329		registrar_id_tag,
330		owner_id_tag,
331		status: IdentityStatus::Pending,
332		address,
333		address_type,
334		dyndns: false,
335		lang: reg_content.lang.as_deref(),
336		expires_at: Some(expires_at),
337	};
338
339	info!(
340		id_tag_prefix = %id_tag_prefix,
341		id_tag_domain = %id_tag_domain,
342		address = ?address,
343		"IDP:REG - Calling IDP adapter create_identity"
344	);
345
346	let identity = idp_adapter.create_identity(create_opts).await.map_err(|e| {
347		warn!("Failed to create identity: {}", e);
348		e
349	})?;
350
351	info!(
352		id_tag_prefix = %identity.id_tag_prefix,
353		id_tag_domain = %identity.id_tag_domain,
354		registrar = %registrar_id_tag,
355		owner = ?identity.owner_id_tag,
356		email = ?identity.email,
357		address = ?identity.address,
358		"IDP:REG - Identity created with Pending status"
359	);
360
361	// Create API key for identity address updates
362	let create_key_opts = cloudillo_types::identity_provider_adapter::CreateApiKeyOptions {
363		id_tag_prefix: &id_tag_prefix,
364		id_tag_domain: &id_tag_domain,
365		name: Some("activation-key"),
366		expires_at: Some(Timestamp::now().add_seconds(86400)), // 24 hours
367	};
368
369	let created_key = idp_adapter.create_api_key(create_key_opts).await.map_err(|e| {
370		warn!("Failed to create API key for identity: {}", e);
371		e
372	})?;
373
374	info!(
375		id_tag_prefix = %identity.id_tag_prefix,
376		id_tag_domain = %identity.id_tag_domain,
377		key_prefix = %created_key.api_key.key_prefix,
378		"API key created for identity activation"
379	);
380
381	// Send activation email (if email is provided)
382	let tn_id = TnId(params.tenant_id as u32);
383	let identity_id = format!("{}.{}", identity.id_tag_prefix, identity.id_tag_domain);
384	let activation_ref = if let Some(ref email) = identity.email {
385		match send_activation_email(
386			app,
387			tn_id,
388			SendActivationEmailParams {
389				id_tag_prefix: &identity.id_tag_prefix,
390				id_tag_domain: &identity.id_tag_domain,
391				email,
392				lang: reg_content.lang.clone(),
393			},
394		)
395		.await
396		{
397			Ok(ref_id) => ref_id,
398			Err(e) => {
399				warn!(
400					id_tag_prefix = %identity.id_tag_prefix,
401					id_tag_domain = %identity.id_tag_domain,
402					error = %e,
403					"Failed to send activation email, continuing registration"
404				);
405				String::new()
406			}
407		}
408	} else {
409		// No email - owner-based activation required
410		info!(
411			id_tag_prefix = %identity.id_tag_prefix,
412			id_tag_domain = %identity.id_tag_domain,
413			owner = ?identity.owner_id_tag,
414			"Identity created without email - activation via owner required"
415		);
416		String::new()
417	};
418
419	// Update quota counts
420	if idp_adapter.get_quota(registrar_id_tag).await.is_ok() {
421		let _ = idp_adapter.increment_quota(registrar_id_tag, 0).await; // Increment identity count
422	}
423
424	// Build success response
425	let message = if let Some(ref email) = identity.email {
426		format!(
427			"Identity '{}' created successfully. Activation email sent to {}",
428			reg_content.id_tag, email
429		)
430	} else {
431		format!(
432			"Identity '{}' created successfully. Activation via owner required.",
433			reg_content.id_tag
434		)
435	};
436	let response = IdpRegResponse {
437		success: true,
438		message,
439		identity_status: identity.status.to_string(),
440		activation_ref: Some(activation_ref.clone()),
441		api_key: Some(created_key.plaintext_key.clone()), // Only shown once!
442	};
443
444	info!(
445		id_tag_prefix = %identity.id_tag_prefix,
446		id_tag_domain = %identity.id_tag_domain,
447		registrar = %registrar_id_tag,
448		"IDP:REG registration successful"
449	);
450
451	Ok(RegistrationResult {
452		identity_id,
453		activation_ref,
454		api_key_prefix: created_key.api_key.key_prefix.to_string(),
455		plaintext_key: created_key.plaintext_key,
456		response,
457	})
458}
459
460#[cfg(test)]
461mod tests {
462	use super::*;
463
464	#[test]
465	fn test_idp_reg_content_parse_with_email() {
466		let json = serde_json::json!({
467			"idTag": "alice",
468			"email": "alice@example.com"
469		});
470
471		let content: IdpRegContent = serde_json::from_value(json).unwrap();
472		assert_eq!(content.id_tag, "alice");
473		assert_eq!(content.email.as_deref(), Some("alice@example.com"));
474		assert!(content.owner_id_tag.is_none());
475		assert!(content.issuer.is_none());
476		assert!(content.lang.is_none());
477	}
478
479	#[test]
480	fn test_idp_reg_content_parse_with_lang() {
481		let json = serde_json::json!({
482			"idTag": "alice",
483			"email": "alice@example.com",
484			"lang": "hu"
485		});
486
487		let content: IdpRegContent = serde_json::from_value(json).unwrap();
488		assert_eq!(content.id_tag, "alice");
489		assert_eq!(content.email.as_deref(), Some("alice@example.com"));
490		assert_eq!(content.lang.as_deref(), Some("hu"));
491	}
492
493	#[test]
494	fn test_idp_reg_content_parse_with_owner() {
495		let json = serde_json::json!({
496			"idTag": "member",
497			"ownerIdTag": "community.cloudillo.net",
498			"issuer": "registrar"
499		});
500
501		let content: IdpRegContent = serde_json::from_value(json).unwrap();
502		assert_eq!(content.id_tag, "member");
503		assert!(content.email.is_none());
504		assert_eq!(content.owner_id_tag.as_deref(), Some("community.cloudillo.net"));
505		assert_eq!(content.issuer.as_deref(), Some("registrar"));
506	}
507
508	#[test]
509	fn test_idp_reg_content_parse_issuer_owner() {
510		let json = serde_json::json!({
511			"idTag": "member",
512			"issuer": "owner"
513		});
514
515		let content: IdpRegContent = serde_json::from_value(json).unwrap();
516		assert_eq!(content.id_tag, "member");
517		assert!(content.email.is_none());
518		assert!(content.owner_id_tag.is_none()); // owner comes from token issuer when issuer="owner"
519		assert_eq!(content.issuer.as_deref(), Some("owner"));
520	}
521
522	#[test]
523	fn test_idp_reg_response_serialize() {
524		let response = IdpRegResponse {
525			success: true,
526			message: "Test message".to_string(),
527			identity_status: "pending".to_string(),
528			activation_ref: Some("ref123".to_string()),
529			api_key: Some("key123".to_string()),
530		};
531
532		let json = serde_json::to_value(&response).unwrap();
533		assert!(json["success"].as_bool().unwrap());
534		assert_eq!(json["message"].as_str().unwrap(), "Test message");
535	}
536}
537
538// vim: ts=4