mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
Redesign sensitive info protection settings
This commit is contained in:
@@ -18,6 +18,7 @@ base64.workspace = true
|
||||
chrono.workspace = true
|
||||
http.workspace = true
|
||||
reqwest.workspace = true
|
||||
regex.workspace = true
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use regex::Regex;
|
||||
use semver::Version;
|
||||
use serde::{de, de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
@@ -72,23 +73,119 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const CHAT_PII_REDACTION_ENTITY_KEYS: &[&str] = &[
|
||||
"email",
|
||||
"cn_phone",
|
||||
"global_phone",
|
||||
"cn_id",
|
||||
"payment_card",
|
||||
"ipv4",
|
||||
"ipv6",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"secret_key",
|
||||
"bearer_token",
|
||||
"jwt",
|
||||
];
|
||||
fn chat_pii_redaction_default_rules() -> serde_json::Value {
|
||||
json!([
|
||||
{
|
||||
"id": "email",
|
||||
"name": "邮箱",
|
||||
"pattern": "(?i)[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\\.[A-Z]{2,63}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "email"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "cn_phone",
|
||||
"name": "手机号",
|
||||
"pattern": "(?:\\+?86[- ]?)?(?:1[3-9]\\d[- ]?\\d{4}[- ]?\\d{4}|0\\d{2,3}[- ]\\d{7,8}(?:-\\d{1,6})?)",
|
||||
"enabled": true,
|
||||
"features": {"validator": "cn_phone"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "global_phone",
|
||||
"name": "国际号码",
|
||||
"pattern": "\\+[1-9]\\d(?:[ -]?\\d){6,13}\\d",
|
||||
"enabled": true,
|
||||
"features": {"validator": "global_phone"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "cn_id",
|
||||
"name": "身份证号",
|
||||
"pattern": "(?i)\\b\\d{17}[\\dX]\\b",
|
||||
"enabled": true,
|
||||
"features": {"validator": "cn_id"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "payment_card",
|
||||
"name": "银行卡号",
|
||||
"pattern": "\\b(?:\\d[ -]?){12,18}\\d\\b",
|
||||
"enabled": true,
|
||||
"features": {"validator": "payment_card"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "ipv4",
|
||||
"name": "IPv4",
|
||||
"pattern": "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b",
|
||||
"enabled": true,
|
||||
"features": {"validator": "ipv4"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "ipv6",
|
||||
"name": "IPv6",
|
||||
"pattern": "\\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f:.]{1,39}\\b",
|
||||
"enabled": true,
|
||||
"features": {"validator": "ipv6"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "api_key",
|
||||
"name": "API Key",
|
||||
"pattern": "\\b(?:sk-(?:proj-)?[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|(?:gh[pousr]_[A-Za-z0-9_]{30,}|github_pat_[A-Za-z0-9_]{30,})|xox[baprs]-[A-Za-z0-9-]{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|[A-Za-z0-9_-]{32,})\\b",
|
||||
"enabled": true,
|
||||
"features": {"validator": "api_key"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "access_token",
|
||||
"name": "Access Token",
|
||||
"pattern": "(?i)\\baccess[_-]?token\\s*[:=]\\s*[\"']?[A-Za-z0-9._~+/=-]{20,}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "access_token"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "secret_key",
|
||||
"name": "Secret Key",
|
||||
"pattern": "(?i)\\bsecret[_-]?key\\s*[:=]\\s*[\"']?[A-Za-z0-9._~+/=-]{20,}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "secret_key"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "bearer_token",
|
||||
"name": "Bearer Token",
|
||||
"pattern": "(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]{20,}",
|
||||
"enabled": true,
|
||||
"features": {"validator": "bearer_token"},
|
||||
"system": true
|
||||
},
|
||||
{
|
||||
"id": "jwt",
|
||||
"name": "JWT",
|
||||
"pattern": "\\b[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b",
|
||||
"enabled": true,
|
||||
"features": {"validator": "jwt"},
|
||||
"system": true
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn chat_pii_redaction_default_entities() -> serde_json::Value {
|
||||
json!(CHAT_PII_REDACTION_ENTITY_KEYS)
|
||||
fn normalize_chat_pii_redaction_placeholder_prefix(raw: &str) -> Option<String> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() || value.len() > 32 {
|
||||
return None;
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(value.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, serde_json::Value) {
|
||||
@@ -1468,10 +1565,9 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
||||
"smtp_from_name" => Some(json!("Aether")),
|
||||
"enable_oauth_token_refresh" => Some(json!(true)),
|
||||
"module.chat_pii_redaction.enabled" => Some(json!(false)),
|
||||
"module.chat_pii_redaction.provider_scope" => Some(json!("selected_providers")),
|
||||
"module.chat_pii_redaction.entities" => Some(chat_pii_redaction_default_entities()),
|
||||
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
|
||||
"module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)),
|
||||
"module.chat_pii_redaction.inject_model_instruction" => Some(json!(true)),
|
||||
"module.chat_pii_redaction.placeholder_prefix" => Some(json!("AETHER")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1528,6 +1624,96 @@ pub fn build_admin_system_config_detail_payload(
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_chat_pii_redaction_rules_value(
|
||||
value: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ()> {
|
||||
let Some(raw_rules) = value.as_array() else {
|
||||
return Err(());
|
||||
};
|
||||
let mut rules = Vec::with_capacity(raw_rules.len());
|
||||
for raw_rule in raw_rules {
|
||||
let Some(raw_rule) = raw_rule.as_object() else {
|
||||
return Err(());
|
||||
};
|
||||
let id = raw_rule
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(())?;
|
||||
let name = raw_rule
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(())?;
|
||||
let pattern = raw_rule
|
||||
.get("pattern")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(())?;
|
||||
Regex::new(pattern).map_err(|_| ())?;
|
||||
let enabled = raw_rule
|
||||
.get("enabled")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let system = raw_rule
|
||||
.get("system")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let features = normalize_chat_pii_redaction_rule_features(raw_rule)?;
|
||||
rules.push(json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"pattern": pattern,
|
||||
"enabled": enabled,
|
||||
"system": system,
|
||||
"features": features,
|
||||
}));
|
||||
}
|
||||
Ok(serde_json::Value::Array(rules))
|
||||
}
|
||||
|
||||
fn normalize_chat_pii_redaction_rule_features(
|
||||
raw_rule: &Map<String, Value>,
|
||||
) -> Result<serde_json::Value, ()> {
|
||||
let mut features = match raw_rule.get("features") {
|
||||
Some(Value::Object(features)) => features.clone(),
|
||||
Some(Value::Null) | None => Map::new(),
|
||||
Some(_) => return Err(()),
|
||||
};
|
||||
|
||||
if !features.contains_key("validator") {
|
||||
if let Some(Value::String(value)) = raw_rule.get("kind") {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
features.insert("validator".to_string(), json!(value));
|
||||
}
|
||||
} else if raw_rule.get("kind").is_some_and(|value| !value.is_null()) {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
|
||||
match features.get("validator") {
|
||||
Some(Value::String(value)) => {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
features.remove("validator");
|
||||
} else {
|
||||
features.insert("validator".to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
Some(Value::Null) => {
|
||||
features.remove("validator");
|
||||
}
|
||||
Some(_) => return Err(()),
|
||||
None => {}
|
||||
}
|
||||
|
||||
Ok(Value::Object(features))
|
||||
}
|
||||
|
||||
pub fn parse_admin_system_config_update(
|
||||
requested_key: &str,
|
||||
request_body: &[u8],
|
||||
@@ -1581,8 +1767,7 @@ pub fn parse_admin_system_config_update(
|
||||
}
|
||||
|
||||
match normalized_key.as_str() {
|
||||
"module.chat_pii_redaction.enabled"
|
||||
| "module.chat_pii_redaction.inject_model_instruction" => match value.as_bool() {
|
||||
"module.chat_pii_redaction.enabled" => match value.as_bool() {
|
||||
Some(enabled) => value = json!(enabled),
|
||||
None if value.is_null() => {
|
||||
value = admin_system_config_default_value(&normalized_key).unwrap();
|
||||
@@ -1594,60 +1779,18 @@ pub fn parse_admin_system_config_update(
|
||||
));
|
||||
}
|
||||
},
|
||||
"module.chat_pii_redaction.provider_scope" => match value.as_str().map(str::trim) {
|
||||
Some("all_providers" | "selected_providers") => {
|
||||
value = json!(value.as_str().unwrap().trim());
|
||||
}
|
||||
Some(_) => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
None if value.is_null() => value = json!("selected_providers"),
|
||||
None => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
},
|
||||
"module.chat_pii_redaction.entities" => match value.as_array() {
|
||||
Some(raw_entities) => {
|
||||
let requested = raw_entities
|
||||
.iter()
|
||||
.map(|entity| entity.as_str().map(str::trim))
|
||||
.collect::<Option<BTreeSet<_>>>()
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
let allowed = CHAT_PII_REDACTION_ENTITY_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !requested.is_subset(&allowed) {
|
||||
return Err((
|
||||
"module.chat_pii_redaction.rules" => {
|
||||
if value.is_null() {
|
||||
value = chat_pii_redaction_default_rules();
|
||||
} else {
|
||||
value = normalize_chat_pii_redaction_rules_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
value = json!(CHAT_PII_REDACTION_ENTITY_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|entity| requested.contains(entity))
|
||||
.collect::<Vec<_>>());
|
||||
)
|
||||
})?;
|
||||
}
|
||||
None if value.is_null() => value = chat_pii_redaction_default_entities(),
|
||||
None => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
"module.chat_pii_redaction.cache_ttl_seconds" => match value.as_u64() {
|
||||
Some(300 | 3600) => value = json!(value.as_u64().unwrap()),
|
||||
Some(_) => {
|
||||
@@ -1664,6 +1807,24 @@ pub fn parse_admin_system_config_update(
|
||||
));
|
||||
}
|
||||
},
|
||||
"module.chat_pii_redaction.placeholder_prefix" => match value.as_str() {
|
||||
Some(raw) => {
|
||||
let Some(normalized) = normalize_chat_pii_redaction_placeholder_prefix(raw) else {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
};
|
||||
value = json!(normalized);
|
||||
}
|
||||
None if value.is_null() => value = json!("AETHER"),
|
||||
None => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN feature_settings TEXT;
|
||||
ALTER TABLE api_keys ADD COLUMN feature_settings TEXT;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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")?,
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user