Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/mesh/src/api/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,84 @@ app.post("/local-session", async (c) => {
}
});

/**
* My Permissions Endpoint (authenticated)
*
* Returns the current user's role and permission map for their active
* organization. Used by the frontend useCapability hook so any member —
* not just admins — can resolve their own capabilities.
*
* This bypasses Better Auth's listRoles permission gate, which restricts
* the full role list to admins/owners. Members need to read just their
* own role, not the entire org's role catalog.
*
* Route: GET /api/auth/custom/my-permissions
*/
app.get("/my-permissions", async (c) => {
const session = (await auth.api.getSession({
headers: c.req.raw.headers,
})) as {
user?: { id: string };
session?: { activeOrganizationId?: string };
} | null;

if (!session?.user) {
return c.json({ error: "Authentication required" }, 401);
}

const orgId = session.session?.activeOrganizationId;
if (!orgId) {
return c.json({ role: null, permission: null });
}

const db = getDb().db;

// Find the user's member row in the active organization
const member = await db
.selectFrom("member")
.select(["role"])
.where("userId", "=", session.user.id)
.where("organizationId", "=", orgId)
.executeTakeFirst();

if (!member?.role) {
return c.json({ role: null, permission: null });
}

// Built-in roles (owner/admin/user) bypass custom permission lookups —
// owner & admin get full access, user gets only basic-usage tools (the
// server's AccessControl handles those rules; clients use the role name).
if (member.role === "owner" || member.role === "admin") {
return c.json({ role: member.role, permission: null });
}
if (member.role === "user") {
return c.json({ role: member.role, permission: {} });
}

// Custom role: look up the permission JSON in organizationRole.
// Better Auth stores it in camelCase column "organizationRole".
const customRole = await db
.selectFrom("organizationRole" as never)
.select(["permission" as never] as never)
.where("role" as never, "=", member.role as never)
.where("organizationId" as never, "=", orgId as never)
.executeTakeFirst();

let permission: Record<string, string[]> | null = null;
const raw = (customRole as { permission?: unknown })?.permission;
if (typeof raw === "string") {
try {
permission = JSON.parse(raw) as Record<string, string[]>;
} catch {
permission = null;
}
} else if (raw && typeof raw === "object") {
permission = raw as Record<string, string[]>;
}

return c.json({ role: member.role, permission });
});

/**
* Domain Lookup Endpoint (authenticated, verified email required)
*
Expand Down
82 changes: 82 additions & 0 deletions apps/mesh/src/auth/grant-resource-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Auto-grant new resources (connections, virtual MCPs) to all existing
* custom roles in an organization.
*
* Default-allow semantic: when an admin adds a new MCP, every member —
* regardless of their custom role — should be able to use it. To restrict
* access, an admin explicitly removes the grant from the role afterward.
*
* Owner / admin roles bypass all permission checks at runtime, so they
* don't need updates here. The built-in "user" role is hardcoded and not
* stored in `organizationRole`, so it's also untouched — by design, "user"
* stays minimal-privilege.
*/

import type { Kysely } from "kysely";
import type { Database } from "@/storage/types";

type Permission = Record<string, string[]>;

function parsePermission(raw: unknown): Permission {
if (!raw) return {};
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed as Permission;
} catch {
return {};
}
}
if (typeof raw === "object") {
return raw as Permission;
}
return {};
}

/**
* Append a grant of all tools on a given resource (connection / virtual MCP)
* to every custom role in the organization.
*
* Idempotent: if the role already has a wildcard "*" rule or already lists
* the resource, the existing grant is preserved.
*/
export async function grantResourceAccessToAllCustomRoles(
db: Kysely<Database>,
organizationId: string,
resourceId: string,
): Promise<void> {
const roles = await db
.selectFrom("organizationRole")
.select(["id", "permission"])
.where("organizationId", "=", organizationId)
.execute();

if (roles.length === 0) return;

await Promise.all(
roles.map(async (row) => {
const permission = parsePermission(row.permission);

// Already has full access via any of the wildcard shapes Better Auth
// accepts — { "*": ["*"] } (all-resources wildcard) or
// { "self": ["*"] } (full org-tool access). Skip to avoid appending
// redundant per-resource entries that would never be consulted.
if (permission["*"]?.includes("*")) return;
if (permission.self?.includes("*")) return;

// Already has an explicit grant on this resource — preserve it.
if (permission[resourceId]) return;

const next: Permission = {
...permission,
[resourceId]: ["*"],
};

await db
.updateTable("organizationRole")
.set({ permission: JSON.stringify(next) })
.where("id", "=", row.id)
.execute();
}),
);
}
12 changes: 12 additions & 0 deletions apps/mesh/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ if (
}
}

// Local-dev fallback: when no email provider is configured, print the
// invitation accept URL to the server console so you can paste it into a
// second browser/incognito session and finish the invite without email.
if (!sendInvitationEmail) {
sendInvitationEmail = async (data) => {
const acceptUrl = `${getBaseUrl()}/auth/accept-invitation?invitationId=${data.invitation.id}&redirectTo=/`;
console.log(
`\n[invitation] No email provider configured. Open this URL in another browser to accept:\n invitee: ${data.email}\n org: ${data.organization.name}\n url: ${acceptUrl}\n`,
);
};
}

// Configure password reset emails if provider is set
let sendResetPassword:
| NonNullable<BetterAuthOptions["emailAndPassword"]>["sendResetPassword"]
Expand Down
6 changes: 5 additions & 1 deletion apps/mesh/src/auth/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ export async function migrateBetterAuth(databaseUrl?: string): Promise<string> {

// Minimal options — only needs plugins to discover which tables to create.
// Does not need auth config, rate limiting, hooks, etc.
// dynamicAccessControl must be enabled here so Better Auth creates the
// organizationRole table that backs custom roles & per-role permissions.
const options = {
database: freshDatabase,
plugins: [
organization(),
organization({
dynamicAccessControl: { enabled: true, enableCustomResources: true },
}),
adminPlugin(),
apiKey(),
jwt(),
Expand Down
31 changes: 29 additions & 2 deletions apps/mesh/src/core/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { MCP_MESH_KEY } from "@/core/constants";
import { BASIC_USAGE_TOOLS } from "../tools/registry-metadata";
import type { BetterAuthInstance, BoundAuthClient } from "./mesh-context";

// ============================================================================
Expand Down Expand Up @@ -144,9 +145,13 @@ export class AccessControl implements Disposable {
}
}

// No permission found
// No permission found. The error message is read by both the chat
// model and the UI error handler — keep it explicit and directive so
// the AI tells the user about the permission instead of trying to
// install / fall back to other tools.
const resourcesText = resourcesToCheck.join(", ");
throw new ForbiddenError(
`Access denied to: ${resourcesToCheck.join(", ")}`,
`Access denied to: ${resourcesText}. The current user does not have permission to use this tool. Tell the user that they need to ask an organization admin to update their role permissions to grant access. Do not attempt to install or use alternative tools — they will likely fail with the same permission error.`,
);
}

Expand All @@ -155,6 +160,12 @@ export class AccessControl implements Disposable {
* Delegates to Better Auth's Organization plugin via boundAuth
*/
private async checkResource(resource: string): Promise<boolean> {
// Basic-usage tools are always granted to authenticated org members,
// regardless of role. The basic-usage capability lives in the registry
// and is hidden from the role editor UI.
if (BASIC_USAGE_TOOLS.has(resource)) {
return true;
}
// No user or bound auth = deny
if (!this.userId && !this.boundAuth) {
return false;
Expand Down Expand Up @@ -205,4 +216,20 @@ export class AccessControl implements Disposable {
granted(): boolean {
return this._granted;
}

/**
* Soft permission check — returns true/false without throwing.
* Useful inside handlers that want to vary behavior based on the
* caller's role (e.g. show all rows vs. only the user's own).
*/
async has(resource: string): Promise<boolean> {
if (!this.userId && !this.boundAuth) {
return false;
}
try {
return await this.checkResource(resource);
} catch {
return false;
}
}
}
16 changes: 16 additions & 0 deletions apps/mesh/src/tools/connection/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
} from "../../core/mesh-context";
import { getMcpListCache } from "../../mcp-clients/mcp-list-cache";
import { fetchToolsFromMCP } from "./fetch-tools";
import { grantResourceAccessToAllCustomRoles } from "../../auth/grant-resource-access";
import { getDb } from "../../database";
import {
buildVirtualUrl,
ConnectionCreateDataSchema,
Expand Down Expand Up @@ -126,6 +128,20 @@ export const COLLECTION_CONNECTIONS_CREATE = defineTool({
.catch(() => {});
}

// Auto-grant the new connection to every existing custom role so
// members aren't locked out of a freshly added MCP. Admins can still
// remove the grant on a per-role basis afterward.
await grantResourceAccessToAllCustomRoles(
getDb().db,
organization.id,
connection.id,
).catch((err) => {
console.error(
"[connection.create] Failed to auto-grant new connection to roles",
err,
);
});

await ctx.eventBus.publish(
organization.id,
WellKnownOrgMCPId.SELF(organization.id),
Expand Down
97 changes: 57 additions & 40 deletions apps/mesh/src/tools/connection/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,47 +157,64 @@ export const COLLECTION_CONNECTIONS_LIST = defineTool({
offset: needsBindingFilter ? undefined : offset,
});

// Only fetch tools from MCP servers when we need them for binding filtering.
// This avoids expensive live listTools() calls on every page load.
if (bindingChecker) {
const cache = getMcpListCache();
const selfId = WellKnownOrgMCPId.SELF(organization.id);
await Promise.all(
connections.map(async (connection) => {
if (connection.tools !== null) return;
// The self MCP requires session auth, so an HTTP round-trip would
// fail without forwarding cookies. Use in-process transport instead.
const fetchLive =
connection.id === selfId
? async () => {
const { listManagementTools } = await import("../../tools");
return listManagementTools(ctx) as Promise<unknown[]>;
}
: async () => {
const client = await clientFromConnection(
connection,
ctx,
true,
);
try {
const result = await client.listTools();
return result.tools;
} finally {
await client.close().catch(() => {});
}
};
const tools = await fetchWithCache(
"tools",
connection.id,
fetchLive,
cache,
);
if (tools !== null) {
connection.tools = tools as Tool[];
// Populate tools on each connection.
// - Binding-filtered queries need tools to evaluate the binding,
// so we may have to do a live listTools() on cache miss.
// - Non-filtered queries (e.g. the role editor's tool selector)
// just want the tool catalog if it exists in cache. Skipping the
// live fallback keeps the page-load cheap while still showing
// freshly-imported MCPs as soon as their tools are cached.
const cache = getMcpListCache();
const selfId = WellKnownOrgMCPId.SELF(organization.id);
await Promise.all(
connections.map(async (connection) => {
if (connection.tools !== null) return;

if (cache && !bindingChecker) {
// Read-only path: cache hit only, no live fallback.
const cached = (await cache.get("tools", connection.id)) as
| Tool[]
| null;
if (cached) {
connection.tools = cached;
}
}),
);
}
return;
}

if (!bindingChecker) return;

// The self MCP requires session auth, so an HTTP round-trip would
// fail without forwarding cookies. Use in-process transport instead.
const fetchLive =
connection.id === selfId
? async () => {
const { listManagementTools } = await import("../../tools");
return listManagementTools(ctx) as Promise<unknown[]>;
}
: async () => {
const client = await clientFromConnection(
connection,
ctx,
true,
);
try {
const result = await client.listTools();
return result.tools;
} finally {
await client.close().catch(() => {});
}
};
const tools = await fetchWithCache(
"tools",
connection.id,
fetchLive,
cache,
);
if (tools !== null) {
connection.tools = tools as Tool[];
}
}),
);

// In dev mode, inject the dev-assets connection for local file storage
// This provides object storage functionality without requiring an external S3 bucket
Expand Down
Loading
Loading