Redesign sensitive info protection settings

This commit is contained in:
fawney19
2026-05-14 11:14:20 +08:00
parent 91955ad1e0
commit 509bd30252
71 changed files with 3254 additions and 884 deletions

View File

@@ -941,6 +941,32 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
Ok(Some(export.clone()))
}
async fn set_user_api_key_feature_settings(
&self,
user_id: &str,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
let mut index = self
.index
.write()
.expect("auth api key snapshot repository lock");
let Some(snapshot) = index.by_api_key_id.get(api_key_id) else {
return Ok(None);
};
if snapshot.user_id != user_id || snapshot.api_key_is_standalone {
return Ok(None);
}
let Some(export) = index.export_by_api_key_id.get_mut(api_key_id) else {
return Ok(None);
};
export.feature_settings = match feature_settings {
Some(serde_json::Value::Null) | None => None,
Some(value) => Some(value),
};
Ok(Some(export.clone()))
}
async fn delete_user_api_key(
&self,
user_id: &str,
@@ -980,6 +1006,31 @@ impl AuthApiKeyWriteRepository for InMemoryAuthApiKeySnapshotRepository {
index.touch_counts.remove(api_key_id);
Ok(true)
}
async fn set_standalone_api_key_feature_settings(
&self,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
let mut index = self
.index
.write()
.expect("auth api key snapshot repository lock");
let Some(snapshot) = index.by_api_key_id.get(api_key_id) else {
return Ok(None);
};
if !snapshot.api_key_is_standalone {
return Ok(None);
}
let Some(export) = index.export_by_api_key_id.get_mut(api_key_id) else {
return Ok(None);
};
export.feature_settings = match feature_settings {
Some(serde_json::Value::Null) | None => None,
Some(value) => Some(value),
};
Ok(Some(export.clone()))
}
}
#[cfg(test)]

View File

@@ -52,6 +52,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
api_keys.expires_at AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -112,11 +113,11 @@ impl MysqlAuthApiKeyReadRepository {
INSERT INTO api_keys (
id, user_id, key_hash, key_encrypted, name, allowed_providers,
allowed_api_formats, allowed_models, rate_limit, concurrent_limit,
force_capabilities, is_active, expires_at, auto_delete_on_expiry,
force_capabilities, feature_settings, is_active, expires_at, auto_delete_on_expiry,
total_requests, total_tokens, total_cost_usd, is_standalone,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&record.api_key_id)
@@ -642,6 +643,34 @@ WHERE id = ?
self.reload_export_by_id(api_key_id).await
}
async fn set_user_api_key_feature_settings(
&self,
user_id: &str,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
sqlx::query(
r#"
UPDATE api_keys
SET feature_settings = ?, updated_at = ?
WHERE id = ?
AND user_id = ?
AND is_standalone = 0
"#,
)
.bind(optional_json_to_string(
&feature_settings,
"api_keys.feature_settings",
)?)
.bind(current_unix_secs() as i64)
.bind(api_key_id)
.bind(user_id)
.execute(&self.pool)
.await
.map_sql_err()?;
self.reload_export_by_id(api_key_id).await
}
async fn delete_user_api_key(
&self,
user_id: &str,
@@ -653,6 +682,31 @@ WHERE id = ?
async fn delete_standalone_api_key(&self, api_key_id: &str) -> Result<bool, DataLayerError> {
self.delete_api_key(api_key_id, None, true).await
}
async fn set_standalone_api_key_feature_settings(
&self,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
sqlx::query(
r#"
UPDATE api_keys
SET feature_settings = ?, updated_at = ?
WHERE id = ?
AND is_standalone = 1
"#,
)
.bind(optional_json_to_string(
&feature_settings,
"api_keys.feature_settings",
)?)
.bind(current_unix_secs() as i64)
.bind(api_key_id)
.execute(&self.pool)
.await
.map_sql_err()?;
self.reload_export_by_id(api_key_id).await
}
}
impl MysqlAuthApiKeyReadRepository {
@@ -875,6 +929,10 @@ fn map_auth_api_key_snapshot_row(
fn map_auth_api_key_export_row(
row: &MySqlRow,
) -> Result<StoredAuthApiKeyExportRecord, DataLayerError> {
let feature_settings = optional_json_from_string(
row.try_get("feature_settings").map_sql_err()?,
"api_keys.feature_settings",
)?;
StoredAuthApiKeyExportRecord::new(
row.try_get("user_id").map_sql_err()?,
row.try_get("api_key_id").map_sql_err()?,
@@ -907,6 +965,7 @@ fn map_auth_api_key_export_row(
row.try_get("total_cost_usd").map_sql_err()?,
row.try_get("is_standalone").map_sql_err()?,
)
.map(|record| record.with_feature_settings(feature_settings))
.and_then(|record| {
record.with_activity_timestamps(
row.try_get("last_used_at_unix_secs").map_sql_err()?,

View File

@@ -146,6 +146,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
CAST(EXTRACT(EPOCH FROM api_keys.expires_at) AS BIGINT) AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -175,6 +176,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
CAST(EXTRACT(EPOCH FROM api_keys.expires_at) AS BIGINT) AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -203,6 +205,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
CAST(EXTRACT(EPOCH FROM api_keys.expires_at) AS BIGINT) AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -231,6 +234,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
CAST(EXTRACT(EPOCH FROM api_keys.expires_at) AS BIGINT) AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -259,6 +263,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
CAST(EXTRACT(EPOCH FROM api_keys.expires_at) AS BIGINT) AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -331,6 +336,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
CAST(EXTRACT(EPOCH FROM api_keys.expires_at) AS BIGINT) AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -366,6 +372,7 @@ INSERT INTO api_keys (
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
expires_at,
is_locked,
@@ -389,10 +396,11 @@ VALUES (
$9,
$10,
$11,
NULL,
$12,
FALSE,
FALSE,
$13,
FALSE,
FALSE,
$14,
$15,
$16,
@@ -412,6 +420,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -437,6 +446,7 @@ INSERT INTO api_keys (
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
expires_at,
is_locked,
@@ -460,10 +470,11 @@ VALUES (
$9,
$10,
$11,
NULL,
$12,
$13,
FALSE,
TRUE,
$13,
$14,
$15,
$16,
@@ -483,6 +494,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -517,6 +529,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -555,6 +568,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -587,6 +601,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -618,6 +633,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -660,6 +676,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -692,6 +709,7 @@ RETURNING
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings,
is_active,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
auto_delete_on_expiry,
@@ -704,6 +722,25 @@ RETURNING
is_standalone
"#;
const SET_USER_API_KEY_FEATURE_SETTINGS_SQL: &str = r#"
UPDATE api_keys
SET
feature_settings = $3,
updated_at = NOW()
WHERE user_id = $1
AND id = $2
AND is_standalone = FALSE
"#;
const SET_STANDALONE_API_KEY_FEATURE_SETTINGS_SQL: &str = r#"
UPDATE api_keys
SET
feature_settings = $2,
updated_at = NOW()
WHERE id = $1
AND is_standalone = TRUE
"#;
const NULL_USAGE_API_KEY_FK_SQL: &str = r#"
UPDATE usage
SET api_key_id = NULL
@@ -1322,6 +1359,30 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
row.as_ref().map(map_auth_api_key_export_row).transpose()
}
async fn set_user_api_key_feature_settings(
&self,
user_id: &str,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
let result = sqlx::query(SET_USER_API_KEY_FEATURE_SETTINGS_SQL)
.bind(user_id)
.bind(api_key_id)
.bind(feature_settings)
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
let api_key_ids = [api_key_id.to_string()];
Ok(self
.list_export_api_keys_by_ids(&api_key_ids)
.await?
.into_iter()
.find(|record| record.user_id == user_id && !record.is_standalone))
}
async fn delete_user_api_key(
&self,
user_id: &str,
@@ -1353,6 +1414,28 @@ impl AuthApiKeyWriteRepository for SqlxAuthApiKeySnapshotReadRepository {
Ok(result.rows_affected() > 0)
}
async fn set_standalone_api_key_feature_settings(
&self,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
let result = sqlx::query(SET_STANDALONE_API_KEY_FEATURE_SETTINGS_SQL)
.bind(api_key_id)
.bind(feature_settings)
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
let api_key_ids = [api_key_id.to_string()];
Ok(self
.list_export_api_keys_by_ids(&api_key_ids)
.await?
.into_iter()
.find(|record| record.is_standalone))
}
async fn delete_standalone_api_key(&self, api_key_id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_postgres_err()?;
sqlx::query(NULL_USAGE_API_KEY_FK_SQL)
@@ -1419,6 +1502,7 @@ fn map_auth_api_key_snapshot_row(
fn map_auth_api_key_export_row(
row: &sqlx::postgres::PgRow,
) -> Result<StoredAuthApiKeyExportRecord, DataLayerError> {
let feature_settings = row_get(row, "feature_settings")?;
StoredAuthApiKeyExportRecord::new(
row_get(row, "user_id")?,
row_get(row, "api_key_id")?,
@@ -1439,6 +1523,7 @@ fn map_auth_api_key_export_row(
row_get(row, "total_cost_usd")?,
row_get(row, "is_standalone")?,
)
.map(|record| record.with_feature_settings(feature_settings))
.and_then(|record| {
record.with_activity_timestamps(
row_get(row, "last_used_at_unix_secs")?,

View File

@@ -52,6 +52,7 @@ SELECT
api_keys.rate_limit,
api_keys.concurrent_limit,
api_keys.force_capabilities,
api_keys.feature_settings,
api_keys.is_active,
api_keys.expires_at AS expires_at_unix_secs,
api_keys.auto_delete_on_expiry,
@@ -112,11 +113,11 @@ impl SqliteAuthApiKeyReadRepository {
INSERT INTO api_keys (
id, user_id, key_hash, key_encrypted, name, allowed_providers,
allowed_api_formats, allowed_models, rate_limit, concurrent_limit,
force_capabilities, is_active, expires_at, auto_delete_on_expiry,
force_capabilities, feature_settings, is_active, expires_at, auto_delete_on_expiry,
total_requests, total_tokens, total_cost_usd, is_standalone,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&record.api_key_id)
@@ -642,6 +643,34 @@ WHERE id = ?
self.reload_export_by_id(api_key_id).await
}
async fn set_user_api_key_feature_settings(
&self,
user_id: &str,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
sqlx::query(
r#"
UPDATE api_keys
SET feature_settings = ?, updated_at = ?
WHERE id = ?
AND user_id = ?
AND is_standalone = 0
"#,
)
.bind(optional_json_to_string(
&feature_settings,
"api_keys.feature_settings",
)?)
.bind(current_unix_secs() as i64)
.bind(api_key_id)
.bind(user_id)
.execute(&self.pool)
.await
.map_sql_err()?;
self.reload_export_by_id(api_key_id).await
}
async fn delete_user_api_key(
&self,
user_id: &str,
@@ -653,6 +682,31 @@ WHERE id = ?
async fn delete_standalone_api_key(&self, api_key_id: &str) -> Result<bool, DataLayerError> {
self.delete_api_key(api_key_id, None, true).await
}
async fn set_standalone_api_key_feature_settings(
&self,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
sqlx::query(
r#"
UPDATE api_keys
SET feature_settings = ?, updated_at = ?
WHERE id = ?
AND is_standalone = 1
"#,
)
.bind(optional_json_to_string(
&feature_settings,
"api_keys.feature_settings",
)?)
.bind(current_unix_secs() as i64)
.bind(api_key_id)
.execute(&self.pool)
.await
.map_sql_err()?;
self.reload_export_by_id(api_key_id).await
}
}
impl SqliteAuthApiKeyReadRepository {
@@ -875,6 +929,10 @@ fn map_auth_api_key_snapshot_row(
fn map_auth_api_key_export_row(
row: &SqliteRow,
) -> Result<StoredAuthApiKeyExportRecord, DataLayerError> {
let feature_settings = optional_json_from_string(
row.try_get("feature_settings").map_sql_err()?,
"api_keys.feature_settings",
)?;
StoredAuthApiKeyExportRecord::new(
row.try_get("user_id").map_sql_err()?,
row.try_get("api_key_id").map_sql_err()?,
@@ -907,6 +965,7 @@ fn map_auth_api_key_export_row(
sqlite_real(row, "total_cost_usd")?,
row.try_get("is_standalone").map_sql_err()?,
)
.map(|record| record.with_feature_settings(feature_settings))
.and_then(|record| {
record.with_activity_timestamps(
row.try_get("last_used_at_unix_secs").map_sql_err()?,

View File

@@ -335,6 +335,7 @@ pub struct StoredAuthApiKeyExportRecord {
pub rate_limit: Option<i32>,
pub concurrent_limit: Option<i32>,
pub force_capabilities: Option<serde_json::Value>,
pub feature_settings: Option<serde_json::Value>,
pub is_active: bool,
pub expires_at_unix_secs: Option<u64>,
pub auto_delete_on_expiry: bool,
@@ -405,6 +406,7 @@ impl StoredAuthApiKeyExportRecord {
rate_limit,
concurrent_limit,
force_capabilities,
feature_settings: None,
is_active,
expires_at_unix_secs: expires_at_unix_secs
.map(|value| parse_u64_i64(value, "api_keys.expires_at_unix_secs"))
@@ -437,6 +439,11 @@ impl StoredAuthApiKeyExportRecord {
.transpose()?;
Ok(self)
}
pub fn with_feature_settings(mut self, feature_settings: Option<serde_json::Value>) -> Self {
self.feature_settings = normalize_optional_json(feature_settings);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
@@ -651,6 +658,13 @@ pub trait AuthApiKeyWriteRepository: Send + Sync {
force_capabilities: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, crate::DataLayerError>;
async fn set_user_api_key_feature_settings(
&self,
user_id: &str,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, crate::DataLayerError>;
async fn delete_user_api_key(
&self,
user_id: &str,
@@ -661,6 +675,12 @@ pub trait AuthApiKeyWriteRepository: Send + Sync {
&self,
api_key_id: &str,
) -> Result<bool, crate::DataLayerError>;
async fn set_standalone_api_key_feature_settings(
&self,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<StoredAuthApiKeyExportRecord>, crate::DataLayerError>;
}
pub trait AuthRepository:
@@ -738,6 +758,13 @@ fn parse_u64_i64(value: i64, field_name: &str) -> Result<u64, crate::DataLayerEr
})
}
fn normalize_optional_json(value: Option<serde_json::Value>) -> Option<serde_json::Value> {
match value {
Some(serde_json::Value::Null) | None => None,
Some(value) => Some(value),
}
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;

View File

@@ -36,6 +36,7 @@ pub struct InMemoryUserReadRepository {
preferences_by_user_id: RwLock<BTreeMap<String, StoredUserPreferenceRecord>>,
sessions_by_id: RwLock<BTreeMap<String, StoredUserSessionRecord>>,
model_settings_by_user_id: RwLock<BTreeMap<String, serde_json::Value>>,
feature_settings_by_user_id: RwLock<BTreeMap<String, serde_json::Value>>,
groups_by_id: RwLock<BTreeMap<String, StoredUserGroup>>,
group_members: RwLock<BTreeMap<(String, String), chrono::DateTime<chrono::Utc>>>,
export_rows: RwLock<Vec<StoredUserExportRow>>,
@@ -61,6 +62,7 @@ impl InMemoryUserReadRepository {
preferences_by_user_id: RwLock::new(BTreeMap::new()),
sessions_by_id: RwLock::new(BTreeMap::new()),
model_settings_by_user_id: RwLock::new(BTreeMap::new()),
feature_settings_by_user_id: RwLock::new(BTreeMap::new()),
groups_by_id: RwLock::new(BTreeMap::new()),
group_members: RwLock::new(BTreeMap::new()),
export_rows: RwLock::new(Vec::new()),
@@ -96,6 +98,7 @@ impl InMemoryUserReadRepository {
preferences_by_user_id: RwLock::new(BTreeMap::new()),
sessions_by_id: RwLock::new(BTreeMap::new()),
model_settings_by_user_id: RwLock::new(BTreeMap::new()),
feature_settings_by_user_id: RwLock::new(BTreeMap::new()),
groups_by_id: RwLock::new(BTreeMap::new()),
group_members: RwLock::new(BTreeMap::new()),
export_rows: RwLock::new(Vec::new()),
@@ -117,6 +120,7 @@ impl InMemoryUserReadRepository {
preferences_by_user_id: RwLock::new(BTreeMap::new()),
sessions_by_id: RwLock::new(BTreeMap::new()),
model_settings_by_user_id: RwLock::new(BTreeMap::new()),
feature_settings_by_user_id: RwLock::new(BTreeMap::new()),
groups_by_id: RwLock::new(BTreeMap::new()),
group_members: RwLock::new(BTreeMap::new()),
export_rows: RwLock::new(items.into_iter().collect()),
@@ -366,6 +370,12 @@ fn memory_export_row_from_auth_user(
.expect("user repository lock")
.get(&user.id)
.cloned();
let feature_settings = repository
.feature_settings_by_user_id
.read()
.expect("user repository lock")
.get(&user.id)
.cloned();
StoredUserExportRow::new(
user.id.clone(),
user.email.clone(),
@@ -383,6 +393,7 @@ fn memory_export_row_from_auth_user(
model_capability_settings,
user.is_active,
)?
.with_feature_settings(feature_settings)
.with_policy_modes(
user.allowed_providers_mode.clone(),
user.allowed_api_formats_mode.clone(),
@@ -1530,6 +1541,58 @@ impl UserReadRepository for InMemoryUserReadRepository {
Ok(normalized)
}
async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, DataLayerError> {
if self.read_only {
return Ok(None);
}
let user_exists = self
.auth_by_id
.read()
.expect("user repository lock")
.contains_key(user_id)
|| self
.export_rows
.read()
.expect("user repository lock")
.iter()
.any(|row| row.id == user_id);
if !user_exists {
return Ok(None);
}
let normalized = normalize_optional_json_value(settings);
let mut feature_settings_by_user = self
.feature_settings_by_user_id
.write()
.expect("user repository lock");
match normalized.clone() {
Some(value) => {
feature_settings_by_user.insert(user_id.to_string(), value);
}
None => {
feature_settings_by_user.remove(user_id);
}
}
drop(feature_settings_by_user);
if let Some(row) = self
.export_rows
.write()
.expect("user repository lock")
.iter_mut()
.find(|row| row.id == user_id)
{
row.feature_settings = normalized.clone();
}
Ok(normalized)
}
async fn create_local_auth_user(
&self,
email: Option<String>,

View File

@@ -42,6 +42,7 @@ SELECT
rate_limit,
rate_limit_mode,
model_capability_settings,
feature_settings,
is_active
FROM users
"#;
@@ -1177,6 +1178,29 @@ WHERE id = ?
Ok(normalized)
}
async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let normalized = normalize_optional_json_value(settings);
let result =
sqlx::query("UPDATE users SET feature_settings = ?, updated_at = ? WHERE id = ?")
.bind(optional_json_string(
normalized.clone(),
"users.feature_settings",
)?)
.bind(chrono::Utc::now().timestamp())
.bind(user_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
Ok(normalized)
}
async fn create_local_auth_user(
&self,
email: Option<String>,
@@ -1849,6 +1873,10 @@ fn map_user_row(row: &MySqlRow) -> Result<StoredUserSummary, DataLayerError> {
}
fn map_user_export_row(row: &MySqlRow) -> Result<StoredUserExportRow, DataLayerError> {
let feature_settings = optional_json_from_string(
row.try_get("feature_settings").map_sql_err()?,
"users.feature_settings",
)?;
StoredUserExportRow::new(
row.try_get("id").map_sql_err()?,
row.try_get("email").map_sql_err()?,
@@ -1876,6 +1904,7 @@ fn map_user_export_row(row: &MySqlRow) -> Result<StoredUserExportRow, DataLayerE
)?,
row.try_get("is_active").map_sql_err()?,
)
.map(|record| record.with_feature_settings(feature_settings))
.and_then(|record| {
record.with_policy_modes(
row.try_get("allowed_providers_mode").map_sql_err()?,

View File

@@ -56,6 +56,7 @@ SELECT
rate_limit,
rate_limit_mode,
model_capability_settings,
feature_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
@@ -81,6 +82,7 @@ SELECT
rate_limit,
rate_limit_mode,
model_capability_settings,
feature_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
@@ -105,6 +107,7 @@ SELECT
rate_limit,
rate_limit_mode,
model_capability_settings,
feature_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
@@ -154,6 +157,7 @@ SELECT
rate_limit,
rate_limit_mode,
model_capability_settings,
feature_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
@@ -1658,6 +1662,31 @@ WHERE id = $1
Ok(normalized)
}
pub async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let normalized = normalize_optional_json_value(settings);
let result = sqlx::query(
r#"
UPDATE users
SET feature_settings = $2,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(normalized.clone())
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
Ok(normalized)
}
pub async fn create_local_auth_user(
&self,
email: Option<String>,
@@ -2112,6 +2141,7 @@ fn map_user_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserSummary, DataLa
}
fn map_user_export_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserExportRow, DataLayerError> {
let feature_settings = row.try_get("feature_settings").map_postgres_err()?;
StoredUserExportRow::new(
row.try_get("id").map_postgres_err()?,
row.try_get("email").map_postgres_err()?,
@@ -2128,6 +2158,7 @@ fn map_user_export_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserExportRo
.map_postgres_err()?,
row.try_get("is_active").map_postgres_err()?,
)
.map(|record| record.with_feature_settings(feature_settings))
.and_then(|record| {
record.with_policy_modes(
row.try_get("allowed_providers_mode").map_postgres_err()?,
@@ -2602,6 +2633,14 @@ impl UserReadRepository for SqlxUserReadRepository {
.await
}
async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, DataLayerError> {
self.update_user_feature_settings(user_id, settings).await
}
async fn create_local_auth_user(
&self,
email: Option<String>,

View File

@@ -42,6 +42,7 @@ SELECT
rate_limit,
rate_limit_mode,
model_capability_settings,
feature_settings,
is_active
FROM users
"#;
@@ -1177,6 +1178,29 @@ WHERE id = ?
Ok(normalized)
}
async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let normalized = normalize_optional_json_value(settings);
let result =
sqlx::query("UPDATE users SET feature_settings = ?, updated_at = ? WHERE id = ?")
.bind(optional_json_string(
normalized.clone(),
"users.feature_settings",
)?)
.bind(chrono::Utc::now().timestamp())
.bind(user_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
Ok(normalized)
}
async fn create_local_auth_user(
&self,
email: Option<String>,
@@ -1853,6 +1877,10 @@ fn map_user_row(row: &SqliteRow) -> Result<StoredUserSummary, DataLayerError> {
}
fn map_user_export_row(row: &SqliteRow) -> Result<StoredUserExportRow, DataLayerError> {
let feature_settings = optional_json_from_string(
row.try_get("feature_settings").map_sql_err()?,
"users.feature_settings",
)?;
StoredUserExportRow::new(
row.try_get("id").map_sql_err()?,
row.try_get("email").map_sql_err()?,
@@ -1880,6 +1908,7 @@ fn map_user_export_row(row: &SqliteRow) -> Result<StoredUserExportRow, DataLayer
)?,
row.try_get("is_active").map_sql_err()?,
)
.map(|record| record.with_feature_settings(feature_settings))
.and_then(|record| {
record.with_policy_modes(
row.try_get("allowed_providers_mode").map_sql_err()?,

View File

@@ -236,6 +236,7 @@ pub struct StoredUserExportRow {
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,
}
@@ -297,6 +298,7 @@ impl StoredUserExportRow {
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())
@@ -322,6 +324,11 @@ impl StoredUserExportRow {
Ok(self)
}
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);
@@ -881,6 +888,12 @@ pub trait UserReadRepository: Send + Sync {
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>,