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:
@@ -0,0 +1,17 @@
|
||||
//! Shared role-capability gate for API handlers.
|
||||
|
||||
use skald_core::db::role_capabilities;
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
use super::ApiError;
|
||||
|
||||
/// Fails with 403 unless the caller's role holds `cap` (admin holds everything).
|
||||
pub async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> {
|
||||
let user = skald_core::db::users::get(skald.db(), user_id).await?
|
||||
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
|
||||
if role_capabilities::has(skald.db(), &user.role_id, cap).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::forbidden(format!("your role lacks the capability `{cap}`")))
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,36 @@ pub async fn require_auth(
|
||||
|
||||
match session_token(req.headers()).and_then(|t| skald.sessions().user_of(&t)) {
|
||||
Some(user_id) => {
|
||||
// `AuthUser` is the bin-private identity for host handlers; `Caller`
|
||||
// is the core-api mirror so plugin routers (which cannot name
|
||||
// bin-crate types) can identify the caller too.
|
||||
req.extensions_mut().insert(core_api::plugin::Caller { user_id: user_id.clone() });
|
||||
req.extensions_mut().insert(AuthUser { user_id });
|
||||
next.run(req).await
|
||||
}
|
||||
None => StatusCode::UNAUTHORIZED.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enabled-gate for plugin-contributed routers (`/api/plugin/<id>/…`).
|
||||
///
|
||||
/// Plugin routers are all mounted at boot — including those of disabled
|
||||
/// plugins, whose `http_router()` must be safe to build before `start`. This
|
||||
/// gate re-checks the enabled flag in the DB on **every** request, so a
|
||||
/// disabled plugin answers 404 (it does not reveal it exists) and enabling one
|
||||
/// at runtime serves its routes immediately, with no restart. Runs inside
|
||||
/// `require_auth`, so by this point the caller is a logged-in user.
|
||||
pub async fn plugin_enabled_gate(
|
||||
State((skald, plugin_id)): State<(Arc<Skald>, String)>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
match skald.plugin_manager().is_enabled(&plugin_id).await {
|
||||
Ok(true) => next.run(req).await,
|
||||
Ok(false) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin = %plugin_id, error = %e, "plugin enabled-gate: DB read failed");
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -20,22 +20,12 @@ use serde_json::{json, Value};
|
||||
use skald_core::db::{mcp_catalog, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
use super::caps::require_cap;
|
||||
use super::guard::AuthUser;
|
||||
use super::{require_context, ApiError};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fails with 403 unless the caller's role holds `cap` (admin holds everything).
|
||||
async fn require_cap(skald: &Skald, user_id: &str, cap: &str) -> Result<(), ApiError> {
|
||||
let user = skald_core::db::users::get(skald.db(), user_id).await?
|
||||
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
|
||||
if role_capabilities::has(skald.db(), &user.role_id, cap).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::forbidden(format!("your role lacks the capability `{cap}`")))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json_opt<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
|
||||
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod auth;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod approval;
|
||||
pub mod caps;
|
||||
pub mod cron;
|
||||
pub mod dev;
|
||||
pub mod file_watch;
|
||||
@@ -170,9 +171,13 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// Config properties
|
||||
.route("/config", get(config::list_properties))
|
||||
.route("/config/{key}", put(config::set_property))
|
||||
// Plugins
|
||||
// Plugins — admin: manage + access grants; user: own view + own config
|
||||
.route("/plugins", get(plugins::list))
|
||||
.route("/plugins/mine", get(plugins::mine))
|
||||
.route("/plugins/pages", get(plugins::pages))
|
||||
.route("/plugins/{id}", put(plugins::update))
|
||||
.route("/plugins/{id}/access", get(plugins::get_access).put(plugins::set_access))
|
||||
.route("/plugins/{id}/my-config", put(plugins::update_my_config))
|
||||
// Roles
|
||||
.route("/roles", get(roles::list).post(roles::create))
|
||||
.route("/roles/{id}", put(roles::update).delete(roles::delete))
|
||||
|
||||
+123
-6
@@ -1,16 +1,36 @@
|
||||
//! Plugin management API.
|
||||
//!
|
||||
//! Two audiences, mirroring the Connectors split:
|
||||
//! - **Admin** (`plugin.manage` capability): enable/disable, instance-wide
|
||||
//! config, and the per-user access grants (`plugin_access`).
|
||||
//! - **Any user**: sees the plugins granted to them (`/plugins/mine`) and
|
||||
//! edits their own per-user config when the plugin declares a
|
||||
//! `user_config_schema` (e.g. Telegram's pairing code).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Extension, Path, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use skald_core::db::{role_capabilities, roles::ADMIN_ROLE_ID, users};
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
use super::caps::require_cap;
|
||||
use super::guard::AuthUser;
|
||||
use super::ApiError;
|
||||
|
||||
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<impl IntoResponse, ApiError> {
|
||||
// ── Admin: enable/disable + instance-wide config ─────────────────────────────
|
||||
|
||||
pub async fn list(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||
let plugins = skald.plugin_manager().list().await?;
|
||||
Ok(Json(plugins))
|
||||
}
|
||||
@@ -22,10 +42,107 @@ pub struct UpdateBody {
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateBody>,
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||
skald.plugin_manager().update_config(&id, body.enabled, body.config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Admin: per-user access grants ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccessEntry {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub role_id: String,
|
||||
pub granted: bool,
|
||||
}
|
||||
|
||||
pub async fn get_access(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||
let granted: std::collections::HashSet<String> =
|
||||
skald.plugin_manager().list_grants(&id).await?.into_iter().collect();
|
||||
let entries: Vec<AccessEntry> = users::list(skald.db())
|
||||
.await?
|
||||
.iter()
|
||||
.map(|u| AccessEntry {
|
||||
granted: granted.contains(&u.id),
|
||||
user_id: u.id.clone(),
|
||||
username: u.username.clone(),
|
||||
role_id: u.role_id.clone(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetAccessBody {
|
||||
pub user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn set_access(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<SetAccessBody>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
|
||||
skald.plugin_manager().set_grants(&id, &body.user_ids).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── User: my plugins + my per-user config ────────────────────────────────────
|
||||
|
||||
/// Whether the caller is on the built-in admin role (admins implicitly hold
|
||||
/// access to every enabled plugin).
|
||||
async fn is_admin(skald: &Skald, user_id: &str) -> Result<bool, ApiError> {
|
||||
let user = users::get(skald.db(), user_id).await?
|
||||
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
|
||||
Ok(user.role_id == ADMIN_ROLE_ID)
|
||||
}
|
||||
|
||||
pub async fn mine(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let admin = is_admin(&skald, &auth.user_id).await?;
|
||||
let plugins = skald.plugin_manager().list_accessible(&auth.user_id, admin).await?;
|
||||
Ok(Json(plugins))
|
||||
}
|
||||
|
||||
/// The plugin-contributed web pages visible to the caller (menu entries).
|
||||
/// Admin sees everything; everyone else sees the non-`admin_only` pages of
|
||||
/// enabled plugins they hold a `plugin_access` grant for.
|
||||
pub async fn pages(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let admin = is_admin(&skald, &auth.user_id).await?;
|
||||
let pages = skald.plugin_manager().web_pages_for(&auth.user_id, admin).await?;
|
||||
Ok(Json(pages))
|
||||
}
|
||||
|
||||
pub async fn update_my_config(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<String>,
|
||||
Json(config): Json<Value>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let admin = is_admin(&skald, &auth.user_id).await?;
|
||||
if !admin && !skald.plugin_manager().has_access(&id, &auth.user_id).await? {
|
||||
return Err(ApiError::forbidden("you have no access to this plugin"));
|
||||
}
|
||||
skald.plugin_manager()
|
||||
.update_user_config(&id, &auth.user_id, config)
|
||||
.await
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+22
-2
@@ -84,9 +84,29 @@ impl WebServer {
|
||||
// stateless plugin routers via `nest`.
|
||||
let mut router = Router::new()
|
||||
.nest("/api", api)
|
||||
.with_state(skald);
|
||||
.with_state(skald.clone());
|
||||
// Every plugin router is mounted — enabled or not (they must be safe to
|
||||
// build pre-start). Two shared gates wrap each one: `require_auth`
|
||||
// (outermost — same session-cookie gate as /api) and the enabled-gate,
|
||||
// which re-checks the DB flag per request so enable/disable takes effect
|
||||
// without a restart. Plugin responses also get `Cache-Control: no-cache`:
|
||||
// they live under /api (no cache headers otherwise) and the browser must
|
||||
// never serve a stale page fragment after a rebuild.
|
||||
for (id, plugin_router) in plugin_routers {
|
||||
router = router.nest(&format!("/api/plugin/{id}"), plugin_router);
|
||||
let gated = plugin_router
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
(Arc::clone(&skald), id.clone()),
|
||||
api::guard::plugin_enabled_gate,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
Arc::clone(&skald),
|
||||
api::guard::require_auth,
|
||||
));
|
||||
router = router.nest(&format!("/api/plugin/{id}"), gated);
|
||||
}
|
||||
// Serve the data/ directory under /data/ (accessible via URL), behind the
|
||||
// same session-cookie gate as /api — uploads are private user content.
|
||||
|
||||
Reference in New Issue
Block a user