Files
mnote/rust/crates/mnote-web/src/routes/ai_settings.rs
T

2299 lines
80 KiB
Rust
Raw Normal View History

2026-07-04 21:47:42 +08:00
//! Provider-neutral AI settings API — effective config, access scopes, and
//! admin-scoped access-scope queries.
//!
//! These endpoints consume MNote's control-plane as the single source of truth:
//! - `directory_grants` → `allowedRoots` (filtered by status, never from `ai_policies`)
//! - `ai_policies` → only `model_policy_json` / `quota_json`
//!
//! Routes are registered by the caller in `routes/mod.rs`.
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::AppendAuditInput;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
// ─── Response types ─────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EffectiveAiSettings {
pub default_model: String,
pub providers: Vec<ProviderEntry>,
pub models: Vec<ModelEntry>,
pub allowed_roots: Vec<AccessScopeEntry>,
pub model_policy: Value,
pub quota: Value,
pub tool_catalog: Vec<ToolCatalogEntry>,
pub skills: Vec<SkillConfig>,
pub mcp_servers: Vec<McpServerConfig>,
pub lightrag_provider: LightRagProviderRef,
pub access_policy_links: AccessPolicyLinks,
/// Always `"directory_grants"` — declares that allowed-roots come from
/// directory_grants, NOT from `ai_policies.allowed_roots_json`.
pub source_of_truth: &'static str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderEntry {
pub id: String,
pub name: String,
pub enabled: bool,
pub default_model: String,
pub base_url: String,
pub secret_ref: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelEntry {
pub provider: &'static str,
pub id: String,
pub name: String,
pub enabled: bool,
pub is_default: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCatalogEntry {
pub name: &'static str,
pub description: &'static str,
/// One of `"allow"`, `"ask"`, `"deny"`.
pub default_policy: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LightRagProviderRef {
pub provider: &'static str,
pub description: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessPolicyLinks {
pub admin: &'static str,
pub user: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessScopeEntry {
pub id: String,
pub user_id: String,
pub workspace_id: Option<String>,
pub root_uri: String,
pub root_path: String,
pub permission: String,
pub recursive: bool,
pub capabilities: Value,
pub source: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessScopesResponse {
pub allowed_roots: Vec<AccessScopeEntry>,
pub source_of_truth: &'static str,
}
// ─── Query parameters ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessScopesQuery {
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReceiptQuery {
pub session_id: Option<String>,
pub user_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserAiPolicyBody {
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub allowed_models: Option<Vec<String>>,
#[serde(default)]
pub tools: Option<HashMap<String, String>>,
#[serde(default)]
pub skills: Option<HashMap<String, bool>>,
#[serde(default)]
pub mcp_servers: Option<HashMap<String, bool>>,
}
// ─── Admin body types ───────────────────────────────────────────────────
/// Admin PUT request body for AI policy.
/// All fields are optional — only provided fields are merged into the
/// existing policy. `allowed_roots` is never accepted from the client
/// (source of truth is `directory_grants`).
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertAiPolicyBody {
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub allowed_models: Option<Vec<String>>,
#[serde(default)]
pub workspace_id: Option<String>,
/// Per-provider configuration. Provider `secretRef` must use
/// `env://` or `secret://` references — raw API keys are rejected.
#[serde(default)]
pub providers: Option<HashMap<String, ProviderConfig>>,
#[serde(default)]
pub build_plan_task: Option<BuildPlanTaskConfig>,
/// Tool-policy overrides. Keys are MNote tool names
/// (e.g. `mnote.local_file.read`), values are `"allow"`, `"ask"`,
/// or `"deny"`.
#[serde(default)]
pub tools: Option<HashMap<String, String>>,
/// Skills registry: name → enabled/disabled.
#[serde(default)]
pub skills: Option<HashMap<String, SkillConfig>>,
/// MCP server registry.
#[serde(default)]
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
/// Optional quota override (merged into existing).
#[serde(default)]
pub quota: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
pub name: String,
pub enabled: bool,
/// Must be an `env://` or `secret://` reference — raw keys are rejected.
pub secret_ref: String,
pub default_model: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub allowed_models: Vec<String>,
#[serde(default)]
pub default_build_model: String,
#[serde(default)]
pub default_plan_model: String,
#[serde(default)]
pub default_task_model: String,
#[serde(default)]
pub failover_chains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildPlanTaskConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub failover: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillConfig {
pub name: String,
pub enabled: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub risk_level: String,
#[serde(default)]
pub required_scopes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerConfig {
pub name: String,
pub enabled: bool,
#[serde(default)]
pub url: String,
#[serde(default = "default_mcp_transport")]
pub transport: String,
#[serde(default)]
pub command: String,
#[serde(default = "default_mcp_network_policy")]
pub network_policy: String,
#[serde(default)]
pub secret_refs: Vec<String>,
/// Must be `true` — only facade proxy mode is accepted.
pub facade_only: bool,
/// Must be `true` — sandboxed execution is required.
pub sandbox: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub risk_level: String,
#[serde(default)]
pub required_scopes: Vec<String>,
}
// ─── Admin response types ──────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAiSettingsResponse {
pub ok: bool,
pub schema: &'static str,
pub default_model: String,
pub allowed_models: Vec<String>,
pub providers: HashMap<String, ProviderConfig>,
pub build_plan_task: Option<BuildPlanTaskConfig>,
pub tools: HashMap<String, String>,
pub skills: HashMap<String, SkillConfig>,
pub mcp_servers: HashMap<String, McpServerConfig>,
pub quota: Value,
pub revision: i64,
pub updated_at: String,
pub tool_catalog: Vec<ToolCatalogEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAiSettingsUpsertResponse {
pub ok: bool,
pub schema: &'static str,
pub revision: i64,
pub updated_at: String,
}
// ─── Handler implementations ─────────────────────────────────────────────
/// `GET /api/ai-settings/effective`
///
/// Returns the effective AI configuration for the current authenticated user:
/// model policy + fallback defaults, quota, MNote-owned tool catalog,
/// LightRAG provider reference, and access-policy management links.
///
pub async fn effective_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<EffectiveAiSettings>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
let (model_policy, quota) =
load_effective_model_policy_and_quota(&state, &actor_id, query.workspace_id.as_deref());
let default_model = effective_default_model(&model_policy);
let models = effective_models(&model_policy, &default_model);
let provider_configs = policy_map::<ProviderConfig>(&model_policy, "providers");
let providers = if provider_configs.is_empty() {
vec![ProviderEntry {
id: "omniroute".into(),
name: "Omniroute".into(),
enabled: true,
default_model: default_model.clone(),
base_url: String::new(),
secret_ref: "env://OMNIROUTE_API_KEY".into(),
}]
} else {
provider_configs
.into_iter()
.map(|(id, provider)| ProviderEntry {
id,
name: provider.name,
enabled: provider.enabled,
default_model: provider.default_model,
base_url: provider.base_url,
secret_ref: provider.secret_ref,
})
.collect()
};
let tool_policies = policy_string_map(&model_policy, "tools");
let skills = effective_skill_registry(&model_policy)
.into_values()
.filter(|skill| skill.enabled)
.collect();
let mcp_servers = effective_mcp_registry(&model_policy)
.into_values()
.filter(|server| server.enabled && server.facade_only && server.sandbox)
.collect();
Ok(Json(EffectiveAiSettings {
default_model: default_model.clone(),
providers,
models,
allowed_roots,
model_policy,
quota,
tool_catalog: effective_tool_catalog(&tool_policies),
skills,
mcp_servers,
lightrag_provider: LightRagProviderRef {
provider: LIGHTRAG_PROVIDER,
description: LIGHTRAG_PROVIDER_DESCRIPTION,
},
access_policy_links: AccessPolicyLinks {
admin: ADMIN_ACCESS_POLICY_PATH,
user: USER_ACCESS_POLICY_PATH,
},
source_of_truth: SOURCE_OF_TRUTH,
}))
}
/// `GET /api/ai-settings/access-scopes`
///
/// Returns the current user's allowed roots derived from `directory_grants`.
/// Only `active` grants are included.
/// Optional `workspaceId` query parameter narrows to a single workspace.
pub async fn user_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
}))
}
/// `GET /api/ai-admin/access-scopes`
///
/// Admin-only variant. Validates that the current actor has admin
/// privileges via `is_local_access_policy_admin_context` before
/// returning directory grants.
pub async fn admin_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
"需要管理员权限才能访问 ai-admin 端点",
)
.with_context(&context));
}
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
}))
}
pub async fn user_receipts(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
load_receipts(&state, &actor_id, query.session_id.as_deref(), query.limit)
}
pub async fn admin_receipts(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
"需要管理员权限才能访问 ai-admin 端点",
)
.with_context(&context));
}
let user_id = query
.user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&actor_id);
load_receipts(&state, user_id, query.session_id.as_deref(), query.limit)
}
fn load_receipts(
state: &AppState,
user_id: &str,
session_id: Option<&str>,
limit: Option<usize>,
) -> Result<Json<Value>, WebError> {
let limit = limit.unwrap_or(50).clamp(1, 200);
let receipts = state
.control_plane()
.list_ai_tool_events(user_id, session_id, limit)
.map_err(|error| WebError::internal(format!("查询 AI receipts 失败: {error}")))?;
let patches = state
.control_plane()
.list_ai_file_patches(user_id, session_id, limit)
.map_err(|error| WebError::internal(format!("查询 AI file patches 失败: {error}")))?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.ai.receipts.v1",
"userId": user_id,
"receipts": receipts,
"patches": patches,
"receiptCount": receipts.len(),
"patchCount": patches.len(),
"storage": "control_plane_turso_libsql_v1",
})))
}
// ─── Admin handler implementations ──────────────────────────────────────
/// `GET /api/ai-admin/settings`
///
/// Returns the effective admin AI policy, including provider registry,
/// model list, build-plan-task config, tool-policy overrides, skills
/// registry, MCP server registry, quota, and revision info.
///
/// Admin auth required. Projects `model_policy_json` from the
/// control-plane `ai_policies` table (single source of truth).
pub async fn admin_get_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<AdminAiSettingsResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let global_owner = global_policy_owner_id(&actor_id);
let policy = state
.control_plane()
.get_ai_policy(&global_owner, None)
.map_err(|error| WebError::internal(format!("读取 AI policy 失败: {error}")))?;
let model_policy = policy
.as_ref()
.and_then(|record| serde_json::from_str(&record.model_policy_json).ok())
.unwrap_or_else(|| json!({}));
let quota = policy
.as_ref()
.and_then(|record| serde_json::from_str(&record.quota_json).ok())
.unwrap_or_else(|| json!({}));
let response = project_admin_settings(&model_policy, &quota, policy.as_ref());
Ok(Json(response))
}
/// `PUT /api/ai-admin/settings`
///
/// Upserts the AI policy document. The client provides the desired
/// policy sections (providers, models, tools, skills, MCP, build-plan,
/// quota). `allowed_roots` is never accepted from the client — the
/// source of truth is `directory_grants`.
///
/// Validation rules:
/// - Provider `secretRef` MUST start with `env://` or `secret://`
/// (raw API keys rejected).
/// - MCP servers MUST set `facadeOnly: true` and `sandbox: true`.
///
/// On success, appends an audit log entry and returns the new revision.
pub async fn admin_put_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<UpsertAiPolicyBody>,
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let global_owner = global_policy_owner_id(&actor_id);
// ── Validate provider secretRefs ──────────────────────────────────
if let Some(ref providers) = body.providers {
for (provider_id, config) in providers {
validate_provider_secret_ref(&config.secret_ref).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_secret_ref",
format!("Provider \"{provider_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
// ── Validate MCP server configs ───────────────────────────────────
if let Some(ref servers) = body.mcp_servers {
for (server_id, config) in servers {
validate_mcp_server_config(config).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_mcp_config",
format!("MCP server \"{server_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
// ── Load existing policy, merge inputs ────────────────────────────
let workspace_id = body.workspace_id.as_deref();
let existing = state
.control_plane()
.get_ai_policy(&global_owner, workspace_id)
.map_err(|e| WebError::internal(format!("读取 AI policy 失败: {e}")))?;
let (merged_model_policy_json, merged_quota_json) =
merge_policy_with_existing(existing.as_ref(), &body);
// ── Upsert ────────────────────────────────────────────────────────
let upsert_input = control_plane::UpsertAiPolicyInput {
id: None,
user_id: Some(global_owner),
workspace_id: workspace_id.map(String::from),
allowed_roots_json: existing
.as_ref()
.map(|record| record.allowed_roots_json.clone())
.unwrap_or_else(|| "[]".into()),
model_policy_json: merged_model_policy_json,
quota_json: merged_quota_json,
};
let result = state
.control_plane()
.upsert_ai_policy(upsert_input)
.map_err(|e| WebError::internal(format!("更新 AI policy 失败: {e}")))?;
// ── Append audit ──────────────────────────────────────────────────
let audit_metadata = serde_json::json!({
"revision": result.revision,
"workspaceId": workspace_id,
"updatedFields": describe_updated_fields(&body),
});
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "admin.upsert_ai_policy".into(),
target_kind: "ai_policy".into(),
target_id: Some(result.id.clone()),
metadata_json: audit_metadata.to_string(),
});
Ok(Json(AdminAiSettingsUpsertResponse {
ok: true,
schema: "mnote.admin.ai.settings.upsert.v1",
revision: result.revision,
updated_at: result.updated_at,
}))
}
pub async fn admin_list_users(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
let users = state
.control_plane()
.list_users(500)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?;
let users = users
.into_iter()
.map(|user| {
json!({
"id": user.id,
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() },
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
})
})
.collect::<Vec<_>>();
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.ai.users.v1",
"users": users,
})))
}
pub async fn admin_get_user_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(user_id): Path<String>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
let global_policy = load_policy_value(&state, &global_owner, None);
let user_policy = if user_id == global_owner {
json!({})
} else {
load_policy_value(&state, &user_id, None)
};
Ok(Json(project_user_settings(
&user_id,
&global_owner,
&global_policy,
&user_policy,
)))
}
pub async fn admin_put_user_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(user_id): Path<String>,
Json(body): Json<UserAiPolicyBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
if user_id == global_owner {
return Err(WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_global_owner_override_forbidden",
"全局策略管理员请在模型、工具或技能/MCP 页面修改默认策略",
)
.with_context(&context));
}
let global_policy = load_policy_value(&state, &global_owner, None);
validate_user_policy_body(&body, &global_policy).map_err(|message| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_user_policy",
message,
)
.with_context(&context)
})?;
let updated_fields = describe_user_updated_fields(&body);
let existing = state
.control_plane()
.get_ai_policy(&user_id, None)
.map_err(|error| WebError::internal(format!("读取用户 AI policy 失败: {error}")))?;
let mut policy = existing
.as_ref()
.and_then(|record| serde_json::from_str::<Value>(&record.model_policy_json).ok())
.unwrap_or_else(|| json!({}));
if let Some(value) = body.default_model {
policy["defaultModel"] = json!(value);
}
if let Some(value) = body.allowed_models {
policy["allowedModels"] = json!(value);
}
if let Some(value) = body.tools {
policy["tools"] = json!(value);
}
if let Some(value) = body.skills {
policy["skillOverrides"] = json!(value);
}
if let Some(value) = body.mcp_servers {
policy["mcpOverrides"] = json!(value);
}
let result = state
.control_plane()
.upsert_ai_policy(control_plane::UpsertAiPolicyInput {
id: None,
user_id: Some(user_id.clone()),
workspace_id: None,
allowed_roots_json: existing
.as_ref()
.map(|record| record.allowed_roots_json.clone())
.unwrap_or_else(|| "[]".into()),
model_policy_json: serde_json::to_string(&policy).unwrap_or_else(|_| "{}".into()),
quota_json: existing
.as_ref()
.map(|record| record.quota_json.clone())
.unwrap_or_else(|| "{}".into()),
})
.map_err(|error| WebError::internal(format!("保存用户 AI policy 失败: {error}")))?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "admin.upsert_user_ai_policy".into(),
target_kind: "user_ai_policy".into(),
target_id: Some(user_id.clone()),
metadata_json: json!({
"revision": result.revision,
"updatedFields": updated_fields,
})
.to_string(),
});
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.ai.user-settings.upsert.v1",
"userId": user_id,
"revision": result.revision,
"updatedAt": result.updated_at,
})))
}
// ─── Constants ──────────────────────────────────────────────────────────
const DEFAULT_PI_MODEL: &str = "omniroute/freefirst";
const LIGHTRAG_PROVIDER: &str = "lightrag";
const LIGHTRAG_PROVIDER_DESCRIPTION: &str = "MNote 知识库默认提供者(LightRAG";
const SOURCE_OF_TRUTH: &str = "directory_grants";
const ADMIN_ACCESS_POLICY_PATH: &str = "/admin/access-policy";
const USER_ACCESS_POLICY_PATH: &str = "/user/access-policy";
fn default_mcp_transport() -> String {
"stdio".into()
}
fn default_mcp_network_policy() -> String {
"deny-all".into()
}
/// Canonical MNote-owned tool catalog for local-first AI editing.
const MNOTE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
ToolCatalogEntry {
name: "mnote.current_page.read",
description: "读取当前 MNote 页面",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.selection.read",
description: "读取当前编辑器选区",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.allowed_roots.describe",
description: "描述当前目录授权范围",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.local_file.read",
description: "读取授权目录内的本地文件",
default_policy: "ask",
},
ToolCatalogEntry {
name: "mnote.local_file.patch",
description: "修改授权目录内的本地文件",
default_policy: "ask",
},
ToolCatalogEntry {
name: "mnote.knowledge_rag.query",
description: "通过 MNote LightRAG facade 查询知识库",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.reference.open",
description: "打开知识库引用来源",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.tool_receipt.write",
description: "记录工具调用回执",
default_policy: "allow",
},
];
fn skill_config(
name: &str,
description: &str,
source: &str,
risk_level: &str,
required_scopes: &[&str],
) -> SkillConfig {
SkillConfig {
name: name.into(),
enabled: true,
description: description.into(),
source: source.into(),
risk_level: risk_level.into(),
required_scopes: required_scopes.iter().map(|scope| (*scope).into()).collect(),
}
}
fn mcp_server_config(
name: &str,
description: &str,
transport: &str,
command: &str,
url: &str,
network_policy: &str,
secret_refs: &[&str],
risk_level: &str,
required_scopes: &[&str],
) -> McpServerConfig {
McpServerConfig {
name: name.into(),
enabled: true,
url: url.into(),
transport: transport.into(),
command: command.into(),
network_policy: network_policy.into(),
secret_refs: secret_refs.iter().map(|value| (*value).into()).collect(),
facade_only: true,
sandbox: true,
description: description.into(),
risk_level: risk_level.into(),
required_scopes: required_scopes.iter().map(|scope| (*scope).into()).collect(),
}
}
fn default_skill_registry() -> HashMap<String, SkillConfig> {
let mut skills = HashMap::new();
skills.insert(
"vpn".into(),
skill_config(
"VPN",
"通过 MNote facade 协助诊断代理、出海访问和本机网络路由问题。",
"/home/lix/.codex/skills/vpn/SKILL.md",
"high",
&["network:diagnose", "admin:network"],
),
);
skills.insert(
"chrome-bridge".into(),
skill_config(
"Chrome Bridge",
"通过受控浏览器桥接执行页面验证、截图和 DOM/网络诊断。",
"mcp://chrome-bridge",
"high",
&["browser:automation", "qa:browser"],
),
);
skills.insert(
"context7".into(),
skill_config(
"Context7",
"查询最新官方库文档、API 参数和发布说明。",
"/home/lix/.codex/skills/context7/SKILL.md",
"medium",
&["network:docs"],
),
);
skills.insert(
"searxng".into(),
skill_config(
"SearXNG Search",
"通过本地 SearXNG MCP 做通用网页检索并保留引用。",
"mcp://searxng",
"medium",
&["network:search"],
),
);
skills.insert(
"global-search".into(),
skill_config(
"Global Search",
"聚合本机/网页搜索线索,适合研究型查询入口。",
"/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md",
"medium",
&["network:search"],
),
);
skills.insert(
"mempalace".into(),
skill_config(
"MemPalace",
"读取共享记忆与历史决策事实层,默认通过 MCP facade 受控访问。",
"mcp://mempalace",
"medium",
&["memory:read"],
),
);
skills.insert(
"codegraph".into(),
skill_config(
"CodeGraph",
"读取项目代码图、符号和调用关系,适合开发者工作区。",
"mcp://codegraph",
"medium",
&["workspace:code-read"],
),
);
skills
}
fn default_mcp_server_registry() -> HashMap<String, McpServerConfig> {
let mut servers = HashMap::new();
servers.insert(
"chrome-bridge".into(),
mcp_server_config(
"Chrome Bridge",
"本机 Chromium/Chrome 桥接,用于浏览器 QA、截图与网络请求核验。",
"stdio",
"node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs",
"",
"allow-local",
&[],
"high",
&["browser:automation", "qa:browser"],
),
);
servers.insert(
"context7".into(),
mcp_server_config(
"Context7",
"官方文档检索 MCP,密钥只允许通过 env://CONTEXT7_API_KEY 引用。",
"streamable-http",
"",
"https://mcp.context7.com/mcp",
"allow-all",
&["env://CONTEXT7_API_KEY"],
"medium",
&["network:docs"],
),
);
servers.insert(
"searxng".into(),
mcp_server_config(
"SearXNG",
"本地 SearXNG 检索 MCP,默认只允许访问本地聚合服务。",
"stdio",
"node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs",
"",
"allow-local",
&[],
"medium",
&["network:search"],
),
);
servers.insert(
"mempalace".into(),
mcp_server_config(
"MemPalace",
"共享记忆事实层 MCP,默认只开放 facade/sandbox 后的受控记忆访问。",
"stdio",
"/home/lix/.local/share/uv/tools/mempalace/bin/python -m mempalace.mcp_server --palace /home/lix/.mempalace/palace",
"",
"deny-all",
&[],
"medium",
&["memory:read"],
),
);
servers.insert(
"codegraph".into(),
mcp_server_config(
"CodeGraph",
"代码图 MCP,默认用于只读符号、调用链和影响面查询。",
"stdio",
"codegraph serve --mcp",
"",
"deny-all",
&[],
"medium",
&["workspace:code-read"],
),
);
servers
}
fn effective_skill_registry(model_policy: &Value) -> HashMap<String, SkillConfig> {
let mut skills = default_skill_registry();
let configured = policy_map::<SkillConfig>(model_policy, "skills");
for (id, config) in configured {
skills.insert(id, config);
}
skills
}
fn effective_mcp_registry(model_policy: &Value) -> HashMap<String, McpServerConfig> {
let mut servers = default_mcp_server_registry();
let configured = policy_map::<McpServerConfig>(model_policy, "mcpServers");
for (id, config) in configured {
servers.insert(id, config);
}
servers
}
// ─── Internal helpers ───────────────────────────────────────────────────
/// Rejects unauthenticated / anonymous requests and returns the actor id.
fn ensure_authenticated(context: &RequestContext) -> Result<String, WebError> {
let actor_id = context.auth.actor_id.trim();
let actor_type = context.auth.actor_type.trim();
if actor_id.is_empty()
|| actor_id == "anonymous"
|| actor_type.is_empty()
|| actor_type == "anonymous"
{
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"ai_settings_unauthorized",
"需要 MNote 登录态才能访问 AI 设置",
)
.with_context(context));
}
Ok(actor_id.to_string())
}
/// Reads `ai_policies` for model_policy and quota only.
/// Returns empty objects when no policy exists or on error.
/// Never reads `allowed_roots_json`.
fn load_model_policy_and_quota(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> (Value, Value) {
let policy = match state.control_plane().get_ai_policy(actor_id, workspace_id) {
Ok(Some(p)) => p,
_ => return (json!({}), json!({})),
};
let model_policy = serde_json::from_str(&policy.model_policy_json).unwrap_or(json!({}));
let quota = serde_json::from_str(&policy.quota_json).unwrap_or(json!({}));
(model_policy, quota)
}
fn load_effective_model_policy_and_quota(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> (Value, Value) {
let global_owner = global_policy_owner_id(actor_id);
let (global_policy, global_quota) = load_model_policy_and_quota(state, &global_owner, None);
if actor_id == global_owner && workspace_id.is_none() {
return (global_policy, global_quota);
}
let (user_policy, user_quota) = load_model_policy_and_quota(state, actor_id, workspace_id);
(
merge_effective_user_policy(&global_policy, &user_policy),
merge_json_objects(&global_quota, &user_quota),
)
}
fn global_policy_owner_id(fallback_actor_id: &str) -> String {
std::env::var("MNOTE_ADMIN_USER_IDS")
.ok()
.and_then(|value| {
value
.split(',')
.map(str::trim)
.find(|value| !value.is_empty())
.map(str::to_string)
})
.unwrap_or_else(|| fallback_actor_id.to_string())
}
fn is_configured_admin_user(user_id: &str) -> bool {
std::env::var("MNOTE_ADMIN_USER_IDS")
.ok()
.map(|value| {
value
.split(',')
.map(str::trim)
.any(|value| !value.is_empty() && value == user_id)
})
.unwrap_or(false)
}
fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value {
load_model_policy_and_quota(state, actor_id, workspace_id).0
}
fn merge_json_objects(base: &Value, override_value: &Value) -> Value {
let mut merged = base.clone();
if !merged.is_object() {
merged = json!({});
}
if let Some(values) = override_value.as_object() {
for (key, value) in values {
merged[key] = value.clone();
}
}
merged
}
fn merge_effective_user_policy(global_policy: &Value, user_policy: &Value) -> Value {
let mut merged = global_policy.clone();
if !merged.is_object() {
merged = json!({});
}
for key in ["allowedModels", "defaultModel", "buildPlanTask"] {
if let Some(value) = user_policy.get(key) {
merged[key] = value.clone();
}
}
if let Some(overrides) = user_policy.get("tools").and_then(Value::as_object) {
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("tools")
.or_insert_with(|| json!({}));
if !target.is_object() {
*target = json!({});
}
for (name, value) in overrides {
target[name] = value.clone();
}
}
if let Some(overrides) = user_policy.get("skillOverrides").and_then(Value::as_object) {
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("skills")
.or_insert_with(|| serde_json::to_value(default_skill_registry()).unwrap_or(json!({})));
if !target.is_object() {
*target = serde_json::to_value(default_skill_registry()).unwrap_or(json!({}));
}
if let Some(skills) = target.as_object_mut() {
for (name, enabled) in overrides {
if let Some(skill) = skills.get_mut(name) {
skill["enabled"] = json!(enabled.as_bool().unwrap_or(false));
}
}
}
}
if let Some(overrides) = user_policy.get("mcpOverrides").and_then(Value::as_object) {
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("mcpServers")
.or_insert_with(|| serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({})));
if !target.is_object() {
*target = serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({}));
}
if let Some(servers) = target.as_object_mut() {
for (name, enabled) in overrides {
if let Some(server) = servers.get_mut(name) {
server["enabled"] = json!(enabled.as_bool().unwrap_or(false));
}
}
}
}
merged
}
fn effective_default_model(model_policy: &Value) -> String {
model_policy
.get("defaultModel")
.or_else(|| model_policy.get("default_model"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PI_MODEL)
.to_string()
}
fn effective_models(model_policy: &Value, default_model: &str) -> Vec<ModelEntry> {
let mut model_ids = model_policy
.get("allowedModels")
.or_else(|| model_policy.get("allowed_models"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if !model_ids.iter().any(|value| value == default_model) {
model_ids.insert(0, default_model.to_string());
}
model_ids
.into_iter()
.map(|id| {
let name = id.clone();
ModelEntry {
provider: "omniroute",
is_default: id == default_model,
id,
name,
enabled: true,
}
})
.collect()
}
fn policy_map<T>(model_policy: &Value, key: &str) -> HashMap<String, T>
where
T: for<'de> Deserialize<'de>,
{
model_policy
.get(key)
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default()
}
fn policy_string_map(model_policy: &Value, key: &str) -> HashMap<String, String> {
model_policy
.get(key)
.and_then(Value::as_object)
.map(|values| {
values
.iter()
.filter_map(|(name, value)| {
value
.as_str()
.map(|policy| (name.clone(), policy.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn effective_tool_catalog(policies: &HashMap<String, String>) -> Vec<ToolCatalogEntry> {
MNOTE_TOOL_CATALOG
.iter()
.cloned()
.map(|mut entry| {
if let Some(policy) = policies.get(entry.name) {
entry.default_policy = match policy.as_str() {
"allow" => "allow",
"ask" => "ask",
_ => "deny",
};
}
entry
})
.collect()
}
/// Loads `directory_grants` for the given actor, filters to active entries,
/// and maps to API-friendly `AccessScopeEntry` values.
fn load_active_directory_grants(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> Result<Vec<AccessScopeEntry>, WebError> {
let grants = state
.control_plane()
.list_directory_grants_for_actor(actor_id)
.map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?;
Ok(filter_directory_grants_to_access_scopes(
grants,
workspace_id,
))
}
/// Pure function: filters `DirectoryGrantRecord` items to active entries,
/// optionally narrowed by workspace, and maps to `AccessScopeEntry`.
fn filter_directory_grants_to_access_scopes(
grants: Vec<control_plane::DirectoryGrantRecord>,
workspace_id: Option<&str>,
) -> Vec<AccessScopeEntry> {
grants
.into_iter()
.filter(|g| g.status.trim() == "active")
.filter(|g| workspace_id.map_or(true, |ws| g.workspace_id.as_deref() == Some(ws)))
.map(|g| {
let capabilities = serde_json::from_str(&g.capabilities_json).unwrap_or(json!({}));
AccessScopeEntry {
id: g.id,
user_id: g.user_id,
workspace_id: g.workspace_id,
root_uri: g.root_uri,
root_path: g.root_path,
permission: g.permission,
recursive: g.recursive,
capabilities,
source: g.source,
status: g.status,
created_at: g.created_at,
updated_at: g.updated_at,
}
})
.collect()
}
// ─── Admin helpers ──────────────────────────────────────────────────────
/// Ensures admin auth. Reuses the existing admin check from
/// `local_folder_source::is_local_access_policy_admin_context`.
fn ensure_admin(context: &RequestContext) -> Result<(), WebError> {
if !local_folder_source::is_local_access_policy_admin_context(context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
"需要管理员权限",
)
.with_context(context));
}
Ok(())
}
fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_user",
"用户 ID 不能为空",
));
}
let exists = state
.control_plane()
.list_users(500)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?
.into_iter()
.any(|user| user.id == user_id);
if !exists {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"ai_admin_user_not_found",
"用户不存在",
));
}
Ok(())
}
fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> Result<(), String> {
let global_models = global_policy
.get("allowedModels")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<std::collections::HashSet<_>>();
if let Some(models) = &body.allowed_models {
for model in models {
if !global_models.contains(model.trim()) {
return Err(format!("模型 {model} 不在管理员允许范围内"));
}
}
}
if let Some(default_model) = &body.default_model {
let allowed = body
.allowed_models
.as_ref()
.map(|models| models.iter().any(|model| model == default_model))
.unwrap_or_else(|| global_models.contains(default_model.as_str()));
if !allowed {
return Err("默认模型必须属于该用户允许的模型".into());
}
}
let global_tools = policy_string_map(global_policy, "tools");
if let Some(tools) = &body.tools {
for (name, action) in tools {
if !matches!(action.as_str(), "allow" | "ask" | "deny") {
return Err(format!("工具 {name} 的策略非法"));
}
let global_action = global_tools
.get(name)
.map(String::as_str)
.or_else(|| {
MNOTE_TOOL_CATALOG
.iter()
.find(|tool| tool.name == name)
.map(|tool| tool.default_policy)
})
.unwrap_or("deny");
if tool_policy_rank(action) > tool_policy_rank(global_action) {
return Err(format!("工具 {name} 不能高于全局策略 {global_action}"));
}
}
}
let global_skills = effective_skill_registry(global_policy);
if let Some(skills) = &body.skills {
for (name, enabled) in skills {
if *enabled
&& !global_skills
.get(name)
.map(|skill| skill.enabled)
.unwrap_or(false)
{
return Err(format!("Skill {name} 未被管理员全局启用"));
}
}
}
let global_mcp = effective_mcp_registry(global_policy);
if let Some(servers) = &body.mcp_servers {
for (name, enabled) in servers {
if *enabled
&& !global_mcp
.get(name)
.map(|server| server.enabled && server.facade_only && server.sandbox)
.unwrap_or(false)
{
return Err(format!("MCP {name} 未被管理员全局启用"));
}
}
}
Ok(())
}
fn tool_policy_rank(action: &str) -> u8 {
match action {
"allow" => 2,
"ask" => 1,
_ => 0,
}
}
fn project_user_settings(
user_id: &str,
global_owner: &str,
global_policy: &Value,
user_policy: &Value,
) -> Value {
let effective = merge_effective_user_policy(global_policy, user_policy);
let global_models = effective_models(global_policy, &effective_default_model(global_policy));
let allowed_models = effective_models(&effective, &effective_default_model(&effective));
let global_tools = policy_string_map(global_policy, "tools");
let user_tools = policy_string_map(user_policy, "tools");
let skills = effective_skill_registry(global_policy)
.into_iter()
.map(|(id, skill)| {
let override_value = user_policy
.get("skillOverrides")
.and_then(|value| value.get(&id))
.and_then(Value::as_bool);
json!({
"id": id,
"name": skill.name,
"globallyEnabled": skill.enabled,
"enabled": override_value.unwrap_or(skill.enabled),
"hasOverride": override_value.is_some(),
})
})
.collect::<Vec<_>>();
let mcp_servers = effective_mcp_registry(global_policy)
.into_iter()
.map(|(id, server)| {
let override_value = user_policy
.get("mcpOverrides")
.and_then(|value| value.get(&id))
.and_then(Value::as_bool);
json!({
"id": id,
"name": server.name,
"globallyEnabled": server.enabled,
"enabled": override_value.unwrap_or(server.enabled),
"hasOverride": override_value.is_some(),
"transport": server.transport,
"facadeOnly": server.facade_only,
"sandbox": server.sandbox,
})
})
.collect::<Vec<_>>();
let tools = MNOTE_TOOL_CATALOG
.iter()
.map(|tool| {
let global_action = global_tools
.get(tool.name)
.map(String::as_str)
.unwrap_or(tool.default_policy);
let user_action = user_tools.get(tool.name);
json!({
"name": tool.name,
"description": tool.description,
"globalAction": global_action,
"action": user_action.map(String::as_str).unwrap_or(global_action),
"hasOverride": user_action.is_some(),
})
})
.collect::<Vec<_>>();
json!({
"ok": true,
"schema": "mnote.admin.ai.user-settings.v1",
"userId": user_id,
"globalPolicyOwner": global_owner,
"isGlobalPolicyOwner": user_id == global_owner,
"defaultModel": effective_default_model(&effective),
"catalogModels": global_models,
"allowedModels": allowed_models,
"tools": tools,
"skills": skills,
"mcpServers": mcp_servers,
})
}
fn describe_user_updated_fields(body: &UserAiPolicyBody) -> Vec<&'static str> {
let mut fields = Vec::new();
if body.default_model.is_some() {
fields.push("defaultModel");
}
if body.allowed_models.is_some() {
fields.push("allowedModels");
}
if body.tools.is_some() {
fields.push("tools");
}
if body.skills.is_some() {
fields.push("skillOverrides");
}
if body.mcp_servers.is_some() {
fields.push("mcpOverrides");
}
fields
}
/// Validates that a provider `secretRef` is an `env://` or `secret://`
/// reference. Raw API keys or empty strings are rejected.
fn validate_provider_secret_ref(secret_ref: &str) -> Result<(), String> {
let trimmed = secret_ref.trim();
if trimmed.is_empty() {
return Err("secretRef 不能为空".into());
}
if trimmed.starts_with("env://") || trimmed.starts_with("secret://") {
Ok(())
} else {
Err("secretRef 必须是 env:// 或 secret:// 引用,不允许直接传 API Key".into())
}
}
/// Validates that an MCP server config has `facadeOnly: true` and
/// `sandbox: true`.
fn validate_mcp_server_config(config: &McpServerConfig) -> Result<(), String> {
if !config.facade_only {
return Err("MCP 服务器必须启用 facadeOnly 模式".into());
}
if !config.sandbox {
return Err("MCP 服务器必须启用 sandbox".into());
}
if !matches!(
config.transport.as_str(),
"stdio" | "sse" | "streamable-http"
) {
return Err("MCP transport 只允许 stdio、sse 或 streamable-http".into());
}
if config.transport == "stdio" && config.command.trim().is_empty() {
return Err("stdio MCP 必须配置 command".into());
}
if config.transport != "stdio" && config.url.trim().is_empty() {
return Err("网络 MCP 必须配置 URL".into());
}
if !matches!(
config.network_policy.as_str(),
"deny-all" | "allow-local" | "allow-all"
) {
return Err("MCP networkPolicy 非法".into());
}
for secret_ref in &config.secret_refs {
validate_provider_secret_ref(secret_ref)?;
}
Ok(())
}
/// Merges the client-provided body into the existing policy (if any).
/// Returns `(model_policy_json, quota_json)` strings suitable for
/// `UpsertAiPolicyInput`.
///
/// `allowed_roots_json` is never touched — it stays at its persisted
/// value (or empty if no existing policy).
fn merge_policy_with_existing(
existing: Option<&control_plane::AiPolicyRecord>,
body: &UpsertAiPolicyBody,
) -> (String, String) {
let mut model_policy = existing
.and_then(|rec| serde_json::from_str::<Value>(&rec.model_policy_json).ok())
.unwrap_or_else(|| json!({}));
// ── Merge scalar fields ─────────────────────────────────────────
if let Some(ref val) = body.default_model {
model_policy["defaultModel"] = json!(val);
}
if let Some(ref val) = body.allowed_models {
model_policy["allowedModels"] = json!(val);
}
if let Some(ref val) = body.build_plan_task {
model_policy["buildPlanTask"] = serde_json::to_value(val).unwrap_or(json!({}));
}
// ── Merge providers map ─────────────────────────────────────────
if let Some(ref providers) = body.providers {
let current = model_policy
.get("providers")
.and_then(|v| v.as_object())
.map(|m| m.clone())
.unwrap_or_default();
let mut merged = serde_json::Map::new();
for (k, v) in current {
merged.insert(k, v.clone());
}
for (k, v) in providers {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["providers"] = Value::Object(merged);
}
// ── Merge tools overrides map ───────────────────────────────────
if let Some(ref tools) = body.tools {
let current = model_policy
.get("tools")
.and_then(|v| v.as_object())
.map(|m| m.clone())
.unwrap_or_default();
let mut merged = serde_json::Map::new();
for (k, v) in current {
merged.insert(k, v.clone());
}
for (k, v) in tools {
merged.insert(k.clone(), json!(v));
}
model_policy["tools"] = Value::Object(merged);
}
// ── Merge skills registry ───────────────────────────────────────
if let Some(ref skills) = body.skills {
let current = model_policy
.get("skills")
.and_then(|v| v.as_object())
.map(|m| m.clone())
.unwrap_or_default();
let mut merged = serde_json::Map::new();
for (k, v) in current {
merged.insert(k, v.clone());
}
for (k, v) in skills {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["skills"] = Value::Object(merged);
}
// ── Merge MCP servers map ───────────────────────────────────────
if let Some(ref servers) = body.mcp_servers {
let current = model_policy
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|m| m.clone())
.unwrap_or_default();
let mut merged = serde_json::Map::new();
for (k, v) in current {
merged.insert(k, v.clone());
}
for (k, v) in servers {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["mcpServers"] = Value::Object(merged);
}
let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into());
// ── Merge quota ─────────────────────────────────────────────────
let quota_json = if let Some(ref quota) = body.quota {
let mut existing_quota = existing
.and_then(|rec| serde_json::from_str::<Value>(&rec.quota_json).ok())
.unwrap_or_else(|| json!({}));
if let Some(obj) = quota.as_object() {
for (k, v) in obj {
existing_quota[k] = v.clone();
}
} else {
existing_quota = quota.clone();
}
serde_json::to_string(&existing_quota).unwrap_or_else(|_| "{}".into())
} else {
existing
.map(|rec| rec.quota_json.clone())
.unwrap_or_else(|| "{}".into())
};
(model_policy_json, quota_json)
}
/// Project the admin-facing effective settings view from the raw
/// model_policy JSON.
fn project_admin_settings(
model_policy: &Value,
quota: &Value,
record: Option<&control_plane::AiPolicyRecord>,
) -> AdminAiSettingsResponse {
let default_model = effective_default_model(model_policy);
let allowed_models: Vec<String> = model_policy
.get("allowedModels")
.or_else(|| model_policy.get("allowed_models"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
let mut providers: HashMap<String, ProviderConfig> = model_policy
.get("providers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if providers.is_empty() {
providers.insert(
"omniroute".into(),
ProviderConfig {
name: "Omniroute".into(),
enabled: true,
secret_ref: "env://OMNIROUTE_API_KEY".into(),
default_model: default_model.clone(),
base_url: String::new(),
allowed_models: vec![default_model.clone()],
default_build_model: default_model.clone(),
default_plan_model: default_model.clone(),
default_task_model: default_model.clone(),
failover_chains: vec![],
},
);
}
let build_plan_task = model_policy
.get("buildPlanTask")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let tools = model_policy
.get("tools")
.and_then(|v| {
v.as_object().map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("deny").to_string()))
.collect::<HashMap<_, _>>()
})
})
.unwrap_or_default();
let skills = effective_skill_registry(model_policy);
let mcp_servers = effective_mcp_registry(model_policy);
let revision = record.map(|value| value.revision).unwrap_or(0);
let updated_at = record
.map(|value| value.updated_at.clone())
.unwrap_or_default();
let tool_catalog = effective_tool_catalog(&tools);
AdminAiSettingsResponse {
ok: true,
schema: "mnote.admin.ai.settings.v1",
default_model,
allowed_models,
providers,
build_plan_task,
tools,
skills,
mcp_servers,
quota: quota.clone(),
revision,
updated_at,
tool_catalog,
}
}
/// Returns a concise list of field names that were provided in the body,
/// for audit-log metadata.
fn describe_updated_fields(body: &UpsertAiPolicyBody) -> Vec<&'static str> {
let mut fields: Vec<&'static str> = Vec::new();
if body.default_model.is_some() {
fields.push("defaultModel");
}
if body.allowed_models.is_some() {
fields.push("allowedModels");
}
if body.providers.is_some() {
fields.push("providers");
}
if body.build_plan_task.is_some() {
fields.push("buildPlanTask");
}
if body.tools.is_some() {
fields.push("tools");
}
if body.skills.is_some() {
fields.push("skills");
}
if body.mcp_servers.is_some() {
fields.push("mcpServers");
}
if body.quota.is_some() {
fields.push("quota");
}
fields
}
// ─── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use control_plane::DirectoryGrantRecord;
fn make_grant(id: &str, workspace_id: Option<&str>, status: &str) -> DirectoryGrantRecord {
DirectoryGrantRecord {
id: id.into(),
user_id: "user_test".into(),
workspace_id: workspace_id.map(Into::into),
root_uri: format!("file:///mnt/test/{id}"),
root_path: format!("/mnt/test/{id}"),
permission: "read".into(),
recursive: false,
capabilities_json: "[]".into(),
source: "access_policy".into(),
status: status.into(),
created_by: None,
created_at: "2026-07-04T00:00:00Z".into(),
updated_at: "2026-07-04T00:00:00Z".into(),
revision: 1,
}
}
#[test]
fn filter_grants_revoked_are_excluded() {
let grants = vec![
make_grant("g1", Some("ws_a"), "active"),
make_grant("g2", Some("ws_a"), "revoked"),
];
let result = filter_directory_grants_to_access_scopes(grants, None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "g1");
}
#[test]
fn filter_grants_workspace_filter() {
let grants = vec![
make_grant("g1", Some("ws_a"), "active"),
make_grant("g2", Some("ws_b"), "active"),
make_grant("g3", None, "active"),
];
let result = filter_directory_grants_to_access_scopes(grants, Some("ws_a"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "g1");
}
#[test]
fn filter_grants_no_workspace_returns_all_active() {
let grants = vec![
make_grant("g1", Some("ws_a"), "active"),
make_grant("g2", None, "active"),
];
let result = filter_directory_grants_to_access_scopes(grants, None);
assert_eq!(result.len(), 2);
}
#[test]
fn effective_response_includes_directory_grant_roots() {
let response = EffectiveAiSettings {
default_model: "test/model".into(),
providers: vec![],
models: vec![],
allowed_roots: vec![AccessScopeEntry {
id: "grant_1".into(),
user_id: "user_1".into(),
workspace_id: None,
root_uri: "file:///tmp/test".into(),
root_path: "/tmp/test".into(),
permission: "read".into(),
recursive: true,
capabilities: json!([]),
source: "test".into(),
status: "active".into(),
created_at: "2026-07-04T00:00:00Z".into(),
updated_at: "2026-07-04T00:00:00Z".into(),
}],
model_policy: json!({"allowedModels": []}),
quota: json!({}),
tool_catalog: vec![],
skills: vec![],
mcp_servers: vec![],
lightrag_provider: LightRagProviderRef {
provider: "test",
description: "test provider",
},
access_policy_links: AccessPolicyLinks {
admin: "/admin",
user: "/user",
},
source_of_truth: "directory_grants",
};
let value = serde_json::to_value(&response).expect("serialize");
assert_eq!(value["allowedRoots"][0]["id"], "grant_1");
assert_eq!(value["sourceOfTruth"], "directory_grants");
}
#[test]
fn model_policy_can_select_default_without_affecting_roots() {
let policy = json!({
"defaultModel": "omniroute/freefirst",
"allowedModels": ["omniroute/freefirst", "omniroute/fast"]
});
assert_eq!(effective_default_model(&policy), "omniroute/freefirst");
let models = effective_models(&policy, "omniroute/freefirst");
assert_eq!(models.len(), 2);
assert!(models[0].is_default);
}
// ─── Admin body validation ────────────────────────────────────────────
#[test]
fn validate_provider_secret_ref_env_passes() {
assert!(validate_provider_secret_ref("env://OPENAI_API_KEY").is_ok());
}
#[test]
fn validate_provider_secret_ref_secret_passes() {
assert!(validate_provider_secret_ref("secret://my-vault-key").is_ok());
}
#[test]
fn validate_provider_secret_ref_empty_fails() {
assert!(validate_provider_secret_ref("").is_err());
}
#[test]
fn validate_provider_secret_ref_raw_key_fails() {
assert!(validate_provider_secret_ref("sk-abc123def456").is_err());
assert!(validate_provider_secret_ref("my-api-key").is_err());
}
#[test]
fn validate_mcp_config_valid() {
let config = McpServerConfig {
name: "test".into(),
enabled: true,
url: "http://localhost:9999".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: true,
sandbox: true,
description: String::new(),
risk_level: String::new(),
required_scopes: vec![],
};
assert!(validate_mcp_server_config(&config).is_ok());
}
#[test]
fn validate_mcp_config_no_facade_fails() {
let config = McpServerConfig {
name: "test".into(),
enabled: true,
url: "http://localhost:9999".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: false,
sandbox: true,
description: String::new(),
risk_level: String::new(),
required_scopes: vec![],
};
assert!(validate_mcp_server_config(&config).is_err());
}
#[test]
fn validate_mcp_config_no_sandbox_fails() {
let config = McpServerConfig {
name: "test".into(),
enabled: true,
url: "http://localhost:9999".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: true,
sandbox: false,
description: String::new(),
risk_level: String::new(),
required_scopes: vec![],
};
assert!(validate_mcp_server_config(&config).is_err());
}
// ─── Admin merge helpers ──────────────────────────────────────────────
#[test]
fn merge_policy_with_existing_creates_from_empty() {
let body = UpsertAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into(), "omniroute/fast".into()]),
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: None,
mcp_servers: None,
quota: None,
};
let (model_json, quota_json) = merge_policy_with_existing(None, &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["defaultModel"], "omniroute/freefirst");
assert_eq!(parsed["allowedModels"][0], "omniroute/freefirst");
assert_eq!(quota_json, "{}");
}
#[test]
fn merge_policy_with_existing_preserves_other_fields() {
let existing_value = json!({
"defaultModel": "old/model",
"allowedModels": ["old/model"],
"providers": {
"keep": {"name": "Keep", "enabled": true, "secretRef": "env://KEEP", "defaultModel": "old/model"}
}
});
let existing_rec = control_plane::AiPolicyRecord {
id: "dummy".into(),
user_id: Some("u1".into()),
workspace_id: None,
allowed_roots_json: "[]".into(),
model_policy_json: serde_json::to_string(&existing_value).unwrap(),
quota_json: r#"{"tokens":1000}"#.into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
revision: 1,
};
let body = UpsertAiPolicyBody {
default_model: Some("new/model".into()),
allowed_models: Some(vec!["new/model".into()]),
workspace_id: None,
providers: None, // don't touch existing providers
build_plan_task: None,
tools: None,
skills: None,
mcp_servers: None,
quota: Some(json!({"tokens": 2000})),
};
let (model_json, quota_json) = merge_policy_with_existing(Some(&existing_rec), &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["defaultModel"], "new/model");
// Existing providers preserved
assert_eq!(parsed["providers"]["keep"]["name"], "Keep");
let quota_parsed: Value = serde_json::from_str(&quota_json).unwrap();
assert_eq!(quota_parsed["tokens"], 2000);
}
#[test]
fn merge_policy_tools_override() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: Some([("mnote.local_file.read".into(), "deny".into())].into()),
skills: None,
mcp_servers: None,
quota: None,
};
let (model_json, _) = merge_policy_with_existing(None, &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["tools"]["mnote.local_file.read"], "deny");
}
#[test]
fn merge_policy_skills_and_mcp() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: Some(
[(
"img-skill".into(),
SkillConfig {
name: "Image Gen".into(),
enabled: true,
description: "Generate images".into(),
source: "test".into(),
risk_level: "low".into(),
required_scopes: vec![],
},
)]
.into(),
),
mcp_servers: Some(
[(
"fs-mcp".into(),
McpServerConfig {
name: "File System".into(),
enabled: true,
url: "http://mcp:9090".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: true,
sandbox: true,
description: "Test MCP".into(),
risk_level: "medium".into(),
required_scopes: vec![],
},
)]
.into(),
),
quota: None,
};
let (model_json, _) = merge_policy_with_existing(None, &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["skills"]["img-skill"]["name"], "Image Gen");
assert_eq!(parsed["skills"]["img-skill"]["enabled"], true);
assert_eq!(parsed["mcpServers"]["fs-mcp"]["url"], "http://mcp:9090");
assert_eq!(parsed["mcpServers"]["fs-mcp"]["facadeOnly"], true);
assert_eq!(parsed["mcpServers"]["fs-mcp"]["sandbox"], true);
}
// ─── Admin projection ────────────────────────────────────────────────
#[test]
fn project_admin_settings_empty_policy_yields_defaults() {
let response = project_admin_settings(&json!({}), &json!({}), None);
assert_eq!(response.default_model, DEFAULT_PI_MODEL);
assert!(response.providers.contains_key("omniroute"));
assert!(response.tools.is_empty());
assert!(response.skills.contains_key("vpn"));
assert!(response.skills.contains_key("chrome-bridge"));
assert!(response.skills.contains_key("context7"));
assert!(response.skills.contains_key("searxng"));
assert!(response.skills.contains_key("global-search"));
assert!(response.skills.contains_key("mempalace"));
assert!(response.skills.contains_key("codegraph"));
assert!(response.mcp_servers.contains_key("chrome-bridge"));
assert!(response.mcp_servers.contains_key("context7"));
assert!(response.mcp_servers.contains_key("searxng"));
assert!(response.mcp_servers.contains_key("mempalace"));
assert!(response.mcp_servers.contains_key("codegraph"));
}
#[test]
fn project_admin_settings_with_full_policy() {
let policy = json!({
"defaultModel": "omniroute/premium",
"allowedModels": ["omniroute/premium", "omniroute/fast"],
"providers": {
"omniroute": {
"name": "Omniroute",
"enabled": true,
"secretRef": "env://OMNIROUTE_KEY",
"defaultModel": "omniroute/premium"
}
},
"buildPlanTask": {
"default": "omniroute/premium",
"failover": ["omniroute/fast"]
},
"tools": {
"mnote.local_file.patch": "deny"
},
"skills": {
"img-skill": {
"name": "Image Gen",
"enabled": true
}
},
"mcpServers": {
"fs-mcp": {
"name": "File System",
"enabled": true,
"url": "http://mcp:9090",
"facadeOnly": true,
"sandbox": true
}
}
});
let quota = json!({"tokens": 5000});
let response = project_admin_settings(&policy, &quota, None);
assert_eq!(response.default_model, "omniroute/premium");
assert_eq!(response.allowed_models.len(), 2);
assert!(response.providers.contains_key("omniroute"));
assert_eq!(
response.tools.get("mnote.local_file.patch").unwrap(),
"deny"
);
assert!(response.skills.contains_key("img-skill"));
assert!(response.mcp_servers.contains_key("fs-mcp"));
assert_eq!(response.quota["tokens"], 5000);
}
#[test]
fn describe_updated_fields_empty() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: None,
mcp_servers: None,
quota: None,
};
let fields = describe_updated_fields(&body);
assert!(fields.is_empty());
}
#[test]
fn describe_updated_fields_all() {
let body = UpsertAiPolicyBody {
default_model: Some("m".into()),
allowed_models: Some(vec![]),
workspace_id: None,
providers: Some(HashMap::new()),
build_plan_task: Some(BuildPlanTaskConfig {
default: Some("m".into()),
failover: vec![],
}),
tools: Some(HashMap::new()),
skills: Some(HashMap::new()),
mcp_servers: Some(HashMap::new()),
quota: Some(json!({})),
};
let fields = describe_updated_fields(&body);
assert_eq!(fields.len(), 8);
}
#[test]
fn user_policy_merges_models_and_disables_skill_and_mcp() {
let global = json!({
"defaultModel": "omniroute/freefirst",
"allowedModels": ["omniroute/freefirst", "omniroute/fast"],
"skills": {
"search": {"name": "Search", "enabled": true}
},
"mcpServers": {
"docs": {"name": "Docs", "enabled": true, "facadeOnly": true, "sandbox": true}
}
});
let user = json!({
"defaultModel": "omniroute/fast",
"allowedModels": ["omniroute/fast"],
"skillOverrides": {"search": false},
"mcpOverrides": {"docs": false}
});
let merged = merge_effective_user_policy(&global, &user);
assert_eq!(merged["defaultModel"], "omniroute/fast");
assert_eq!(merged["allowedModels"], json!(["omniroute/fast"]));
assert_eq!(merged["skills"]["search"]["enabled"], false);
assert_eq!(merged["mcpServers"]["docs"]["enabled"], false);
}
#[test]
fn user_policy_rejects_privilege_escalation() {
let global = json!({
"allowedModels": ["omniroute/freefirst"],
"tools": {"mnote.local_file.patch": "ask"},
"skills": {"search": {"name": "Search", "enabled": false}},
"mcpServers": {"docs": {"name": "Docs", "enabled": false, "facadeOnly": true, "sandbox": true}}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/premium".into()),
allowed_models: Some(vec!["omniroute/premium".into()]),
tools: Some([("mnote.local_file.patch".into(), "allow".into())].into()),
skills: Some([("search".into(), true)].into()),
mcp_servers: Some([("docs".into(), true)].into()),
};
assert!(validate_user_policy_body(&body, &global).is_err());
}
#[test]
fn user_tool_policy_can_only_reduce_global_permission() {
let global = json!({
"allowedModels": ["omniroute/freefirst"],
"tools": {"mnote.local_file.patch": "ask"}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into()]),
tools: Some([("mnote.local_file.patch".into(), "deny".into())].into()),
skills: None,
mcp_servers: None,
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
}