mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
[codex] harden authenticated routes and issue editor reliability (#3741)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The control plane depends on authenticated routes enforcing company boundaries and role permissions correctly > - This branch also touches the issue detail and markdown editing flows operators use while handling advisory and triage work > - Partial issue cache seeds and fragile rich-editor parsing could leave important issue content missing or blank at the moment an operator needed it > - Blocked issues becoming actionable again should wake their assignee automatically instead of silently staying idle > - This pull request rebases the advisory follow-up branch onto current `master`, hardens authenticated route authorization, and carries the issue-detail/editor reliability fixes forward with regression tests > - The benefit is tighter authz on sensitive routes plus more reliable issue/advisory editing and wakeup behavior on top of the latest base ## What Changed - Hardened authenticated route authorization across agent, activity, approval, access, project, plugin, health, execution-workspace, portability, and related server paths, with new cross-tenant and runtime-authz regression coverage. - Switched issue detail queries from `initialData` to placeholder-based hydration so list/quicklook seeds still refetch full issue bodies. - Normalized advisory-style HTML images before mounting the markdown editor and strengthened fallback behavior when the rich editor silently fails or rejects the content. - Woke assigned agents when blocked issues move back to `todo`, with route coverage for reopen and unblock transitions. - Rebasing note: this branch now sits cleanly on top of the latest `master` tip used for the PR base. ## Verification - `pnpm exec vitest run ui/src/lib/issueDetailQuery.test.tsx ui/src/components/MarkdownEditor.test.tsx server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/activity-routes.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts` - Confirmed `pnpm-lock.yaml` is not part of the PR diff. - Rebased the branch onto current `public-gh/master` before publishing. ## Risks - Broad authz tightening may expose existing flows that were relying on permissive board or agent access and now need explicit grants. - Markdown editor fallback changes could affect focus or rendering in edge-case content that mixes HTML-like advisory markup with normal markdown. - This verification was intentionally scoped to touched regressions and did not run the full repository suite. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment with tool use for terminal, git, and GitHub operations. The exact runtime model identifier is not exposed inside this session. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, it is behavior-only and does not need before/after screenshots - [x] I have updated relevant documentation to reflect my changes, or no documentation changes were needed for these internal fixes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
50cd76d8a3
commit
32a9165ddf
39 changed files with 3014 additions and 153 deletions
|
|
@ -48,7 +48,7 @@ import {
|
|||
logActivity,
|
||||
notifyHireApproved
|
||||
} from "../services/index.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
import { assertAuthenticated, assertCompanyAccess } from "./authz.js";
|
||||
import {
|
||||
claimBoardOwnership,
|
||||
inspectBoardClaimChallenge
|
||||
|
|
@ -863,6 +863,7 @@ function toInviteSummaryResponse(
|
|||
const baseUrl = requestBaseUrl(req);
|
||||
const onboardingPath = `/api/invites/${token}/onboarding`;
|
||||
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
||||
const skillIndexPath = `/api/invites/${token}/skills/index`;
|
||||
const inviteMessage = extractInviteMessage(invite);
|
||||
return {
|
||||
id: invite.id,
|
||||
|
|
@ -877,10 +878,10 @@ function toInviteSummaryResponse(
|
|||
onboardingTextUrl: baseUrl
|
||||
? `${baseUrl}${onboardingTextPath}`
|
||||
: onboardingTextPath,
|
||||
skillIndexPath: "/api/skills/index",
|
||||
skillIndexPath,
|
||||
skillIndexUrl: baseUrl
|
||||
? `${baseUrl}/api/skills/index`
|
||||
: "/api/skills/index",
|
||||
? `${baseUrl}${skillIndexPath}`
|
||||
: skillIndexPath,
|
||||
inviteMessage
|
||||
};
|
||||
}
|
||||
|
|
@ -1004,7 +1005,7 @@ function buildInviteOnboardingManifest(
|
|||
}
|
||||
) {
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
const skillPath = "/api/skills/paperclip";
|
||||
const skillPath = `/api/invites/${token}/skills/paperclip`;
|
||||
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
||||
const registrationEndpointPath = `/api/invites/${token}/accept`;
|
||||
const registrationEndpointUrl = baseUrl
|
||||
|
|
@ -1906,11 +1907,13 @@ export function accessRoutes(
|
|||
return company?.name ?? null;
|
||||
}
|
||||
|
||||
router.get("/skills/available", (_req, res) => {
|
||||
router.get("/skills/available", (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
res.json({ skills: listAvailableSkills() });
|
||||
});
|
||||
|
||||
router.get("/skills/index", (_req, res) => {
|
||||
router.get("/skills/index", (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
res.json({
|
||||
skills: [
|
||||
{ name: "paperclip", path: "/api/skills/paperclip" },
|
||||
|
|
@ -1927,6 +1930,7 @@ export function accessRoutes(
|
|||
});
|
||||
|
||||
router.get("/skills/:skillName", (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
const skillName = (req.params.skillName as string).trim().toLowerCase();
|
||||
const markdown = readSkillMarkdown(skillName);
|
||||
if (!markdown) throw notFound("Skill not found");
|
||||
|
|
@ -2100,6 +2104,47 @@ export function accessRoutes(
|
|||
);
|
||||
});
|
||||
|
||||
router.get("/invites/:token/skills/index", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(token)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
res.json({
|
||||
skills: [
|
||||
{
|
||||
name: "paperclip",
|
||||
path: `/api/invites/${token}/skills/paperclip`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/invites/:token/skills/:skillName", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(token)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
const skillName = (req.params.skillName as string).trim().toLowerCase();
|
||||
if (skillName !== "paperclip") throw notFound("Skill not found");
|
||||
const markdown = readSkillMarkdown(skillName);
|
||||
if (!markdown) throw notFound("Skill not found");
|
||||
res.type("text/markdown").send(markdown);
|
||||
});
|
||||
|
||||
router.get("/invites/:token/test-resolution", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { z } from "zod";
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { heartbeatService, issueService } from "../services/index.js";
|
||||
import { sanitizeRecord } from "../redaction.js";
|
||||
|
||||
|
|
@ -81,6 +81,7 @@ export function activityRoutes(db: Db) {
|
|||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
const runId = req.params.runId as string;
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (!run) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Router, type Request } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
|
@ -46,6 +46,10 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectAgentAdapterWorkspaceCommandPaths,
|
||||
} from "./workspace-command-authz.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findActiveServerAdapter,
|
||||
|
|
@ -231,10 +235,33 @@ export function agentRoutes(db: Db) {
|
|||
return actorAgent;
|
||||
}
|
||||
|
||||
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: agents:create");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanReadConfigurations(req: Request, companyId: string) {
|
||||
return assertCanCreateAgentsForCompany(req, companyId);
|
||||
}
|
||||
|
||||
async function getAccessibleAgent(req: Request, res: Response, id: string) {
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return null;
|
||||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (req.actor.type === "board") {
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") {
|
||||
|
|
@ -317,7 +344,10 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (req.actor.type === "board") {
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
return;
|
||||
}
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
|
|
@ -339,7 +369,10 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (req.actor.type === "board") {
|
||||
await assertCanReadConfigurations(req, targetAgent.companyId);
|
||||
return;
|
||||
}
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
|
|
@ -610,19 +643,24 @@ export function agentRoutes(db: Db) {
|
|||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
);
|
||||
}
|
||||
if (actorAgent.id === targetAgent.id) return;
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
}
|
||||
|
||||
const chainOfCommand = await svc.getChainOfCommand(targetAgent.id);
|
||||
if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return;
|
||||
|
||||
throw forbidden("Only the target agent or an ancestor manager can update instructions path");
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
req: Request,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (req.actor.type !== "agent" || !adapterConfig) return;
|
||||
const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined);
|
||||
if (changedSensitiveKeys.length === 0) return;
|
||||
throw forbidden(
|
||||
`Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
|
||||
|
|
@ -997,7 +1035,7 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
const result = await svc.list(companyId);
|
||||
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
|
||||
if (canReadConfigs || req.actor.type === "board") {
|
||||
if (canReadConfigs) {
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1173,12 +1211,13 @@ export function agentRoutes(db: Db) {
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
||||
if (!canRead) {
|
||||
res.json(await buildAgentDetail(agent, { restricted: true }));
|
||||
return;
|
||||
}
|
||||
const isSelf = req.actor.type === "agent" && req.actor.agentId === id;
|
||||
const canReadSensitiveDetail = isSelf
|
||||
? true
|
||||
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
|
||||
if (!canReadSensitiveDetail) {
|
||||
res.json(await buildAgentDetail(agent, { restricted: true }));
|
||||
return;
|
||||
}
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
|
@ -1266,6 +1305,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
const state = await heartbeat.getRuntimeState(id);
|
||||
|
|
@ -1280,6 +1320,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
const sessions = await heartbeat.listTaskSessions(id);
|
||||
|
|
@ -1299,6 +1340,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
const taskKey =
|
||||
|
|
@ -1331,6 +1373,14 @@ export function agentRoutes(db: Db) {
|
|||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
|
||||
);
|
||||
assertNoAgentInstructionsConfigMutation(
|
||||
req,
|
||||
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
|
|
@ -1590,6 +1640,8 @@ export function agentRoutes(db: Db) {
|
|||
res.status(403).json({ error: "Only CEO can manage permissions" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await assertBoardCanManageAgentsForCompany(req, existing.companyId);
|
||||
}
|
||||
|
||||
const agent = await svc.updatePermissions(id, req.body);
|
||||
|
|
@ -1890,10 +1942,15 @@ export function agentRoutes(db: Db) {
|
|||
res.status(422).json({ error: "adapterConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
const changingInstructionsPath = Object.keys(adapterConfig).some((key) =>
|
||||
KNOWN_INSTRUCTIONS_PATH_KEYS.has(key),
|
||||
assertNoAgentInstructionsConfigMutation(req, adapterConfig);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(adapterConfig),
|
||||
);
|
||||
if (changingInstructionsPath) {
|
||||
const changingInstructionsConfig = Object.keys(adapterConfig).some((key) =>
|
||||
KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]),
|
||||
);
|
||||
if (changingInstructionsConfig) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
patchData.adapterConfig = adapterConfig;
|
||||
|
|
@ -1994,6 +2051,9 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/pause", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.pause(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2017,6 +2077,9 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/resume", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.resume(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2038,6 +2101,9 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/terminate", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.terminate(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2061,6 +2127,9 @@ export function agentRoutes(db: Db) {
|
|||
router.delete("/agents/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
if (!(await getAccessibleAgent(req, res, id))) {
|
||||
return;
|
||||
}
|
||||
const agent = await svc.remove(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -2082,6 +2151,10 @@ export function agentRoutes(db: Db) {
|
|||
router.get("/agents/:id/keys", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const agent = await getAccessibleAgent(req, res, id);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const keys = await svc.listKeys(id);
|
||||
res.json(keys);
|
||||
});
|
||||
|
|
@ -2089,32 +2162,56 @@ export function agentRoutes(db: Db) {
|
|||
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const agent = await getAccessibleAgent(req, res, id);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const key = await svc.createApiKey(id, req.body.name);
|
||||
|
||||
const agent = await svc.getById(id);
|
||||
if (agent) {
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
});
|
||||
}
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
});
|
||||
|
||||
res.status(201).json(key);
|
||||
});
|
||||
|
||||
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const keyId = req.params.keyId as string;
|
||||
const revoked = await svc.revokeKey(keyId);
|
||||
const agent = await getAccessibleAgent(req, res, id);
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await svc.getKeyById(keyId);
|
||||
if (!key || key.agentId !== agent.id) {
|
||||
res.status(404).json({ error: "Key not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await svc.revokeKey(agent.id, keyId);
|
||||
if (!revoked) {
|
||||
res.status(404).json({ error: "Key not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "agent.key_revoked",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
|
@ -2127,9 +2224,13 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.wakeup(id, {
|
||||
|
|
@ -2177,9 +2278,13 @@ export function agentRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== id) {
|
||||
res.status(403).json({ error: "Agent can only invoke itself" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
}
|
||||
|
||||
const run = await heartbeat.invoke(
|
||||
|
|
@ -2225,6 +2330,7 @@ export function agentRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (agent.adapterType !== "claude_local") {
|
||||
res.status(400).json({ error: "Login is only supported for claude_local agents" });
|
||||
|
|
|
|||
|
|
@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
const { approval, applied } = await svc.approve(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
const decidedByUserId = req.actor.userId ?? "board";
|
||||
const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote);
|
||||
|
||||
if (applied) {
|
||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||
|
|
@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
const { approval, applied } = await svc.reject(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
const decidedByUserId = req.actor.userId ?? "board";
|
||||
const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote);
|
||||
|
||||
if (applied) {
|
||||
await logActivity(db, {
|
||||
|
|
@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) {
|
|||
res.status(404).json({ error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
const approval = await svc.requestRevision(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
const decidedByUserId = req.actor.userId ?? "board";
|
||||
const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import type { Request } from "express";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
|
||||
export function assertAuthenticated(req: Request) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBoard(req: Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
|
|
@ -16,9 +22,7 @@ export function assertInstanceAdmin(req: Request) {
|
|||
}
|
||||
|
||||
export function assertCompanyAccess(req: Request, companyId: string) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
}
|
||||
assertAuthenticated(req);
|
||||
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
|
@ -31,9 +35,7 @@ export function assertCompanyAccess(req: Request, companyId: string) {
|
|||
}
|
||||
|
||||
export function getActorInfo(req: Request) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
}
|
||||
assertAuthenticated(req);
|
||||
if (req.actor.type === "agent") {
|
||||
return {
|
||||
actorType: "agent" as const,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ import {
|
|||
stopRuntimeServicesForExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectExecutionWorkspaceCommandPaths,
|
||||
} from "./workspace-command-authz.js";
|
||||
import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
||||
|
||||
export function executionWorkspaceRoutes(db: Db) {
|
||||
const router = Router();
|
||||
|
|
@ -96,6 +101,12 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
await assertCanManageExecutionWorkspaceRuntimeServices(db, req, {
|
||||
companyId: existing.companyId,
|
||||
executionWorkspaceId: existing.id,
|
||||
sourceIssueId: existing.sourceIssueId,
|
||||
});
|
||||
|
||||
const workspaceCwd = existing.cwd;
|
||||
if (!workspaceCwd) {
|
||||
res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" });
|
||||
|
|
@ -428,6 +439,13 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectExecutionWorkspaceCommandPaths({
|
||||
config: req.body.config,
|
||||
metadata: req.body.metadata,
|
||||
}),
|
||||
);
|
||||
const patch: Record<string, unknown> = {
|
||||
...(req.body.name === undefined ? {} : { name: req.body.name }),
|
||||
...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-se
|
|||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
|
||||
function shouldExposeFullHealthDetails(
|
||||
actorType: "none" | "board" | "agent" | null | undefined,
|
||||
deploymentMode: DeploymentMode,
|
||||
) {
|
||||
if (deploymentMode !== "authenticated") return true;
|
||||
return actorType === "board" || actorType === "agent";
|
||||
}
|
||||
|
||||
export function healthRoutes(
|
||||
db?: Db,
|
||||
opts: {
|
||||
|
|
@ -23,9 +31,19 @@ export function healthRoutes(
|
|||
) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
router.get("/", async (req, res) => {
|
||||
const actorType = "actor" in req ? req.actor?.type : null;
|
||||
const exposeFullDetails = shouldExposeFullHealthDetails(
|
||||
actorType,
|
||||
opts.deploymentMode,
|
||||
);
|
||||
|
||||
if (!db) {
|
||||
res.json({ status: "ok", version: serverVersion });
|
||||
res.json(
|
||||
exposeFullDetails
|
||||
? { status: "ok", version: serverVersion }
|
||||
: { status: "ok", deploymentMode: opts.deploymentMode },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +88,7 @@ export function healthRoutes(
|
|||
|
||||
const persistedDevServerStatus = readPersistedDevServerStatus();
|
||||
let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;
|
||||
if (persistedDevServerStatus) {
|
||||
if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const experimentalSettings = await instanceSettings.getExperimental();
|
||||
const activeRunCount = await db
|
||||
|
|
@ -85,6 +103,16 @@ export function healthRoutes(
|
|||
});
|
||||
}
|
||||
|
||||
if (!exposeFullDetails) {
|
||||
res.json({
|
||||
status: "ok",
|
||||
deploymentMode: opts.deploymentMode,
|
||||
bootstrapStatus,
|
||||
bootstrapInviteActive,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: "ok",
|
||||
version: serverVersion,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ import {
|
|||
import { logger } from "../middleware/logger.js";
|
||||
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectIssueWorkspaceCommandPaths,
|
||||
} from "./workspace-command-authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
isInlineAttachmentContentType,
|
||||
|
|
@ -1323,6 +1327,7 @@ export function issueRoutes(
|
|||
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, companyId);
|
||||
}
|
||||
|
|
@ -1373,6 +1378,7 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
* - Retrieving UI slot contributions for frontend rendering
|
||||
* - Discovering and executing plugin-contributed agent tools
|
||||
*
|
||||
* All routes require board-level authentication (assertBoard middleware).
|
||||
* All routes require board-level authentication, and sensitive instance-wide
|
||||
* mutations such as install/upgrade require instance-admin privileges.
|
||||
*
|
||||
* @module server/routes/plugins
|
||||
* @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification
|
||||
|
|
@ -47,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
|
|||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
|
||||
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
|
|
@ -583,6 +584,9 @@ export function pluginRoutes(
|
|||
*
|
||||
* Install a plugin from npm or a local filesystem path.
|
||||
*
|
||||
* Instance-wide plugin installation is restricted to instance admins because
|
||||
* the install flow fetches and inspects package contents on the host.
|
||||
*
|
||||
* Request body:
|
||||
* - packageName: npm package name or local path (required)
|
||||
* - version: Target version for npm packages (optional)
|
||||
|
|
@ -601,7 +605,7 @@ export function pluginRoutes(
|
|||
* - `500` — installation succeeded but manifest is missing (indicates a loader bug)
|
||||
*/
|
||||
router.post("/plugins/install", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertInstanceAdmin(req);
|
||||
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
|
||||
|
||||
// Input validation
|
||||
|
|
@ -1450,6 +1454,9 @@ export function pluginRoutes(
|
|||
*
|
||||
* Upgrade a plugin to a newer version.
|
||||
*
|
||||
* Upgrades are restricted to instance admins because they fetch and inspect
|
||||
* new package contents on the host before activation.
|
||||
*
|
||||
* Request body (optional):
|
||||
* - version: Target version (defaults to latest)
|
||||
*
|
||||
|
|
@ -1461,7 +1468,7 @@ export function pluginRoutes(
|
|||
* Errors: 404 if plugin not found, 400 for lifecycle errors
|
||||
*/
|
||||
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
|
||||
assertBoard(req);
|
||||
assertInstanceAdmin(req);
|
||||
const { pluginId } = req.params;
|
||||
const body = req.body as { version?: string } | undefined;
|
||||
const version = body?.version;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ import {
|
|||
startRuntimeServicesForWorkspaceControl,
|
||||
stopRuntimeServicesForProjectWorkspace,
|
||||
} from "../services/workspace-runtime.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectProjectExecutionWorkspaceCommandPaths,
|
||||
collectProjectWorkspaceCommandPaths,
|
||||
} from "./workspace-command-authz.js";
|
||||
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function projectRoutes(db: Db) {
|
||||
|
|
@ -93,6 +99,13 @@ export function projectRoutes(db: Db) {
|
|||
};
|
||||
|
||||
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
[
|
||||
...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy),
|
||||
...collectProjectWorkspaceCommandPaths(workspace, "workspace"),
|
||||
],
|
||||
);
|
||||
if (projectData.env !== undefined) {
|
||||
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
|
||||
companyId,
|
||||
|
|
@ -144,6 +157,10 @@ export function projectRoutes(db: Db) {
|
|||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const body = { ...req.body };
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||
);
|
||||
if (typeof body.archivedAt === "string") {
|
||||
body.archivedAt = new Date(body.archivedAt);
|
||||
}
|
||||
|
|
@ -200,6 +217,10 @@ export function projectRoutes(db: Db) {
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
);
|
||||
const workspace = await svc.createWorkspace(id, req.body);
|
||||
if (!workspace) {
|
||||
res.status(422).json({ error: "Invalid project workspace payload" });
|
||||
|
|
@ -238,6 +259,10 @@ export function projectRoutes(db: Db) {
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
);
|
||||
const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId);
|
||||
if (!workspaceExists) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
|
|
@ -290,6 +315,11 @@ export function projectRoutes(db: Db) {
|
|||
return;
|
||||
}
|
||||
|
||||
await assertCanManageProjectWorkspaceRuntimeServices(db, req, {
|
||||
companyId: project.companyId,
|
||||
projectWorkspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const workspaceCwd = workspace.cwd;
|
||||
if (!workspaceCwd) {
|
||||
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" });
|
||||
|
|
|
|||
115
server/src/routes/workspace-command-authz.ts
Normal file
115
server/src/routes/workspace-command-authz.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { Request } from "express";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOwn(value: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function prefixPath(prefix: string, key: string) {
|
||||
return prefix.length > 0 ? `${prefix}.${key}` : key;
|
||||
}
|
||||
|
||||
function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] {
|
||||
if (!isRecord(raw)) return [];
|
||||
const paths: string[] = [];
|
||||
if (hasOwn(raw, "provisionCommand")) {
|
||||
paths.push(prefixPath(prefix, "provisionCommand"));
|
||||
}
|
||||
if (hasOwn(raw, "teardownCommand")) {
|
||||
paths.push(prefixPath(prefix, "teardownCommand"));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] {
|
||||
if (!isRecord(raw)) return [];
|
||||
const paths: string[] = [];
|
||||
if (hasOwn(raw, "provisionCommand")) {
|
||||
paths.push(prefixPath(prefix, "provisionCommand"));
|
||||
}
|
||||
if (hasOwn(raw, "teardownCommand")) {
|
||||
paths.push(prefixPath(prefix, "teardownCommand"));
|
||||
}
|
||||
if (hasOwn(raw, "cleanupCommand")) {
|
||||
paths.push(prefixPath(prefix, "cleanupCommand"));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) {
|
||||
if (req.actor.type !== "agent" || paths.length === 0) return;
|
||||
throw forbidden(
|
||||
`Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
|
||||
export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] {
|
||||
if (!isRecord(adapterConfig)) return [];
|
||||
return collectWorkspaceStrategyCommandPaths(
|
||||
adapterConfig.workspaceStrategy,
|
||||
"adapterConfig.workspaceStrategy",
|
||||
);
|
||||
}
|
||||
|
||||
export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] {
|
||||
if (!isRecord(policy)) return [];
|
||||
return collectWorkspaceStrategyCommandPaths(
|
||||
policy.workspaceStrategy,
|
||||
"executionWorkspacePolicy.workspaceStrategy",
|
||||
);
|
||||
}
|
||||
|
||||
export function collectProjectWorkspaceCommandPaths(
|
||||
workspacePatch: unknown,
|
||||
prefix = "",
|
||||
): string[] {
|
||||
if (!isRecord(workspacePatch)) return [];
|
||||
return hasOwn(workspacePatch, "cleanupCommand")
|
||||
? [prefixPath(prefix, "cleanupCommand")]
|
||||
: [];
|
||||
}
|
||||
|
||||
export function collectIssueWorkspaceCommandPaths(input: {
|
||||
executionWorkspaceSettings?: unknown;
|
||||
assigneeAdapterOverrides?: unknown;
|
||||
}): string[] {
|
||||
const paths: string[] = [];
|
||||
if (isRecord(input.executionWorkspaceSettings)) {
|
||||
paths.push(
|
||||
...collectWorkspaceStrategyCommandPaths(
|
||||
input.executionWorkspaceSettings.workspaceStrategy,
|
||||
"executionWorkspaceSettings.workspaceStrategy",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isRecord(input.assigneeAdapterOverrides)) {
|
||||
const adapterConfig = input.assigneeAdapterOverrides.adapterConfig;
|
||||
if (isRecord(adapterConfig)) {
|
||||
paths.push(
|
||||
...collectWorkspaceStrategyCommandPaths(
|
||||
adapterConfig.workspaceStrategy,
|
||||
"assigneeAdapterOverrides.adapterConfig.workspaceStrategy",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function collectExecutionWorkspaceCommandPaths(input: {
|
||||
config?: unknown;
|
||||
metadata?: unknown;
|
||||
}): string[] {
|
||||
const paths: string[] = [];
|
||||
if (input.config !== undefined) {
|
||||
paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config"));
|
||||
}
|
||||
if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) {
|
||||
paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config"));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
138
server/src/routes/workspace-runtime-service-authz.ts
Normal file
138
server/src/routes/workspace-runtime-service-authz.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, issues } from "@paperclipai/db";
|
||||
import type { Request } from "express";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) {
|
||||
const companyAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
reportsTo: agents.reportsTo,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||||
|
||||
const reportsByManager = new Map<string, string[]>();
|
||||
for (const agent of companyAgents) {
|
||||
if (!agent.reportsTo) continue;
|
||||
const reports = reportsByManager.get(agent.reportsTo) ?? [];
|
||||
reports.push(agent.id);
|
||||
reportsByManager.set(agent.reportsTo, reports);
|
||||
}
|
||||
|
||||
const visited = new Set<string>([actorAgentId]);
|
||||
const queue = [actorAgentId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) continue;
|
||||
const reports = reportsByManager.get(current) ?? [];
|
||||
for (const reportId of reports) {
|
||||
if (visited.has(reportId)) continue;
|
||||
visited.add(reportId);
|
||||
queue.push(reportId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...visited];
|
||||
}
|
||||
|
||||
async function assertAgentCanManageRuntimeServicesForWorkspace(
|
||||
db: Db,
|
||||
req: Request,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectWorkspaceId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
sourceIssueId?: string | null;
|
||||
},
|
||||
) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) {
|
||||
throw forbidden("Agent authentication required");
|
||||
}
|
||||
|
||||
const actorAgent = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
role: agents.role,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, req.actor.agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!actorAgent || actorAgent.companyId !== input.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
if (actorAgent.role === "ceo") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id);
|
||||
const workspaceScopeConditions = [
|
||||
input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null,
|
||||
input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null,
|
||||
input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
|
||||
|
||||
if (workspaceScopeConditions.length === 0) {
|
||||
throw forbidden("Missing permission to manage workspace runtime services");
|
||||
}
|
||||
|
||||
const linkedIssue = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, input.companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES),
|
||||
inArray(issues.assigneeAgentId, eligibleAgentIds),
|
||||
workspaceScopeConditions.length === 1
|
||||
? workspaceScopeConditions[0]!
|
||||
: or(...workspaceScopeConditions),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (linkedIssue) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw forbidden("Missing permission to manage workspace runtime services");
|
||||
}
|
||||
|
||||
export async function assertCanManageProjectWorkspaceRuntimeServices(
|
||||
db: Db,
|
||||
req: Request,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectWorkspaceId: string;
|
||||
},
|
||||
) {
|
||||
assertCompanyAccess(req, input.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input);
|
||||
}
|
||||
|
||||
export async function assertCanManageExecutionWorkspaceRuntimeServices(
|
||||
db: Db,
|
||||
req: Request,
|
||||
input: {
|
||||
companyId: string;
|
||||
executionWorkspaceId: string;
|
||||
sourceIssueId?: string | null;
|
||||
},
|
||||
) {
|
||||
assertCompanyAccess(req, input.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue