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
+18
View File
@@ -272,6 +272,24 @@ pub async fn count(pool: &SqlitePool) -> Result<i64> {
Ok(n)
}
/// Whether this user holds the admin role — the one predicate behind every
/// "admins hold it implicitly" short-circuit (`plugin_access`,
/// `mcp_catalog_access`, `mcp_global_access`).
///
/// It lives here, as one function, because the alternative is what actually
/// happened: each grant table open-coded the role lookup, one of them was written
/// without it, and admins were denied their own connectors while
/// [`super::access_defaults`] skipped seeding them rows on the grounds that the
/// short-circuit existed. An unknown user is not an admin; errors propagate so
/// callers fail closed.
pub async fn is_admin(pool: &SqlitePool, user_id: &str) -> Result<bool> {
let role = sqlx::query_as::<_, (String,)>("SELECT role_id FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(matches!(role, Some((r,)) if r == super::roles::ADMIN_ROLE_ID))
}
// ── Writes ────────────────────────────────────────────────────────────────────
/// `id` is supplied by the caller and must be opaque (never the username), so a