feat(FN-1122): add agent permission access APIs

- Define canonical agent permission types and export permission helpers from @fusion/core
- Add permission normalization and access-state computation with role defaults plus explicit grants
- Extend AgentStore with getAccessState and add unit tests for permission logic and store behavior
- Add dashboard routes for GET /api/agents/:id/access and PATCH /api/agents/:id/permissions with request validation and serialization
- Add a minor changeset for @gsxdsm/fusion documenting the new agent permission endpoints
This commit is contained in:
gsxdsm
2026-04-08 01:39:15 -07:00
parent 343a575b8e
commit 025b60164d
9 changed files with 767 additions and 2 deletions

View File

@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
computeAccessState,
isValidPermission,
normalizePermissions,
} from "./agent-permissions.js";
import { AGENT_PERMISSIONS } from "./types.js";
import type { Agent, AgentCapability, AgentPermission } from "./types.js";
function makeAgent(role: AgentCapability, permissions?: Record<string, boolean>): Agent {
return {
id: "agent-001",
name: "Test Agent",
role,
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
permissions,
};
}
describe("normalizePermissions", () => {
it("returns empty set for empty input", () => {
expect(normalizePermissions({})).toEqual(new Set());
});
it("returns only valid permission keys", () => {
const result = normalizePermissions({
"tasks:execute": true,
"foo:bar": true,
"budget:spend": true,
"agents:view": true,
});
expect(result).toEqual(new Set<AgentPermission>(["tasks:execute", "agents:view"]));
});
it("returns only granted permissions", () => {
const result = normalizePermissions({
"tasks:execute": true,
"tasks:assign": false,
"agents:view": false,
});
expect(result).toEqual(new Set<AgentPermission>(["tasks:execute"]));
});
it("returns all valid permissions when all are true", () => {
const allPermissions = Object.fromEntries(
AGENT_PERMISSIONS.map((permission) => [permission, true]),
);
const result = normalizePermissions(allPermissions);
expect(result).toEqual(new Set<AgentPermission>(AGENT_PERMISSIONS));
});
});
describe("isValidPermission", () => {
it("returns true for every entry in AGENT_PERMISSIONS", () => {
for (const permission of AGENT_PERMISSIONS) {
expect(isValidPermission(permission)).toBe(true);
}
});
it("returns false for invalid strings", () => {
expect(isValidPermission("budget:spend")).toBe(false);
expect(isValidPermission("invalid")).toBe(false);
expect(isValidPermission("")).toBe(false);
});
});
describe("computeAccessState", () => {
it("executor role gets execute by default and cannot assign tasks", () => {
const state = computeAccessState(makeAgent("executor"));
expect(state.canExecuteTasks).toBe(true);
expect(state.canAssignTasks).toBe(false);
expect(state.taskAssignSource).toBe("denied");
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
});
it("scheduler role gets assign by default", () => {
const state = computeAccessState(makeAgent("scheduler"));
expect(state.canAssignTasks).toBe(true);
expect(state.taskAssignSource).toBe("role_default");
expect(state.roleDefaultPermissions.has("tasks:assign")).toBe(true);
});
it("custom role has no defaults", () => {
const state = computeAccessState(makeAgent("custom"));
expect(state.canAssignTasks).toBe(false);
expect(state.canCreateAgents).toBe(false);
expect(state.canExecuteTasks).toBe(false);
expect(state.canReviewTasks).toBe(false);
expect(state.canMergeTasks).toBe(false);
expect(state.canDeleteAgents).toBe(false);
expect(state.canManageMissions).toBe(false);
expect(state.canSendMessages).toBe(false);
expect(state.resolvedPermissions.size).toBe(0);
});
it("explicit grant enables assignment and reports explicit_grant source", () => {
const state = computeAccessState(
makeAgent("executor", { "tasks:assign": true }),
);
expect(state.canAssignTasks).toBe(true);
expect(state.taskAssignSource).toBe("explicit_grant");
});
it("explicit false does not remove role defaults", () => {
const state = computeAccessState(
makeAgent("executor", { "tasks:execute": false }),
);
expect(state.canExecuteTasks).toBe(true);
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
expect(state.explicitPermissions.has("tasks:execute")).toBe(false);
});
it("separates explicit and role-default permissions in mixed cases", () => {
const state = computeAccessState(
makeAgent("engineer", {
"tasks:assign": true,
"tasks:merge": true,
"messages:send": true,
}),
);
expect(state.explicitPermissions).toEqual(
new Set<AgentPermission>(["tasks:assign", "tasks:merge", "messages:send"]),
);
expect(state.roleDefaultPermissions).toEqual(
new Set<AgentPermission>([
"tasks:execute",
"tasks:review",
"agents:view",
"messages:read",
"messages:send",
]),
);
expect(state.resolvedPermissions.has("tasks:assign")).toBe(true);
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
});
it("still gets role defaults when permissions field is undefined", () => {
const state = computeAccessState(makeAgent("reviewer", undefined));
expect(state.canReviewTasks).toBe(true);
expect(state.resolvedPermissions.has("tasks:review")).toBe(true);
});
it("ignores invalid permission keys", () => {
const state = computeAccessState(
makeAgent("custom", { "budget:spend": true, "tasks:execute": true }),
);
expect(state.explicitPermissions).toEqual(new Set<AgentPermission>(["tasks:execute"]));
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
expect(state.resolvedPermissions.has("budget:spend" as AgentPermission)).toBe(false);
});
it("resolved permissions are the union of role defaults and explicit grants", () => {
const state = computeAccessState(
makeAgent("executor", {
"tasks:assign": true,
"agents:create": true,
}),
);
const expected = new Set<AgentPermission>([
...state.roleDefaultPermissions,
...state.explicitPermissions,
]);
expect(state.resolvedPermissions).toEqual(expected);
});
});