Files
Aether/crates/aether-data/contracts/src/repository/users.rs
T

1972 lines
65 KiB
Rust

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value;
fn redacted_optional_secret<T>(value: &Option<T>) -> Option<&'static str> {
value.as_ref().map(|_| "[REDACTED]")
}
pub const LAST_ACTIVE_ADMIN_UPDATE_DENIED: &str = "last_active_admin_update_denied";
pub const LAST_ACTIVE_ADMIN_DELETE_DENIED: &str = "last_active_admin_delete_denied";
pub fn is_last_active_admin_update_denied(error: &crate::DataLayerError) -> bool {
matches!(error, crate::DataLayerError::InvalidInput(message) if message == LAST_ACTIVE_ADMIN_UPDATE_DENIED)
}
pub fn is_last_active_admin_delete_denied(error: &crate::DataLayerError) -> bool {
matches!(error, crate::DataLayerError::InvalidInput(message) if message == LAST_ACTIVE_ADMIN_DELETE_DENIED)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserSummary {
pub id: String,
pub username: String,
pub email: Option<String>,
pub role: String,
pub is_active: bool,
pub is_deleted: bool,
}
impl StoredUserSummary {
pub fn new(
id: String,
username: String,
email: Option<String>,
role: String,
is_active: bool,
is_deleted: bool,
) -> Result<Self, crate::DataLayerError> {
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,
username,
email,
role,
is_active,
is_deleted,
})
}
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserAuthRecord {
pub id: String,
pub email: Option<String>,
pub email_verified: bool,
pub username: String,
pub password_hash: Option<String>,
pub role: String,
pub auth_source: String,
pub allowed_providers: Option<Vec<String>>,
pub allowed_providers_mode: String,
pub allowed_api_formats: Option<Vec<String>>,
pub allowed_api_formats_mode: String,
pub allowed_models: Option<Vec<String>>,
pub allowed_models_mode: String,
pub is_active: bool,
pub is_deleted: bool,
#[serde(default)]
pub security_version: i64,
pub created_at: Option<DateTime<Utc>>,
pub last_login_at: Option<DateTime<Utc>>,
}
impl std::fmt::Debug for StoredUserAuthRecord {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StoredUserAuthRecord")
.field("id", &self.id)
.field("email", &self.email)
.field("email_verified", &self.email_verified)
.field("username", &self.username)
.field(
"password_hash",
&redacted_optional_secret(&self.password_hash),
)
.field("role", &self.role)
.field("auth_source", &self.auth_source)
.field("allowed_providers", &self.allowed_providers)
.field("allowed_providers_mode", &self.allowed_providers_mode)
.field("allowed_api_formats", &self.allowed_api_formats)
.field("allowed_api_formats_mode", &self.allowed_api_formats_mode)
.field("allowed_models", &self.allowed_models)
.field("allowed_models_mode", &self.allowed_models_mode)
.field("is_active", &self.is_active)
.field("is_deleted", &self.is_deleted)
.field("security_version", &self.security_version)
.field("created_at", &self.created_at)
.field("last_login_at", &self.last_login_at)
.finish()
}
}
impl StoredUserAuthRecord {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: Option<String>,
role: String,
auth_source: String,
allowed_providers: Option<Value>,
allowed_api_formats: Option<Value>,
allowed_models: Option<Value>,
is_active: bool,
is_deleted: bool,
created_at: Option<DateTime<Utc>>,
last_login_at: Option<DateTime<Utc>>,
) -> Result<Self, crate::DataLayerError> {
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(),
));
}
if auth_source.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"users.auth_source is empty".to_string(),
));
}
Ok(Self {
id,
email,
email_verified,
username,
password_hash,
role,
auth_source,
allowed_providers: parse_string_list(allowed_providers, "users.allowed_providers")?,
allowed_providers_mode: "unrestricted".to_string(),
allowed_api_formats: parse_string_list(
allowed_api_formats,
"users.allowed_api_formats",
)?,
allowed_api_formats_mode: "unrestricted".to_string(),
allowed_models: parse_string_list(allowed_models, "users.allowed_models")?,
allowed_models_mode: "unrestricted".to_string(),
is_active,
is_deleted,
security_version: 0,
created_at,
last_login_at,
})
.map(|record| record.with_legacy_policy_modes())
}
pub fn with_policy_modes(
mut self,
allowed_providers_mode: String,
allowed_api_formats_mode: String,
allowed_models_mode: String,
) -> Result<Self, crate::DataLayerError> {
self.allowed_providers_mode =
normalize_list_policy_mode(&allowed_providers_mode, "users.allowed_providers_mode")?;
self.allowed_api_formats_mode = normalize_list_policy_mode(
&allowed_api_formats_mode,
"users.allowed_api_formats_mode",
)?;
self.allowed_models_mode =
normalize_list_policy_mode(&allowed_models_mode, "users.allowed_models_mode")?;
Ok(self)
}
pub fn with_security_version(
mut self,
security_version: i64,
) -> Result<Self, crate::DataLayerError> {
if security_version < 0 {
return Err(crate::DataLayerError::UnexpectedValue(
"users.security_version is negative".to_string(),
));
}
self.security_version = security_version;
Ok(self)
}
/// Compare the user fields that an aggregate import is allowed to restore.
/// Passwords, security versions, and timestamps are intentionally excluded:
/// password restoration has its own nullable CAS operation and the remaining
/// fields are server-managed concurrency markers.
pub fn matches_restore_state(&self, expected: &Self) -> bool {
self.id == expected.id
&& self.email == expected.email
&& self.email_verified == expected.email_verified
&& self.username == expected.username
&& self.role == expected.role
&& self.auth_source == expected.auth_source
&& self.allowed_providers == expected.allowed_providers
&& self.allowed_providers_mode == expected.allowed_providers_mode
&& self.allowed_api_formats == expected.allowed_api_formats
&& self.allowed_api_formats_mode == expected.allowed_api_formats_mode
&& self.allowed_models == expected.allowed_models
&& self.allowed_models_mode == expected.allowed_models_mode
&& self.is_active == expected.is_active
&& self.is_deleted == expected.is_deleted
}
fn with_legacy_policy_modes(mut self) -> Self {
self.allowed_providers_mode = legacy_list_policy_mode(&self.allowed_providers);
self.allowed_api_formats_mode = legacy_list_policy_mode(&self.allowed_api_formats);
self.allowed_models_mode = legacy_list_policy_mode(&self.allowed_models);
self
}
pub fn to_summary(&self) -> Result<StoredUserSummary, crate::DataLayerError> {
StoredUserSummary::new(
self.id.clone(),
self.username.clone(),
self.email.clone(),
self.role.clone(),
self.is_active,
self.is_deleted,
)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LdapAuthUserProvisioningOutcome {
pub user: StoredUserAuthRecord,
pub created: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteUserOAuthLinkOutcome {
Deleted,
NotFound,
LastOAuthBinding,
LastLoginMethod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindUserOAuthLinkOutcome {
Bound,
IdentityAlreadyBoundToUser,
IdentityBoundToAnotherUser,
UserAlreadyLinkedProvider,
UserNotFound,
SessionUnavailable,
ProviderNotFound,
ProviderDisabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindUserOAuthLinkSessionExpectation {
pub session_id: String,
pub client_device_id: String,
pub security_version: i64,
pub checked_at: DateTime<Utc>,
}
impl BindUserOAuthLinkSessionExpectation {
pub fn new(
session_id: impl Into<String>,
client_device_id: impl Into<String>,
security_version: i64,
checked_at: DateTime<Utc>,
) -> Result<Self, crate::DataLayerError> {
let session_id = session_id.into();
let client_device_id = client_device_id.into();
if session_id.trim().is_empty()
|| session_id != session_id.trim()
|| client_device_id.trim().is_empty()
|| client_device_id != client_device_id.trim()
|| security_version < 0
{
return Err(crate::DataLayerError::InvalidInput(
"OAuth link session expectation is invalid".to_string(),
));
}
Ok(Self {
session_id,
client_device_id,
security_version,
checked_at,
})
}
}
// Returning the full linked-user record avoids a second repository lookup and
// is the established public contract. Keep the success value inline rather
// than changing every adapter/caller to an allocated box.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum ResolveOAuthLinkedUserOutcome {
Linked(StoredUserAuthRecord),
NotLinked,
ProviderUnavailable,
}
pub fn last_oauth_unbind_denial(
auth_source: &str,
password_hash: Option<&str>,
local_password_login_allowed: bool,
) -> Option<DeleteUserOAuthLinkOutcome> {
if auth_source.eq_ignore_ascii_case("oauth") {
return Some(DeleteUserOAuthLinkOutcome::LastOAuthBinding);
}
if !auth_source.eq_ignore_ascii_case("local")
|| !local_password_login_allowed
|| !password_hash.is_some_and(is_valid_bcrypt_hash)
{
return Some(DeleteUserOAuthLinkOutcome::LastLoginMethod);
}
None
}
pub fn is_valid_bcrypt_hash(value: &str) -> bool {
use base64::Engine as _;
use std::str::FromStr;
let bytes = value.as_bytes();
if value.len() != 60
|| !matches!(value.get(0..4), Some("$2a$") | Some("$2b$") | Some("$2y$"))
|| !bytes.get(4).is_some_and(u8::is_ascii_digit)
|| !bytes.get(5).is_some_and(u8::is_ascii_digit)
|| bytes.get(6) != Some(&b'$')
{
return false;
}
let Ok(parts) = bcrypt::HashParts::from_str(value) else {
return false;
};
if !(4..=31).contains(&parts.get_cost()) {
return false;
}
let Some(payload) = value.get(7..) else {
return false;
};
if !payload.bytes().all(is_bcrypt_base64_byte) {
return false;
}
bcrypt::BASE_64
.decode(&payload[..22])
.is_ok_and(|salt| salt.len() == 16)
&& bcrypt::BASE_64
.decode(&payload[22..])
.is_ok_and(|hash| hash.len() == 23)
}
fn is_bcrypt_base64_byte(value: u8) -> bool {
value.is_ascii_alphanumeric() || matches!(value, b'.' | b'/')
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserOAuthLinkSummary {
pub provider_type: String,
pub display_name: String,
pub provider_username: Option<String>,
pub provider_email: Option<String>,
pub linked_at: Option<DateTime<Utc>>,
pub last_login_at: Option<DateTime<Utc>>,
pub provider_enabled: bool,
}
impl StoredUserOAuthLinkSummary {
#[allow(clippy::too_many_arguments)]
pub fn new(
provider_type: String,
display_name: String,
provider_username: Option<String>,
provider_email: Option<String>,
linked_at: Option<DateTime<Utc>>,
last_login_at: Option<DateTime<Utc>>,
provider_enabled: bool,
) -> Result<Self, crate::DataLayerError> {
if provider_type.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_oauth_links.provider_type is empty".to_string(),
));
}
if display_name.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"oauth_providers.display_name is empty".to_string(),
));
}
Ok(Self {
provider_type,
display_name,
provider_username,
provider_email,
linked_at,
last_login_at,
provider_enabled,
})
}
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserExportRow {
pub id: String,
pub email: Option<String>,
pub email_verified: bool,
pub username: String,
pub password_hash: Option<String>,
pub role: String,
pub auth_source: String,
pub allowed_providers: Option<Vec<String>>,
pub allowed_providers_mode: String,
pub allowed_api_formats: Option<Vec<String>>,
pub allowed_api_formats_mode: String,
pub allowed_models: Option<Vec<String>>,
pub allowed_models_mode: String,
pub rate_limit: Option<i32>,
pub rate_limit_mode: String,
pub model_capability_settings: Option<Value>,
pub feature_settings: Option<Value>,
pub is_active: bool,
}
impl std::fmt::Debug for StoredUserExportRow {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StoredUserExportRow")
.field("id", &self.id)
.field("email", &self.email)
.field("email_verified", &self.email_verified)
.field("username", &self.username)
.field(
"password_hash",
&redacted_optional_secret(&self.password_hash),
)
.field("role", &self.role)
.field("auth_source", &self.auth_source)
.field("allowed_providers", &self.allowed_providers)
.field("allowed_providers_mode", &self.allowed_providers_mode)
.field("allowed_api_formats", &self.allowed_api_formats)
.field("allowed_api_formats_mode", &self.allowed_api_formats_mode)
.field("allowed_models", &self.allowed_models)
.field("allowed_models_mode", &self.allowed_models_mode)
.field("rate_limit", &self.rate_limit)
.field("rate_limit_mode", &self.rate_limit_mode)
.field("model_capability_settings", &self.model_capability_settings)
.field("feature_settings", &self.feature_settings)
.field("is_active", &self.is_active)
.finish()
}
}
impl StoredUserExportRow {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: Option<String>,
role: String,
auth_source: String,
allowed_providers: Option<Value>,
allowed_api_formats: Option<Value>,
allowed_models: Option<Value>,
rate_limit: Option<i32>,
model_capability_settings: Option<Value>,
is_active: bool,
) -> Result<Self, crate::DataLayerError> {
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(),
));
}
if auth_source.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"users.auth_source is empty".to_string(),
));
}
Ok(Self {
id,
email,
email_verified,
username,
password_hash,
role,
auth_source,
allowed_providers: parse_string_list(allowed_providers, "users.allowed_providers")?,
allowed_providers_mode: "unrestricted".to_string(),
allowed_api_formats: parse_string_list(
allowed_api_formats,
"users.allowed_api_formats",
)?,
allowed_api_formats_mode: "unrestricted".to_string(),
allowed_models: parse_string_list(allowed_models, "users.allowed_models")?,
allowed_models_mode: "unrestricted".to_string(),
rate_limit,
rate_limit_mode: "system".to_string(),
model_capability_settings: normalize_optional_json(model_capability_settings),
feature_settings: None,
is_active,
})
.map(|record| record.with_legacy_policy_modes())
}
pub fn with_policy_modes(
mut self,
allowed_providers_mode: String,
allowed_api_formats_mode: String,
allowed_models_mode: String,
rate_limit_mode: String,
) -> Result<Self, crate::DataLayerError> {
self.allowed_providers_mode =
normalize_list_policy_mode(&allowed_providers_mode, "users.allowed_providers_mode")?;
self.allowed_api_formats_mode = normalize_list_policy_mode(
&allowed_api_formats_mode,
"users.allowed_api_formats_mode",
)?;
self.allowed_models_mode =
normalize_list_policy_mode(&allowed_models_mode, "users.allowed_models_mode")?;
self.rate_limit_mode =
normalize_rate_limit_policy_mode(&rate_limit_mode, "users.rate_limit_mode")?;
Ok(self)
}
/// Compare the identity and policy fields that an aggregate import may
/// restore. Passwords, rate/settings payloads, and server timestamps are
/// checked separately by the rollback operation.
pub fn matches_restore_state(&self, expected: &Self) -> bool {
self.id == expected.id
&& self.email == expected.email
&& self.email_verified == expected.email_verified
&& self.username == expected.username
&& self.role == expected.role
&& self.auth_source == expected.auth_source
&& self.allowed_providers == expected.allowed_providers
&& self.allowed_providers_mode == expected.allowed_providers_mode
&& self.allowed_api_formats == expected.allowed_api_formats
&& self.allowed_api_formats_mode == expected.allowed_api_formats_mode
&& self.allowed_models == expected.allowed_models
&& self.allowed_models_mode == expected.allowed_models_mode
&& self.is_active == expected.is_active
}
pub fn with_feature_settings(mut self, feature_settings: Option<Value>) -> Self {
self.feature_settings = normalize_optional_json(feature_settings);
self
}
fn with_legacy_policy_modes(mut self) -> Self {
self.allowed_providers_mode = legacy_list_policy_mode(&self.allowed_providers);
self.allowed_api_formats_mode = legacy_list_policy_mode(&self.allowed_api_formats);
self.allowed_models_mode = legacy_list_policy_mode(&self.allowed_models);
self.rate_limit_mode = if self.rate_limit.is_some() {
"custom".to_string()
} else {
"system".to_string()
};
self
}
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserSessionRecord {
pub id: String,
pub user_id: String,
pub client_device_id: String,
pub device_label: Option<String>,
pub refresh_token_hash: String,
pub prev_refresh_token_hash: Option<String>,
pub rotated_at: Option<DateTime<Utc>>,
pub last_seen_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub revoke_reason: Option<String>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
#[serde(skip)]
pub security_version: i64,
}
impl std::fmt::Debug for StoredUserSessionRecord {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StoredUserSessionRecord")
.field("id", &self.id)
.field("user_id", &self.user_id)
.field("client_device_id", &self.client_device_id)
.field("device_label", &self.device_label)
.field("refresh_token_hash", &"[REDACTED]")
.field(
"prev_refresh_token_hash",
&redacted_optional_secret(&self.prev_refresh_token_hash),
)
.field("rotated_at", &self.rotated_at)
.field("last_seen_at", &self.last_seen_at)
.field("expires_at", &self.expires_at)
.field("revoked_at", &self.revoked_at)
.field("revoke_reason", &self.revoke_reason)
.field("ip_address", &self.ip_address)
.field("user_agent", &self.user_agent)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("security_version", &self.security_version)
.finish()
}
}
impl StoredUserSessionRecord {
pub const REFRESH_GRACE_SECONDS: i64 = 10;
pub const TOUCH_INTERVAL_SECONDS: i64 = 300;
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
user_id: String,
client_device_id: String,
device_label: Option<String>,
refresh_token_hash: String,
prev_refresh_token_hash: Option<String>,
rotated_at: Option<DateTime<Utc>>,
last_seen_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
revoked_at: Option<DateTime<Utc>>,
revoke_reason: Option<String>,
ip_address: Option<String>,
user_agent: Option<String>,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
) -> Result<Self, crate::DataLayerError> {
if id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_sessions.id is empty".to_string(),
));
}
if user_id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_sessions.user_id is empty".to_string(),
));
}
if client_device_id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_sessions.client_device_id is empty".to_string(),
));
}
if refresh_token_hash.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_sessions.refresh_token_hash is empty".to_string(),
));
}
Ok(Self {
id,
user_id,
client_device_id,
device_label,
refresh_token_hash,
prev_refresh_token_hash,
rotated_at,
last_seen_at,
expires_at,
revoked_at,
revoke_reason,
ip_address,
user_agent,
created_at,
updated_at,
security_version: 0,
})
}
pub fn with_security_version(
mut self,
security_version: i64,
) -> Result<Self, crate::DataLayerError> {
if security_version < 0 {
return Err(crate::DataLayerError::UnexpectedValue(
"user_sessions.security_version is negative".to_string(),
));
}
self.security_version = security_version;
Ok(self)
}
pub fn hash_refresh_token(token: &str) -> String {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn verify_refresh_token(&self, token: &str, now: DateTime<Utc>) -> (bool, bool) {
let token_hash = Self::hash_refresh_token(token);
if self.refresh_token_hash == token_hash {
return (true, false);
}
let Some(prev_hash) = self.prev_refresh_token_hash.as_ref() else {
return (false, false);
};
let Some(rotated_at) = self.rotated_at else {
return (false, false);
};
let age = now.signed_duration_since(rotated_at);
if prev_hash == &token_hash
&& age >= chrono::Duration::zero()
&& age <= chrono::Duration::seconds(Self::REFRESH_GRACE_SECONDS)
{
return (true, true);
}
(false, false)
}
pub fn is_revoked(&self) -> bool {
self.revoked_at.is_some()
}
pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
self.expires_at.is_none_or(|expires_at| expires_at <= now)
}
pub fn should_touch(&self, now: DateTime<Utc>) -> bool {
self.last_seen_at
.map(|last_seen_at| {
now.signed_duration_since(last_seen_at).num_seconds()
>= Self::TOUCH_INTERVAL_SECONDS
})
.unwrap_or(true)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserPreferenceRecord {
pub user_id: String,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub default_provider_id: Option<String>,
pub default_provider_name: Option<String>,
pub theme: String,
pub language: String,
pub timezone: String,
pub email_notifications: bool,
pub usage_alerts: bool,
pub announcement_notifications: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserGroup {
pub id: String,
pub name: String,
pub normalized_name: String,
pub description: Option<String>,
pub priority: i32,
pub allowed_providers: Option<Vec<String>>,
pub allowed_providers_mode: String,
pub allowed_api_formats: Option<Vec<String>>,
pub allowed_api_formats_mode: String,
pub allowed_models: Option<Vec<String>>,
pub allowed_models_mode: String,
pub rate_limit: Option<i32>,
pub rate_limit_mode: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
impl StoredUserGroup {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
name: String,
normalized_name: String,
description: Option<String>,
priority: i32,
allowed_providers: Option<Value>,
allowed_providers_mode: String,
allowed_api_formats: Option<Value>,
allowed_api_formats_mode: String,
allowed_models: Option<Value>,
allowed_models_mode: String,
rate_limit: Option<i32>,
rate_limit_mode: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
) -> Result<Self, crate::DataLayerError> {
if id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_groups.id is empty".to_string(),
));
}
if name.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_groups.name is empty".to_string(),
));
}
if normalized_name.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_groups.normalized_name is empty".to_string(),
));
}
Ok(Self {
id,
name,
normalized_name,
description,
priority,
allowed_providers: parse_string_list(
allowed_providers,
"user_groups.allowed_providers",
)?,
allowed_providers_mode: normalize_list_policy_mode(
&allowed_providers_mode,
"user_groups.allowed_providers_mode",
)?,
allowed_api_formats: parse_string_list(
allowed_api_formats,
"user_groups.allowed_api_formats",
)?,
allowed_api_formats_mode: normalize_list_policy_mode(
&allowed_api_formats_mode,
"user_groups.allowed_api_formats_mode",
)?,
allowed_models: parse_string_list(allowed_models, "user_groups.allowed_models")?,
allowed_models_mode: normalize_list_policy_mode(
&allowed_models_mode,
"user_groups.allowed_models_mode",
)?,
rate_limit,
rate_limit_mode: normalize_rate_limit_policy_mode(
&rate_limit_mode,
"user_groups.rate_limit_mode",
)?,
created_at,
updated_at,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserGroupMember {
pub group_id: String,
pub user_id: String,
pub username: String,
pub email: Option<String>,
pub role: String,
pub is_active: bool,
pub is_deleted: bool,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserGroupMembership {
pub user_id: String,
pub group_id: String,
pub group_name: String,
pub group_priority: i32,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct UpsertUserGroupRecord {
pub name: String,
pub description: Option<String>,
pub priority: i32,
pub allowed_providers: Option<Vec<String>>,
pub allowed_providers_mode: String,
pub allowed_api_formats: Option<Vec<String>>,
pub allowed_api_formats_mode: String,
pub allowed_models: Option<Vec<String>>,
pub allowed_models_mode: String,
pub rate_limit: Option<i32>,
pub rate_limit_mode: String,
}
impl UpsertUserGroupRecord {
pub fn normalized_name(&self) -> String {
normalize_user_group_name(&self.name).to_ascii_lowercase()
}
}
impl StoredUserPreferenceRecord {
pub fn default_for_user(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
avatar_url: None,
bio: None,
default_provider_id: None,
default_provider_name: None,
theme: "light".to_string(),
language: "zh-CN".to_string(),
timezone: "Asia/Shanghai".to_string(),
email_notifications: true,
usage_alerts: true,
announcement_notifications: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UserExportListQuery {
pub skip: usize,
pub limit: usize,
pub role: Option<String>,
pub is_active: Option<bool>,
pub search: Option<String>,
pub group_id: Option<String>,
pub sort_by: UserExportSortBy,
pub sort_order: UserExportSortOrder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserExportSortBy {
#[default]
Id,
CreatedAt,
}
impl UserExportSortBy {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"created_at" => Some(Self::CreatedAt),
"id" => Some(Self::Id),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserExportSortOrder {
#[default]
Asc,
Desc,
}
impl UserExportSortOrder {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"asc" => Some(Self::Asc),
"desc" => Some(Self::Desc),
_ => None,
}
}
pub fn is_desc(self) -> bool {
matches!(self, Self::Desc)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct UserExportSummary {
pub total: u64,
pub active: u64,
}
#[async_trait]
pub trait UserReadRepository: Send + Sync {
async fn list_users_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserSummary>, crate::DataLayerError>;
async fn list_users_by_username_search(
&self,
username_search: &str,
) -> Result<Vec<StoredUserSummary>, crate::DataLayerError>;
async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
async fn list_export_users_page(
&self,
query: &UserExportListQuery,
) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
async fn count_export_users(
&self,
query: &UserExportListQuery,
) -> Result<u64, crate::DataLayerError>;
async fn summarize_export_users(&self) -> Result<UserExportSummary, crate::DataLayerError>;
async fn find_export_user_by_id(
&self,
user_id: &str,
) -> Result<Option<StoredUserExportRow>, crate::DataLayerError>;
async fn list_user_groups(&self) -> Result<Vec<StoredUserGroup>, crate::DataLayerError>;
async fn find_user_group_by_id(
&self,
group_id: &str,
) -> Result<Option<StoredUserGroup>, crate::DataLayerError>;
async fn list_user_groups_by_ids(
&self,
group_ids: &[String],
) -> Result<Vec<StoredUserGroup>, crate::DataLayerError>;
async fn create_user_group(
&self,
record: UpsertUserGroupRecord,
) -> Result<Option<StoredUserGroup>, crate::DataLayerError>;
async fn update_user_group(
&self,
group_id: &str,
record: UpsertUserGroupRecord,
) -> Result<Option<StoredUserGroup>, crate::DataLayerError>;
/// Restore an existing user group only when its complete stored snapshot
/// still equals `expected`. The compare and replacement must be atomic so
/// an import rollback cannot overwrite a concurrent administrator update.
/// `false` means the row is missing, the identities differ, or the current
/// snapshot no longer matches the expected post-import state.
async fn restore_user_group_if_matches(
&self,
expected: &StoredUserGroup,
restored: &StoredUserGroup,
) -> Result<bool, crate::DataLayerError> {
let _ = (expected, restored);
Err(crate::DataLayerError::InvalidInput(
"atomic user group restore is not available".to_string(),
))
}
async fn delete_user_group(&self, group_id: &str) -> Result<bool, crate::DataLayerError>;
async fn list_user_group_members(
&self,
group_id: &str,
) -> Result<Vec<StoredUserGroupMember>, crate::DataLayerError>;
async fn replace_user_group_members(
&self,
group_id: &str,
user_ids: &[String],
) -> Result<Vec<StoredUserGroupMember>, crate::DataLayerError>;
async fn list_user_groups_for_user(
&self,
user_id: &str,
) -> Result<Vec<StoredUserGroup>, crate::DataLayerError>;
async fn list_user_group_memberships_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserGroupMembership>, crate::DataLayerError>;
async fn replace_user_groups_for_user(
&self,
user_id: &str,
group_ids: &[String],
) -> Result<Vec<StoredUserGroup>, crate::DataLayerError>;
/// Restore a user's group memberships only when the current set still
/// equals the post-import set. The compare and replacement are atomic.
async fn restore_user_groups_if_matches(
&self,
user_id: &str,
expected_group_ids: &[String],
restored_group_ids: &[String],
) -> Result<bool, crate::DataLayerError> {
let _ = (user_id, expected_group_ids, restored_group_ids);
Err(crate::DataLayerError::InvalidInput(
"atomic user group restore is not available".to_string(),
))
}
async fn add_user_to_group(
&self,
group_id: &str,
user_id: &str,
) -> Result<bool, crate::DataLayerError>;
async fn list_non_admin_export_users(
&self,
) -> Result<Vec<StoredUserExportRow>, crate::DataLayerError>;
async fn find_user_auth_by_id(
&self,
user_id: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn list_user_auth_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_user_auth_by_identifier(
&self,
identifier: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_user_auth_by_email(
&self,
email: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_active_user_auth_by_email_ci(
&self,
email: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_user_auth_by_username(
&self,
username: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn list_user_oauth_links(
&self,
user_id: &str,
) -> Result<Vec<StoredUserOAuthLinkSummary>, crate::DataLayerError>;
async fn find_oauth_linked_user(
&self,
provider_type: &str,
provider_user_id: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn resolve_enabled_oauth_linked_user(
&self,
provider_type: &str,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
extra_data: Option<Value>,
verified_email: Option<&str>,
touched_at: DateTime<Utc>,
provider_enabled_snapshot: bool,
) -> Result<ResolveOAuthLinkedUserOutcome, crate::DataLayerError>;
async fn touch_oauth_link(
&self,
provider_type: &str,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
extra_data: Option<Value>,
touched_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
async fn create_oauth_auth_user(
&self,
email: Option<String>,
email_verified: bool,
username: String,
created_at: DateTime<Utc>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_oauth_link_owner(
&self,
provider_type: &str,
provider_user_id: &str,
) -> Result<Option<String>, crate::DataLayerError>;
async fn has_user_oauth_provider_link(
&self,
user_id: &str,
provider_type: &str,
) -> Result<bool, crate::DataLayerError>;
async fn count_user_oauth_links(&self, user_id: &str) -> Result<u64, crate::DataLayerError>;
async fn has_oauth_links_for_provider(
&self,
provider_type: &str,
) -> Result<bool, crate::DataLayerError>;
async fn count_locked_users_if_oauth_provider_disabled(
&self,
_provider_type: &str,
_enabled_provider_types_snapshot: &[String],
_ldap_exclusive: bool,
) -> Result<usize, crate::DataLayerError> {
Ok(0)
}
#[allow(clippy::too_many_arguments)]
async fn bind_user_oauth_link(
&self,
user_id: &str,
provider_type: &str,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
extra_data: Option<Value>,
linked_at: DateTime<Utc>,
) -> Result<BindUserOAuthLinkOutcome, crate::DataLayerError> {
self.bind_user_oauth_link_if_provider_enabled(
user_id,
provider_type,
provider_user_id,
provider_username,
provider_email,
extra_data,
linked_at,
true,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn bind_user_oauth_link_if_provider_enabled(
&self,
user_id: &str,
provider_type: &str,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
extra_data: Option<Value>,
linked_at: DateTime<Utc>,
provider_enabled_snapshot: bool,
session_expectation: Option<&BindUserOAuthLinkSessionExpectation>,
) -> Result<BindUserOAuthLinkOutcome, crate::DataLayerError>;
/// Marks an email as verified only if the user's current normalized email still matches.
async fn upgrade_oauth_email_verification_if_matches(
&self,
user_id: &str,
verified_email: &str,
verified_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
/// Atomically deletes the requested link only when doing so leaves a valid login method.
async fn delete_user_oauth_link(
&self,
user_id: &str,
provider_type: &str,
local_password_login_allowed: bool,
enabled_provider_types_snapshot: &[String],
) -> Result<DeleteUserOAuthLinkOutcome, crate::DataLayerError>;
async fn get_or_create_ldap_auth_user(
&self,
email: String,
username: String,
ldap_dn: Option<String>,
ldap_username: Option<String>,
logged_in_at: DateTime<Utc>,
) -> Result<Option<LdapAuthUserProvisioningOutcome>, crate::DataLayerError>;
async fn touch_auth_user_last_login(
&self,
user_id: &str,
logged_in_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
async fn update_local_auth_user_profile(
&self,
user_id: &str,
email_present: bool,
email: Option<String>,
email_verified: Option<bool>,
username: Option<String>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
/// Restore all user fields represented by an aggregate import checkpoint in
/// one compare-and-write transaction. The password hash is deliberately
/// excluded and must be restored through the nullable password CAS method.
/// Implementations must not write anything when the current state differs
/// from `expected_auth` (or the exported rate/settings fields).
#[allow(clippy::too_many_arguments)]
async fn restore_local_auth_user_state_if_matches(
&self,
expected_auth: &StoredUserAuthRecord,
restored_auth: &StoredUserAuthRecord,
expected_export: &StoredUserExportRow,
restored_export: &StoredUserExportRow,
expected_model_capability_settings: Option<&Value>,
restored_model_capability_settings: Option<Value>,
expected_feature_settings: Option<&Value>,
restored_feature_settings: Option<Value>,
) -> Result<bool, crate::DataLayerError> {
let _ = (
expected_auth,
restored_auth,
expected_export,
restored_export,
expected_model_capability_settings,
restored_model_capability_settings,
expected_feature_settings,
restored_feature_settings,
);
Err(crate::DataLayerError::InvalidInput(
"atomic user state restore is not available".to_string(),
))
}
async fn update_local_auth_user_password_hash(
&self,
user_id: &str,
password_hash: String,
updated_at: DateTime<Utc>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
/// Replace a user's password hash, including an explicit `NULL`, only when the current hash
/// still equals `expected_password_hash`. The compare and write must be atomic.
async fn restore_local_auth_user_password_hash_if_matches(
&self,
user_id: &str,
expected_password_hash: Option<&str>,
password_hash: Option<String>,
updated_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError> {
let _ = (user_id, expected_password_hash, password_hash, updated_at);
Err(crate::DataLayerError::InvalidInput(
"atomic nullable password restore is not available".to_string(),
))
}
async fn reset_local_auth_user_password_and_revoke_sessions(
&self,
user_id: &str,
password_hash: String,
changed_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
async fn change_local_auth_password_and_revoke_sessions(
&self,
user_id: &str,
current_session_id: &str,
expected_password_hash: Option<&str>,
next_password_hash: String,
changed_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn update_local_auth_user_admin_fields(
&self,
user_id: &str,
role: Option<String>,
allowed_providers_present: bool,
allowed_providers: Option<Vec<String>>,
allowed_api_formats_present: bool,
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit_present: bool,
rate_limit: Option<i32>,
is_active: Option<bool>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn update_local_auth_user_policy_modes(
&self,
user_id: &str,
allowed_providers_mode: Option<String>,
allowed_api_formats_mode: Option<String>,
allowed_models_mode: Option<String>,
rate_limit_mode: Option<String>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn update_user_model_capability_settings(
&self,
user_id: &str,
settings: Option<Value>,
) -> Result<Option<Value>, crate::DataLayerError>;
async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<Value>,
) -> Result<Option<Value>, crate::DataLayerError>;
async fn create_local_auth_user(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn create_local_auth_user_with_settings(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
role: String,
allowed_providers: Option<Vec<String>>,
allowed_api_formats: Option<Vec<String>>,
allowed_models: Option<Vec<String>>,
rate_limit: Option<i32>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn delete_local_auth_user(&self, user_id: &str) -> Result<bool, crate::DataLayerError>;
/// Delete a local-auth user only when no wallet currently belongs to the user.
///
/// Authentication provisioning compensation uses this guard after removing a
/// wallet it can prove ownership of. Implementations must evaluate the
/// wallet absence check in the same database transaction as the user delete;
/// a default implementation is deliberately fail-closed for repositories
/// that cannot provide that atomicity.
async fn delete_local_auth_user_if_wallet_absent(
&self,
user_id: &str,
) -> Result<bool, crate::DataLayerError> {
let _ = user_id;
Err(crate::DataLayerError::InvalidInput(
"atomic user deletion without a wallet is not available".to_string(),
))
}
async fn read_user_preferences(
&self,
user_id: &str,
) -> Result<Option<StoredUserPreferenceRecord>, crate::DataLayerError>;
async fn write_user_preferences(
&self,
preferences: &StoredUserPreferenceRecord,
) -> Result<Option<StoredUserPreferenceRecord>, crate::DataLayerError>;
async fn find_user_session(
&self,
user_id: &str,
session_id: &str,
) -> Result<Option<StoredUserSessionRecord>, crate::DataLayerError>;
async fn list_user_sessions(
&self,
user_id: &str,
) -> Result<Vec<StoredUserSessionRecord>, crate::DataLayerError>;
async fn create_user_session(
&self,
session: &StoredUserSessionRecord,
) -> Result<Option<StoredUserSessionRecord>, crate::DataLayerError>;
async fn create_user_session_if_password_matches(
&self,
session: &StoredUserSessionRecord,
expected_password_hash: &str,
) -> Result<Option<StoredUserSessionRecord>, crate::DataLayerError>;
async fn touch_user_session(
&self,
user_id: &str,
session_id: &str,
touched_at: DateTime<Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<bool, crate::DataLayerError>;
async fn update_user_session_device_label(
&self,
user_id: &str,
session_id: &str,
device_label: &str,
updated_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn rotate_user_session_refresh_token(
&self,
user_id: &str,
session_id: &str,
expected_refresh_token_hash: &str,
next_refresh_token_hash: &str,
rotated_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<bool, crate::DataLayerError>;
async fn revoke_user_session(
&self,
user_id: &str,
session_id: &str,
revoked_at: DateTime<Utc>,
reason: &str,
) -> Result<bool, crate::DataLayerError>;
async fn revoke_all_user_sessions(
&self,
user_id: &str,
revoked_at: DateTime<Utc>,
reason: &str,
) -> Result<u64, crate::DataLayerError>;
async fn count_active_admin_users(&self) -> Result<u64, crate::DataLayerError>;
async fn count_active_local_admin_users_with_valid_password(
&self,
) -> Result<u64, crate::DataLayerError>;
}
fn normalize_optional_json(value: Option<Value>) -> Option<Value> {
match value {
Some(Value::Null) | None => None,
Some(value) => Some(value),
}
}
pub fn normalize_user_group_name(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn normalize_list_policy_mode(
value: &str,
field_name: &str,
) -> Result<String, crate::DataLayerError> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" => Ok("inherit".to_string()),
"unrestricted" => Ok("unrestricted".to_string()),
"specific" => Ok("specific".to_string()),
"deny_all" => Ok("deny_all".to_string()),
_ => Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} is not a valid list policy mode"
))),
}
}
pub fn normalize_rate_limit_policy_mode(
value: &str,
field_name: &str,
) -> Result<String, crate::DataLayerError> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" => Ok("inherit".to_string()),
"system" => Ok("system".to_string()),
"custom" => Ok("custom".to_string()),
_ => Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} is not a valid rate limit policy mode"
))),
}
}
fn legacy_list_policy_mode(values: &Option<Vec<String>>) -> String {
if values.as_ref().is_some_and(|items| !items.is_empty()) {
"specific".to_string()
} else {
"unrestricted".to_string()
}
}
fn parse_string_list(
value: Option<Value>,
field_name: &str,
) -> Result<Option<Vec<String>>, crate::DataLayerError> {
let Some(value) = value else {
return Ok(None);
};
parse_string_list_value(&value, field_name)
}
fn parse_string_list_value(
value: &Value,
field_name: &str,
) -> Result<Option<Vec<String>>, crate::DataLayerError> {
match value {
Value::Null => Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} contains JSON null; use SQL NULL for an unset policy"
))),
Value::Array(array) => parse_string_list_array(array, field_name).map(Some),
Value::String(raw) => parse_embedded_string_list(raw, field_name),
_ => Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} is not a JSON array"
))),
}
}
fn parse_embedded_string_list(
raw: &str,
field_name: &str,
) -> Result<Option<Vec<String>>, crate::DataLayerError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} contains an empty string"
)));
}
if raw.eq_ignore_ascii_case("null") {
return Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} contains stringified JSON null; use SQL NULL for an unset policy"
)));
}
if let Ok(decoded) = serde_json::from_str::<Value>(raw) {
return parse_string_list_value(&decoded, field_name);
}
Ok(Some(vec![raw.to_string()]))
}
fn parse_string_list_array(
array: &[Value],
field_name: &str,
) -> Result<Vec<String>, crate::DataLayerError> {
let mut items = Vec::with_capacity(array.len());
for item in array {
let Some(item) = item.as_str() else {
return Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} contains a non-string item"
)));
};
let item = item.trim();
if item.is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(format!(
"{field_name} contains an empty item"
)));
}
items.push(item.to_string());
}
Ok(items)
}
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use serde_json::Value;
use super::{
is_valid_bcrypt_hash, last_oauth_unbind_denial, legacy_list_policy_mode,
DeleteUserOAuthLinkOutcome, StoredUserAuthRecord, StoredUserExportRow,
StoredUserPreferenceRecord, StoredUserSessionRecord,
};
#[test]
fn classifies_last_oauth_unbind_by_remaining_login_method() {
let valid_hash =
bcrypt::hash("Secret123!", bcrypt::DEFAULT_COST).expect("bcrypt fixture should hash");
assert_eq!(
last_oauth_unbind_denial("oauth", None, false),
Some(DeleteUserOAuthLinkOutcome::LastOAuthBinding)
);
assert_eq!(
last_oauth_unbind_denial("local", None, true),
Some(DeleteUserOAuthLinkOutcome::LastLoginMethod)
);
assert_eq!(
last_oauth_unbind_denial("local", Some("not-a-password-hash"), true),
Some(DeleteUserOAuthLinkOutcome::LastLoginMethod)
);
assert_eq!(
last_oauth_unbind_denial("local", Some(&valid_hash), false),
Some(DeleteUserOAuthLinkOutcome::LastLoginMethod)
);
assert_eq!(
last_oauth_unbind_denial("local", Some(&valid_hash), true),
None
);
}
#[test]
fn validates_complete_bcrypt_encoding_and_cost() {
let valid_hash =
bcrypt::hash("Secret123!", bcrypt::DEFAULT_COST).expect("bcrypt fixture should hash");
assert!(is_valid_bcrypt_hash(&valid_hash));
for invalid in [
format!("$2b$99${}", &valid_hash[7..]),
format!("$2x$12${}", &valid_hash[7..]),
format!("$2b$12${}", "!".repeat(53)),
valid_hash[..59].to_string(),
] {
assert!(!is_valid_bcrypt_hash(&invalid), "accepted {invalid}");
}
}
#[test]
fn builds_user_export_row_with_allowed_lists() {
let row = StoredUserExportRow::new(
"user-1".to_string(),
Some("[email protected]".to_string()),
true,
"alice".to_string(),
Some("hash".to_string()),
"user".to_string(),
"local".to_string(),
Some(serde_json::json!(["openai", "anthropic"])),
Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-4.1"])),
Some(60),
Some(serde_json::json!({"gpt-4.1": {"cache_1h": true}})),
true,
)
.expect("row should build");
assert_eq!(
row.allowed_providers,
Some(vec!["openai".to_string(), "anthropic".to_string()])
);
assert_eq!(
row.allowed_api_formats,
Some(vec!["openai:chat".to_string()])
);
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
assert_eq!(
row.model_capability_settings,
Some(serde_json::json!({"gpt-4.1": {"cache_1h": true}}))
);
}
#[test]
fn accepts_embedded_string_lists_for_user_export_row() {
let row = StoredUserExportRow::new(
"user-1".to_string(),
None,
false,
"alice".to_string(),
None,
"user".to_string(),
"local".to_string(),
Some(serde_json::json!("[\"openai\"]")),
None,
Some(serde_json::json!("gpt-4.1")),
None,
Some(Value::Null),
true,
)
.expect("row should build");
assert_eq!(row.allowed_providers, Some(vec!["openai".to_string()]));
assert_eq!(row.allowed_api_formats, None);
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
assert_eq!(row.model_capability_settings, None);
}
#[test]
fn stored_user_security_lists_distinguish_sql_null_from_malformed_json_null() {
assert_eq!(
super::parse_string_list(None, "users.allowed_providers")
.expect("SQL NULL should remain an unset policy"),
None
);
assert!(
super::parse_string_list(Some(serde_json::Value::Null), "users.allowed_providers")
.is_err()
);
assert!(super::parse_string_list(
Some(serde_json::json!("null")),
"users.allowed_providers"
)
.is_err());
assert!(super::parse_string_list(
Some(serde_json::json!([" "])),
"users.allowed_providers"
)
.is_err());
}
#[test]
fn rejects_object_allowed_providers_for_user_export_row() {
let result = StoredUserExportRow::new(
"user-1".to_string(),
None,
false,
"alice".to_string(),
None,
"user".to_string(),
"local".to_string(),
Some(serde_json::json!({"bad": true})),
None,
None,
None,
None,
true,
);
assert!(result.is_err());
}
#[test]
fn builds_user_auth_record_with_allowed_lists() {
let row = StoredUserAuthRecord::new(
"user-1".to_string(),
Some("[email protected]".to_string()),
true,
"alice".to_string(),
Some("hash".to_string()),
"user".to_string(),
"local".to_string(),
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-4.1"])),
true,
false,
None,
None,
)
.expect("auth row should build");
assert_eq!(row.allowed_providers, Some(vec!["openai".to_string()]));
assert_eq!(
row.allowed_api_formats,
Some(vec!["openai:chat".to_string()])
);
assert_eq!(row.allowed_models, Some(vec!["gpt-4.1".to_string()]));
}
#[test]
fn user_record_debug_output_redacts_password_and_refresh_hashes() {
let password_hash = "debug-secret-password-hash";
let auth = StoredUserAuthRecord::new(
"user-debug".to_string(),
Some("[email protected]".to_string()),
true,
"debug-user".to_string(),
Some(password_hash.to_string()),
"user".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
None,
None,
)
.expect("auth record should build");
let export = StoredUserExportRow::new(
"user-debug".to_string(),
Some("[email protected]".to_string()),
true,
"debug-user".to_string(),
Some(password_hash.to_string()),
"user".to_string(),
"local".to_string(),
None,
None,
None,
None,
None,
true,
)
.expect("export record should build");
let current_hash = "debug-secret-current-refresh-hash";
let previous_hash = "debug-secret-previous-refresh-hash";
let session = StoredUserSessionRecord::new(
"session-debug".to_string(),
"user-debug".to_string(),
"device-debug".to_string(),
None,
current_hash.to_string(),
Some(previous_hash.to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.expect("session should build");
for rendered in [format!("{auth:?}"), format!("{export:?}")] {
assert!(!rendered.contains(password_hash));
assert!(rendered.contains("[REDACTED]"));
}
let rendered = format!("{session:?}");
assert!(!rendered.contains(current_hash));
assert!(!rendered.contains(previous_hash));
assert!(rendered.contains("[REDACTED]"));
}
#[test]
fn legacy_policy_mode_treats_empty_lists_as_unrestricted() {
assert_eq!(legacy_list_policy_mode(&None), "unrestricted");
assert_eq!(legacy_list_policy_mode(&Some(Vec::new())), "unrestricted");
assert_eq!(
legacy_list_policy_mode(&Some(vec!["openai".to_string()])),
"specific"
);
}
#[test]
fn user_session_previous_refresh_token_has_grace_window() {
let now = Utc::now();
let session = StoredUserSessionRecord::new(
"session-1".to_string(),
"user-1".to_string(),
"device-1".to_string(),
None,
StoredUserSessionRecord::hash_refresh_token("current-token"),
Some(StoredUserSessionRecord::hash_refresh_token("prev-token")),
Some(now - Duration::seconds(StoredUserSessionRecord::REFRESH_GRACE_SECONDS - 1)),
None,
None,
None,
None,
None,
None,
None,
None,
)
.expect("session should build");
assert_eq!(
session.verify_refresh_token("prev-token", now),
(true, true)
);
assert_eq!(
session.verify_refresh_token("current-token", now),
(true, false)
);
let future_rotation = StoredUserSessionRecord::new(
"session-2".to_string(),
"user-1".to_string(),
"device-1".to_string(),
None,
StoredUserSessionRecord::hash_refresh_token("current-token"),
Some(StoredUserSessionRecord::hash_refresh_token("prev-token")),
Some(now + Duration::seconds(1)),
None,
None,
None,
None,
None,
None,
None,
None,
)
.expect("session should build");
assert_eq!(
future_rotation.verify_refresh_token("prev-token", now),
(false, false)
);
}
#[test]
fn user_preference_defaults_match_gateway_expectations() {
let record = StoredUserPreferenceRecord::default_for_user("user-1");
assert_eq!(record.user_id, "user-1");
assert_eq!(record.theme, "light");
assert_eq!(record.language, "zh-CN");
assert_eq!(record.timezone, "Asia/Shanghai");
assert!(record.email_notifications);
assert!(record.usage_alerts);
assert!(record.announcement_notifications);
}
}