cloudillo_types/identity_provider_adapter.rs
1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Adapter that manages identity registration and DNS modifications.
5//!
6//! The Identity Provider Adapter is responsible for handling DNS modifications
7//! for identity registration. Each identity (id_tag) is associated with an email
8//! address and has lifecycle timestamps.
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use std::fmt::Debug;
13
14pub use crate::address::AddressType;
15use crate::prelude::*;
16
17/// Status of an identity in the registration lifecycle
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum IdentityStatus {
20 /// Identity is awaiting activation/validation
21 Pending,
22 /// Identity is active and can be used
23 Active,
24 /// Identity is suspended and cannot be used
25 Suspended,
26}
27
28impl std::fmt::Display for IdentityStatus {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 IdentityStatus::Pending => write!(f, "pending"),
32 IdentityStatus::Active => write!(f, "active"),
33 IdentityStatus::Suspended => write!(f, "suspended"),
34 }
35 }
36}
37
38impl std::str::FromStr for IdentityStatus {
39 type Err = Error;
40 fn from_str(s: &str) -> Result<Self, Self::Err> {
41 match s {
42 "pending" => Ok(IdentityStatus::Pending),
43 "active" => Ok(IdentityStatus::Active),
44 "suspended" => Ok(IdentityStatus::Suspended),
45 _ => Err(Error::ValidationError(format!("invalid identity status: {}", s))),
46 }
47 }
48}
49
50/// Quota tracking for identity registrations
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RegistrarQuota {
53 /// The registrar's id_tag
54 pub registrar_id_tag: Box<str>,
55 /// Maximum number of identities this registrar can create
56 pub max_identities: i32,
57 /// Maximum total storage for all identities (in bytes)
58 pub max_storage_bytes: i64,
59 /// Current count of identities created by this registrar
60 pub current_identities: i32,
61 /// Current storage used by this registrar (in bytes)
62 pub current_storage_bytes: i64,
63 /// Timestamp when the quota was last updated
64 pub updated_at: Timestamp,
65}
66
67/// Represents an identity registration
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Identity {
70 /// Unique identifier prefix (local part) for this identity
71 pub id_tag_prefix: Box<str>,
72 /// Domain part of the identity (e.g., cloudillo.net)
73 pub id_tag_domain: Box<str>,
74 /// Email address associated with this identity (optional for community-owned identities)
75 pub email: Option<Box<str>>,
76 /// ID tag of the registrar who created this identity
77 pub registrar_id_tag: Box<str>,
78 /// ID tag of the owner who controls this identity (if different from registrar)
79 /// When set, the owner has permanent control; registrar only has control while Pending
80 pub owner_id_tag: Option<Box<str>>,
81 /// Address (DNS record, server address, or other routing info)
82 pub address: Option<Box<str>>,
83 /// Type of the address (IPv4, IPv6, or Hostname)
84 pub address_type: Option<AddressType>,
85 /// Timestamp when the address was last updated
86 pub address_updated_at: Option<Timestamp>,
87 /// Whether this identity uses dynamic DNS (60s TTL instead of 3600s)
88 pub dyndns: bool,
89 /// Preferred language for emails and notifications (e.g., "hu", "de")
90 pub lang: Option<Box<str>>,
91 /// Status of this identity in its lifecycle
92 pub status: IdentityStatus,
93 /// Timestamp when the identity was created
94 pub created_at: Timestamp,
95 /// Timestamp when the identity was last updated
96 pub updated_at: Timestamp,
97 /// Timestamp when the identity expires
98 pub expires_at: Timestamp,
99}
100
101/// Options for creating a new identity
102#[derive(Debug, Clone)]
103pub struct CreateIdentityOptions<'a> {
104 /// The unique identifier prefix (local part) for this identity
105 pub id_tag_prefix: &'a str,
106 /// The domain part of the identity identifier
107 pub id_tag_domain: &'a str,
108 /// Email address to associate with this identity (optional for community-owned identities)
109 pub email: Option<&'a str>,
110 /// The id_tag of the registrar creating this identity
111 pub registrar_id_tag: &'a str,
112 /// The id_tag of the owner who will control this identity (optional)
113 /// When issuer="owner" in the registration token, this is set from the token issuer
114 pub owner_id_tag: Option<&'a str>,
115 /// Initial status of the identity (default: Pending)
116 pub status: IdentityStatus,
117 /// Initial address for this identity (optional)
118 pub address: Option<&'a str>,
119 /// Type of the address being set (if address is provided)
120 pub address_type: Option<AddressType>,
121 /// Whether this identity uses dynamic DNS (60s TTL instead of 3600s)
122 pub dyndns: bool,
123 /// Preferred language for emails and notifications (e.g., "hu", "de")
124 pub lang: Option<&'a str>,
125 /// When the identity should expire (optional, can have default)
126 pub expires_at: Option<Timestamp>,
127}
128
129/// Options for updating an existing identity
130#[derive(Debug, Clone, Default)]
131pub struct UpdateIdentityOptions {
132 /// New email address (if changing)
133 pub email: Option<Box<str>>,
134 /// New owner id_tag (for ownership transfer)
135 pub owner_id_tag: Option<Box<str>>,
136 /// New address (if changing)
137 pub address: Option<Box<str>>,
138 /// Type of the address being set (if address is provided)
139 pub address_type: Option<AddressType>,
140 /// Whether to use dynamic DNS (60s TTL instead of 3600s)
141 pub dyndns: Option<bool>,
142 /// New preferred language (if changing)
143 pub lang: Option<Option<Box<str>>>,
144 /// New status (if changing)
145 pub status: Option<IdentityStatus>,
146 /// New expiration timestamp (if changing)
147 pub expires_at: Option<Timestamp>,
148}
149
150/// Options for listing identities
151#[derive(Debug, Clone)]
152pub struct ListIdentityOptions {
153 /// Filter by identity domain (the domain part of id_tag, e.g., "home.w9.hu")
154 /// This is REQUIRED - only show identities belonging to this domain
155 pub id_tag_domain: String,
156 /// Filter by email address (partial match)
157 pub email: Option<String>,
158 /// Filter by registrar id_tag
159 pub registrar_id_tag: Option<String>,
160 /// Filter by owner id_tag
161 pub owner_id_tag: Option<String>,
162 /// Filter by identity status
163 pub status: Option<IdentityStatus>,
164 /// Only include identities that expire after this timestamp
165 pub expires_after: Option<Timestamp>,
166 /// Only include expired identities
167 pub expired_only: bool,
168 /// Limit the number of results
169 pub limit: Option<u32>,
170 /// Offset for pagination
171 pub offset: Option<u32>,
172}
173
174/// Represents an API key in the system
175#[derive(Debug, Clone)]
176pub struct ApiKey {
177 pub id: i32,
178 pub id_tag_prefix: String,
179 pub id_tag_domain: String,
180 pub key_prefix: String,
181 pub name: Option<String>,
182 pub created_at: Timestamp,
183 pub last_used_at: Option<Timestamp>,
184 pub expires_at: Option<Timestamp>,
185}
186
187/// Options for creating a new API key
188#[derive(Debug)]
189pub struct CreateApiKeyOptions<'a> {
190 pub id_tag_prefix: &'a str,
191 pub id_tag_domain: &'a str,
192 pub name: Option<&'a str>,
193 pub expires_at: Option<Timestamp>,
194}
195
196/// Result of creating a new API key - includes the plaintext key (shown only once)
197#[derive(Debug)]
198pub struct CreatedApiKey {
199 pub api_key: ApiKey,
200 pub plaintext_key: String,
201}
202
203/// Options for listing API keys
204#[derive(Debug, Default)]
205pub struct ListApiKeyOptions {
206 pub id_tag_prefix: Option<String>,
207 pub id_tag_domain: Option<String>,
208 pub limit: Option<u32>,
209 pub offset: Option<u32>,
210}
211
212/// A `Cloudillo` identity provider adapter
213///
214/// Every `IdentityProviderAdapter` implementation is required to implement this trait.
215/// An `IdentityProviderAdapter` is responsible for managing identity registrations
216/// and handling DNS modifications for identity registration.
217#[async_trait]
218pub trait IdentityProviderAdapter: Debug + Send + Sync {
219 /// Creates a new identity registration
220 ///
221 /// This method registers a new identity with the given id_tag and email address.
222 /// It should also handle any necessary DNS modifications for the identity.
223 ///
224 /// # Arguments
225 /// * `opts` - Options containing id_tag, email, and optional expiration
226 ///
227 /// # Returns
228 /// The newly created `Identity` with all timestamps populated
229 ///
230 /// # Errors
231 /// Returns an error if:
232 /// - The id_tag already exists
233 /// - The email is invalid or already in use
234 /// - DNS modifications fail
235 async fn create_identity(&self, opts: CreateIdentityOptions<'_>) -> ClResult<Identity>;
236
237 /// Reads an identity by its id_tag
238 ///
239 /// # Arguments
240 /// * `id_tag` - The unique identifier tag to look up
241 ///
242 /// # Returns
243 /// `Some(Identity)` if found, `None` otherwise
244 async fn read_identity(
245 &self,
246 id_tag_prefix: &str,
247 id_tag_domain: &str,
248 ) -> ClResult<Option<Identity>>;
249
250 /// Reads an identity by its email address
251 ///
252 /// # Arguments
253 /// * `email` - The email address to look up
254 ///
255 /// # Returns
256 /// `Some(Identity)` if found, `None` otherwise
257 async fn read_identity_by_email(&self, email: &str) -> ClResult<Option<Identity>>;
258
259 /// Updates an existing identity
260 ///
261 /// # Arguments
262 /// * `id_tag` - The identifier of the identity to update
263 /// * `opts` - Options containing fields to update
264 ///
265 /// # Errors
266 /// Returns an error if the identity doesn't exist or the update fails
267 async fn update_identity(
268 &self,
269 id_tag_prefix: &str,
270 id_tag_domain: &str,
271 opts: UpdateIdentityOptions,
272 ) -> ClResult<Identity>;
273
274 /// Updates only the address of an identity (optimized for performance)
275 ///
276 /// This method is optimized for updating just the address and address type,
277 /// avoiding unnecessary updates to other fields. Useful for frequent address updates.
278 ///
279 /// # Arguments
280 /// * `id_tag` - The identifier of the identity to update
281 /// * `address` - The new address to set
282 /// * `address_type` - The type of the address (IPv4, IPv6, or Hostname)
283 ///
284 /// # Returns
285 /// The updated `Identity` with the new address
286 ///
287 /// # Errors
288 /// Returns an error if the identity doesn't exist or the update fails
289 async fn update_identity_address(
290 &self,
291 id_tag_prefix: &str,
292 id_tag_domain: &str,
293 address: &str,
294 address_type: AddressType,
295 ) -> ClResult<Identity>;
296
297 /// Deletes an identity and cleans up associated DNS records
298 ///
299 /// # Arguments
300 /// * `id_tag` - The identifier of the identity to delete
301 ///
302 /// # Errors
303 /// Returns an error if the identity doesn't exist or DNS cleanup fails
304 async fn delete_identity(&self, id_tag_prefix: &str, id_tag_domain: &str) -> ClResult<()>;
305
306 /// Lists identities matching the given criteria
307 ///
308 /// # Arguments
309 /// * `opts` - Filtering and pagination options
310 ///
311 /// # Returns
312 /// A vector of matching identities
313 async fn list_identities(&self, opts: ListIdentityOptions) -> ClResult<Vec<Identity>>;
314
315 /// Checks if an identity exists
316 ///
317 /// # Arguments
318 /// * `id_tag` - The identifier to check
319 ///
320 /// # Returns
321 /// `true` if the identity exists, `false` otherwise
322 async fn identity_exists(&self, id_tag_prefix: &str, id_tag_domain: &str) -> ClResult<bool> {
323 Ok(self.read_identity(id_tag_prefix, id_tag_domain).await?.is_some())
324 }
325
326 /// Cleans up expired identities
327 ///
328 /// This method should be called periodically to remove identities that have expired.
329 /// It should also clean up any associated DNS records.
330 ///
331 /// **Contract:** the deadline used MUST be `Identity.expires_at`, NOT
332 /// `Identity.created_at + N`. The verify-idp onboarding gate, the resend
333 /// endpoint, and the frontend countdown all derive their semantics from
334 /// `expires_at` being the authoritative deletion deadline. Resends do not
335 /// bump it, so a Pending identity is reaped at exactly its original
336 /// `created_at + 24h` regardless of how many activation emails were sent.
337 ///
338 /// # Returns
339 /// The number of identities that were cleaned up
340 async fn cleanup_expired_identities(&self) -> ClResult<u32>;
341
342 /// Renews an identity's expiration timestamp
343 ///
344 /// # Arguments
345 /// * `id_tag` - The identifier of the identity to renew
346 /// * `new_expires_at` - The new expiration timestamp
347 ///
348 /// # Errors
349 /// Returns an error if the identity doesn't exist
350 async fn renew_identity(
351 &self,
352 id_tag_prefix: &str,
353 id_tag_domain: &str,
354 new_expires_at: Timestamp,
355 ) -> ClResult<Identity>;
356
357 /// Creates a new API key for an identity
358 ///
359 /// Returns the created API key with the plaintext key (shown only once)
360 async fn create_api_key(&self, opts: CreateApiKeyOptions<'_>) -> ClResult<CreatedApiKey>;
361
362 /// Verifies an API key and returns the associated identity if valid
363 ///
364 /// Returns None if the key is invalid or expired
365 /// Updates the last_used_at timestamp on successful verification
366 ///
367 /// # Security Note
368 /// Implementations MUST reject identities with the prefix 'cl-o' as it is reserved
369 /// and should not be allowed to authenticate via API keys.
370 async fn verify_api_key(&self, key: &str) -> ClResult<Option<String>>;
371
372 /// Lists API keys with optional filtering
373 ///
374 /// Note: Only returns metadata, not the actual keys
375 async fn list_api_keys(&self, opts: ListApiKeyOptions) -> ClResult<Vec<ApiKey>>;
376
377 /// Deletes an API key by ID
378 async fn delete_api_key(&self, id: i32) -> ClResult<()>;
379
380 /// Deletes an API key by ID, ensuring it belongs to the specified identity
381 ///
382 /// Returns true if a key was deleted, false if no matching key was found
383 async fn delete_api_key_for_identity(
384 &self,
385 id: i32,
386 id_tag_prefix: &str,
387 id_tag_domain: &str,
388 ) -> ClResult<bool>;
389
390 /// Cleans up expired API keys
391 ///
392 /// Returns the number of keys deleted
393 async fn cleanup_expired_api_keys(&self) -> ClResult<u32>;
394
395 /// Lists identities registered by a specific registrar
396 ///
397 /// # Arguments
398 /// * `registrar_id_tag` - The registrar's id_tag
399 /// * `limit` - Optional limit on results
400 /// * `offset` - Optional pagination offset
401 ///
402 /// # Returns
403 /// A vector of identities created by this registrar
404 async fn list_identities_by_registrar(
405 &self,
406 registrar_id_tag: &str,
407 limit: Option<u32>,
408 offset: Option<u32>,
409 ) -> ClResult<Vec<Identity>>;
410
411 /// Gets the quota for a specific registrar
412 ///
413 /// # Arguments
414 /// * `registrar_id_tag` - The registrar's id_tag
415 ///
416 /// # Returns
417 /// The quota information, or an error if not found
418 async fn get_quota(&self, registrar_id_tag: &str) -> ClResult<RegistrarQuota>;
419
420 /// Sets quota limits for a registrar
421 ///
422 /// # Arguments
423 /// * `registrar_id_tag` - The registrar's id_tag
424 /// * `max_identities` - Maximum number of identities allowed
425 /// * `max_storage_bytes` - Maximum storage in bytes
426 ///
427 /// # Errors
428 /// Returns an error if the quota doesn't exist or update fails
429 async fn set_quota_limits(
430 &self,
431 registrar_id_tag: &str,
432 max_identities: i32,
433 max_storage_bytes: i64,
434 ) -> ClResult<RegistrarQuota>;
435
436 /// Checks if a registrar has quota available for a new identity
437 ///
438 /// # Arguments
439 /// * `registrar_id_tag` - The registrar's id_tag
440 /// * `storage_bytes` - Storage required for the new identity
441 ///
442 /// # Returns
443 /// `true` if quota is available, `false` otherwise
444 async fn check_quota(&self, registrar_id_tag: &str, storage_bytes: i64) -> ClResult<bool>;
445
446 /// Increments the quota usage for a registrar
447 ///
448 /// # Arguments
449 /// * `registrar_id_tag` - The registrar's id_tag
450 /// * `storage_bytes` - Storage bytes to add
451 ///
452 /// # Errors
453 /// Returns an error if the quota doesn't exist or update fails
454 async fn increment_quota(
455 &self,
456 registrar_id_tag: &str,
457 storage_bytes: i64,
458 ) -> ClResult<RegistrarQuota>;
459
460 /// Decrements the quota usage for a registrar
461 ///
462 /// # Arguments
463 /// * `registrar_id_tag` - The registrar's id_tag
464 /// * `storage_bytes` - Storage bytes to subtract
465 ///
466 /// # Errors
467 /// Returns an error if the quota doesn't exist or update fails
468 async fn decrement_quota(
469 &self,
470 registrar_id_tag: &str,
471 storage_bytes: i64,
472 ) -> ClResult<RegistrarQuota>;
473
474 /// Updates quota counts when an identity changes status
475 ///
476 /// Used when an identity is activated, suspended, or deleted to adjust quota tracking.
477 ///
478 /// # Arguments
479 /// * `registrar_id_tag` - The registrar's id_tag
480 /// * `old_status` - The identity's previous status
481 /// * `new_status` - The identity's new status
482 ///
483 /// # Errors
484 /// Returns an error if the quota doesn't exist or update fails
485 async fn update_quota_on_status_change(
486 &self,
487 registrar_id_tag: &str,
488 old_status: IdentityStatus,
489 new_status: IdentityStatus,
490 ) -> ClResult<RegistrarQuota>;
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn test_identity_structure() {
499 let now = Timestamp::now();
500 let identity = Identity {
501 id_tag_prefix: "test_user".into(),
502 id_tag_domain: "cloudillo.net".into(),
503 email: Some("test@example.com".into()),
504 registrar_id_tag: "registrar".into(),
505 owner_id_tag: None,
506 address: Some("192.168.1.1".into()),
507 address_type: Some(AddressType::Ipv4),
508 address_updated_at: Some(now),
509 dyndns: false,
510 lang: Some("hu".into()),
511 status: IdentityStatus::Active,
512 created_at: now,
513 updated_at: now,
514 expires_at: now.add_seconds(86400), // 1 day later
515 };
516
517 assert_eq!(identity.id_tag_prefix.as_ref(), "test_user");
518 assert_eq!(identity.id_tag_domain.as_ref(), "cloudillo.net");
519 assert_eq!(identity.email.as_deref(), Some("test@example.com"));
520 assert_eq!(identity.registrar_id_tag.as_ref(), "registrar");
521 assert_eq!(identity.lang.as_deref(), Some("hu"));
522 assert_eq!(identity.status, IdentityStatus::Active);
523 assert!(!identity.dyndns);
524 assert!(identity.expires_at > identity.created_at);
525 }
526
527 #[test]
528 fn test_identity_with_owner() {
529 let now = Timestamp::now();
530 let identity = Identity {
531 id_tag_prefix: "community_member".into(),
532 id_tag_domain: "cloudillo.net".into(),
533 email: None, // No email for community-owned identity
534 registrar_id_tag: "registrar".into(),
535 owner_id_tag: Some("community.cloudillo.net".into()),
536 address: None,
537 address_type: None,
538 address_updated_at: None,
539 dyndns: false,
540 lang: None,
541 status: IdentityStatus::Pending,
542 created_at: now,
543 updated_at: now,
544 expires_at: now.add_seconds(86400),
545 };
546
547 assert_eq!(identity.id_tag_prefix.as_ref(), "community_member");
548 assert!(identity.email.is_none());
549 assert_eq!(identity.owner_id_tag.as_deref(), Some("community.cloudillo.net"));
550 assert_eq!(identity.status, IdentityStatus::Pending);
551 }
552
553 #[test]
554 fn test_identity_status_display() {
555 assert_eq!(IdentityStatus::Pending.to_string(), "pending");
556 assert_eq!(IdentityStatus::Active.to_string(), "active");
557 assert_eq!(IdentityStatus::Suspended.to_string(), "suspended");
558 }
559
560 #[test]
561 fn test_identity_status_from_str() {
562 use std::str::FromStr;
563 assert_eq!(
564 IdentityStatus::from_str("pending").expect("should parse"),
565 IdentityStatus::Pending
566 );
567 assert_eq!(
568 IdentityStatus::from_str("active").expect("should parse"),
569 IdentityStatus::Active
570 );
571 assert_eq!(
572 IdentityStatus::from_str("suspended").expect("should parse"),
573 IdentityStatus::Suspended
574 );
575 assert!(IdentityStatus::from_str("invalid").is_err());
576 }
577
578 #[test]
579 fn test_create_identity_options() {
580 let opts = CreateIdentityOptions {
581 id_tag_prefix: "test_user",
582 id_tag_domain: "cloudillo.net",
583 email: Some("test@example.com"),
584 registrar_id_tag: "registrar",
585 owner_id_tag: None,
586 status: IdentityStatus::Pending,
587 address: Some("192.168.1.1"),
588 address_type: Some(AddressType::Ipv4),
589 dyndns: false,
590 lang: Some("de"),
591 expires_at: Some(Timestamp::now().add_seconds(86400)),
592 };
593
594 assert_eq!(opts.id_tag_prefix, "test_user");
595 assert_eq!(opts.id_tag_domain, "cloudillo.net");
596 assert_eq!(opts.email, Some("test@example.com"));
597 assert_eq!(opts.registrar_id_tag, "registrar");
598 assert_eq!(opts.lang, Some("de"));
599 assert_eq!(opts.status, IdentityStatus::Pending);
600 assert!(!opts.dyndns);
601 assert!(opts.expires_at.is_some());
602 }
603
604 #[test]
605 fn test_create_identity_options_with_owner() {
606 let opts = CreateIdentityOptions {
607 id_tag_prefix: "member",
608 id_tag_domain: "cloudillo.net",
609 email: None, // No email for owner-managed identity
610 registrar_id_tag: "registrar",
611 owner_id_tag: Some("owner.cloudillo.net"),
612 status: IdentityStatus::Pending,
613 address: None,
614 address_type: None,
615 dyndns: false,
616 lang: None,
617 expires_at: None,
618 };
619
620 assert_eq!(opts.id_tag_prefix, "member");
621 assert!(opts.email.is_none());
622 assert_eq!(opts.owner_id_tag, Some("owner.cloudillo.net"));
623 }
624
625 #[test]
626 fn test_registrar_quota() {
627 let now = Timestamp::now();
628 let quota = RegistrarQuota {
629 registrar_id_tag: "registrar".into(),
630 max_identities: 1000,
631 max_storage_bytes: 1_000_000_000,
632 current_identities: 50,
633 current_storage_bytes: 50_000_000,
634 updated_at: now,
635 };
636
637 assert_eq!(quota.registrar_id_tag.as_ref(), "registrar");
638 assert_eq!(quota.max_identities, 1000);
639 assert!(quota.current_identities < quota.max_identities);
640 }
641}
642
643// vim: ts=4