mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
fix(server): revoke terminal run JWTs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
276e24d210
commit
ea34e9b25b
3 files changed
+125
-27
No files matched your search
@@ -598,6 +598,13 @@ Every issue PATCH emits an `issue.updated` activity receipt containing the
|
||||
actor, responsible user, run, authorization reason, and field-level before/after
|
||||
changes so both agent and board edits are visible in the issue activity stream.
|
||||
|
||||
Local-agent run JWTs are valid only while their exact persisted heartbeat run is
|
||||
`running` and has no completion timestamp. The server revalidates that lifecycle
|
||||
state on every authenticated request. Moving the run to any terminal status
|
||||
revokes the run JWT immediately; there is no post-terminal grace window for
|
||||
agent API reads or writes. Server-owned finalization and recovery bookkeeping
|
||||
uses internal authority rather than the terminated run's credential.
|
||||
|
||||
## 9.4 Permission Terminology and Default Visibility Rule
|
||||
|
||||
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
|
||||
|
||||
@@ -33,7 +33,14 @@ function createSelectChain(rowsForTable: (table: unknown) => unknown[]) {
|
||||
function createDbState(input: {
|
||||
agent: { id: string; companyId: string; status?: string };
|
||||
agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null };
|
||||
run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null };
|
||||
run?: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
status?: string;
|
||||
finishedAt?: Date | null;
|
||||
responsibleUserId?: string | null;
|
||||
};
|
||||
}) {
|
||||
const activity: Array<Record<string, unknown>> = [];
|
||||
const agentRow = {
|
||||
@@ -57,6 +64,8 @@ function createDbState(input: {
|
||||
id: input.run.id,
|
||||
companyId: input.run.companyId,
|
||||
agentId: input.run.agentId,
|
||||
status: input.run.status ?? "running",
|
||||
finishedAt: input.run.finishedAt ?? null,
|
||||
responsibleUserId: input.run.responsibleUserId ?? null,
|
||||
}
|
||||
: null;
|
||||
@@ -286,6 +295,70 @@ describe("agent auth middleware", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["succeeded", "failed", "cancelled", "timed_out", "interrupted"])(
|
||||
"revokes the run JWT before reads or writes when the run is %s",
|
||||
async (status) => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const { db } = createDbState({
|
||||
agent: { id: agentId, companyId },
|
||||
run: {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status,
|
||||
finishedAt: new Date(),
|
||||
responsibleUserId: "user-claim",
|
||||
},
|
||||
});
|
||||
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
|
||||
|
||||
const app = createApp(db);
|
||||
const readRes = await request(app)
|
||||
.get(`/companies/${companyId}/issues/${issueId}`)
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.set("X-Paperclip-Run-Id", runId);
|
||||
const writeRes = await request(app)
|
||||
.patch(`/companies/${companyId}/issues/${issueId}`)
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.set("X-Paperclip-Run-Id", runId)
|
||||
.send({ title: "should not write" });
|
||||
|
||||
expect(readRes.status).toBe(401);
|
||||
expect(readRes.body.error).toBe("Agent run is not active");
|
||||
expect(writeRes.status).toBe(401);
|
||||
expect(writeRes.body.error).toBe("Agent run is not active");
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed when a running run already has a completion timestamp", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const { db } = createDbState({
|
||||
agent: { id: agentId, companyId },
|
||||
run: {
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
finishedAt: new Date(),
|
||||
responsibleUserId: "user-claim",
|
||||
},
|
||||
});
|
||||
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
|
||||
|
||||
const res = await request(createApp(db))
|
||||
.get(`/companies/${companyId}/protected`)
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.set("X-Paperclip-Run-Id", runId);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("Agent run is not active");
|
||||
});
|
||||
|
||||
it("rejects fork-minted run JWTs before issue reads or writes reach live issue data", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -46,7 +46,7 @@ function pruneCloudTenantWriteDebounce(
|
||||
}
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
import { forbidden, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
export { isCloudManagedInstance } from "../services/cloud-instance.js";
|
||||
|
||||
@@ -58,13 +58,18 @@ function normalizeOptionalString(value: string | null | undefined) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function resolveLegacyRunResponsibleUserId(
|
||||
async function loadAgentJwtRun(
|
||||
db: Db,
|
||||
input: { companyId: string; agentId: string; runId: string },
|
||||
) {
|
||||
if (!isUuidLike(input.runId)) return null;
|
||||
const run = await db
|
||||
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
||||
return db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
@@ -74,7 +79,6 @@ async function resolveLegacyRunResponsibleUserId(
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return normalizeOptionalString(run?.responsibleUserId);
|
||||
}
|
||||
|
||||
async function loadResponsibleUserMemberships(
|
||||
@@ -301,22 +305,6 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
return;
|
||||
}
|
||||
|
||||
const agentRecord = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, claims.sub))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRunIdHeader = normalizeOptionalString(runIdHeader);
|
||||
if (normalizedRunIdHeader && normalizedRunIdHeader !== claims.run_id) {
|
||||
await auditAgentJwtRunHeaderMismatch(db, {
|
||||
@@ -337,13 +325,43 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
return;
|
||||
}
|
||||
|
||||
const [agentRecord, jwtRun] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, claims.sub))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
loadAgentJwtRun(db, {
|
||||
companyId: claims.company_id,
|
||||
agentId: claims.sub,
|
||||
runId: claims.run_id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// A local-agent JWT is a capability for exactly one live heartbeat run,
|
||||
// not a long-lived agent credential. Revalidate the persisted lifecycle
|
||||
// on every request so terminalizing the run revokes the capability
|
||||
// immediately instead of leaving it usable until the JWT's expiry.
|
||||
// `finishedAt` is checked as a fail-closed guard for an inconsistent row
|
||||
// whose status was not updated atomically with finalization.
|
||||
if (!jwtRun || jwtRun.status !== "running" || jwtRun.finishedAt !== null) {
|
||||
next(unauthorized("Agent run is not active"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onBehalfOfUserId = claims.responsible_user_id !== undefined
|
||||
? normalizeOptionalString(claims.responsible_user_id)
|
||||
: await resolveLegacyRunResponsibleUserId(db, {
|
||||
companyId: claims.company_id,
|
||||
agentId: claims.sub,
|
||||
runId: claims.run_id,
|
||||
});
|
||||
: normalizeOptionalString(jwtRun.responsibleUserId);
|
||||
const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
|
||||
companyId: claims.company_id,
|
||||
userId: onBehalfOfUserId,
|
||||
|
||||
Reference in new issue
Block a user