mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(cli): manage individual agent permission grants as a Board operator
Add a company-scoped Board-only route and CLI grant/revoke commands. Preserve other grants and inactive membership state, serialize mutations, and persist their audit records in the same transaction. Cover real commentary reads before and after grant and revoke. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9dd05f2aa3
commit
a72aa691be
11 files changed
+262
No files matched your search
@@ -131,6 +131,17 @@ describe("agent lifecycle commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("grants and revokes one permission without replacing the grant list", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await run(["agent", "permission:grant", AGENT_ID, "commentary:view"]);
|
||||
await run(["agent", "permission:revoke", AGENT_ID, "commentary:view"]);
|
||||
expect(fetchMock.mock.calls.map(call => [call[0], call[1]?.method, JSON.parse(call[1]?.body)])).toEqual([
|
||||
[`http://localhost:3100/api/agents/${AGENT_ID}/permission-grants`, "PATCH", { permissionKey: "commentary:view", enabled: true }],
|
||||
[`http://localhost:3100/api/agents/${AGENT_ID}/permission-grants`, "PATCH", { permissionKey: "commentary:view", enabled: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to delete an instructions file without confirmation", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateAgentInstructionsBundleSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentPermissionGrantSchema,
|
||||
updateAgentSchema,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
wakeAgentSchema,
|
||||
@@ -462,6 +463,24 @@ export function registerAgentCommands(program: Command): void {
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [command, enabled] of [["permission:grant", true], ["permission:revoke", false]] as const) {
|
||||
addCommonClientOptions(
|
||||
agent.command(command)
|
||||
.description(`${enabled ? "Grant" : "Revoke"} one agent permission (Board permission manager only)`)
|
||||
.argument("<agentId>", "Agent UUID")
|
||||
.argument("<permissionKey>", "Permission key, such as commentary:view")
|
||||
.action(async (agentId: string, permissionKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = updateAgentPermissionGrantSchema.parse({ permissionKey, enabled });
|
||||
printOutput(await ctx.api.patch(apiPath`/api/agents/${agentId}/permission-grants`, payload), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("configuration")
|
||||
|
||||
@@ -588,6 +588,8 @@ Agent configuration and runtime endpoints:
|
||||
|
||||
```sh
|
||||
npx paperclipai agent permissions:update <agent-id> --payload-json '{"canCreateAgents":true,"canCreateSkills":true,"canAssignTasks":true}'
|
||||
npx paperclipai agent permission:grant <agent-id> commentary:view
|
||||
npx paperclipai agent permission:revoke <agent-id> commentary:view
|
||||
npx paperclipai agent configuration <agent-id>
|
||||
npx paperclipai agent config-revisions <agent-id>
|
||||
npx paperclipai agent config-revision:get <agent-id> <revision-id>
|
||||
@@ -605,6 +607,13 @@ npx paperclipai agent instructions-file:put <agent-id> --path AGENTS.md --conten
|
||||
npx paperclipai agent instructions-file:delete <agent-id> --path AGENTS.md --yes
|
||||
```
|
||||
|
||||
`permission:grant` and `permission:revoke` require a Board login with
|
||||
`users:manage_permissions` in the agent's company. They change one permission
|
||||
key and preserve all other grants. A grant applies without starting a new run.
|
||||
Granting a key replaces that key's scope with an unscoped company grant;
|
||||
revoking removes the key. Use `agent get <agent-id> --json` to inspect
|
||||
`access.grants`. These commands do not reactivate an inactive membership.
|
||||
|
||||
Agent config, instructions, skills, project env, environment, secret, and workspace edits affect the next run. Active runs finish with the config they started with. When a saved session, reused workspace, or sandbox lease no longer matches the effective next-run config, Paperclip may start fresh execution and records non-sensitive freshness categories in run result JSON and workspace operation logs.
|
||||
|
||||
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
|
||||
|
||||
@@ -2374,6 +2374,8 @@ export {
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
archiveCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateAgentPermissionGrantSchema,
|
||||
type UpdateAgentPermissionGrant,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCostEvent,
|
||||
|
||||
@@ -104,6 +104,13 @@ export const updateMemberPermissionsSchema = z.object({
|
||||
|
||||
export type UpdateMemberPermissions = z.infer<typeof updateMemberPermissionsSchema>;
|
||||
|
||||
export const updateAgentPermissionGrantSchema = z.object({
|
||||
permissionKey: z.enum(PERMISSION_KEYS),
|
||||
enabled: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export type UpdateAgentPermissionGrant = z.infer<typeof updateAgentPermissionGrantSchema>;
|
||||
|
||||
const editableMembershipStatuses = ["pending", "active", "suspended"] as const;
|
||||
|
||||
export const updateCompanyMemberSchema = z.object({
|
||||
|
||||
@@ -768,6 +768,8 @@ export {
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
archiveCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateAgentPermissionGrantSchema,
|
||||
type UpdateAgentPermissionGrant,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCompanyInvite,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, authUsers, companies, companyMemberships, createDb,
|
||||
principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { agentPermissionGrantRoutes } from "../routes/agent-permission-grants.js";
|
||||
import { agentCommentaryRoutes } from "../routes/agent-commentary.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
describe("Board agent permission grants", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("agent-permission-grants-");
|
||||
db = createDb(database.connectionString);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Permission grants", issuePrefix: `G${companyId.slice(0, 6)}` });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.com`,
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "process", status: "idle" });
|
||||
await db.insert(companyMemberships).values([
|
||||
{ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" },
|
||||
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" },
|
||||
]);
|
||||
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user",
|
||||
principalId: userId, permissionKey: "users:manage_permissions" });
|
||||
return { companyId, agentId, userId };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof seed>>;
|
||||
function app(actor: express.Request["actor"]) {
|
||||
const api = express();
|
||||
api.use(express.json());
|
||||
api.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
api.use(agentPermissionGrantRoutes(db));
|
||||
api.use(agentCommentaryRoutes(db));
|
||||
api.use(errorHandler);
|
||||
return api;
|
||||
}
|
||||
function owner(f: Fixture) {
|
||||
return app({ type: "board", source: "session", userId: f.userId, companyIds: [f.companyId],
|
||||
memberships: [{ companyId: f.companyId, membershipRole: "owner", status: "active" }] });
|
||||
}
|
||||
const path = (f: Fixture) => `/agents/${f.agentId}/permission-grants`;
|
||||
const grant = { permissionKey: "commentary:view", enabled: true };
|
||||
const grants = (f: Fixture) => db.select().from(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalType, "agent"),
|
||||
eq(principalPermissionGrants.principalId, f.agentId),
|
||||
));
|
||||
|
||||
it("grants and revokes commentary reads while preserving other grants and recording the operator", async () => {
|
||||
const f = await seed();
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent",
|
||||
principalId: f.agentId, permissionKey: "tasks:assign", scope: { keep: true } });
|
||||
const reader = app({ type: "agent", source: "agent_key", companyId: f.companyId, agentId: f.agentId });
|
||||
const commentary = `/companies/${f.companyId}/agent-commentary`;
|
||||
expect((await request(reader).get(commentary)).status).toBe(403);
|
||||
const response = await request(owner(f)).patch(path(f)).send(grant);
|
||||
expect(response.status, response.text).toBe(200);
|
||||
expect(response.body).toEqual({ agentId: f.agentId, companyId: f.companyId, ...grant });
|
||||
expect((await request(reader).get(commentary)).status).toBe(200);
|
||||
expect(await grants(f)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ permissionKey: "tasks:assign", scope: { keep: true } }),
|
||||
expect.objectContaining({ permissionKey: "commentary:view", scope: null, grantedByUserId: f.userId }),
|
||||
]));
|
||||
expect((await request(owner(f)).patch(path(f)).send({ ...grant, enabled: false })).status).toBe(200);
|
||||
expect((await request(reader).get(commentary)).status).toBe(403);
|
||||
expect(await grants(f)).toEqual([expect.objectContaining({ permissionKey: "tasks:assign", scope: { keep: true } })]);
|
||||
const audit = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId),
|
||||
eq(activityLog.action, "agent.permission_grant_updated")));
|
||||
expect(audit).toHaveLength(2);
|
||||
expect(audit.map(row => row.details)).toEqual(expect.arrayContaining([grant, { ...grant, enabled: false }]));
|
||||
expect(audit.every(row => row.actorId === f.userId && row.companyId === f.companyId)).toBe(true);
|
||||
});
|
||||
|
||||
it("denies agent self-grants, foreign-company owners, and users without permission-management authority", async () => {
|
||||
const f = await seed(), foreign = await seed();
|
||||
const self = app({ type: "agent", source: "agent_key", companyId: f.companyId, agentId: f.agentId });
|
||||
expect((await request(self).patch(path(f)).send(grant)).status).toBe(403);
|
||||
expect((await request(app({ type: "none", source: "none" })).patch(path(f)).send(grant)).status).toBe(401);
|
||||
expect((await request(owner(foreign)).patch(path(f)).send(grant)).status).toBe(404);
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(
|
||||
eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId)));
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.userId));
|
||||
expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(403);
|
||||
expect(await grants(f)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects invalid grants and inactive agents or memberships without reactivating them", async () => {
|
||||
const f = await seed(), api = owner(f);
|
||||
expect((await request(api).patch(path(f)).send({ ...grant, permissionKey: "invalid" })).status).toBe(400);
|
||||
expect((await request(api).patch(path(f)).send({ ...grant, scope: { companyId: "foreign" } })).status).toBe(400);
|
||||
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
|
||||
expect((await request(api).patch(path(f)).send(grant)).status).toBe(409);
|
||||
await db.update(agents).set({ status: "idle" }).where(eq(agents.id, f.agentId));
|
||||
await db.update(companyMemberships).set({ status: "suspended" }).where(and(
|
||||
eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId)));
|
||||
expect((await request(api).patch(path(f)).send(grant)).status).toBe(409);
|
||||
expect(await grants(f)).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes concurrent additions of the same grant", async () => {
|
||||
const f = await seed(), api = owner(f);
|
||||
const responses = await Promise.all([request(api).patch(path(f)).send(grant), request(api).patch(path(f)).send(grant)]);
|
||||
expect(responses.map(response => response.status)).toEqual([200, 200]);
|
||||
expect(await grants(f)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"adapters.ts": "/api",
|
||||
"agents.ts": "/api",
|
||||
"agent-project-access.ts": "/api",
|
||||
"agent-permission-grants.ts": "/api",
|
||||
"agent-commentary.ts": "/api",
|
||||
"agent-chat-messages.ts": "/api",
|
||||
"agent-avatars.ts": "/api",
|
||||
@@ -1180,6 +1181,16 @@ describe("openapi routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("documents agent permission grants as Board-only with a strict request", () => {
|
||||
const operation = loadSpecRoutes().spec.paths["/api/agents/{id}/permission-grants"].patch;
|
||||
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
|
||||
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
||||
expect(operation.requestBody.content["application/json"].schema).toMatchObject({
|
||||
additionalProperties: false, required: ["permissionKey", "enabled"],
|
||||
properties: { enabled: { type: "boolean" }, permissionKey: { enum: expect.arrayContaining(["commentary:view"]) } },
|
||||
});
|
||||
});
|
||||
|
||||
it("documents additive project access as board-only with a strict request", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const path = spec.paths["/api/agents/{id}/project-access"];
|
||||
|
||||
@@ -106,6 +106,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js";
|
||||
import { statusCardRoutes } from "./routes/status-cards.js";
|
||||
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { agentPermissionGrantRoutes } from "./routes/agent-permission-grants.js";
|
||||
import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js";
|
||||
import type { SetupTokenSessionService } from "./services/setup-token-session.js";
|
||||
import {
|
||||
@@ -795,6 +796,7 @@ export async function createApp(
|
||||
},
|
||||
}),
|
||||
);
|
||||
api.use(agentPermissionGrantRoutes(db));
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(agentProfileAvatarRoutes(db, opts.storageService));
|
||||
api.use(projectToolRoutes(db));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Router } from "express";
|
||||
import { agents, companyMemberships, principalPermissionGrants, type Db } from "@paperclipai/db";
|
||||
import { updateAgentPermissionGrantSchema, isUuidLike } from "@paperclipai/shared";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { badRequest, conflict, forbidden, notFound } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "../services/activity-log.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
|
||||
|
||||
export function agentPermissionGrantRoutes(db: Db) {
|
||||
const router = Router();
|
||||
|
||||
router.patch("/agents/:id/permission-grants", validate(updateAgentPermissionGrantSchema), async (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!isUuidLike(id)) throw badRequest("Agent ID must be a UUID");
|
||||
const { permissionKey, enabled } = req.body;
|
||||
const publications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const agent = await tx.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
|
||||
.from(agents).where(eq(agents.id, id)).for("update").then(rows => rows[0]);
|
||||
if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
const txDb = tx as unknown as Db;
|
||||
const access = accessService(txDb);
|
||||
if (req.actor.source !== "local_implicit"
|
||||
&& !await access.canUser(agent.companyId, req.actor.userId, "users:manage_permissions")) {
|
||||
throw forbidden("Missing permission: users:manage_permissions");
|
||||
}
|
||||
if (enabled) {
|
||||
if (agent.status === "pending_approval" || agent.status === "terminated") {
|
||||
throw conflict("Approve or restore the agent before granting permissions");
|
||||
}
|
||||
const membership = await tx.select().from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, agent.companyId),
|
||||
eq(companyMemberships.principalType, "agent"),
|
||||
eq(companyMemberships.principalId, agent.id),
|
||||
)).for("update").then(rows => rows[0]);
|
||||
if (membership && membership.status !== "active") {
|
||||
throw conflict("Agent company membership is not active");
|
||||
}
|
||||
if (!membership) await access.ensureMembership(agent.companyId, "agent", agent.id);
|
||||
await tx.insert(principalPermissionGrants).values({
|
||||
companyId: agent.companyId, principalType: "agent", principalId: agent.id,
|
||||
permissionKey, scope: null, grantedByUserId: req.actor.userId ?? null,
|
||||
}).onConflictDoUpdate({
|
||||
target: [principalPermissionGrants.companyId, principalPermissionGrants.principalType,
|
||||
principalPermissionGrants.principalId, principalPermissionGrants.permissionKey],
|
||||
set: { scope: null, grantedByUserId: req.actor.userId ?? null, updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await tx.delete(principalPermissionGrants).where(and(
|
||||
eq(principalPermissionGrants.companyId, agent.companyId),
|
||||
eq(principalPermissionGrants.principalType, "agent"),
|
||||
eq(principalPermissionGrants.principalId, agent.id),
|
||||
eq(principalPermissionGrants.permissionKey, permissionKey),
|
||||
));
|
||||
}
|
||||
await logActivity(txDb, {
|
||||
companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board",
|
||||
action: "agent.permission_grant_updated", entityType: "agent", entityId: agent.id,
|
||||
details: { permissionKey, enabled },
|
||||
}, publications);
|
||||
return { agentId: agent.id, companyId: agent.companyId, permissionKey, enabled };
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
updateAgentSchema,
|
||||
updatePrimaryAgentSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentPermissionGrantSchema,
|
||||
addAgentProjectAccessSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
updateAgentInstructionsBundleSchema,
|
||||
@@ -1429,6 +1430,7 @@ const browserUseOperations = [
|
||||
] as const;
|
||||
|
||||
const BOARD_ONLY_OPERATIONS = new Set([
|
||||
"PATCH /api/agents/{id}/permission-grants",
|
||||
// Budget changes are board-only (assertBoard in routes/costs.ts).
|
||||
"POST /api/companies/{companyId}/budgets/policies",
|
||||
"POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve",
|
||||
@@ -3834,6 +3836,16 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/agents/{id}/permission-grants",
|
||||
tags: ["agents"],
|
||||
summary: "Grant or revoke one explicit agent permission",
|
||||
description: "Board-only. Requires users:manage_permissions in the agent's company. Enables an unscoped grant or removes that permission key without replacing other grants. Does not reactivate inactive memberships or start agent work. Writes an activity record in the same transaction.",
|
||||
request: { params: z.object({ id: z.string().uuid() }), body: jsonBody(updateAgentPermissionGrantSchema) },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() });
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
|
||||
Reference in new issue
Block a user