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:
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user