feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail) - Plugin access grants + per-user config (DB tables + API + frontend forms) - Capabilities-based guard (caps.rs) replacing role-id checks - Mobile connector: message routing, payload types, router refactor - Telegram bot: auth flow, event handling improvements - Honcho plugin: substantial rework - Sidebar: plugin pages integration, role-driven visibility - i18n: new strings for plugins, connectors, capabilities - Remove unused mascot asset
This commit is contained in:
@@ -19,6 +19,8 @@ pub mod mcp_user_servers;
|
||||
pub mod memory_docs;
|
||||
pub mod oauth_providers;
|
||||
pub mod plugins;
|
||||
pub mod plugin_access;
|
||||
pub mod plugin_user_configs;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
pub mod scheduled_jobs;
|
||||
@@ -281,6 +283,36 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Which users may see/configure each plugin. `plugin_id` is deliberately
|
||||
// NOT a foreign key to plugins.id: plugin identity comes from compiled
|
||||
// registration, and a `plugins` row is only created lazily on first
|
||||
// toggle — a plugin never configured must still be grantable.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS plugin_access (
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (plugin_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Per-user plugin settings (e.g. Telegram's pairing status). Lives in
|
||||
// `system.db` — admin-readable, never secrets. `plugin_id` not a FK for
|
||||
// the same reason as plugin_access.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS plugin_user_configs (
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (plugin_id, user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS tool_permission_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1094,4 +1126,87 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `plugin_access` / `plugin_user_configs`: grant/revoke round-trip, JSON
|
||||
/// blob round-trip, and the `users(id)` cascade. `plugin_id` deliberately
|
||||
/// accepts ids with no `plugins` row (identity = compiled registration).
|
||||
#[tokio::test]
|
||||
async fn plugin_access_and_user_configs_round_trip() {
|
||||
let dir = temp_dir("plugin-tables");
|
||||
let path = dir.join("system.db");
|
||||
let pool = init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
let mk_user = |id: &str| {
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)")
|
||||
.bind(id.to_string()).bind(id.to_string())
|
||||
.execute(&pool)
|
||||
};
|
||||
mk_user("u1").await.unwrap();
|
||||
mk_user("u2").await.unwrap();
|
||||
|
||||
// No `plugins` row for "telegram" — grants must still work.
|
||||
plugin_access::grant(&pool, "telegram", "u1").await.unwrap();
|
||||
plugin_access::grant(&pool, "telegram", "u1").await.unwrap(); // idempotent
|
||||
plugin_access::grant(&pool, "telegram", "u2").await.unwrap();
|
||||
assert!(plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
|
||||
assert!(!plugin_access::has_access(&pool, "comfyui", "u1").await.unwrap());
|
||||
assert_eq!(plugin_access::plugin_ids_for_user(&pool, "u1").await.unwrap(), vec!["telegram"]);
|
||||
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u1", "u2"]);
|
||||
|
||||
plugin_access::set_access(&pool, "telegram", &["u2".to_string()]).await.unwrap();
|
||||
assert!(!plugin_access::has_access(&pool, "telegram", "u1").await.unwrap());
|
||||
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), vec!["u2"]);
|
||||
|
||||
plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": true})).await.unwrap();
|
||||
assert_eq!(
|
||||
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(),
|
||||
Some(serde_json::json!({"linked": true})),
|
||||
);
|
||||
plugin_user_configs::set(&pool, "telegram", "u2", &serde_json::json!({"linked": false})).await.unwrap();
|
||||
assert_eq!(
|
||||
plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(),
|
||||
Some(serde_json::json!({"linked": false})),
|
||||
);
|
||||
assert_eq!(plugin_user_configs::get(&pool, "telegram", "u1").await.unwrap(), None);
|
||||
|
||||
// Deleting the user cascades both tables.
|
||||
sqlx::query("DELETE FROM users WHERE id = 'u2'").execute(&pool).await.unwrap();
|
||||
assert_eq!(plugin_access::users_for_plugin(&pool, "telegram").await.unwrap(), Vec::<String>::new());
|
||||
assert_eq!(plugin_user_configs::get(&pool, "telegram", "u2").await.unwrap(), None);
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `effective_access`: the runtime gate channels enforce. Admin holds every
|
||||
/// plugin implicitly (even one they were never granted); a member needs an
|
||||
/// explicit grant; an unknown user fails closed.
|
||||
#[tokio::test]
|
||||
async fn plugin_effective_access_admin_short_circuit_and_grants() {
|
||||
let dir = temp_dir("plugin-effective-access");
|
||||
let path = dir.join("system.db");
|
||||
let pool = init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
// A non-admin role, plus one admin and one member user.
|
||||
sqlx::query("INSERT INTO roles (id, label, permission_group) VALUES ('member', 'Member', 'default')")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('adm', 'adm', 'admin', 0)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('mem', 'mem', 'member', 0)")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
plugin_access::grant(&pool, "telegram", "mem").await.unwrap();
|
||||
|
||||
// Admin: every plugin, granted or not.
|
||||
assert!(plugin_access::effective_access(&pool, "telegram", "adm").await.unwrap());
|
||||
assert!(plugin_access::effective_access(&pool, "comfyui", "adm").await.unwrap());
|
||||
// Member: only what they were granted.
|
||||
assert!(plugin_access::effective_access(&pool, "telegram", "mem").await.unwrap());
|
||||
assert!(!plugin_access::effective_access(&pool, "comfyui", "mem").await.unwrap());
|
||||
// Unknown user → fail closed.
|
||||
assert!(!plugin_access::effective_access(&pool, "telegram", "ghost").await.unwrap());
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Which users may see and configure each plugin.
|
||||
//!
|
||||
//! Registry junction table in `system.db` — opt-in access: a plugin with no
|
||||
//! rows here is visible to admins only. Mirrors `mcp_global_access`, except
|
||||
//! `plugin_id` is a bare TEXT (not a FK to `plugins.id`): plugin identity
|
||||
//! comes from compiled registration and a `plugins` row exists only after
|
||||
//! the first toggle, so a never-configured plugin must still be grantable.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The ids of the plugins a user has been granted access to.
|
||||
pub async fn plugin_ids_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT plugin_id FROM plugin_access WHERE user_id = ? ORDER BY plugin_id",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(p,)| p).collect())
|
||||
}
|
||||
|
||||
/// The ids of the users granted access to a given plugin.
|
||||
pub async fn users_for_plugin(pool: &SqlitePool, plugin_id: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT user_id FROM plugin_access WHERE plugin_id = ? ORDER BY user_id",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(u,)| u).collect())
|
||||
}
|
||||
|
||||
pub async fn has_access(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<bool> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"SELECT 1 FROM plugin_access WHERE plugin_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// The effective runtime access decision for a channel adapter: the admin role
|
||||
/// holds every plugin implicitly (mirroring the web `/plugins/mine` view),
|
||||
/// otherwise the user must be granted in `plugin_access`. An unknown user id
|
||||
/// resolves to `false`. Errors propagate — the caller fails closed.
|
||||
pub async fn effective_access(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<bool> {
|
||||
let role = sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
match role {
|
||||
Some((r,)) if r == crate::db::roles::ADMIN_ROLE_ID => Ok(true),
|
||||
Some(_) => has_access(pool, plugin_id, user_id).await,
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Grants a user access to a plugin. Idempotent on the PK.
|
||||
pub async fn grant(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ? AND user_id = ?")
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the full access list for a plugin in one shot (the admin UI's
|
||||
/// "who can use this" checklist).
|
||||
pub async fn set_access(pool: &SqlitePool, plugin_id: &str, user_ids: &[String]) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("DELETE FROM plugin_access WHERE plugin_id = ?")
|
||||
.bind(plugin_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for user_id in user_ids {
|
||||
sqlx::query("INSERT OR IGNORE INTO plugin_access (plugin_id, user_id) VALUES (?, ?)")
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Per-user plugin configuration blobs (`plugin_user_configs` table).
|
||||
//!
|
||||
//! Registry table in `system.db` — **admin-readable, never secrets**. A plugin
|
||||
//! with a non-empty `user_config_schema()` lets each granted user submit their
|
||||
//! own settings from the UI (e.g. Telegram's pairing code); the plugin's
|
||||
//! `update_user_config` hook validates and stores here. `plugin_id` is a bare
|
||||
//! TEXT for the same reason as `plugin_access`.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn get(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<Option<Value>> {
|
||||
let row = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT config FROM plugin_user_configs WHERE plugin_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((json,)) => Ok(Some(serde_json::from_str(&json)?)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set(pool: &SqlitePool, plugin_id: &str, user_id: &str, config: &Value) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO plugin_user_configs (plugin_id, user_id, config, updated_at)
|
||||
VALUES (?1, ?2, ?3, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id)
|
||||
DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.bind(serde_json::to_string(config)?)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, plugin_id: &str, user_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM plugin_user_configs WHERE plugin_id = ? AND user_id = ?")
|
||||
.bind(plugin_id)
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,6 +31,11 @@ pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
||||
/// is a single [`grant`], no code change.
|
||||
pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
||||
|
||||
/// Enable/disable plugins, edit their instance-wide config and grant per-user
|
||||
/// access (the `plugin_access` table). Admin-only for now — same implicit-hold
|
||||
/// pattern as [`MANAGE_SHARED_FOLDERS`].
|
||||
pub const MANAGE_PLUGINS: &str = "plugin.manage";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user