mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(cli): manage agent grants and keep remote login on the public origin (#20323)
## Thinking Path
> - Paperclip lets people control their AI agents and permissions.
> - Remote operators need a browser-approved CLI login.
> - Proxies can send that login's approval link to an internal host.
> - Human member commands cannot grant an agent an explicit permission
such as commentary:view.
> - This change keeps approval on the canonical public origin and adds
Board-only single-grant commands.
> - The API checks company access and permission-management authority
and records each change.
> - An operator can sign in, grant one agent permission, and preserve
the agent's other grants.
## Linked Issues or Issue Description
**What happened?**
A proxied CLI login returned an approval URL on the upstream host. The
signed-in browser was on the public host. The CLI also had no command to
add an arbitrary explicit agent grant: member commands manage human
members, while agent permissions:update exposes fixed configuration
fields.
**Expected behavior**
Approval uses the configured canonical public origin. A Board operator
with users:manage_permissions can grant or revoke one explicit agent
permission without replacing other grants or activating an inactive
membership.
**Steps to reproduce**
Configure a public auth origin. Create a CLI challenge through a proxy
that replaces forwarded-host headers. Inspect the approval URL. Then try
to grant commentary:view to an agent through the CLI.
## What Changed
- Use the existing public-origin resolver for CLI approval URLs.
- Add agent permission:grant and permission:revoke commands.
- Add PATCH /api/agents/{id}/permission-grants with strict shared
validation and Board-only OpenAPI metadata.
- Serialize each mutation on the agent row. Preserve unrelated grants
and membership state. Commit the grant and its activity record together
and publish activity after commit.
- Document remote login and single-grant management.
## Verification
- The new approval-link regression failed before the fix and passes
after it.
- All 25 CLI auth route tests pass.
- All 72 affected CLI, login and OpenAPI tests pass.
- All 5 PostgreSQL grant tests pass, including actual commentary reads
(403 before grant, 200 after grant, 403 after revoke), preservation,
audit attribution, concurrent additions, invalid inputs, inactive or
missing membership, agent self-grants, cross-company denial, and
rollback on audit failure.
- Shared and CLI typechecks and server declaration-layer checks pass.
- pnpm verify requests broad CI coverage because doc/CLI.md is a global
verification input. Greptile reviewed final head
338c58fb24 at 5/5 with no findings. All 58
non-skipped checks pass on that head. CI run:
https://github.com/paperclipai/paperclip/actions/runs/42663640985. Live
target acceptance is in progress.
## Risks
This adds an explicit permission-management API. It requires Board
authentication, company access, and users:manage_permissions.
Foreign-company agents return 404. Granting a key intentionally makes
that key unscoped in its company; revocation removes that key. Other
keys and agent configuration remain unchanged. Inactive memberships and
unapproved or terminated agents cannot gain grants. No schema change is
needed. Revert this change to remove the new management surface;
existing grants retain their normal authorization rules.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, code editing, shell tools,
and browser testing. The runtime does not expose a more specific model
ID or context window.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1b7fe33432
commit
ff2d463eed
13 files changed
+304
-3
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")
|
||||
|
||||
+12
-1
@@ -9,7 +9,9 @@ Paperclip CLI now supports both:
|
||||
## Browser-approved Board authentication
|
||||
|
||||
`npx paperclipai auth login --api-base <instance-url>` creates a ten-minute
|
||||
challenge and prints a browser approval link. Sign in to the instance, review
|
||||
challenge and prints a browser approval link. The link uses the instance's
|
||||
canonical public origin when configured, so reverse proxies do not send the
|
||||
browser to an internal upstream hostname. Sign in to the instance, review
|
||||
the requesting command and full Board access, then approve. The pending token
|
||||
cannot authenticate before approval. The CLI verifies its resulting identity
|
||||
before storing its credential in its private auth store. The default Board key
|
||||
@@ -586,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>
|
||||
@@ -603,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,127 @@
|
||||
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, vi } 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";
|
||||
import * as activityLogging from "../services/activity-log.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([]);
|
||||
await db.delete(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId)));
|
||||
expect((await request(api).patch(path(f)).send(grant)).status).toBe(409);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("rolls back the grant if its audit record cannot be saved", async () => {
|
||||
const f = await seed();
|
||||
const failure = vi.spyOn(activityLogging, "logActivity").mockRejectedValueOnce(new Error("Audit storage unavailable"));
|
||||
try {
|
||||
expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(500);
|
||||
expect(await grants(f)).toEqual([]);
|
||||
} finally { failure.mockRestore(); }
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ function registerModuleMocks() {
|
||||
|
||||
let appImportCounter = 0;
|
||||
|
||||
async function createApp(actor: any, db: any = {} as any, options: { cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) {
|
||||
async function createApp(actor: any, db: any = {} as any, options: { authPublicBaseUrl?: string; cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) {
|
||||
appImportCounter += 1;
|
||||
const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`;
|
||||
const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`;
|
||||
@@ -137,6 +137,31 @@ describe("cli auth routes", () => {
|
||||
expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
||||
});
|
||||
|
||||
it("uses the configured public origin for CLI approval behind an upstream proxy", async () => {
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
challenge: {
|
||||
id: "12345678-1234-4123-8123-123456789abc",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
challengeSecret: "pcp_cli_auth_secret",
|
||||
pendingBoardToken: "pcp_board_token",
|
||||
});
|
||||
const app = await createApp({ type: "none", source: "none" }, {}, {
|
||||
authPublicBaseUrl: "https://workspace.example.com/",
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges")
|
||||
.set("Host", "upstream.example.net")
|
||||
.set("X-Forwarded-Host", "upstream.example.net")
|
||||
.set("X-Forwarded-Proto", "https")
|
||||
.send({ command: "paperclipai auth login", requestedAccess: "board" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.approvalUrl).toBe(
|
||||
`https://workspace.example.com${res.body.approvalPath}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds anonymous create and poll independently without trusting spoofed forwarding headers", async () => {
|
||||
let now = 1_000;
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -2795,7 +2795,7 @@ export function accessRoutes(
|
||||
created.challenge.id,
|
||||
created.challengeSecret,
|
||||
);
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
const baseUrl = resolveBaseUrl(req, opts.authPublicBaseUrl);
|
||||
res.status(201).json({
|
||||
id: created.challenge.id,
|
||||
token: created.challengeSecret,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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("Cannot grant permissions to an unapproved or terminated agent");
|
||||
}
|
||||
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");
|
||||
}
|
||||
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