Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .github/workflows/api-pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ jobs:
cache-dependency-path: services/api/go.sum

- name: Run tests (race detector)
run: go test -race -timeout 60s -coverprofile=coverage.out $(go list ./... | grep -v '/test/e2e')
# internal/platform/plugin's suite (WASM JIT-compiling several
# plugin fixtures under -race) runs ~45s on a quiet machine with
# near-zero headroom under the previous 60s budget — enough to tip
# into a hard timeout on a slower/contended CI runner with no
# underlying test hang (reproduces green locally every time).
# 120s matches the budget acp-bridge-pr-ci.yml already uses for the
# same class of race-detector run.
run: go test -race -timeout 120s -coverprofile=coverage.out $(go list ./... | grep -v '/test/e2e')

- name: Upload coverage report
uses: actions/upload-artifact@v6
Expand Down
79 changes: 79 additions & 0 deletions apps/mcp/src/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,33 @@ describe("hasPermission", () => {
};
expect(hasPermission(map, "tasks.read", "proj-1")).toBe(false);
});

// Regression coverage: some permission keys now nest three or more
// segments deep (project.settings.task_types.read), with the granted
// wildcard sitting below the top level (project.settings.*, not a
// bare project.*) — mirrors the Go backend's authorizer, which checks
// every granted "<prefix>.*" key rather than deriving a single
// candidate from the required key's first segment.
it("grants via a nested domain wildcard (project.settings.*)", () => {
const map: PermissionMap = {
global: { "project.settings.*": true },
projects: {},
};
expect(hasPermission(map, "project.settings.task_types.read")).toBe(true);
expect(hasPermission(map, "project.settings.custom_fields.write")).toBe(
true,
);
});

it("does not grant a nested permission via a same-prefix but unrelated wildcard", () => {
const map: PermissionMap = {
global: { "project.roles.*": true },
projects: {},
};
expect(hasPermission(map, "project.settings.task_types.read")).toBe(
false,
);
});
});

describe("project-scoped permissions", () => {
Expand Down Expand Up @@ -104,6 +131,16 @@ describe("hasPermission", () => {
};
expect(hasPermission(map, "tasks.read")).toBe(false);
});

it("grants via a nested project domain wildcard (views.* covering views.write)", () => {
const map: PermissionMap = {
global: {},
projects: { "proj-1": { "project.settings.*": true } },
};
expect(
hasPermission(map, "project.settings.task_statuses.write", "proj-1"),
).toBe(true);
});
});

describe("precedence", () => {
Expand Down Expand Up @@ -161,10 +198,52 @@ describe("getToolPermission", () => {

it("returns the correct permission for list_views", () => {
const perm = getToolPermission("list_views");
expect(perm?.permissionKey).toBe("views.read");
expect(perm?.requiresProject).toBe(true);
});

// Regression coverage: redefining a task-type/task-status/custom-field
// (create/update/delete/etc.) is split off tasks.write onto its own
// project.settings.*.write key, since the backend stopped requiring
// tasks.write to edit project schema (see router.go's task-types/task-
// statuses/custom-fields route comments) — these write tools previously
// stayed mapped to tasks.write, which would show them as available to a
// member who can edit tasks but was never granted schema access, only
// for the backend to 403 the call. Viewing the schema has no such split
// — it stays on tasks.read (see list_task_statuses below).
it("returns the correct permission for create_task_type", () => {
const perm = getToolPermission("create_task_type");
expect(perm?.permissionKey).toBe("project.settings.task_types.write");
expect(perm?.requiresProject).toBe(true);
});

// list_task_types/list_task_statuses/list_custom_fields/get_custom_field
// have no dedicated read permission — viewing project schema is implied
// by tasks.read, same as viewing the tasks that reference it. Only
// redefining it (create/update/delete/set-default/reorder) is its own,
// narrower project.settings.*.write capability — see create_task_type
// and update_custom_field below.
it("returns the correct permission for list_task_statuses", () => {
const perm = getToolPermission("list_task_statuses");
expect(perm?.permissionKey).toBe("tasks.read");
expect(perm?.requiresProject).toBe(true);
});

it("returns the correct permission for update_custom_field", () => {
const perm = getToolPermission("update_custom_field");
expect(perm?.permissionKey).toBe("project.settings.custom_fields.write");
expect(perm?.requiresProject).toBe(true);
});

// list_task_positions/bulk_move_tasks/move_task stay on tasks.* even
// after the views.* split above — moving a task between statuses within
// a view is still editing a task, not the view or the status list.
it("returns the correct permission for bulk_move_tasks", () => {
const perm = getToolPermission("bulk_move_tasks");
expect(perm?.permissionKey).toBe("tasks.write");
expect(perm?.requiresProject).toBe(true);
});

it("returns the correct permission for read_conversation — gated on conversations.read, not left unmapped, even though the backend also enforces its own agent_id match separately", () => {
const perm = getToolPermission("read_conversation");
expect(perm?.permissionKey).toBe("conversations.read");
Expand Down
150 changes: 91 additions & 59 deletions apps/mcp/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,85 +152,102 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [
requiresProject: true,
},

// Task type tools
// Task type tools — project *schema* (which task types exist). Redefining
// the type list is gated on project.settings.task_types.write, a
// different capability from "edit a task's content" (see router.go's
// task-types route comment / authz.
// PermissionProjectSettingsTaskTypesWrite's doc comment). Viewing the
// list has no dedicated read permission — it's gated on tasks.read like
// its own consumer (a task's type field), not a separate key. A member
// with only tasks.write (no project.settings.task_types.write) can still
// edit tasks via update_task, but not these.
{
toolName: "list_task_types",
permissionKey: "tasks.read",
requiresProject: true,
},
{
toolName: "create_task_type",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_types.write",
requiresProject: true,
},
{
toolName: "update_task_type",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_types.write",
requiresProject: true,
},
{
toolName: "delete_task_type",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_types.write",
requiresProject: true,
},
{
toolName: "set_default_task_type",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_types.write",
requiresProject: true,
},

// Task status tools
// Task status tools — project *schema* (which statuses exist, their
// order, which is the default), same split as task types above (view via
// tasks.read, redefine via project.settings.task_statuses.write). Moving
// a task *between* existing statuses (update_task, move_task/
// bulk_move_tasks below) stays on tasks.write — that's editing a task,
// not the status list.
{
toolName: "list_task_statuses",
permissionKey: "tasks.read",
requiresProject: true,
},
{
toolName: "create_task_status",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_statuses.write",
requiresProject: true,
},
{
toolName: "update_task_status",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_statuses.write",
requiresProject: true,
},
{
toolName: "delete_task_status",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_statuses.write",
requiresProject: true,
},
{
toolName: "set_default_task_status",
permissionKey: "tasks.write",
permissionKey: "project.settings.task_statuses.write",
requiresProject: true,
},

// View tools
// View tools — gated on their own views.read/write, not a borrowed
// tasks.read/write (there was no dedicated permission for the view
// resource itself before — see router.go's views route comment).
// Moving a *task* within a view (list_task_positions/bulk_move_tasks/
// move_task below) stays on tasks.* — that's still editing a task.
{
toolName: "list_views",
permissionKey: "tasks.read",
permissionKey: "views.read",
requiresProject: true,
},
{
toolName: "create_view",
permissionKey: "tasks.write",
permissionKey: "views.write",
requiresProject: true,
},
{
toolName: "reorder_views",
permissionKey: "tasks.write",
permissionKey: "views.write",
requiresProject: true,
},
{ toolName: "get_view", permissionKey: "tasks.read", requiresProject: true },
{ toolName: "get_view", permissionKey: "views.read", requiresProject: true },
{
toolName: "update_view",
permissionKey: "tasks.write",
permissionKey: "views.write",
requiresProject: true,
},
{
toolName: "delete_view",
permissionKey: "tasks.write",
permissionKey: "views.write",
requiresProject: true,
},
{
Expand All @@ -249,15 +266,17 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [
requiresProject: true,
},

// Custom field tools
// Custom field tools — project schema, same split as task types/statuses
// above (view via tasks.read, redefine via
// project.settings.custom_fields.write).
{
toolName: "list_custom_fields",
permissionKey: "tasks.read",
requiresProject: true,
},
{
toolName: "create_custom_field",
permissionKey: "tasks.write",
permissionKey: "project.settings.custom_fields.write",
requiresProject: true,
},
{
Expand All @@ -267,12 +286,12 @@ export const TOOL_PERMISSIONS: ToolPermission[] = [
},
{
toolName: "update_custom_field",
permissionKey: "tasks.write",
permissionKey: "project.settings.custom_fields.write",
requiresProject: true,
},
{
toolName: "delete_custom_field",
permissionKey: "tasks.write",
permissionKey: "project.settings.custom_fields.write",
requiresProject: true,
},

Expand Down Expand Up @@ -616,61 +635,74 @@ export async function fetchAgentPermissions(
return { global, projects };
}

export function hasPermission(
permissionMap: PermissionMap,
/**
* Checks one flat permission map (either the global map or a single
* project's) for an exact match or a covering wildcard, mirroring the Go
* backend's own matcher (internal/platform/authz/authorizer.go's
* hasPermission): every granted key ending in ".*" is tried as a prefix
* against permissionKey, not just one wildcard derived from permissionKey's
* first segment. That distinction matters now that some domains nest a
* wildcard below the top level — project.settings.* (granted to e.g.
* PROJECT_OWNER/PROJECT_MANAGER by default) must cover
* project.settings.task_types.read, but there is no such permission as a
* bare "project.*"; a single derived `${parts[0]}.*` candidate would only
* ever check that non-existent key and never match. Scanning every granted
* wildcard also means this needs no updating if a future permission adds
* another nesting level.
*/
function matchesPermissionMap(
map: Record<string, boolean>,
permissionKey: string,
projectId?: string,
scopeLabel: string,
): boolean {
if (!permissionKey) return true;

const { global, projects } = permissionMap;

if (global["*"] === true) {
console.error(`[permissions] Granting ${permissionKey} via global *`);
if (map["*"] === true) {
console.error(
`[permissions] Granting ${permissionKey} via ${scopeLabel} *`,
);
return true;
}

if (global[permissionKey] === true) {
if (map[permissionKey] === true) {
console.error(
`[permissions] Granting ${permissionKey} via global exact match`,
`[permissions] Granting ${permissionKey} via ${scopeLabel} exact match`,
);
return true;
}

const parts = permissionKey.split(".");
if (parts.length >= 2) {
const wildcardKey = `${parts[0]}.*`;
if (global[wildcardKey] === true) {
for (const [key, granted] of Object.entries(map)) {
if (!granted || !key.endsWith(".*")) continue;
const prefix = key.slice(0, -1); // strip the trailing "*", keep the dot
if (permissionKey.startsWith(prefix)) {
console.error(
`[permissions] Granting ${permissionKey} via global ${wildcardKey}`,
`[permissions] Granting ${permissionKey} via ${scopeLabel} ${key}`,
);
return true;
}
}
return false;
}

export function hasPermission(
permissionMap: PermissionMap,
permissionKey: string,
projectId?: string,
): boolean {
if (!permissionKey) return true;

const { global, projects } = permissionMap;

if (matchesPermissionMap(global, permissionKey, "global")) {
return true;
}

if (projectId && projects[projectId]) {
if (projects[projectId]["*"] === true) {
console.error(
`[permissions] Granting ${permissionKey} via project ${projectId} *`,
);
return true;
}
if (projects[projectId][permissionKey] === true) {
console.error(
`[permissions] Granting ${permissionKey} via project ${projectId} exact match`,
);
if (
matchesPermissionMap(
projects[projectId],
permissionKey,
`project ${projectId}`,
)
) {
return true;
}
const parts = permissionKey.split(".");
if (parts.length >= 2) {
const wildcardKey = `${parts[0]}.*`;
if (projects[projectId][wildcardKey] === true) {
console.error(
`[permissions] Granting ${permissionKey} via project ${projectId} ${wildcardKey}`,
);
return true;
}
}
console.error(
`[permissions] Denying ${permissionKey} for project ${projectId} - no matching permission`,
);
Expand Down
Loading