use async_trait::async_trait; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct StoredManagementTokenUserSummary { pub id: String, pub email: Option, pub username: String, pub role: String, } impl StoredManagementTokenUserSummary { pub fn new( id: String, email: Option, username: String, role: String, ) -> Result { if id.trim().is_empty() { return Err(crate::DataLayerError::UnexpectedValue( "users.id is empty".to_string(), )); } if username.trim().is_empty() { return Err(crate::DataLayerError::UnexpectedValue( "users.username is empty".to_string(), )); } if role.trim().is_empty() { return Err(crate::DataLayerError::UnexpectedValue( "users.role is empty".to_string(), )); } Ok(Self { id, email, username, role, }) } } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct StoredManagementToken { pub id: String, pub user_id: String, pub name: String, pub description: Option, pub token_prefix: Option, pub allowed_ips: Option, pub permissions: Option, pub expires_at_unix_secs: Option, pub last_used_at_unix_secs: Option, pub last_used_ip: Option, pub usage_count: u64, pub is_active: bool, pub created_at_unix_ms: Option, pub updated_at_unix_secs: Option, } impl StoredManagementToken { pub fn new(id: String, user_id: String, name: String) -> Result { if id.trim().is_empty() { return Err(crate::DataLayerError::UnexpectedValue( "management_tokens.id is empty".to_string(), )); } if user_id.trim().is_empty() { return Err(crate::DataLayerError::UnexpectedValue( "management_tokens.user_id is empty".to_string(), )); } if name.trim().is_empty() { return Err(crate::DataLayerError::UnexpectedValue( "management_tokens.name is empty".to_string(), )); } Ok(Self { id, user_id, name, description: None, token_prefix: None, allowed_ips: None, permissions: None, expires_at_unix_secs: None, last_used_at_unix_secs: None, last_used_ip: None, usage_count: 0, is_active: true, created_at_unix_ms: None, updated_at_unix_secs: None, }) } pub fn with_display_fields( mut self, description: Option, token_prefix: Option, allowed_ips: Option, ) -> Self { self.description = description; self.token_prefix = token_prefix; self.allowed_ips = allowed_ips; self } pub fn with_permissions(mut self, permissions: Option) -> Self { self.permissions = permissions; self } pub fn with_runtime_fields( mut self, expires_at_unix_secs: Option, last_used_at_unix_secs: Option, last_used_ip: Option, usage_count: u64, is_active: bool, ) -> Self { self.expires_at_unix_secs = expires_at_unix_secs; self.last_used_at_unix_secs = last_used_at_unix_secs; self.last_used_ip = last_used_ip; self.usage_count = usage_count; self.is_active = is_active; self } pub fn with_timestamps( mut self, created_at_unix_ms: Option, updated_at_unix_secs: Option, ) -> Self { self.created_at_unix_ms = created_at_unix_ms; self.updated_at_unix_secs = updated_at_unix_secs; self } pub fn token_display(&self) -> String { self.token_prefix .as_deref() .map(|prefix| format!("{prefix}...****")) .unwrap_or_else(|| "ae-****".to_string()) } } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct StoredManagementTokenWithUser { pub token: StoredManagementToken, pub user: StoredManagementTokenUserSummary, } impl StoredManagementTokenWithUser { pub fn new(token: StoredManagementToken, user: StoredManagementTokenUserSummary) -> Self { Self { token, user } } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ManagementTokenListQuery { pub user_id: Option, pub is_active: Option, pub offset: usize, pub limit: usize, } #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct CreateManagementTokenRecord { pub id: String, pub user_id: String, pub user: StoredManagementTokenUserSummary, pub token_hash: String, pub token_prefix: Option, pub name: String, pub description: Option, pub allowed_ips: Option, pub permissions: Option, pub expires_at_unix_secs: Option, pub is_active: bool, } impl std::fmt::Debug for CreateManagementTokenRecord { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("CreateManagementTokenRecord") .field("id", &self.id) .field("user_id", &self.user_id) .field("token_hash", &"[REDACTED]") .field( "token_prefix", &self.token_prefix.as_ref().map(|_| "[REDACTED]"), ) .field("name", &self.name) .field("expires_at_unix_secs", &self.expires_at_unix_secs) .field("is_active", &self.is_active) .finish_non_exhaustive() } } fn validate_management_token_unix_secs_storage_range( value: u64, field_name: &str, ) -> Result<(), crate::DataLayerError> { if i64::try_from(value).is_err() { return Err(crate::DataLayerError::InvalidInput(format!( "{field_name} exceeds supported storage range" ))); } Ok(()) } fn validate_optional_management_token_unix_secs_storage_range( value: Option, field_name: &str, ) -> Result<(), crate::DataLayerError> { match value { Some(value) => validate_management_token_unix_secs_storage_range(value, field_name), None => Ok(()), } } impl CreateManagementTokenRecord { pub fn validate(&self) -> Result<(), crate::DataLayerError> { if self.id.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_id is required".to_string(), )); } if self.user_id.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "user_id is required".to_string(), )); } if self.user.id != self.user_id { return Err(crate::DataLayerError::InvalidInput( "management token user summary does not match user_id".to_string(), )); } if self.token_hash.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_hash is required".to_string(), )); } if self.name.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "name is required".to_string(), )); } if let Some(allowed_ips) = &self.allowed_ips { let Some(items) = allowed_ips.as_array() else { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则必须是数组".to_string(), )); }; if items.is_empty() { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则不能为空".to_string(), )); } if items.iter().any(|value| value.as_str().is_none()) { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则只能包含字符串".to_string(), )); } } validate_management_token_permissions(self.permissions.as_ref())?; validate_optional_management_token_unix_secs_storage_range( self.expires_at_unix_secs, "expires_at_unix_secs", )?; Ok(()) } } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct UpdateManagementTokenRecord { pub token_id: String, pub name: Option, pub description: Option, pub clear_description: bool, pub allowed_ips: Option, pub clear_allowed_ips: bool, pub permissions: Option, pub expires_at_unix_secs: Option, pub clear_expires_at: bool, pub is_active: Option, } impl UpdateManagementTokenRecord { pub fn validate(&self) -> Result<(), crate::DataLayerError> { if self.token_id.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_id is required".to_string(), )); } if self.clear_description && self.description.is_some() { return Err(crate::DataLayerError::InvalidInput( "description and clear_description are mutually exclusive".to_string(), )); } if self.clear_allowed_ips && self.allowed_ips.is_some() { return Err(crate::DataLayerError::InvalidInput( "allowed_ips and clear_allowed_ips are mutually exclusive".to_string(), )); } if self.clear_expires_at && self.expires_at_unix_secs.is_some() { return Err(crate::DataLayerError::InvalidInput( "expires_at_unix_secs and clear_expires_at are mutually exclusive".to_string(), )); } if let Some(name) = &self.name { if name.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "name must not be empty".to_string(), )); } } if let Some(allowed_ips) = &self.allowed_ips { let Some(items) = allowed_ips.as_array() else { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则必须是数组".to_string(), )); }; if items.is_empty() { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则不能为空".to_string(), )); } if items.iter().any(|value| value.as_str().is_none()) { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则只能包含字符串".to_string(), )); } } validate_management_token_permissions(self.permissions.as_ref())?; validate_optional_management_token_unix_secs_storage_range( self.expires_at_unix_secs, "expires_at_unix_secs", )?; Ok(()) } } fn validate_management_token_permissions( permissions: Option<&serde_json::Value>, ) -> Result<(), crate::DataLayerError> { let Some(permissions) = permissions else { return Ok(()); }; let Some(items) = permissions.as_array() else { return Err(crate::DataLayerError::InvalidInput( "permissions must be an array".to_string(), )); }; if items.is_empty() { return Err(crate::DataLayerError::InvalidInput( "permissions must not be empty".to_string(), )); } if items.iter().any(|value| value.as_str().is_none()) { return Err(crate::DataLayerError::InvalidInput( "permissions must contain only strings".to_string(), )); } Ok(()) } #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct RegenerateManagementTokenSecret { pub token_id: String, pub token_hash: String, pub token_prefix: Option, } impl std::fmt::Debug for RegenerateManagementTokenSecret { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("RegenerateManagementTokenSecret") .field("token_id", &self.token_id) .field("token_hash", &"[REDACTED]") .field( "token_prefix", &self.token_prefix.as_ref().map(|_| "[REDACTED]"), ) .finish() } } #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ActivateManagementTokenIfMatches { pub expected_token: StoredManagementToken, pub token_hash: String, pub expected_user_security_version: i64, pub now_unix_secs: u64, } impl std::fmt::Debug for ActivateManagementTokenIfMatches { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("ActivateManagementTokenIfMatches") .field("expected_token", &self.expected_token) .field("token_hash", &"[REDACTED]") .field( "expected_user_security_version", &self.expected_user_security_version, ) .field("now_unix_secs", &self.now_unix_secs) .finish() } } impl ActivateManagementTokenIfMatches { pub fn validate(&self) -> Result<(), crate::DataLayerError> { if self.expected_token.id.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_id is required".to_string(), )); } if self.expected_token.user_id.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "user_id is required".to_string(), )); } if self.expected_token.name.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token name is required".to_string(), )); } if self.expected_token.is_active { return Err(crate::DataLayerError::InvalidInput( "activation snapshot must be inactive".to_string(), )); } if self.token_hash.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_hash is required".to_string(), )); } if self.expected_user_security_version < 0 { return Err(crate::DataLayerError::InvalidInput( "expected_user_security_version must not be negative".to_string(), )); } validate_management_token_unix_secs_storage_range(self.now_unix_secs, "now_unix_secs")?; validate_optional_management_token_unix_secs_storage_range( self.expected_token.expires_at_unix_secs, "expires_at_unix_secs", )?; validate_optional_management_token_unix_secs_storage_range( self.expected_token.last_used_at_unix_secs, "last_used_at_unix_secs", )?; validate_optional_management_token_unix_secs_storage_range( self.expected_token.created_at_unix_ms, "created_at_unix_ms", )?; validate_optional_management_token_unix_secs_storage_range( self.expected_token.updated_at_unix_secs, "updated_at_unix_secs", )?; validate_management_token_unix_secs_storage_range( self.expected_token.usage_count, "usage_count", )?; if let Some(allowed_ips) = &self.expected_token.allowed_ips { let Some(items) = allowed_ips.as_array() else { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则必须是数组".to_string(), )); }; if items.is_empty() || items.iter().any(|value| value.as_str().is_none()) { return Err(crate::DataLayerError::InvalidInput( "IP 限制规则只能是非空字符串数组".to_string(), )); } } validate_management_token_permissions(self.expected_token.permissions.as_ref()) } pub fn matches_locked_token_snapshot( &self, current: &StoredManagementToken, current_token_hash: &str, ) -> bool { current_token_hash == self.token_hash && current == &self.expected_token } } impl RegenerateManagementTokenSecret { pub fn validate(&self) -> Result<(), crate::DataLayerError> { if self.token_id.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_id is required".to_string(), )); } if self.token_hash.trim().is_empty() { return Err(crate::DataLayerError::InvalidInput( "token_hash is required".to_string(), )); } Ok(()) } } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct StoredManagementTokenListPage { pub items: Vec, pub total: usize, } #[async_trait] pub trait ManagementTokenReadRepository: Send + Sync { async fn list_management_tokens( &self, query: &ManagementTokenListQuery, ) -> Result; async fn get_management_token_with_user( &self, token_id: &str, ) -> Result, crate::DataLayerError>; async fn get_management_token_with_user_by_hash( &self, token_hash: &str, ) -> Result, crate::DataLayerError>; } #[async_trait] pub trait ManagementTokenWriteRepository: Send + Sync { async fn create_management_token( &self, record: &CreateManagementTokenRecord, ) -> Result; async fn update_management_token( &self, record: &UpdateManagementTokenRecord, ) -> Result, crate::DataLayerError>; /// Update a token only while it still belongs to `user_id`. /// /// Self-service callers must use this owner-scoped mutation instead of /// relying on a preceding read-side ownership check. async fn update_management_token_for_user( &self, record: &UpdateManagementTokenRecord, user_id: &str, ) -> Result, crate::DataLayerError>; async fn delete_management_token(&self, token_id: &str) -> Result; async fn delete_management_token_for_user( &self, token_id: &str, user_id: &str, ) -> Result; async fn set_management_token_active( &self, token_id: &str, is_active: bool, ) -> Result, crate::DataLayerError>; async fn set_management_token_active_for_user( &self, token_id: &str, user_id: &str, is_active: bool, ) -> Result, crate::DataLayerError>; /// Atomically activate an inactive one-time-install token only while all /// security-relevant fields still match the session that issued it. async fn activate_management_token_if_matches( &self, mutation: &ActivateManagementTokenIfMatches, ) -> Result; /// Delete an unclaimed one-time-install token only while it still matches /// the session that created it and remains inactive. async fn delete_inactive_management_token_if_matches( &self, mutation: &ActivateManagementTokenIfMatches, ) -> Result; async fn regenerate_management_token_secret( &self, mutation: &RegenerateManagementTokenSecret, ) -> Result, crate::DataLayerError>; async fn regenerate_management_token_secret_for_user( &self, mutation: &RegenerateManagementTokenSecret, user_id: &str, ) -> Result, crate::DataLayerError>; async fn record_management_token_usage( &self, token_id: &str, last_used_ip: Option<&str>, ) -> Result, crate::DataLayerError>; } #[cfg(test)] mod tests { use super::{ ActivateManagementTokenIfMatches, CreateManagementTokenRecord, RegenerateManagementTokenSecret, StoredManagementTokenUserSummary, UpdateManagementTokenRecord, }; fn activation() -> ActivateManagementTokenIfMatches { ActivateManagementTokenIfMatches { expected_token: super::StoredManagementToken::new( "token-1".to_string(), "user-1".to_string(), "install token".to_string(), ) .expect("token should build") .with_display_fields(None, Some("ae_install".to_string()), None) .with_permissions(Some(serde_json::json!(["admin:proxy_nodes:write"]))) .with_runtime_fields(Some(2_000_000_000), None, None, 0, false) .with_timestamps(Some(1_800_000_000), Some(1_800_000_000)), token_hash: "hash-1".to_string(), expected_user_security_version: 7, now_unix_secs: 1_900_000_000, } } #[test] fn activation_rejects_timestamps_outside_sql_storage_range() { let mut mutation = activation(); mutation.expected_token.expires_at_unix_secs = Some(i64::MAX as u64 + 1); assert!(mutation.validate().is_err()); let mut mutation = activation(); mutation.now_unix_secs = i64::MAX as u64 + 1; assert!(mutation.validate().is_err()); } #[test] fn activation_requires_non_negative_user_security_version() { let mut mutation = activation(); mutation.expected_user_security_version = -1; assert!(mutation.validate().is_err()); } #[test] fn activation_matches_the_complete_canonical_token_snapshot() { let mutation = activation(); assert!( mutation.matches_locked_token_snapshot(&mutation.expected_token, &mutation.token_hash,) ); let mut changed = mutation.expected_token.clone(); changed.description = Some("changed after session creation".to_string()); assert!(!mutation.matches_locked_token_snapshot(&changed, &mutation.token_hash)); let mut changed = mutation.expected_token.clone(); changed.updated_at_unix_secs = changed.updated_at_unix_secs.map(|value| value + 1); assert!(!mutation.matches_locked_token_snapshot(&changed, &mutation.token_hash)); assert!( !mutation.matches_locked_token_snapshot(&mutation.expected_token, "different-hash",) ); } #[test] fn management_token_secret_debug_output_is_redacted() { let activation = ActivateManagementTokenIfMatches { token_hash: "activation-token-hash-canary".to_string(), ..activation() }; let activation_debug = format!("{activation:?}"); assert!(activation_debug.contains("[REDACTED]")); assert!(!activation_debug.contains("activation-token-hash-canary")); let regenerate = RegenerateManagementTokenSecret { token_id: "token-1".to_string(), token_hash: "regenerate-token-hash-canary".to_string(), token_prefix: Some("regenerate-token-prefix-canary".to_string()), }; let regenerate_debug = format!("{regenerate:?}"); assert!(regenerate_debug.contains("[REDACTED]")); assert!(!regenerate_debug.contains("regenerate-token-hash-canary")); assert!(!regenerate_debug.contains("regenerate-token-prefix-canary")); let user = StoredManagementTokenUserSummary::new( "user-1".to_string(), None, "admin".to_string(), "admin".to_string(), ) .expect("user summary should build"); let create = CreateManagementTokenRecord { id: "token-1".to_string(), user_id: user.id.clone(), user, token_hash: "create-token-hash-canary".to_string(), token_prefix: Some("create-token-prefix-canary".to_string()), name: "token".to_string(), description: None, allowed_ips: None, permissions: None, expires_at_unix_secs: None, is_active: true, }; let create_debug = format!("{create:?}"); assert!(create_debug.contains("[REDACTED]")); assert!(!create_debug.contains("create-token-hash-canary")); assert!(!create_debug.contains("create-token-prefix-canary")); } #[test] fn activation_rejects_json_null_without_conflating_it_with_sql_null() { let mutation = activation(); let mut json_null_allowed_ips = mutation.expected_token.clone(); json_null_allowed_ips.allowed_ips = Some(serde_json::Value::Null); assert!( !mutation.matches_locked_token_snapshot(&json_null_allowed_ips, &mutation.token_hash) ); let mut invalid = mutation.clone(); invalid.expected_token = json_null_allowed_ips; assert!(invalid.validate().is_err()); let mut json_null_permissions = mutation.expected_token.clone(); json_null_permissions.permissions = Some(serde_json::Value::Null); assert!( !mutation.matches_locked_token_snapshot(&json_null_permissions, &mutation.token_hash) ); let mut invalid = mutation; invalid.expected_token = json_null_permissions; assert!(invalid.validate().is_err()); } #[test] fn create_and_update_reject_expiry_outside_sql_storage_range() { let unsupported_expiry = i64::MAX as u64 + 1; let user = StoredManagementTokenUserSummary::new( "user-1".to_string(), None, "admin".to_string(), "admin".to_string(), ) .expect("user summary should build"); let create = CreateManagementTokenRecord { id: "token-1".to_string(), user_id: user.id.clone(), user, token_hash: "hash-1".to_string(), token_prefix: Some("ae_1234".to_string()), name: "token".to_string(), description: None, allowed_ips: None, permissions: Some(serde_json::json!(["admin:proxy_nodes:write"])), expires_at_unix_secs: Some(unsupported_expiry), is_active: false, }; assert!(create.validate().is_err()); let update = UpdateManagementTokenRecord { token_id: "token-1".to_string(), name: None, description: None, clear_description: false, allowed_ips: None, clear_allowed_ips: false, permissions: None, expires_at_unix_secs: Some(unsupported_expiry), clear_expires_at: false, is_active: None, }; assert!(update.validate().is_err()); } #[test] fn management_token_update_rejects_ambiguous_set_and_clear_operations() { let base = UpdateManagementTokenRecord { token_id: "token-1".to_string(), name: None, description: None, clear_description: false, allowed_ips: None, clear_allowed_ips: false, permissions: None, expires_at_unix_secs: None, clear_expires_at: false, is_active: None, }; assert!(UpdateManagementTokenRecord { description: Some("description".to_string()), clear_description: true, ..base.clone() } .validate() .is_err()); assert!(UpdateManagementTokenRecord { allowed_ips: Some(serde_json::json!(["127.0.0.1"])), clear_allowed_ips: true, ..base.clone() } .validate() .is_err()); assert!(UpdateManagementTokenRecord { expires_at_unix_secs: Some(100), clear_expires_at: true, ..base } .validate() .is_err()); } }