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

@@ -0,0 +1,5 @@
ALTER TABLE users
ADD COLUMN feature_settings TEXT NULL AFTER model_capability_settings;
ALTER TABLE api_keys
ADD COLUMN feature_settings TEXT NULL AFTER force_capabilities;

View File

@@ -0,0 +1,5 @@
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS feature_settings jsonb;
ALTER TABLE public.api_keys
ADD COLUMN IF NOT EXISTS feature_settings jsonb;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN feature_settings TEXT;
ALTER TABLE api_keys ADD COLUMN feature_settings TEXT;

View File

@@ -177,6 +177,7 @@ CREATE TABLE IF NOT EXISTS public.api_keys (
rate_limit integer DEFAULT 100,
concurrent_limit integer,
force_capabilities json,
feature_settings jsonb,
is_active boolean DEFAULT true NOT NULL,
last_used_at timestamp with time zone,
expires_at timestamp with time zone,
@@ -1277,6 +1278,7 @@ CREATE TABLE IF NOT EXISTS public.users (
allowed_models json,
allowed_models_mode text DEFAULT 'unrestricted'::text NOT NULL,
model_capability_settings json,
feature_settings jsonb,
is_active boolean DEFAULT true NOT NULL,
is_deleted boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
`allowed_api_formats` JSON,
`allowed_api_formats_mode` VARCHAR(32) NOT NULL DEFAULT 'unrestricted',
`model_capability_settings` JSON,
`feature_settings` JSON,
`rate_limit` INT,
`rate_limit_mode` VARCHAR(32) NOT NULL DEFAULT 'system',
`metadata` JSON,
@@ -75,6 +76,7 @@ CREATE TABLE IF NOT EXISTS api_keys (
`rate_limit` INT DEFAULT 100,
`concurrent_limit` INT,
`force_capabilities` JSON,
`feature_settings` JSON,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`is_locked` TINYINT(1) NOT NULL DEFAULT 0,
`is_standalone` TINYINT(1) NOT NULL DEFAULT 0,

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS public.users (
allowed_api_formats jsonb,
allowed_api_formats_mode character varying(32) DEFAULT 'unrestricted' NOT NULL,
model_capability_settings jsonb,
feature_settings jsonb,
rate_limit integer,
rate_limit_mode character varying(32) DEFAULT 'system' NOT NULL,
metadata jsonb,
@@ -78,6 +79,7 @@ CREATE TABLE IF NOT EXISTS public.api_keys (
rate_limit integer DEFAULT 100,
concurrent_limit integer,
force_capabilities jsonb,
feature_settings jsonb,
is_active boolean DEFAULT true NOT NULL,
is_locked boolean DEFAULT false NOT NULL,
is_standalone boolean DEFAULT false NOT NULL,

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
allowed_api_formats TEXT,
allowed_api_formats_mode TEXT NOT NULL DEFAULT 'unrestricted',
model_capability_settings TEXT,
feature_settings TEXT,
rate_limit INTEGER,
rate_limit_mode TEXT NOT NULL DEFAULT 'system',
metadata TEXT,
@@ -73,6 +74,7 @@ CREATE TABLE IF NOT EXISTS api_keys (
rate_limit INTEGER DEFAULT 100,
concurrent_limit INTEGER,
force_capabilities TEXT,
feature_settings TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
is_locked INTEGER NOT NULL DEFAULT 0,
is_standalone INTEGER NOT NULL DEFAULT 0,

View File

@@ -97,6 +97,11 @@ name = "model_capability_settings"
type = "json"
nullable = true
[[table.users.columns]]
name = "feature_settings"
type = "json"
nullable = true
[[table.users.columns]]
name = "rate_limit"
type = "int32"
@@ -333,6 +338,11 @@ name = "force_capabilities"
type = "json"
nullable = true
[[table.api_keys.columns]]
name = "feature_settings"
type = "json"
nullable = true
[[table.api_keys.columns]]
name = "is_active"
type = "bool"

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260511130000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260512000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT

View File

@@ -301,6 +301,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260511000000,
20260511120000,
20260511130000,
20260512000000,
]
);
}
@@ -567,6 +568,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260510120000,
20260511120000,
20260511130000,
20260512000000,
]
);
assert_eq!(
@@ -580,6 +582,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260510120000,
20260511120000,
20260511130000,
20260512000000,
]
);
}
@@ -1092,6 +1095,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260511000000,
20260511120000,
20260511130000,
20260512000000,
]
);
}

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>,