move per-user plugin grants to the user's page
Nightly Build / build (push) Successful in 7m16s

Granting was a checklist of every user on each plugin's page, so "what may
this person use?" meant opening every plugin in turn — and the answer lived
on N pages while the connector half of it already lived on one. Both grant
sections now sit together on #users/{id}: same row list, same disabled chip,
same replace-the-whole-set save. The plugin's own page keeps a read-only
roster of who holds it, linking back to each person.

- db: plugin_access::set_for_user, the per-user twin of set_for_user on
  mcp_catalog_access; set_access stays as the inverse read model
- PluginManager: list_grants_for_user / set_grants_for_user, which omit and
  reject manages_own_access plugins (a box that controls nothing is worse
  than no box)
- GET/PUT /api/users/{id}/plugins, mounted next to /users/{id}/connectors;
  PUT /api/plugins/{id}/access is gone, GET remains as the roster

No push after the write, unlike a connector grant: that one gates a runtime
snapshotted at login, while a plugin grant is re-read from plugin_access on
every request that depends on it (sidebar pages, /plugins/mine, and each
inbound channel message), so a revoke lands with no bus event.

Docs updated with where access is granted, and why mobile-connector is
absent from that list.
This commit is contained in:
2026-07-29 11:36:47 +01:00
parent 8bcf09a67e
commit da8a835d70
15 changed files with 327 additions and 93 deletions
+6 -4
View File
@@ -179,12 +179,13 @@ pub fn router() -> Router<Arc<Skald>> {
// Config properties
.route("/config", get(config::list_properties))
.route("/config/{key}", put(config::set_property))
// Plugins — admin: manage + access grants; user: own view + own config
// Plugins — admin: manage + read a plugin's audience; user: own view + own
// config. Granting is user-side, next to the connectors (see below).
.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}/access", get(plugins::get_access))
.route("/plugins/{id}/my-config", put(plugins::update_my_config))
// Roles
.route("/roles", get(roles::list).post(roles::create))
@@ -193,9 +194,10 @@ pub fn router() -> Router<Arc<Skald>> {
.route("/users", get(users_mgmt::list).post(users_mgmt::create))
.route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete))
.route("/users/{id}/password", post(users_mgmt::reset_password))
// Per-user connector access (admin curates which registered MCP connectors
// each user may use — globals + per-user catalog, in one surface).
// Per-user access grants: what this person may use. Both live here — one
// page answers "what does Marco have?" instead of N connector/plugin pages.
.route("/users/{id}/connectors", get(mcp::user_connectors_get).put(mcp::user_connectors_set))
.route("/users/{id}/plugins", get(plugins::user_plugins_get).put(plugins::user_plugins_set))
// Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
+54 -10
View File
@@ -1,11 +1,18 @@
//! 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`).
//! - **Admin** (`plugin.manage` capability): enable/disable and instance-wide
//! config here; the per-user access grants (`plugin_access`) are **written**
//! from the user's own page (`PUT /api/users/{id}/plugins`, below), leaving
//! `GET /{id}/access` as the read-only "who has this" list.
//! - **Any user**: sees the plugins granted to them (`/plugins/mine`, read by
//! the plugins' own page fragments) and submits their own per-user config
//! (`/{id}/my-config` — e.g. Telegram's pairing code from its sidebar page).
//!
//! Grants live on the user's page for the same reason connector grants do: the
//! question an admin actually asks is "what may this person use", and answering
//! it plugin-by-plugin meant opening every plugin in turn. One surface owns the
//! write, so "who has what" cannot drift between two forms.
use axum::{
extract::{Extension, Path, State},
@@ -52,7 +59,7 @@ pub async fn update(
Ok(())
}
// ── Admin: per-user access grants ─────────────────────────────────────────────
// ── Admin: access grants, read plugin-side / written user-side ───────────────
#[derive(Serialize)]
pub struct AccessEntry {
@@ -62,6 +69,8 @@ pub struct AccessEntry {
pub granted: bool,
}
/// Who currently holds a grant on this plugin — read-only, for the summary on
/// the plugin's page. The checkboxes that change it are on each user's page.
pub async fn get_access(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
@@ -83,19 +92,54 @@ pub async fn get_access(
Ok(Json(entries))
}
#[derive(Deserialize)]
pub struct SetAccessBody {
pub user_ids: Vec<String>,
// ── Admin: one user's grants across every plugin (the Users page) ────────────
//
// The twin of `mcp::user_connectors_{get,set}`, and deliberately the same shape:
// one round-trip fills the checklist, one PUT replaces the whole grant set.
//
// Nothing is pushed after the write. A connector grant gates a runtime that was
// snapshotted at login, so revoking one has to reach into the live user; a
// plugin grant is re-read from `plugin_access` on every request that depends on
// it — the sidebar page list, `/plugins/mine`, and each inbound channel message
// (Telegram checks it per message) — so a revoke lands on the next interaction
// with no bus event and no synchronous refresh.
/// Rejects the target user id when it names nobody, so the checklist cannot
/// write grants for a ghost.
async fn require_user(skald: &Skald, user_id: &str) -> Result<(), ApiError> {
users::get(skald.db(), user_id).await?
.ok_or_else(|| ApiError::not_found("no such user"))?;
Ok(())
}
pub async fn set_access(
pub async fn user_plugins_get(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<String>,
Json(body): Json<SetAccessBody>,
Path(target): Path<String>,
) -> 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?;
require_user(&skald, &target).await?;
Ok(Json(skald.plugin_manager().list_grants_for_user(&target).await?))
}
#[derive(Deserialize)]
pub struct UserPluginsBody {
#[serde(default)]
pub plugin_ids: Vec<String>,
}
pub async fn user_plugins_set(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
Json(body): Json<UserPluginsBody>,
) -> Result<impl IntoResponse, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_PLUGINS).await?;
require_user(&skald, &target).await?;
skald.plugin_manager()
.set_grants_for_user(&target, &body.plugin_ids)
.await
.map_err(|e| ApiError::bad_request(e.to_string()))?;
Ok(())
}