From 3cc7fb3b24f2e91ed61bd02bd449d6c47de37197 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 2 Jun 2026 16:56:27 -0300 Subject: [PATCH 1/6] feat(permissions): enforce the built-in user role (remove its bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the built-in `user` role bypassed all permission checks via `createBoundAuthClient.hasPermission`, which returned true for every member of BUILTIN_ROLES. So UI capability gating only truly bit custom roles; a `user` had full server-side access. Flip the runtime bypass to ADMIN_ROLES (owner/admin) only. A `user` is now enforced like any member: it receives basic-usage (granted out-of-band in AccessControl) plus its explicit Better Auth / connection grants, and nothing else. This is the capstone of the capability-RBAC work; basic-usage was rounded out first (#3654) so normal members keep working after the flip. Mechanics that make this safe: - AccessControl already bypassed only admin/owner (access-control.ts); the unit tests already model `user` as enforced. This aligns the second bypass site with the first. - fetchRolePermissions still treats `user` as built-in (no organizationRole row), returning undefined → its Better Auth role grant is consulted. - The Better Auth `user` role is `self: ["*"]`; authorize() matches actions literally (the reason owner/admin enumerate the full tool list), so `["*"]` grants no specific tool → gated tools are denied. - The MY_CAPABILITIES endpoint already reports all-false for `user`, so the UI was already restricted; this just makes the server match. Comments updated at all three sites (roles.ts, context-factory.ts, auth/index.ts) to reflect that built-in != bypass. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/auth/index.ts | 12 +++++++----- apps/mesh/src/auth/roles.ts | 10 +++++++--- apps/mesh/src/core/context-factory.ts | 20 +++++++++++--------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index dd7d12c7ca..351ea16743 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -102,16 +102,18 @@ const ac = createAccessControl(statement); // The role-creating built-in roles (owner/admin) must enumerate every `self` // tool, not just `["*"]`. Better Auth's access-control `authorize()` matches // actions literally — `["*"]` authorizes only a request for the literal action -// "*", NOT specific tools. Runtime checks bypass this for built-in roles, but +// "*", NOT specific tools. Runtime checks bypass this for owner/admin, but // `create-role` gates the *creator* on whether they hold each permission they // grant; with `self: ["*"]` an owner is reported as "missing self:SOME_TOOL" // and can't create a capability-scoped custom role at all. Enumerating the full // tool list fixes that. // -// `user` is intentionally left as-is: it can't create roles -// (allowedRolesToCreateResources = ADMIN_ROLES), and its `self` is never -// consulted at runtime (the built-in-role bypass short-circuits first). Giving -// it the full tool list would only mis-signal that "user" holds full access. +// `user` keeps `self: ["*"]` and is NOT given the full tool list: it is +// enforced at runtime (only owner/admin bypass — see ADMIN_ROLES), so its grant +// IS now consulted. By the literal-match rule above, `["*"]` authorizes no +// specific tool, so a member gets only basic-usage (granted out-of-band in +// AccessControl) plus any connection-scoped grants — never full access. It also +// can't create roles (allowedRolesToCreateResources = ADMIN_ROLES). const creatorSelf = ["*", ...allTools]; const user = ac.newRole({ diff --git a/apps/mesh/src/auth/roles.ts b/apps/mesh/src/auth/roles.ts index e8ac96d649..679eb758a0 100644 --- a/apps/mesh/src/auth/roles.ts +++ b/apps/mesh/src/auth/roles.ts @@ -5,14 +5,18 @@ */ /** - * Built-in roles that have full access (owner, admin, user) - * These bypass custom permission checks + * Built-in (non-custom) roles. These have no row in the organizationRole table, + * so `fetchRolePermissions` returns no stored permissions for them. + * + * NOTE: being built-in does NOT mean bypassing permission checks. Only + * ADMIN_ROLES (owner/admin) bypass; the `user` role is enforced like any + * member — it receives basic-usage plus its explicit grants and nothing else. */ export const BUILTIN_ROLES = ["owner", "admin", "user"] as const; export type BuiltinRole = (typeof BUILTIN_ROLES)[number]; /** - * Roles that have admin privileges + * Roles with full org access — they bypass all permission checks at runtime. */ export const ADMIN_ROLES: BuiltinRole[] = ["owner", "admin"]; diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index c6169207e9..ef892ba4d2 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -264,11 +264,11 @@ export function createBoundAuthClient(ctx: AuthContext): BoundAuthClient { requestedPermission: Permission, options?: { organizationId?: string }, ): Promise => { - // Built-in roles bypass all permission checks - if ( - role && - BUILTIN_ROLES.includes(role as (typeof BUILTIN_ROLES)[number]) - ) { + // Only owner/admin bypass all permission checks (full org access). The + // built-in `user` role is enforced like any member: it gets basic-usage + // (granted out-of-band in AccessControl) plus its explicit Better Auth / + // connection grants, and nothing else. See ADMIN_ROLES in auth/roles.ts. + if (role && ADMIN_ROLES.includes(role as (typeof ADMIN_ROLES)[number])) { return true; } @@ -439,7 +439,7 @@ export function createBoundAuthClient(ctx: AuthContext): BoundAuthClient { import { createMCPProxy } from "@/api/routes/mcp-proxy-factory"; import { ConnectionEntity } from "@/tools/connection/schema"; -import { BUILTIN_ROLES } from "../auth/roles"; +import { ADMIN_ROLES, BUILTIN_ROLES } from "../auth/roles"; import { OrgScopedThreadStorage, SqlThreadStorage } from "@/storage/threads"; import { OrgScopedAsyncResearchJobStorage, @@ -459,15 +459,17 @@ import { DevObjectStorage } from "../object-storage/dev-object-storage"; import { decorateStorageWithAssetHoisting } from "../object-storage/asset-hoister"; /** - * Fetch role permissions from the database - * Returns undefined for built-in roles (they bypass permission checks) + * Fetch role permissions from the database. + * Built-in roles have no row in the organizationRole table, so this returns + * undefined for them. owner/admin additionally bypass all checks at runtime; + * `user` falls through to its (intentionally empty) Better Auth role grant. */ export async function fetchRolePermissions( db: Kysely, organizationId: string, role: string, ): Promise { - // Built-in roles bypass permission checks + // Built-in roles have no custom-role permission row to fetch. if (BUILTIN_ROLES.includes(role as (typeof BUILTIN_ROLES)[number])) { return undefined; } From 62c770e5ea2dc48b41f87f1c555b1614117b76fa Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 2 Jun 2026 18:24:25 -0300 Subject: [PATCH 2/6] fix(permissions): close user-role wildcard self grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bypass flip alone left the privilege reduction incomplete: the built-in `user` role is defined with `self: ["*"]`, and `createBoundAuthClient.hasPermission` falls back to an explicit `{ self: ["*"] }` wildcard probe when the exact check misses. That probe matches the user role's own `self: ["*"]`, so a member was still granted every `self` tool — re-introducing the full access we just removed. Root cause is the role definition, not the bypass. Define the built-in `user` role with `self: []` so neither the exact check nor the wildcard fallback matches any gated tool. Basic-usage is granted out-of-band in AccessControl, so legitimate member usage is unaffected. Add an e2e regression test: a member left on the built-in `user` role gets basic-usage (AUTOMATION_LIST) but is denied a gated tool (MONITORING_STATS). The existing test only covered a custom role with an empty permission set, which never held the `self` wildcard — so this exact path was untested. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/e2e/tests/basic-usage-grant.spec.ts | 78 +++++++++++++++++++ apps/mesh/src/auth/index.ts | 16 ++-- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts index 489e35b016..fcd4a72525 100644 --- a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts +++ b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts @@ -11,6 +11,11 @@ * stored permission does NOT list the tool) can still call a basic-usage * tool, yet is denied a non-basic tool it was never granted. This is the * test that actually exercises the runtime grant. + * - A member on the built-in "user" role likewise gets basic-usage but is + * denied a gated tool. The `user` role is defined with `self: ["*"]`, and + * `createBoundAuthClient` falls back to a `{ self: ["*"] }` wildcard probe, + * so this guards against that combination re-granting full access once the + * owner/admin-only bypass is in place. * - A NON-member cannot call a basic-usage tool against the org — the grant * must never leak past membership. * @@ -157,6 +162,79 @@ test.describe("runtime basic-usage grant", () => { await memberCtx.dispose(); }); + test("the built-in user role gets basic-usage but is denied gated tools", async ({ + playwright, + }) => { + const ownerCtx = await newApiContext(playwright); + const owner = await signUpViaApi(ownerCtx); + const orgRow = await db.query<{ id: string }>( + `SELECT id FROM "organization" WHERE slug = $1`, + [owner.orgSlug], + ); + const orgId = orgRow.rows[0]?.id; + if (!orgId) throw new Error("org not found after signup"); + + // A second user, invited and LEFT on the built-in "user" role (no custom + // role reassignment). This is the path that regressed: the `user` role is + // defined with `self: ["*"]`, and the runtime wildcard fallback would + // otherwise grant it every tool once the owner/admin-only bypass is in + // place. It must get basic-usage only. + const memberCtx = await newApiContext(playwright); + const member = await signUpViaApi(memberCtx); + + const invite = await ownerCtx.post("/api/auth/organization/invite-member", { + data: { organizationId: orgId, email: member.email, role: "user" }, + }); + expect(invite.ok()).toBe(true); + const inviteJson = (await invite.json()) as { + id?: string; + invitation?: { id?: string }; + }; + const invitationId = inviteJson.id ?? inviteJson.invitation?.id; + expect(invitationId).toBeTruthy(); + + const accept = await memberCtx.post( + "/api/auth/organization/accept-invitation", + { data: { invitationId } }, + ); + expect( + accept.ok(), + `accept-invitation failed: ${await accept.text().catch(() => "")}`, + ).toBe(true); + + // Basic-usage tool → granted at runtime regardless of role. + const automations = await callSelfMcpTool<{ automations: unknown[] }>( + memberCtx, + owner.orgSlug, + "AUTOMATION_LIST", + {}, + ); + expect(Array.isArray(automations.automations)).toBe(true); + + // Gated tool (monitoring:view) → denied. Before enforcing the user role, + // the `self: ["*"]` grant + wildcard fallback leaked full access here. + const deniedRes = await memberCtx.post(`/api/${owner.orgSlug}/mcp/self`, { + data: toolCallBody("MONITORING_STATS"), + headers: MCP_HEADERS, + }); + const denied = (await deniedRes.json()) as { + result?: { isError?: boolean; content?: Array<{ text?: string }> }; + error?: { message?: string }; + }; + const errText = + denied.result?.content?.[0]?.text ?? denied.error?.message ?? ""; + expect( + denied.result?.isError === true || !!denied.error, + `expected MONITORING_STATS to be denied for built-in user, got: ${JSON.stringify( + denied, + )}`, + ).toBe(true); + expect(errText).toMatch(/access denied|permission/i); + + await ownerCtx.dispose(); + await memberCtx.dispose(); + }); + test("a non-member cannot call a basic-usage tool against the org", async ({ playwright, }) => { diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index 351ea16743..a3520a57f7 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -108,16 +108,18 @@ const ac = createAccessControl(statement); // and can't create a capability-scoped custom role at all. Enumerating the full // tool list fixes that. // -// `user` keeps `self: ["*"]` and is NOT given the full tool list: it is -// enforced at runtime (only owner/admin bypass — see ADMIN_ROLES), so its grant -// IS now consulted. By the literal-match rule above, `["*"]` authorizes no -// specific tool, so a member gets only basic-usage (granted out-of-band in -// AccessControl) plus any connection-scoped grants — never full access. It also -// can't create roles (allowedRolesToCreateResources = ADMIN_ROLES). +// `user` must hold NO `self` tools (`self: []`). It is enforced at runtime — +// only owner/admin bypass (see ADMIN_ROLES), so its grant IS consulted. Crucially +// `createBoundAuthClient` falls back to a `{ self: ["*"] }` wildcard probe when +// the exact check misses, and a `self: ["*"]` grant here would satisfy that probe +// and hand every member full access — re-introducing the very bypass we removed. +// With `self: []` a member gets only basic-usage (granted out-of-band in +// AccessControl) plus any connection-scoped grants. It also can't create roles +// (allowedRolesToCreateResources = ADMIN_ROLES). const creatorSelf = ["*", ...allTools]; const user = ac.newRole({ - self: ["*"], + self: [], ...adminAc.statements, }) as Role; From 951c457d5ebe8c4db3ff583b7e92e2158667aa85 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 2 Jun 2026 18:35:25 -0300 Subject: [PATCH 3/6] fix(permissions): give the user role member-level org statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in `user` role spread `...adminAc.statements` (a copy of the admin/owner definitions). Those org statements — organization:update, member:create/update/delete, invitation:create/cancel, team:*, ac:* — gate Better Auth's NATIVE org-plugin endpoints (update org, invite/remove members, update member roles, create roles, cancel invitations). So a plain member could manage the org through those endpoints, independent of the MCP-tool bypass we just removed (AccessControl only checks self/connection buckets, so MCP tools were unaffected — but the native endpoints enforce on these statements directly). Spread `...memberAc.statements` instead — the org plugin's member role, which grants only `ac: ["read"]` (read roles for the UI) and no org management. owner/admin keep adminAc. Extend the built-in-user e2e: the member is now also denied invite-member (requires invitation:["create"], which memberAc omits and adminAc granted). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/e2e/tests/basic-usage-grant.spec.ts | 29 ++++++++++++++++--- apps/mesh/src/auth/index.ts | 9 +++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts index fcd4a72525..4e31575cce 100644 --- a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts +++ b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts @@ -12,10 +12,10 @@ * tool, yet is denied a non-basic tool it was never granted. This is the * test that actually exercises the runtime grant. * - A member on the built-in "user" role likewise gets basic-usage but is - * denied a gated tool. The `user` role is defined with `self: ["*"]`, and - * `createBoundAuthClient` falls back to a `{ self: ["*"] }` wildcard probe, - * so this guards against that combination re-granting full access once the - * owner/admin-only bypass is in place. + * denied a gated tool, and is denied org management via Better Auth's native + * endpoints (invite-member). Guards two regressions: the `self: ["*"]` grant + * + wildcard fallback re-granting every tool, and the `user` role spreading + * `adminAc` (org-admin statements) instead of member-level `memberAc`. * - A NON-member cannot call a basic-usage tool against the org — the grant * must never leak past membership. * @@ -231,6 +231,27 @@ test.describe("runtime basic-usage grant", () => { ).toBe(true); expect(errText).toMatch(/access denied|permission/i); + // The built-in user role uses member-level org statements (memberAc), not + // adminAc — so Better Auth's native org endpoints reject org management. + // invite-member requires `invitation: ["create"]`, which memberAc omits; + // adminAc would have granted it. + const escalation = await memberCtx.post( + "/api/auth/organization/invite-member", + { + data: { + organizationId: orgId, + email: `escalation-${Date.now()}@example.com`, + role: "user", + }, + }, + ); + expect( + escalation.ok(), + `expected invite-member to be denied for built-in user, got ${escalation.status()}: ${await escalation + .text() + .catch(() => "")}`, + ).toBe(false); + await ownerCtx.dispose(); await memberCtx.dispose(); }); diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index a3520a57f7..cae94c70be 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -31,6 +31,7 @@ import { import { adminAc, defaultStatements, + memberAc, } from "@decocms/better-auth/plugins/organization/access"; import { getConfig } from "@/core/config"; @@ -118,9 +119,15 @@ const ac = createAccessControl(statement); // (allowedRolesToCreateResources = ADMIN_ROLES). const creatorSelf = ["*", ...allTools]; +// `user` spreads `memberAc` (the org plugin's member role), NOT `adminAc`. These +// org statements (organization/member/invitation/team/ac) gate Better Auth's +// native org-plugin endpoints — not MCP tools, which our AccessControl checks on +// `self`/connection buckets. `adminAc` grants org:update + member/invitation/team +// management; spreading it here would let a plain member manage the org via those +// endpoints. `memberAc` grants only `ac: ["read"]` (read roles for the UI). const user = ac.newRole({ self: [], - ...adminAc.statements, + ...memberAc.statements, }) as Role; const admin = ac.newRole({ From f7039ffd569120a2bd63164ffc3d22327cd565e4 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 3 Jun 2026 09:42:59 -0300 Subject: [PATCH 4/6] feat(permissions): scaffold extra capabilities for the built-in user role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single source of truth, USER_ROLE_TOOLS in registry-metadata, derived from USER_ROLE_CAPABILITY_IDS — the gated capabilities granted to every member beyond basic-usage. Empty by default, so behavior is unchanged; flipping one capability id on grants all its tools to every member of every org (no migration). Wired through the two layers that must stay in sync: - Enforcement: the built-in `user` role's `self` grant is now `[...USER_ROLE_TOOLS]` (auth/index.ts). Specific tool names only — never `"*"` — so the `createBoundAuthClient` wildcard fallback can't leak. - UI gating: the MY_CAPABILITIES endpoint resolves the built-in `user` role from USER_ROLE_TOOLS instead of the (empty) organizationRole row, so affordances match what the server allows. For per-org or per-member grants, custom roles remain the mechanism; this constant is global to the built-in user role. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/src/api/routes/auth.ts | 16 ++++++++++++--- apps/mesh/src/auth/index.ts | 21 ++++++++++---------- apps/mesh/src/tools/registry-metadata.ts | 25 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 1bfbfa4cd2..f4e51582fa 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -28,6 +28,7 @@ import { import { allCapabilitiesGranted, resolveCapabilities, + USER_ROLE_TOOLS, } from "@/tools/registry-metadata"; const app = new Hono(); @@ -237,9 +238,18 @@ app.get("/my-capabilities/:slug", async (c) => { return c.json({ role, capabilities: allCapabilitiesGranted() }); } - // Any other role (built-in "user" or a custom role) is resolved from its - // stored permission. Built-in "user" has no organizationRole row, so its - // permission is empty and it resolves to no gated capabilities. + // The built-in "user" role has no organizationRole row; its gated grants are + // baked into code (USER_ROLE_TOOLS, empty by default), NOT stored in the DB. + // Resolve from that set so UI gating matches the role's `self` grant in the + // auth config. Empty set → no gated capabilities, same as before. + if (role === "user") { + return c.json({ + role, + capabilities: resolveCapabilities({ self: [...USER_ROLE_TOOLS] }), + }); + } + + // A custom role is resolved from its stored permission. const customRole = await db .selectFrom("organizationRole") .select(["permission"]) diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index cae94c70be..5de32bfa3b 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -10,7 +10,7 @@ */ import { getSettings } from "../settings"; -import { getToolsByCategory } from "@/tools/registry-metadata"; +import { getToolsByCategory, USER_ROLE_TOOLS } from "@/tools/registry-metadata"; import { sso } from "@better-auth/sso"; import { organization } from "@decocms/better-auth/plugins"; import { betterAuth, BetterAuthOptions } from "better-auth"; @@ -109,14 +109,15 @@ const ac = createAccessControl(statement); // and can't create a capability-scoped custom role at all. Enumerating the full // tool list fixes that. // -// `user` must hold NO `self` tools (`self: []`). It is enforced at runtime — -// only owner/admin bypass (see ADMIN_ROLES), so its grant IS consulted. Crucially -// `createBoundAuthClient` falls back to a `{ self: ["*"] }` wildcard probe when -// the exact check misses, and a `self: ["*"]` grant here would satisfy that probe -// and hand every member full access — re-introducing the very bypass we removed. -// With `self: []` a member gets only basic-usage (granted out-of-band in -// AccessControl) plus any connection-scoped grants. It also can't create roles -// (allowedRolesToCreateResources = ADMIN_ROLES). +// `user`'s `self` is exactly USER_ROLE_TOOLS — the gated tools granted to every +// member beyond basic-usage (empty by default; see registry-metadata). It is +// enforced at runtime: only owner/admin bypass (see ADMIN_ROLES), so this grant +// IS consulted. It must NEVER contain `"*"` — `createBoundAuthClient` falls back +// to a `{ self: ["*"] }` wildcard probe when the exact check misses, and a `"*"` +// here would satisfy it and hand every member full access (the bypass we removed). +// Specific tool names match only via the exact check. A member otherwise gets +// only basic-usage (granted out-of-band in AccessControl) plus connection-scoped +// grants, and can't create roles (allowedRolesToCreateResources = ADMIN_ROLES). const creatorSelf = ["*", ...allTools]; // `user` spreads `memberAc` (the org plugin's member role), NOT `adminAc`. These @@ -126,7 +127,7 @@ const creatorSelf = ["*", ...allTools]; // management; spreading it here would let a plain member manage the org via those // endpoints. `memberAc` grants only `ac: ["read"]` (read roles for the UI). const user = ac.newRole({ - self: [], + self: [...USER_ROLE_TOOLS], ...memberAc.statements, }) as Role; diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index a119adfdb7..930b514947 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -1322,6 +1322,31 @@ export const BASIC_USAGE_TOOLS: ReadonlySet = new Set( ?.tools ?? [], ); +/** + * Gated capability ids additionally granted to the built-in `user` role, beyond + * basic-usage. EMPTY by default — every member is otherwise enforced down to + * basic-usage only. Add a capability id here (e.g. "agents:manage") to grant ALL + * of its tools to every member of every org. This is a GLOBAL change with no + * migration; for per-org grants use a custom role instead. + */ +const USER_ROLE_CAPABILITY_IDS: string[] = []; + +/** + * Tools the built-in `user` role gets beyond basic-usage, derived from + * USER_ROLE_CAPABILITY_IDS. Single source of truth for the two layers that must + * stay in sync: + * - enforcement: baked into the `user` role's `self` grant (auth/index.ts) + * - UI gating: the MY_CAPABILITIES endpoint (api/routes/auth.ts) + * + * List specific tool names only — never `"*"` — so the wildcard fallback in + * `createBoundAuthClient` can't be tricked into granting everything. + */ +export const USER_ROLE_TOOLS: ReadonlySet = new Set( + PERMISSION_CAPABILITIES.filter((c) => + USER_ROLE_CAPABILITY_IDS.includes(c.id), + ).flatMap((c) => c.tools), +); + export function getCapabilitySections(): Array<{ section: string; capabilities: PermissionCapability[]; From 084eebecacfef4747d9f0cecb7b8163b226c2ad4 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 3 Jun 2026 09:55:31 -0300 Subject: [PATCH 5/6] feat(permissions): grant agents:manage to the built-in user role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set USER_ROLE_CAPABILITY_IDS = ["agents:manage"], so every member can create/update/delete agents and edit plugin config / pinned views — via both layers already wired up: the user role's `self` grant (enforcement) and the MY_CAPABILITIES endpoint (UI gating). e2e updates: - basic-usage-grant: the built-in user role can create an agent (COLLECTION_VIRTUAL_MCP_CREATE succeeds), while a restrictive custom role with only basic-usage is denied the same call. Sent with valid `data` so the custom-role denial is an access error, not validation. - my-capabilities: the user role now resolves agents:manage = true, every other gated capability false. - connections-agents-monitor-gating: the "Create Agent" affordance is now shown for a plain member (still can't manage connections or view monitoring). Verified the enforcement + gating logic locally: Better Auth authorize() grants the agent tools to the user role and still denies monitoring/org tools, the `{ self: ["*"] }` wildcard probe, and org-plugin statements; resolveCapabilities lights up only agents:manage. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/e2e/tests/basic-usage-grant.spec.ts | 66 +++++++++++++++++-- .../connections-agents-monitor-gating.spec.ts | 15 +++-- apps/mesh/e2e/tests/my-capabilities.spec.ts | 11 +++- apps/mesh/src/tools/registry-metadata.ts | 2 +- 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts index 4e31575cce..3822d89ef4 100644 --- a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts +++ b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts @@ -9,20 +9,25 @@ * * - A member with a restrictive CUSTOM role (not owner/admin, and whose * stored permission does NOT list the tool) can still call a basic-usage - * tool, yet is denied a non-basic tool it was never granted. This is the + * tool, yet is denied a non-basic tool it was never granted — including an + * agents:manage tool, which only the built-in user role gets. This is the * test that actually exercises the runtime grant. - * - A member on the built-in "user" role likewise gets basic-usage but is - * denied a gated tool, and is denied org management via Better Auth's native + * - A member on the built-in "user" role gets basic-usage AND agents:manage + * (USER_ROLE_CAPABILITY_IDS) — so it can create an agent — but is still + * denied other gated tools and org management via Better Auth's native * endpoints (invite-member). Guards two regressions: the `self: ["*"]` grant * + wildcard fallback re-granting every tool, and the `user` role spreading * `adminAc` (org-admin statements) instead of member-level `memberAc`. * - A NON-member cannot call a basic-usage tool against the org — the grant * must never leak past membership. * - * Tool choices have all-optional input schemas, so a denial surfaces as an - * access error rather than a schema-validation error: - * - AUTOMATION_LIST → basic-usage - * - MONITORING_STATS → NOT basic-usage (monitoring:view capability) + * Tool choices (denials use all-optional input schemas, so a denial surfaces as + * an access error rather than a schema-validation error): + * - AUTOMATION_LIST → basic-usage + * - MONITORING_STATS → NOT basic-usage (monitoring:view capability) + * - COLLECTION_VIRTUAL_MCP_CREATE → agents:manage; granted to the built-in + * user role only. Sent with valid `data` so the custom-role denial is an + * access error, not validation. */ import type { Client } from "pg"; @@ -40,6 +45,19 @@ const toolCallBody = (name: string) => ({ params: { name, arguments: {} }, }); +// COLLECTION_VIRTUAL_MCP_CREATE (agents:manage) with VALID minimal data, so a +// denial is an access error rather than input validation. `title` + an (empty) +// `connections` array are the only required fields. +const agentCreateBody = () => ({ + jsonrpc: "2.0" as const, + id: 1, + method: "tools/call", + params: { + name: "COLLECTION_VIRTUAL_MCP_CREATE", + arguments: { data: { title: "gating probe", connections: [] } }, + }, +}); + test.describe("runtime basic-usage grant", () => { let db: Client; @@ -158,6 +176,29 @@ test.describe("runtime basic-usage grant", () => { ).toBe(true); expect(errText).toMatch(/access denied|permission/i); + // agents:manage is NOT basic-usage and is NOT granted to this custom role, + // so creating an agent is denied. (The built-in user role IS granted + // agents:manage — see the next test — proving this is role-specific.) + const agentRes = await memberCtx.post(`/api/${owner.orgSlug}/mcp/self`, { + data: agentCreateBody(), + headers: MCP_HEADERS, + }); + const agentDenied = (await agentRes.json()) as { + result?: { isError?: boolean; content?: Array<{ text?: string }> }; + error?: { message?: string }; + }; + const agentErr = + agentDenied.result?.content?.[0]?.text ?? + agentDenied.error?.message ?? + ""; + expect( + agentDenied.result?.isError === true || !!agentDenied.error, + `expected COLLECTION_VIRTUAL_MCP_CREATE to be denied for the custom role, got: ${JSON.stringify( + agentDenied, + )}`, + ).toBe(true); + expect(agentErr).toMatch(/access denied|permission/i); + await ownerCtx.dispose(); await memberCtx.dispose(); }); @@ -211,6 +252,17 @@ test.describe("runtime basic-usage grant", () => { ); expect(Array.isArray(automations.automations)).toBe(true); + // agents:manage IS granted to the built-in user role + // (USER_ROLE_CAPABILITY_IDS) → creating an agent succeeds. callSelfMcpTool + // throws on an access denial, so a returned item proves the grant. + const created = await callSelfMcpTool<{ item: { id: string } }>( + memberCtx, + owner.orgSlug, + "COLLECTION_VIRTUAL_MCP_CREATE", + { data: { title: "user-managed agent", connections: [] } }, + ); + expect(created.item?.id).toBeTruthy(); + // Gated tool (monitoring:view) → denied. Before enforcing the user role, // the `self: ["*"]` grant + wildcard fallback leaked full access here. const deniedRes = await memberCtx.post(`/api/${owner.orgSlug}/mcp/self`, { diff --git a/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts b/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts index 67ec3cc6ce..c5267f5813 100644 --- a/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts +++ b/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts @@ -1,10 +1,12 @@ /** * E2E: capability gating of the Connections, Agents and Monitor surfaces. * - * Drives the real browser as a built-in "user" member (no gated capabilities): + * Drives the real browser as a built-in "user" member: * - Monitor is capability-gated (monitoring:view) → no-access panel; - * - Connections + Agents stay viewable (basic-usage), but their create - * affordances are hidden (connections:manage / agents:manage). + * - Connections stay viewable (basic-usage) but the create affordance is + * hidden (connections:manage); + * - Agents are manageable — the built-in user role is granted agents:manage + * (USER_ROLE_CAPABILITY_IDS), so the create affordance is shown. */ import type { APIRequestContext, Page } from "@playwright/test"; @@ -64,7 +66,7 @@ test.describe("connections / agents / monitor gating", () => { await db?.end(); }); - test("a plain member can't manage connections, agents, or view monitoring", async ({ + test("a plain member can't manage connections or view monitoring, but can manage agents", async ({ page, playwright, }) => { @@ -95,14 +97,15 @@ test.describe("connections / agents / monitor gating", () => { page.getByRole("button", { name: "Custom Connection" }), ).toHaveCount(0); - // Agents page loads but the create CTA is hidden without agents:manage. + // Agents page loads and the create CTA IS shown — the built-in user role + // is granted agents:manage (USER_ROLE_CAPABILITY_IDS). await page.goto(`/${owner.orgSlug}/settings/agents`); await expect(page.getByPlaceholder("Search for an agent...")).toBeVisible({ timeout: 15_000, }); await expect( page.getByRole("button", { name: "Create Agent" }), - ).toHaveCount(0); + ).toBeVisible(); await ownerCtx.dispose(); }); diff --git a/apps/mesh/e2e/tests/my-capabilities.spec.ts b/apps/mesh/e2e/tests/my-capabilities.spec.ts index 5136859f8c..2f4cd744a5 100644 --- a/apps/mesh/e2e/tests/my-capabilities.spec.ts +++ b/apps/mesh/e2e/tests/my-capabilities.spec.ts @@ -98,9 +98,14 @@ test.describe("GET /api/auth/custom/my-capabilities/:slug", () => { const body = (await res.json()) as CapabilitiesResponse; expect(body.role).toBe("user"); - const values = Object.values(body.capabilities); - expect(values.length).toBeGreaterThan(0); - expect(values.every((v) => v === false)).toBe(true); + // The built-in user role is granted agents:manage via USER_ROLE_CAPABILITY_IDS; + // every other gated capability stays false. + expect(body.capabilities["agents:manage"]).toBe(true); + const others = Object.entries(body.capabilities) + .filter(([id]) => id !== "agents:manage") + .map(([, granted]) => granted); + expect(others.length).toBeGreaterThan(0); + expect(others.every((granted) => granted === false)).toBe(true); await ownerCtx.dispose(); await memberCtx.dispose(); diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 930b514947..98b4557c28 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -1329,7 +1329,7 @@ export const BASIC_USAGE_TOOLS: ReadonlySet = new Set( * of its tools to every member of every org. This is a GLOBAL change with no * migration; for per-org grants use a custom role instead. */ -const USER_ROLE_CAPABILITY_IDS: string[] = []; +const USER_ROLE_CAPABILITY_IDS: string[] = ["agents:manage"]; /** * Tools the built-in `user` role gets beyond basic-usage, derived from From 7b23f7cbb07673eb0795845ca9beb0e87922b71a Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 3 Jun 2026 10:01:45 -0300 Subject: [PATCH 6/6] feat(permissions): grant connections:manage to the built-in user role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "connections:manage" to USER_ROLE_CAPABILITY_IDS alongside agents:manage, so every member can create/update/delete connections too — via the same two wired layers (user role `self` grant + MY_CAPABILITIES). e2e updates: - basic-usage-grant: the built-in user role can now create a connection (COLLECTION_CONNECTIONS_CREATE succeeds; the unreachable URL is swallowed server-side), while a basic-only custom role is denied the same call. - my-capabilities: the user role resolves agents:manage + connections:manage true, every other gated capability false. - connections-agents-monitor-gating: the "Custom Connection" affordance is now shown for a plain member (still can't view monitoring). Verified locally: Better Auth authorize() grants the connection + agent tools to the user role and still denies monitoring/member tools and the `{ self: ["*"] }` wildcard probe; resolveCapabilities lights up only connections:manage + agents:manage. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mesh/e2e/tests/basic-usage-grant.spec.ts | 89 +++++++++++++++---- .../connections-agents-monitor-gating.spec.ts | 15 ++-- apps/mesh/e2e/tests/my-capabilities.spec.ts | 12 +-- apps/mesh/src/tools/registry-metadata.ts | 5 +- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts index 3822d89ef4..c49b488464 100644 --- a/apps/mesh/e2e/tests/basic-usage-grant.spec.ts +++ b/apps/mesh/e2e/tests/basic-usage-grant.spec.ts @@ -9,25 +9,27 @@ * * - A member with a restrictive CUSTOM role (not owner/admin, and whose * stored permission does NOT list the tool) can still call a basic-usage - * tool, yet is denied a non-basic tool it was never granted — including an - * agents:manage tool, which only the built-in user role gets. This is the - * test that actually exercises the runtime grant. - * - A member on the built-in "user" role gets basic-usage AND agents:manage - * (USER_ROLE_CAPABILITY_IDS) — so it can create an agent — but is still - * denied other gated tools and org management via Better Auth's native - * endpoints (invite-member). Guards two regressions: the `self: ["*"]` grant - * + wildcard fallback re-granting every tool, and the `user` role spreading - * `adminAc` (org-admin statements) instead of member-level `memberAc`. + * tool, yet is denied tools it was never granted — including agents:manage + * and connections:manage tools, which only the built-in user role gets. This + * is the test that actually exercises the runtime grant. + * - A member on the built-in "user" role gets basic-usage AND agents:manage + + * connections:manage (USER_ROLE_CAPABILITY_IDS) — so it can create an agent + * and a connection — but is still denied other gated tools and org + * management via Better Auth's native endpoints (invite-member). Guards two + * regressions: the `self: ["*"]` grant + wildcard fallback re-granting every + * tool, and the `user` role spreading `adminAc` (org-admin statements) + * instead of member-level `memberAc`. * - A NON-member cannot call a basic-usage tool against the org — the grant * must never leak past membership. * - * Tool choices (denials use all-optional input schemas, so a denial surfaces as - * an access error rather than a schema-validation error): - * - AUTOMATION_LIST → basic-usage - * - MONITORING_STATS → NOT basic-usage (monitoring:view capability) - * - COLLECTION_VIRTUAL_MCP_CREATE → agents:manage; granted to the built-in - * user role only. Sent with valid `data` so the custom-role denial is an - * access error, not validation. + * Tool choices (denials use all-optional input schemas, or valid `data`, so a + * denial surfaces as an access error rather than a schema-validation error): + * - AUTOMATION_LIST → basic-usage + * - MONITORING_STATS → NOT basic-usage (monitoring:view) + * - COLLECTION_VIRTUAL_MCP_CREATE → agents:manage (built-in user role only) + * - COLLECTION_CONNECTIONS_CREATE → connections:manage (built-in user role + * only). Sent with valid `data`; an unreachable URL is swallowed server-side + * so the user's create still succeeds. */ import type { Client } from "pg"; @@ -58,6 +60,26 @@ const agentCreateBody = () => ({ }, }); +// COLLECTION_CONNECTIONS_CREATE (connections:manage) with VALID minimal data, so +// a denial is an access error rather than input validation. The handler swallows +// an unreachable URL (fetchToolsFromMCP().catch(() => null)), so a granted call +// still creates the row. +const connectionCreateBody = () => ({ + jsonrpc: "2.0" as const, + id: 1, + method: "tools/call", + params: { + name: "COLLECTION_CONNECTIONS_CREATE", + arguments: { + data: { + title: "gating probe conn", + connection_type: "HTTP", + connection_url: "https://example.com/mcp", + }, + }, + }, +}); + test.describe("runtime basic-usage grant", () => { let db: Client; @@ -199,6 +221,25 @@ test.describe("runtime basic-usage grant", () => { ).toBe(true); expect(agentErr).toMatch(/access denied|permission/i); + // connections:manage is likewise NOT granted to this custom role → denied. + const connRes = await memberCtx.post(`/api/${owner.orgSlug}/mcp/self`, { + data: connectionCreateBody(), + headers: MCP_HEADERS, + }); + const connDenied = (await connRes.json()) as { + result?: { isError?: boolean; content?: Array<{ text?: string }> }; + error?: { message?: string }; + }; + const connErr = + connDenied.result?.content?.[0]?.text ?? connDenied.error?.message ?? ""; + expect( + connDenied.result?.isError === true || !!connDenied.error, + `expected COLLECTION_CONNECTIONS_CREATE to be denied for the custom role, got: ${JSON.stringify( + connDenied, + )}`, + ).toBe(true); + expect(connErr).toMatch(/access denied|permission/i); + await ownerCtx.dispose(); await memberCtx.dispose(); }); @@ -263,6 +304,22 @@ test.describe("runtime basic-usage grant", () => { ); expect(created.item?.id).toBeTruthy(); + // connections:manage IS granted to the built-in user role too → creating a + // connection succeeds (the unreachable URL is swallowed server-side). + const createdConn = await callSelfMcpTool<{ item: { id: string } }>( + memberCtx, + owner.orgSlug, + "COLLECTION_CONNECTIONS_CREATE", + { + data: { + title: "user-managed conn", + connection_type: "HTTP", + connection_url: "https://example.com/mcp", + }, + }, + ); + expect(createdConn.item?.id).toBeTruthy(); + // Gated tool (monitoring:view) → denied. Before enforcing the user role, // the `self: ["*"]` grant + wildcard fallback leaked full access here. const deniedRes = await memberCtx.post(`/api/${owner.orgSlug}/mcp/self`, { diff --git a/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts b/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts index c5267f5813..c48a2c6c5f 100644 --- a/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts +++ b/apps/mesh/e2e/tests/connections-agents-monitor-gating.spec.ts @@ -3,10 +3,9 @@ * * Drives the real browser as a built-in "user" member: * - Monitor is capability-gated (monitoring:view) → no-access panel; - * - Connections stay viewable (basic-usage) but the create affordance is - * hidden (connections:manage); - * - Agents are manageable — the built-in user role is granted agents:manage - * (USER_ROLE_CAPABILITY_IDS), so the create affordance is shown. + * - Connections AND Agents are manageable — the built-in user role is granted + * connections:manage + agents:manage (USER_ROLE_CAPABILITY_IDS), so both + * create affordances are shown. */ import type { APIRequestContext, Page } from "@playwright/test"; @@ -66,7 +65,7 @@ test.describe("connections / agents / monitor gating", () => { await db?.end(); }); - test("a plain member can't manage connections or view monitoring, but can manage agents", async ({ + test("a plain member can't view monitoring, but can manage connections and agents", async ({ page, playwright, }) => { @@ -87,15 +86,15 @@ test.describe("connections / agents / monitor gating", () => { timeout: 15_000, }); - // Connections page loads (viewing is basic-usage) but the create CTA is - // hidden without connections:manage. + // Connections page loads and the create CTA IS shown — the built-in user + // role is granted connections:manage (USER_ROLE_CAPABILITY_IDS). await page.goto(`/${owner.orgSlug}/settings/connections`); await expect(page.getByPlaceholder("Search for a connection")).toBeVisible({ timeout: 15_000, }); await expect( page.getByRole("button", { name: "Custom Connection" }), - ).toHaveCount(0); + ).toBeVisible(); // Agents page loads and the create CTA IS shown — the built-in user role // is granted agents:manage (USER_ROLE_CAPABILITY_IDS). diff --git a/apps/mesh/e2e/tests/my-capabilities.spec.ts b/apps/mesh/e2e/tests/my-capabilities.spec.ts index 2f4cd744a5..7bdc59fc12 100644 --- a/apps/mesh/e2e/tests/my-capabilities.spec.ts +++ b/apps/mesh/e2e/tests/my-capabilities.spec.ts @@ -98,14 +98,16 @@ test.describe("GET /api/auth/custom/my-capabilities/:slug", () => { const body = (await res.json()) as CapabilitiesResponse; expect(body.role).toBe("user"); - // The built-in user role is granted agents:manage via USER_ROLE_CAPABILITY_IDS; - // every other gated capability stays false. + // The built-in user role is granted agents:manage + connections:manage via + // USER_ROLE_CAPABILITY_IDS; every other gated capability stays false. expect(body.capabilities["agents:manage"]).toBe(true); + expect(body.capabilities["connections:manage"]).toBe(true); + const granted = new Set(["agents:manage", "connections:manage"]); const others = Object.entries(body.capabilities) - .filter(([id]) => id !== "agents:manage") - .map(([, granted]) => granted); + .filter(([id]) => !granted.has(id)) + .map(([, isGranted]) => isGranted); expect(others.length).toBeGreaterThan(0); - expect(others.every((granted) => granted === false)).toBe(true); + expect(others.every((isGranted) => isGranted === false)).toBe(true); await ownerCtx.dispose(); await memberCtx.dispose(); diff --git a/apps/mesh/src/tools/registry-metadata.ts b/apps/mesh/src/tools/registry-metadata.ts index 98b4557c28..2cb6978ef5 100644 --- a/apps/mesh/src/tools/registry-metadata.ts +++ b/apps/mesh/src/tools/registry-metadata.ts @@ -1329,7 +1329,10 @@ export const BASIC_USAGE_TOOLS: ReadonlySet = new Set( * of its tools to every member of every org. This is a GLOBAL change with no * migration; for per-org grants use a custom role instead. */ -const USER_ROLE_CAPABILITY_IDS: string[] = ["agents:manage"]; +const USER_ROLE_CAPABILITY_IDS: string[] = [ + "agents:manage", + "connections:manage", +]; /** * Tools the built-in `user` role gets beyond basic-usage, derived from