mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
PAP-11957 scope Hermes task bridge credentials
This commit is contained in:
1 parent
f0e2bffe42
commit
73e5bd8f4e
22 files changed
+493
-78
No files matched your search
@@ -73,6 +73,8 @@ async function createIssue(
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
return db
|
||||
@@ -86,6 +88,8 @@ async function createIssue(
|
||||
projectId: input.projectId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
originId: input.originId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
@@ -1187,4 +1191,105 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
grant: { permissionKey: "tasks:assign" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes task bridge keys away from company-wide reads and unrelated issue writes", async () => {
|
||||
const company = await createCompany(db, "TaskBridge");
|
||||
const bridgeAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
const project = await createProject(db, company.id, "Bridge");
|
||||
const parentIssue = await createIssue(db, company.id, { projectId: project.id });
|
||||
const assignedIssue = await createIssue(db, company.id, { assigneeAgentId: bridgeAgent.id });
|
||||
const keyId = randomUUID();
|
||||
const bridgeCreatedIssue = await createIssue(db, company.id, {
|
||||
originKind: "task_bridge",
|
||||
originId: keyId,
|
||||
});
|
||||
const unrelatedIssue = await createIssue(db, company.id);
|
||||
const actor = {
|
||||
type: "agent" as const,
|
||||
agentId: bridgeAgent.id,
|
||||
companyId: company.id,
|
||||
source: "agent_key" as const,
|
||||
keyId,
|
||||
keyScope: {
|
||||
kind: "task_bridge" as const,
|
||||
parentIssueId: parentIssue.id,
|
||||
allowedAssigneeAgentIds: [targetAgent.id],
|
||||
},
|
||||
};
|
||||
const authz = authorizationService(db);
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "company_scope:read",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "tasks:assign",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
parentIssueId: parentIssue.id,
|
||||
assigneeAgentId: targetAgent.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "tasks:assign",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
assigneeUserId: randomUUID(),
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "issue:comment",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
issueId: assignedIssue.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "issue:mutate",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
issueId: bridgeCreatedIssue.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor,
|
||||
action: "issue:mutate",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: company.id,
|
||||
issueId: unrelatedIssue.id,
|
||||
},
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -669,6 +669,22 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
mockStorageService.deleteObject.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("denies company-wide issue list routes for task bridge keys", async () => {
|
||||
const app = await createApp(peerActor({
|
||||
keyId: "99999999-9999-4999-8999-999999999999",
|
||||
keyScope: {
|
||||
kind: "task_bridge",
|
||||
parentIssueId: issueId,
|
||||
},
|
||||
}));
|
||||
|
||||
const res = await request(app).get(`/api/companies/${companyId}/issues`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs");
|
||||
expect(mockIssueService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the company-scope fast path on the issue list route", async () => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => {
|
||||
if (input.action === "company_scope:read") {
|
||||
|
||||
@@ -14,7 +14,7 @@ Don't use when:
|
||||
Runtime distinction:
|
||||
- hermes_local: Paperclip starts Hermes on the Paperclip host through the external Hermes adapter plugin.
|
||||
- hermes_gateway: Paperclip calls an already-running Hermes API server using agentDefaultsPayload.apiBaseUrl.
|
||||
- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_API_KEY after invite approval and key claim. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls.
|
||||
- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_BRIDGE_API_KEY. Create that Paperclip key with scope.kind = "task_bridge" and an approved parent/project boundary; do not expose a normal claimed agent key to Hermes-facing chat/webhook surfaces. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls.
|
||||
|
||||
Hermes gateway process setup:
|
||||
- Set API_SERVER_ENABLED=true.
|
||||
@@ -49,7 +49,8 @@ Network examples:
|
||||
- Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy.
|
||||
|
||||
Security notes:
|
||||
- Treat API_SERVER_KEY and claimed PAPERCLIP_API_KEY as secrets.
|
||||
- Treat API_SERVER_KEY and PAPERCLIP_BRIDGE_API_KEY as secrets.
|
||||
- Never use a normal claimed Paperclip agent API key for internet-facing Hermes-originated task bridge calls; task_bridge keys cannot use company-wide issue list/search/read surfaces and can only mutate bridge-created or assigned issues.
|
||||
- Prefer private network or TLS for non-loopback gateway access.
|
||||
- Use placeholders such as <same-value-as-API_SERVER_KEY> in docs and tests.
|
||||
`;
|
||||
@@ -4,7 +4,7 @@ import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
|
||||
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { boardAuthService } from "../services/board-auth.js";
|
||||
@@ -192,6 +192,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
agentId: key.agentId,
|
||||
companyId: key.companyId,
|
||||
keyId: key.id,
|
||||
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
|
||||
runId: runIdHeader || undefined,
|
||||
source: "agent_key",
|
||||
};
|
||||
|
||||
@@ -1947,6 +1947,18 @@ export function agentRoutes(
|
||||
res.json(buildLowTrustSelfView(agent));
|
||||
return;
|
||||
}
|
||||
if (req.actor.keyScope?.kind === "task_bridge") {
|
||||
res.json({
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
title: agent.title,
|
||||
status: agent.status,
|
||||
keyScope: req.actor.keyScope,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(await buildAgentDetail(agent));
|
||||
});
|
||||
|
||||
@@ -3188,7 +3200,7 @@ export function agentRoutes(
|
||||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const key = await svc.createApiKey(id, req.body.name);
|
||||
const key = await svc.createApiKey(id, req.body.name, req.body.scope);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -3197,7 +3209,7 @@ export function agentRoutes(
|
||||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name },
|
||||
details: { keyId: key.id, name: key.name, scope: key.scope },
|
||||
});
|
||||
|
||||
res.status(201).json(key);
|
||||
|
||||
+52
-19
@@ -1847,6 +1847,25 @@ export function issueRoutes(
|
||||
throw forbidden(decision.explanation);
|
||||
}
|
||||
|
||||
function isTaskBridgeKeyActor(req: Request) {
|
||||
return req.actor.type === "agent" && req.actor.source === "agent_key" && req.actor.keyScope?.kind === "task_bridge";
|
||||
}
|
||||
|
||||
function taskBridgeOriginForActor(req: Request) {
|
||||
return isTaskBridgeKeyActor(req) && req.actor.keyId
|
||||
? { originKind: "task_bridge", originId: req.actor.keyId }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function assertTaskBridgeCreateAllowed(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
assignmentScope: TaskAssignmentAuthorizationScope,
|
||||
) {
|
||||
if (!isTaskBridgeKeyActor(req)) return;
|
||||
await assertCanAssignTasks(req, companyId, assignmentScope);
|
||||
}
|
||||
|
||||
async function decideIssueAccess(
|
||||
req: Request,
|
||||
issue: {
|
||||
@@ -2924,6 +2943,10 @@ export function issueRoutes(
|
||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (isTaskBridgeKeyActor(req)) {
|
||||
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" });
|
||||
return;
|
||||
}
|
||||
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
|
||||
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
|
||||
const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined;
|
||||
@@ -3084,6 +3107,10 @@ export function issueRoutes(
|
||||
router.get("/companies/:companyId/issues/count", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (isTaskBridgeKeyActor(req)) {
|
||||
res.status(403).json({ error: "Task bridge keys cannot use company-wide issue count APIs" });
|
||||
return;
|
||||
}
|
||||
const attention = req.query.attention as string | undefined;
|
||||
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
|
||||
if (attention !== "blocked") {
|
||||
@@ -5005,7 +5032,7 @@ export function issueRoutes(
|
||||
if (watchdogProductBugFollowUp === false) return;
|
||||
const effectiveParentId = watchdogProductBugFollowUp ? null : rawCreateBody.parentId;
|
||||
let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null;
|
||||
if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp) {
|
||||
if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp && !isTaskBridgeKeyActor(req)) {
|
||||
const companyScopeDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "company_scope:read",
|
||||
@@ -5022,7 +5049,7 @@ export function issueRoutes(
|
||||
res.status(404).json({ error: "Parent issue not found" });
|
||||
return;
|
||||
}
|
||||
if (!(await assertIssueReadAllowed(req, res, createParent))) return;
|
||||
if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, createParent))) return;
|
||||
}
|
||||
if (
|
||||
!watchdogProductBugFollowUp &&
|
||||
@@ -5068,17 +5095,19 @@ export function issueRoutes(
|
||||
: {}),
|
||||
};
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
|
||||
const createAssignmentScope = {
|
||||
projectId: await resolveAssignmentProjectId({
|
||||
companyId,
|
||||
projectId: createBody.projectId,
|
||||
parentIssueId: createBody.parentId,
|
||||
}),
|
||||
parentIssueId: createBody.parentId ?? null,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
|
||||
};
|
||||
await assertTaskBridgeCreateAllowed(req, companyId, createAssignmentScope);
|
||||
if (rawCreateBody.assigneeAgentId || rawCreateBody.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, companyId, {
|
||||
projectId: await resolveAssignmentProjectId({
|
||||
companyId,
|
||||
projectId: createBody.projectId,
|
||||
parentIssueId: createBody.parentId,
|
||||
}),
|
||||
parentIssueId: createBody.parentId ?? null,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
|
||||
});
|
||||
await assertCanAssignTasks(req, companyId, createAssignmentScope);
|
||||
}
|
||||
await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
@@ -5096,6 +5125,7 @@ export function issueRoutes(
|
||||
}, actor);
|
||||
const issue = await svc.create(companyId, {
|
||||
...createBody,
|
||||
...(taskBridgeOriginForActor(req) ?? {}),
|
||||
id: issueId,
|
||||
executionPolicy,
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
@@ -5213,7 +5243,7 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, parent.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, parent))) return;
|
||||
if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, parent))) return;
|
||||
if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return;
|
||||
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
@@ -5226,13 +5256,15 @@ export function issueRoutes(
|
||||
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
|
||||
};
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return;
|
||||
const childAssignmentScope = {
|
||||
projectId: createBody.projectId ?? parent.projectId ?? null,
|
||||
parentIssueId: parent.id,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: createBody.assigneeUserId ?? null,
|
||||
};
|
||||
await assertTaskBridgeCreateAllowed(req, parent.companyId, childAssignmentScope);
|
||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, parent.companyId, {
|
||||
projectId: createBody.projectId ?? parent.projectId ?? null,
|
||||
parentIssueId: parent.id,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: createBody.assigneeUserId ?? null,
|
||||
});
|
||||
await assertCanAssignTasks(req, parent.companyId, childAssignmentScope);
|
||||
}
|
||||
await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
@@ -5255,6 +5287,7 @@ export function issueRoutes(
|
||||
}, actor);
|
||||
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
||||
...createBody,
|
||||
...(taskBridgeOriginForActor(req) ?? {}),
|
||||
id: issueId,
|
||||
executionPolicy,
|
||||
...(currentSerializedChild
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
getAgentWorkEligibility,
|
||||
isUuidLike,
|
||||
normalizeAgentApiKeyScope,
|
||||
normalizeAgentUrlKey,
|
||||
type AgentEligibilityAgent,
|
||||
type AgentApiKeyScope,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
@@ -756,7 +758,7 @@ export function agentService(db: Db) {
|
||||
});
|
||||
},
|
||||
|
||||
createApiKey: async (id: string, name: string) => {
|
||||
createApiKey: async (id: string, name: string, scope: AgentApiKeyScope = { kind: "standard" }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) throw notFound("Agent not found");
|
||||
if (existing.status === "pending_approval") {
|
||||
@@ -775,6 +777,7 @@ export function agentService(db: Db) {
|
||||
companyId: existing.companyId,
|
||||
name,
|
||||
keyHash,
|
||||
scopeConfig: scope.kind === "standard" ? null : scope,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
@@ -782,6 +785,7 @@ export function agentService(db: Db) {
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
scope: normalizeAgentApiKeyScope(created.scopeConfig),
|
||||
token,
|
||||
createdAt: created.createdAt,
|
||||
};
|
||||
@@ -792,11 +796,19 @@ export function agentService(db: Db) {
|
||||
.select({
|
||||
id: agentApiKeys.id,
|
||||
name: agentApiKeys.name,
|
||||
scopeConfig: agentApiKeys.scopeConfig,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.agentId, id)),
|
||||
.where(eq(agentApiKeys.agentId, id))
|
||||
.then((rows) => rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
scope: normalizeAgentApiKeyScope(row.scopeConfig),
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
}))),
|
||||
|
||||
getKeyById: async (keyId: string) =>
|
||||
db
|
||||
@@ -805,12 +817,21 @@ export function agentService(db: Db) {
|
||||
agentId: agentApiKeys.agentId,
|
||||
companyId: agentApiKeys.companyId,
|
||||
name: agentApiKeys.name,
|
||||
scopeConfig: agentApiKeys.scopeConfig,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.id, keyId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
scope: normalizeAgentApiKeyScope(row.scopeConfig),
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
|
||||
revokeKey: async (agentId: string, keyId: string) => {
|
||||
const rows = await db
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import type { AgentApiKeyScope, PermissionKey, PrincipalType, TaskBridgeAgentKeyScope } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared";
|
||||
import {
|
||||
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
|
||||
@@ -29,6 +29,8 @@ export type AuthorizationActor =
|
||||
isInstanceAdmin?: boolean;
|
||||
agentId?: string | null;
|
||||
companyId?: string | null;
|
||||
keyId?: string | null;
|
||||
keyScope?: AgentApiKeyScope | null;
|
||||
runId?: string | null;
|
||||
source?:
|
||||
| "local_implicit"
|
||||
@@ -66,6 +68,8 @@ export type AuthorizationResource =
|
||||
parentIssueId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
@@ -211,6 +215,8 @@ type IssueAuthorizationRow = {
|
||||
assigneeUserId: string | null;
|
||||
status: string;
|
||||
executionPolicy: unknown;
|
||||
originKind: string | null;
|
||||
originId: string | null;
|
||||
};
|
||||
|
||||
function evaluateAuthorizationPolicyForAssignment(
|
||||
@@ -550,6 +556,8 @@ export function authorizationService(db: Db) {
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
status: issues.status,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
@@ -802,6 +810,130 @@ export function authorizationService(db: Db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskBridgeScopeIds(
|
||||
scope: TaskBridgeAgentKeyScope,
|
||||
singularKey: "projectId" | "parentIssueId",
|
||||
pluralKey: "projectIds" | "parentIssueIds",
|
||||
) {
|
||||
return [
|
||||
...(typeof scope[singularKey] === "string" ? [scope[singularKey]] : []),
|
||||
...(Array.isArray(scope[pluralKey]) ? scope[pluralKey] : []),
|
||||
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
async function parentIssueMatchesTaskBridgeBoundary(
|
||||
parentIssueId: string | null | undefined,
|
||||
companyId: string,
|
||||
allowedParentIssueIds: string[],
|
||||
) {
|
||||
if (!parentIssueId || allowedParentIssueIds.length === 0) return false;
|
||||
if (allowedParentIssueIds.includes(parentIssueId)) return true;
|
||||
for (const rootIssueId of allowedParentIssueIds) {
|
||||
if (await issueIdIsDescendantOf(parentIssueId, rootIssueId, companyId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function issueMatchesTaskBridgeCreateBoundary(
|
||||
scope: TaskBridgeAgentKeyScope,
|
||||
resource: Extract<AuthorizationResource, { type: "issue" }>,
|
||||
) {
|
||||
const allowedProjectIds = taskBridgeScopeIds(scope, "projectId", "projectIds");
|
||||
const allowedParentIssueIds = taskBridgeScopeIds(scope, "parentIssueId", "parentIssueIds");
|
||||
if (resource.projectId && allowedProjectIds.includes(resource.projectId)) return true;
|
||||
if (await parentIssueMatchesTaskBridgeBoundary(resource.parentIssueId, resource.companyId, allowedParentIssueIds)) {
|
||||
return true;
|
||||
}
|
||||
if (resource.parentIssueId && allowedProjectIds.length > 0) {
|
||||
const parent = await loadIssue(resource.parentIssueId);
|
||||
if (parent?.companyId === resource.companyId && parent.projectId && allowedProjectIds.includes(parent.projectId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function issueMatchesTaskBridgeWriteBoundary(input: {
|
||||
actorAgentId: string;
|
||||
keyId: string;
|
||||
resource: Extract<AuthorizationResource, { type: "issue" }>;
|
||||
}) {
|
||||
const issue = input.resource.issueId ? await loadIssue(input.resource.issueId) : null;
|
||||
const assigneeAgentId = issue?.assigneeAgentId ?? input.resource.assigneeAgentId ?? null;
|
||||
if (assigneeAgentId === input.actorAgentId) return true;
|
||||
const originKind = issue?.originKind ?? input.resource.originKind ?? null;
|
||||
const originId = issue?.originId ?? input.resource.originId ?? null;
|
||||
return originKind === "task_bridge" && originId === input.keyId;
|
||||
}
|
||||
|
||||
async function decideTaskBridgeAccess(input: {
|
||||
actorAgentId: string;
|
||||
action: AuthorizationAction;
|
||||
resource: AuthorizationResource;
|
||||
scope: TaskBridgeAgentKeyScope;
|
||||
keyId: string;
|
||||
}): Promise<AuthorizationDecision | null> {
|
||||
const denyBridge = (explanation: string) =>
|
||||
deny({
|
||||
action: input.action,
|
||||
reason: "deny_scope",
|
||||
explanation,
|
||||
});
|
||||
const allowBridge = (explanation: string) =>
|
||||
allow({
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation,
|
||||
});
|
||||
|
||||
if (
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "agent:read" ||
|
||||
input.action === "agent:wake" ||
|
||||
input.action === "project:read" ||
|
||||
input.action === "runtime:manage" ||
|
||||
input.action === "secrets:read"
|
||||
) {
|
||||
return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs.");
|
||||
}
|
||||
|
||||
if (input.action === "tasks:assign") {
|
||||
if (input.resource.type !== "issue") {
|
||||
return denyBridge("Task bridge assignment requires an issue resource.");
|
||||
}
|
||||
if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) {
|
||||
return denyBridge("Task bridge key is outside its approved parent or project boundary.");
|
||||
}
|
||||
if (input.resource.assigneeUserId) {
|
||||
return denyBridge("Task bridge keys cannot assign work to board users.");
|
||||
}
|
||||
const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? [];
|
||||
if (
|
||||
input.resource.assigneeAgentId &&
|
||||
input.resource.assigneeAgentId !== input.actorAgentId &&
|
||||
!allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId)
|
||||
) {
|
||||
return denyBridge("Task bridge key cannot assign work to that agent.");
|
||||
}
|
||||
return allowBridge("Allowed by task bridge create boundary.");
|
||||
}
|
||||
|
||||
if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") {
|
||||
if (input.resource.type !== "issue") {
|
||||
return denyBridge("Task bridge issue access requires an issue resource.");
|
||||
}
|
||||
return await issueMatchesTaskBridgeWriteBoundary({
|
||||
actorAgentId: input.actorAgentId,
|
||||
keyId: input.keyId,
|
||||
resource: input.resource,
|
||||
})
|
||||
? allowBridge("Allowed for bridge-created or assigned issue.")
|
||||
: denyBridge("Task bridge key can only access assigned or bridge-created issues.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
|
||||
if (resource.type !== "issue") return true;
|
||||
if (resource.assigneeAgentId) {
|
||||
@@ -1183,6 +1315,25 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") {
|
||||
const keyId = input.actor.keyId ?? null;
|
||||
if (!keyId) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_scope",
|
||||
explanation: "Task bridge key context is missing.",
|
||||
});
|
||||
}
|
||||
const taskBridgeDecision = await decideTaskBridgeAccess({
|
||||
actorAgentId,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
scope: input.actor.keyScope,
|
||||
keyId,
|
||||
});
|
||||
if (taskBridgeDecision) return taskBridgeDecision;
|
||||
}
|
||||
|
||||
const lowTrustDecision = await decideLowTrustAccess({
|
||||
actorAgentId,
|
||||
action: input.action,
|
||||
|
||||
Vendored
+3
@@ -1,5 +1,7 @@
|
||||
export {};
|
||||
|
||||
import type { AgentApiKeyScope } from "@paperclipai/shared";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
@@ -18,6 +20,7 @@ declare global {
|
||||
}>;
|
||||
isInstanceAdmin?: boolean;
|
||||
keyId?: string;
|
||||
keyScope?: AgentApiKeyScope;
|
||||
runId?: string;
|
||||
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user