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:
2026-07-19 20:47:09 +01:00
parent f85876350e
commit ba911ae8cb
50 changed files with 3186 additions and 305 deletions
+17
View File
@@ -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}`")))
}
}
+27
View File
@@ -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
View File
@@ -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())
}
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.