feat: grant a new plugin or connector to everyone by default — the admin's job is now removal, not distribution
Nightly Build / build (push) Successful in 7m23s

The grant junctions (plugin_access, mcp_global_access, mcp_catalog_access)
stay deny-by-default internally, but the rows are written for you at two
moments and never again:

  — an object is CREATED: PluginManager::update_config (first toggle —
    the plugins row's birth), mcp::catalog_upsert, marketplace install,
    mcp::global_enable
  — a user is CREATED: UserManager::register_user

Who is included is the role attrs.auto_grant flag (default true, so every
role predating the attribute behaves like an adult member). The seeded
Children preset sets it to false, which is the whole reason the attribute
exists. Admins are skipped because they hold everything implicitly. The
role editor now exposes the switch as a checkbox.

New crate module: db::access_defaults (seed_new_object, seed_new_user,
set_grant_by_default). Additive columns: grant_by_default on plugins,
mcp_catalog, mcp_global_servers (INTEGER NOT NULL DEFAULT 1).

On the frontend the Roles page gets a "New extensions" column and
checklist; the user's plugin/connector rosters are unchanged. i18n:
en, fr, it.

Docs: new docs/access.md for the assistant, plus index.md cross-link.
CLAUDE.md updated with a full default-access section.
This commit is contained in:
2026-07-29 15:53:51 +01:00
parent 0ed94225b2
commit e6818408cb
15 changed files with 700 additions and 19 deletions
+19
View File
@@ -38,6 +38,7 @@ use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
use skald_core::db::access_defaults;
use skald_core::db::{mcp_catalog, role_capabilities};
use skald_core::skald::Skald;
@@ -743,6 +744,10 @@ pub async fn install(
let icon_small_path = installed_icon(h.entry.icon_small.as_deref(), &folder, &installed);
let icon_large_path = installed_icon(h.entry.icon_large.as_deref(), &folder, &installed);
// An update or re-install keeps the audience the admin has curated since; only
// a first install applies the default one (`db::access_defaults`).
let is_new_entry = mcp_catalog::get_by_name(skald.db(), &h.entry.id).await?.is_none();
let id = mcp_catalog::upsert(
skald.db(),
mcp_catalog::UpsertCatalog {
@@ -795,6 +800,20 @@ pub async fn install(
)
.await?;
// Authorize the standard audience to activate it, so an installed connector is
// usable by the household without a second pass on the Users page. Best-effort
// and additive: a failure leaves grants to be set by hand, never withdraws one.
if is_new_entry {
match access_defaults::seed_new_object(
skald.db(),
access_defaults::Grantable::Catalog(&h.entry.id),
).await {
Ok(0) => {}
Ok(n) => tracing::info!(connector = %h.entry.id, users = n, "connector granted to auto-grant users"),
Err(e) => tracing::warn!(connector = %h.entry.id, error = %e, "default connector grants failed (non-fatal)"),
}
}
// Announce the (re)install so anything already running it catches up without
// waiting for each user's next login: enabled global servers re-snapshot the
// description and restart; each live user who activated it gets its files/deps
+36
View File
@@ -18,6 +18,7 @@ use core_api::system_bus::SystemEvent;
use serde::Deserialize;
use serde_json::{json, Value};
use skald_core::db::access_defaults as mcp_access;
use skald_core::db::{mcp_catalog, mcp_catalog_access, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
use skald_core::skald::Skald;
@@ -31,6 +32,21 @@ fn to_json_opt<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
v.as_ref().and_then(|x| serde_json::to_string(x).ok())
}
/// Grants a **just-created** connector to everyone whose role auto-grants, so the
/// admin's remaining job is to take it away from whoever should not have it rather
/// than to hand it out one person at a time (see `db::access_defaults`).
///
/// Best-effort by design: the connector row already landed, seeding only ever adds
/// access, and a grant that did not get written is fixable from the user's page —
/// failing the install over it would be the worse trade.
async fn seed_default_access(skald: &Skald, target: mcp_access::Grantable<'_>) {
match mcp_access::seed_new_object(skald.db(), target).await {
Ok(0) => {}
Ok(n) => tracing::info!(?target, users = n, "connector granted to auto-grant users"),
Err(e) => tracing::warn!(?target, error = %e, "default connector grants failed (non-fatal)"),
}
}
/// Installs the connector folder that `script_path` (`<connector>/<file>`) belongs
/// to into the caller's container home, and returns the path the entry file will
/// have INSIDE the container.
@@ -297,6 +313,8 @@ pub async fn catalog_upsert(
if body.source == "local_script" {
require_cap(&skald, &auth.user_id, role_capabilities::REGISTER_LOCAL_SCRIPT).await?;
}
// An edit of an existing entry must not re-apply the default audience.
let is_new_entry = mcp_catalog::get_by_name(skald.db(), &body.name).await?.is_none();
let id = mcp_catalog::upsert(skald.db(), mcp_catalog::UpsertCatalog {
name: &body.name,
scope: &body.scope,
@@ -332,6 +350,11 @@ pub async fn catalog_upsert(
version_string: None,
version_release_date: None,
}).await?;
// Authorize the standard audience to activate it (a no-op for a `global` entry,
// which nobody activates — `seed_new_object` filters on scope).
if is_new_entry {
seed_default_access(&skald, mcp_access::Grantable::Catalog(&body.name)).await;
}
Ok(Json(json!({ "id": id })))
}
@@ -470,6 +493,11 @@ pub async fn global_enable(
entry.args_json.clone()
};
// Whether this is an install or a re-configuration of an existing connector —
// the default audience is applied to a brand-new row only (see the seed call
// below and `db::access_defaults`).
let is_new_server = mcp_global_servers::get_by_name(skald.db(), &name).await?.is_none();
// Snapshot the concrete config from the catalog; the admin supplies the secret.
let id = mcp_global_servers::upsert(skald.db(), mcp_global_servers::UpsertGlobal {
name: &name,
@@ -486,6 +514,14 @@ pub async fn global_enable(
description: entry.description.as_deref(),
}).await?;
// Hand the new connector to everyone whose role auto-grants, before it is even
// verified: the grants are what make it appear, and a failed verify only leaves
// the row disabled — the admin fixes the key and re-enables without having to
// remember an audience. Best-effort, and additive only.
if is_new_server {
seed_default_access(&skald, mcp_access::Grantable::GlobalServer(id)).await;
}
// Verify the admin-supplied credentials before starting the server. A failure
// disables the row so it does not run with bad creds; the admin sees the
// message and can fix + re-enable. A connector with no verify step is allowed