fix: let an admin use the connectors they implicitly hold
Nightly Build / build (push) Successful in 7m50s

Activating a per-user connector as admin failed with "you are not
authorized to use this connector — ask an admin to enable it for you".

`db::access_defaults` deliberately writes no grant rows for admins, and
says why: "they already hold every plugin and connector implicitly, so a
row for them would be noise". That implicit hold was only ever
implemented for plugins (`plugin_access::effective_access`). The two MCP
grant tables had nothing but the raw junction read, so an admin ended up
with no row *and* no short-circuit — denied their own connectors, and
denied more the more the seeding was trusted to skip them.

The reported symptom was the mildest of four:

  - `activate` refused, while `available` listed the entry (an admin
    holds `mcp.manage_catalog`) — visible but unusable;
  - the login-time startup filter dropped an admin's already-activated
    catalog connectors, so they silently stopped running;
  - `accessible_global` snapshotted an empty set, so an admin's sessions
    were offered no shared MCP tools at all — no error, just absence;
  - the connector report told the agent an admin's own global connector
    was "not granted to you".

`users::is_admin` is now the single predicate behind every "admins hold
it implicitly" short-circuit, and `plugin_access` was moved onto it too:
three tables open-coding the same role lookup is what let one of them be
written without it. Each MCP table grows an `effective_access` beside its
`has_access`, and the distinction is the point — `has_access` stays the
roster question ("what did the admin tick"), which the access-editing
surfaces must keep asking, while the gates ask the authorization one.

Nothing widens for anyone else: deny-by-default is untouched for
non-admins, an unknown user is nobody, a disabled global stays excluded
for admins too, and the `not_granted` report branch survives for a
non-admin who was given the catalog-management capability.
This commit is contained in:
2026-08-07 12:37:23 +01:00
parent c1177a934d
commit c0a779b79e
8 changed files with 248 additions and 21 deletions
+7 -2
View File
@@ -837,6 +837,11 @@ pub async fn available(
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
let manages_catalog =
role_capabilities::has(skald.db(), &user.role_id, role_capabilities::MANAGE_CATALOG).await?;
// Admins hold every connector implicitly and are deliberately never given grant
// rows (see `mcp_catalog_access::effective_access`), so every "may I use this"
// answer below has to OR this in — otherwise the page shows an admin their own
// connectors greyed out as unusable.
let is_admin = skald_core::db::users::is_admin(skald.db(), &auth.user_id).await?;
let granted_catalog: std::collections::HashSet<String> =
mcp_catalog_access::catalog_names_for_user(skald.db(), &auth.user_id).await?
@@ -863,7 +868,7 @@ pub async fn available(
// entry enabled for someone else becomes invisible and unmanageable.
.filter(|r| manages_catalog || granted.contains(&r.name))
.map(|r| GlobalView {
can_use: granted.contains(&r.name),
can_use: is_admin || granted.contains(&r.name),
id: r.id,
name: r.name,
catalog_name: r.catalog_name,
@@ -919,7 +924,7 @@ pub async fn activate(
// 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? {
if !mcp_catalog_access::effective_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",
));