mcp: per-user connector access control with deny-by-default grants
Nightly Build / build (push) Successful in 6m33s

This commit is contained in:
2026-07-21 20:48:56 +01:00
parent c8e4cb4384
commit 17f5769e0d
11 changed files with 503 additions and 8 deletions
+120 -6
View File
@@ -17,7 +17,7 @@ use axum::Json;
use serde::Deserialize;
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::db::{mcp_catalog, mcp_catalog_access, mcp_global_access, mcp_global_servers, mcp_user_servers, oauth_providers, role_capabilities};
use skald_core::skald::Skald;
use super::caps::require_cap;
@@ -633,6 +633,111 @@ pub async fn global_set_access(
Ok(Json(json!({ "ok": true })))
}
// ── admin: per-user connector access (the Users-page "who can use what") ───────
//
// One surface over both access tables: which registered connectors the admin has
// authorized for a given user. `global` rows write `mcp_global_access`, `catalog`
// rows write `mcp_catalog_access`. For a global the grant is immediate access; for
// a catalog entry it is *eligibility to activate* — the user still supplies their
// own credentials / OAuth in their own Connectors page. Admin-only.
/// One registered connector as the Users-page access checklist renders it.
#[derive(serde::Serialize)]
pub struct UserConnectorView {
/// `"global"` | `"catalog"` — which access table `name`/`id` belongs to.
pub kind: &'static str,
/// Global server id (the `mcp_global_access` key); `None` for catalog rows.
pub id: Option<i64>,
/// Global runtime name OR catalog entry name — the grant key for its table.
pub name: String,
pub friendly_name: Option<String>,
pub description: Option<String>,
/// Global only: a disabled global is nobody's to use yet (shown greyed).
pub enabled: bool,
/// Whether this user is currently authorized for it.
pub granted: bool,
}
/// `GET /api/users/{id}/connectors` — every registered connector with this user's
/// grant flag. Admin-only.
pub async fn user_connectors_get(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
) -> Result<Json<Vec<UserConnectorView>>, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
skald_core::db::users::get(skald.db(), &target).await?
.ok_or_else(|| ApiError::not_found("no such user"))?;
let granted_catalog: std::collections::HashSet<String> =
mcp_catalog_access::catalog_names_for_user(skald.db(), &target).await?
.into_iter().collect();
let mut out: Vec<UserConnectorView> = Vec::new();
for s in mcp_global_servers::all(skald.db()).await? {
let granted = mcp_global_access::has_access(skald.db(), s.id, &target).await?;
out.push(UserConnectorView {
kind: "global", id: Some(s.id), name: s.name,
friendly_name: s.friendly_name, description: s.description,
enabled: s.enabled, granted,
});
}
for e in mcp_catalog::list_for_scope(skald.db(), "per_user").await? {
let granted = granted_catalog.contains(&e.name);
out.push(UserConnectorView {
kind: "catalog", id: None, name: e.name.clone(),
friendly_name: e.friendly_name, description: e.description,
enabled: true, granted,
});
}
Ok(Json(out))
}
#[derive(Deserialize)]
pub struct UserConnectorsBody {
#[serde(default)]
pub global_ids: Vec<i64>,
#[serde(default)]
pub catalog_names: Vec<String>,
}
/// `PUT /api/users/{id}/connectors` — replaces this user's full access set across
/// both tables. Admin-only.
pub async fn user_connectors_set(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(target): Path<String>,
Json(body): Json<UserConnectorsBody>,
) -> Result<Json<Value>, ApiError> {
require_cap(&skald, &auth.user_id, role_capabilities::MANAGE_CATALOG).await?;
skald_core::db::users::get(skald.db(), &target).await?
.ok_or_else(|| ApiError::not_found("no such user"))?;
// Globals settle at the target user's next login (their `accessible_global`
// snapshot is captured then) — same as the existing per-server access flow.
mcp_global_access::set_for_user(skald.db(), &target, &body.global_ids).await?;
// Catalog: apply the grant set; `set_for_user` returns the names this revoked.
let revoked = mcp_catalog_access::set_for_user(skald.db(), &target, &body.catalog_names).await?;
// Immediate revoke for a LIVE user: stop + drop any now-forbidden activation.
// A locked user cannot be reached (their DB is sealed to the admin); the
// startup access filter keeps the connector dormant from their next login on.
if !revoked.is_empty() {
if let Some(ctx) = skald.user_context_if_live(&target).await {
if let Ok(rows) = mcp_user_servers::all(&ctx.pool).await {
for r in rows {
if r.catalog_name.as_deref().is_some_and(|c| revoked.iter().any(|n| n == c)) {
ctx.user_mcp.stop_server(&r.name);
let _ = mcp_user_servers::delete(&ctx.pool, r.id).await;
}
}
}
}
}
Ok(Json(json!({ "ok": true })))
}
// ── user: available catalog + activation ──────────────────────────────────────
/// A globally-active connector as the Connectors page renders it.
@@ -673,9 +778,15 @@ pub async fn available(
let manages_catalog =
role_capabilities::has(skald.db(), &user.role_id, role_capabilities::MANAGE_CATALOG).await?;
let granted_catalog: std::collections::HashSet<String> =
mcp_catalog_access::catalog_names_for_user(skald.db(), &auth.user_id).await?
.into_iter()
.collect();
let mut catalog: Vec<_> = mcp_catalog::list_for_scope(skald.db(), "per_user").await?
.into_iter()
.filter(|e| e.allowed_for_role(&user.role_id))
// Deny-by-default: a user sees a per-user catalog entry only if the admin
// granted it; a catalog manager sees every entry to curate it.
.filter(|e| manages_catalog || granted_catalog.contains(&e.name))
.collect();
if manages_catalog {
catalog.extend(mcp_catalog::list_for_scope(skald.db(), "global").await?);
@@ -736,8 +847,6 @@ pub async fn activate(
Json(body): Json<ActivateBody>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let user = skald_core::db::users::get(skald.db(), &auth.user_id).await?
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
// Resolve the row to insert from either the catalog or a self-registered remote.
let insert = match &body.catalog_name {
@@ -747,8 +856,13 @@ pub async fn activate(
if entry.scope != "per_user" {
return Err(ApiError::bad_request("catalog entry is not a per-user connector"));
}
if !entry.allowed_for_role(&user.role_id) {
return Err(ApiError::forbidden("your role may not activate this connector"));
// Deny-by-default per-user access: the admin must have granted this user
// the connector (`mcp_catalog_access`). This is the real boundary — the
// `available` list only hides it in the UI.
if !mcp_catalog_access::has_access(skald.db(), cat_name, &auth.user_id).await? {
return Err(ApiError::forbidden(
"you are not authorized to use this connector — ask an admin to enable it for you",
));
}
let cap = if entry.source == "local_script" {
role_capabilities::REGISTER_LOCAL_FROM_CATALOG
+3
View File
@@ -186,6 +186,9 @@ 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).
.route("/users/{id}/connectors", get(mcp::user_connectors_get).put(mcp::user_connectors_set))
// Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))