mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat: track agent API key usage
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a4aac781b7
commit
8b288dbc1e
7 files changed
+211
-50
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentApiKeys,
|
||||
@@ -32,7 +32,7 @@ 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 };
|
||||
agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null; lastUsedAt?: Date | null };
|
||||
run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null };
|
||||
}) {
|
||||
const activity: Array<Record<string, unknown>> = [];
|
||||
@@ -48,6 +48,7 @@ function createDbState(input: {
|
||||
companyId: input.agentKey.companyId,
|
||||
keyHash: input.agentKey.keyHash,
|
||||
responsibleUserId: input.agentKey.responsibleUserId ?? null,
|
||||
lastUsedAt: input.agentKey.lastUsedAt ?? null,
|
||||
revokedAt: null,
|
||||
scopeConfig: null,
|
||||
}
|
||||
@@ -61,6 +62,7 @@ function createDbState(input: {
|
||||
}
|
||||
: null;
|
||||
|
||||
const keyUpdates: Array<Record<string, unknown>> = [];
|
||||
const db = {
|
||||
select: () =>
|
||||
createSelectChain((table) => {
|
||||
@@ -70,10 +72,14 @@ function createDbState(input: {
|
||||
if (table === heartbeatRuns) return runRow ? [runRow] : [];
|
||||
return [];
|
||||
}),
|
||||
update: () => ({
|
||||
set() {
|
||||
update: (table: unknown) => ({
|
||||
set(values: Record<string, unknown>) {
|
||||
return {
|
||||
where() {
|
||||
if (table === agentApiKeys && keyRow) {
|
||||
keyUpdates.push(values);
|
||||
Object.assign(keyRow, values);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
};
|
||||
@@ -87,7 +93,7 @@ function createDbState(input: {
|
||||
}),
|
||||
} as any;
|
||||
|
||||
return { db, activity };
|
||||
return { db, activity, keyUpdates };
|
||||
}
|
||||
|
||||
function createApp(db: any) {
|
||||
@@ -163,6 +169,7 @@ describe("agent auth middleware", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
|
||||
if (originalTtl === undefined) delete process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS;
|
||||
@@ -345,12 +352,44 @@ describe("agent auth middleware", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throttles persisted last-used updates for repeated agent-key authentication", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-22T18:00:00.000Z"));
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const token = "pcp_test_throttled_agent_key";
|
||||
const { db, keyUpdates } = createDbState({
|
||||
agent: { id: agentId, companyId },
|
||||
agentKey: {
|
||||
id: randomUUID(),
|
||||
agentId,
|
||||
companyId,
|
||||
keyHash: hashToken(token),
|
||||
responsibleUserId: "user-key",
|
||||
lastUsedAt: new Date("2026-07-22T17:58:00.000Z"),
|
||||
},
|
||||
});
|
||||
const app = createApp(db);
|
||||
|
||||
const first = await request(app).get("/actor").set("Authorization", `Bearer ${token}`);
|
||||
vi.setSystemTime(new Date("2026-07-22T18:00:30.000Z"));
|
||||
const second = await request(app).get("/actor").set("Authorization", `Bearer ${token}`);
|
||||
vi.setSystemTime(new Date("2026-07-22T18:01:01.000Z"));
|
||||
const third = await request(app).get("/actor").set("Authorization", `Bearer ${token}`);
|
||||
|
||||
expect([first.status, second.status, third.status]).toEqual([200, 200, 200]);
|
||||
expect(keyUpdates).toEqual([
|
||||
{ lastUsedAt: new Date("2026-07-22T18:00:00.000Z") },
|
||||
{ lastUsedAt: new Date("2026-07-22T18:01:01.000Z") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects agent keys that lack a responsible user binding and audits the denial", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const keyId = randomUUID();
|
||||
const token = "pcp_test_agent_key_without_user";
|
||||
const { db, activity } = createDbState({
|
||||
const { db, activity, keyUpdates } = createDbState({
|
||||
agent: { id: agentId, companyId },
|
||||
agentKey: {
|
||||
id: keyId,
|
||||
@@ -367,6 +406,7 @@ describe("agent auth middleware", () => {
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe("RESPONSIBLE_USER_UNAVAILABLE");
|
||||
expect(keyUpdates).toHaveLength(0);
|
||||
expect(activity).toHaveLength(1);
|
||||
expect(activity[0]).toMatchObject({
|
||||
companyId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { Request, RequestHandler } from "express";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq, isNull, lt, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -20,6 +20,12 @@ import { boardAuthService } from "../services/board-auth.js";
|
||||
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
|
||||
const AGENT_API_KEY_LAST_USED_THROTTLE_MS = 60_000;
|
||||
|
||||
function shouldUpdateAgentApiKeyLastUsed(lastUsedAt: Date | null, now: Date): boolean {
|
||||
return lastUsedAt === null || now.getTime() - lastUsedAt.getTime() > AGENT_API_KEY_LAST_USED_THROTTLE_MS;
|
||||
}
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
@@ -325,11 +331,6 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(agentApiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(agentApiKeys.id, key.id));
|
||||
|
||||
const agentRecord = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
@@ -356,6 +357,24 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
return;
|
||||
}
|
||||
|
||||
const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
|
||||
companyId: key.companyId,
|
||||
userId: responsibleUserId,
|
||||
});
|
||||
const authenticatedAt = new Date();
|
||||
if (shouldUpdateAgentApiKeyLastUsed(key.lastUsedAt, authenticatedAt)) {
|
||||
await db
|
||||
.update(agentApiKeys)
|
||||
.set({ lastUsedAt: authenticatedAt })
|
||||
.where(and(
|
||||
eq(agentApiKeys.id, key.id),
|
||||
or(
|
||||
isNull(agentApiKeys.lastUsedAt),
|
||||
lt(agentApiKeys.lastUsedAt, new Date(authenticatedAt.getTime() - AGENT_API_KEY_LAST_USED_THROTTLE_MS)),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
req.actor = {
|
||||
type: "agent",
|
||||
agentId: key.agentId,
|
||||
@@ -363,10 +382,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
keyId: key.id,
|
||||
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
|
||||
onBehalfOfUserId: responsibleUserId,
|
||||
onBehalfOfMemberships: await loadResponsibleUserMemberships(db, {
|
||||
companyId: key.companyId,
|
||||
userId: responsibleUserId,
|
||||
}),
|
||||
onBehalfOfMemberships,
|
||||
runId: runIdHeader || undefined,
|
||||
source: "agent_key",
|
||||
};
|
||||
|
||||
@@ -926,6 +926,7 @@ export function agentService(db: Db) {
|
||||
name: agentApiKeys.name,
|
||||
responsibleUserId: agentApiKeys.responsibleUserId,
|
||||
scopeConfig: agentApiKeys.scopeConfig,
|
||||
lastUsedAt: agentApiKeys.lastUsedAt,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
@@ -936,6 +937,7 @@ export function agentService(db: Db) {
|
||||
name: row.name,
|
||||
scope: normalizeAgentApiKeyScope(row.scopeConfig),
|
||||
responsibleUserId: row.responsibleUserId,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
revokedAt: row.revokedAt,
|
||||
}))),
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AgentKey {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: AgentApiKeyScope;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
} from "./agent-configuration-shell";
|
||||
|
||||
describe("agent configuration shell", () => {
|
||||
it("matches both section names and field labels", () => {
|
||||
it("matches section names, field metadata, help copy, and synonyms", () => {
|
||||
expect([...filterAgentConfigurationSections("")].slice(0, 2)).toEqual(["runtime", "environment"]);
|
||||
expect([...filterAgentConfigurationSections("heartbeat")]).toEqual(["schedule"]);
|
||||
expect([...filterAgentConfigurationSections("heartbeat")]).toEqual(["runtime", "schedule"]);
|
||||
expect([...filterAgentConfigurationSections("API Keys")]).toEqual(["keys"]);
|
||||
expect([...filterAgentConfigurationSections("sandbox")]).toEqual(["danger"]);
|
||||
expect([...filterAgentConfigurationSections("filesystem")]).toEqual(["danger"]);
|
||||
expect([...filterAgentConfigurationSections("cron")]).toEqual(["schedule"]);
|
||||
expect([...filterAgentConfigurationSections("periodic tasks")]).toEqual(["schedule"]);
|
||||
});
|
||||
|
||||
it("does not guess an inherited adapter model from available choices", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Search, Zap } from "lucide-react";
|
||||
import type { Agent, AgentEnvConfig } from "@paperclipai/shared";
|
||||
import { adapterLabels } from "./agent-config-primitives";
|
||||
import { adapterLabels, help } from "./agent-config-primitives";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -14,28 +14,114 @@ export type AgentConfigurationSectionId =
|
||||
| "danger"
|
||||
| "history";
|
||||
|
||||
export type AgentConfigurationSetting = {
|
||||
key: string;
|
||||
label: string;
|
||||
helpText: string;
|
||||
synonyms?: readonly string[];
|
||||
};
|
||||
|
||||
export const agentConfigurationSections: ReadonlyArray<{
|
||||
id: AgentConfigurationSectionId;
|
||||
label: string;
|
||||
instant?: boolean;
|
||||
fields: string[];
|
||||
settings: readonly AgentConfigurationSetting[];
|
||||
}> = [
|
||||
{ id: "runtime", label: "Runtime", fields: ["adapter", "model", "effort", "turns", "command", "arguments", "engine", "timeout", "chrome"] },
|
||||
{ id: "environment", label: "Environment", fields: ["execution environment", "environment override", "variables", "secrets"] },
|
||||
{ id: "schedule", label: "Schedule & Runs", fields: ["heartbeat", "wake on demand", "cooldown", "concurrent runs", "continuation"] },
|
||||
{ id: "access", label: "Access & Governance", instant: true, fields: ["trust preset", "boundary", "create agents", "create skills", "assign tasks"] },
|
||||
{ id: "keys", label: "API Keys", instant: true, fields: ["credentials", "create key", "revoke key"] },
|
||||
{ id: "danger", label: "Danger & Legacy", fields: ["skip permissions", "bypass sandbox", "working directory", "deprecated"] },
|
||||
{ id: "history", label: "History", instant: true, fields: ["configuration revisions", "restore"] },
|
||||
{
|
||||
id: "runtime",
|
||||
label: "Runtime",
|
||||
settings: [
|
||||
{ key: "adapterType", label: "Adapter type", helpText: help.adapterType },
|
||||
{ key: "model", label: "Model", helpText: help.model },
|
||||
{ key: "thinkingEffort", label: "Thinking effort", helpText: help.thinkingEffort, synonyms: ["reasoning"] },
|
||||
{ key: "maxTurnsPerRun", label: "Max turns per run", helpText: help.maxTurnsPerRun },
|
||||
{ key: "command", label: "Command", helpText: help.localCommand, synonyms: ["arguments", "args", "engine"] },
|
||||
{ key: "timeoutSec", label: "Timeout", helpText: help.timeoutSec },
|
||||
{ key: "chrome", label: "Chrome", helpText: help.chrome },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "environment",
|
||||
label: "Environment",
|
||||
settings: [
|
||||
{ key: "defaultEnvironmentId", label: "Execution environment", helpText: "Override the company default execution environment for this agent." },
|
||||
{ key: "env", label: "Environment variables", helpText: help.envVars },
|
||||
{ key: "secretAccess", label: "Secret access", helpText: help.secretAccess },
|
||||
{ key: "workspaceStrategy", label: "Workspace strategy", helpText: help.workspaceStrategy, synonyms: ["worktree"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "schedule",
|
||||
label: "Schedule & Runs",
|
||||
settings: [
|
||||
{ key: "heartbeat.enabled", label: "Heartbeat on interval", helpText: help.heartbeatInterval, synonyms: ["cron", "schedule", "timer"] },
|
||||
{ key: "heartbeat.intervalSec", label: "Heartbeat interval", helpText: help.intervalSec, synonyms: ["cron", "cadence"] },
|
||||
{ key: "heartbeat.wakeOnDemand", label: "Wake on demand", helpText: help.wakeOnDemand },
|
||||
{ key: "heartbeat.cooldownSec", label: "Cooldown", helpText: help.cooldownSec },
|
||||
{ key: "heartbeat.maxConcurrentRuns", label: "Max concurrent runs", helpText: help.maxConcurrentRuns },
|
||||
{ key: "heartbeat.maxTurnContinuation", label: "Continue after max-turn stop", helpText: help.maxTurnContinuationEnabled, synonyms: ["continuation"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
label: "Access & Governance",
|
||||
instant: true,
|
||||
settings: [
|
||||
{ key: "permissions.trustPreset", label: "Trust preset", helpText: "Choose the default governance boundary for this agent." },
|
||||
{ key: "permissions.canCreateAgents", label: "Create agents", helpText: "Allow this agent to create or hire other agents." },
|
||||
{ key: "permissions.canCreateSkills", label: "Create skills", helpText: "Allow this agent to create reusable company skills." },
|
||||
{ key: "permissions.canAssignTasks", label: "Assign tasks", helpText: "Allow this agent to assign tasks to other agents." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "keys",
|
||||
label: "API Keys",
|
||||
instant: true,
|
||||
settings: [
|
||||
{ key: "apiKeys", label: "API keys", helpText: "Credentials used by this agent to authenticate calls to the Paperclip server.", synonyms: ["credentials", "revoke"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "danger",
|
||||
label: "Danger & Legacy",
|
||||
settings: [
|
||||
{ key: "dangerouslySkipPermissions", label: "Skip permissions", helpText: help.dangerouslySkipPermissions },
|
||||
{ key: "dangerouslyBypassSandbox", label: "Bypass sandbox", helpText: help.dangerouslyBypassSandbox, synonyms: ["filesystem", "network"] },
|
||||
{ key: "cwd", label: "Working directory", helpText: help.cwd, synonyms: ["deprecated", "legacy"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "History",
|
||||
instant: true,
|
||||
settings: [
|
||||
{ key: "configurationRevisions", label: "Configuration revisions", helpText: "Review and restore earlier saved agent configurations.", synonyms: ["restore", "rollback"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const agentConfigurationSettingIndex = agentConfigurationSections.flatMap((section) =>
|
||||
section.settings.map((setting) => ({
|
||||
...setting,
|
||||
section: section.id,
|
||||
sectionLabel: section.label,
|
||||
sectionAnchor: `config-${section.id}`,
|
||||
})),
|
||||
);
|
||||
|
||||
export function filterAgentConfigurationSections(query: string): Set<AgentConfigurationSectionId> {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return new Set(agentConfigurationSections.map((section) => section.id));
|
||||
return new Set(
|
||||
agentConfigurationSections
|
||||
.filter((section) => section.label.toLowerCase().includes(normalized) || section.fields.some((field) => field.includes(normalized)))
|
||||
.map((section) => section.id),
|
||||
agentConfigurationSettingIndex
|
||||
.filter((setting) => [
|
||||
setting.key,
|
||||
setting.label,
|
||||
setting.helpText,
|
||||
setting.sectionLabel,
|
||||
...(setting.synonyms ?? []),
|
||||
].some((value) => value.toLowerCase().includes(normalized)))
|
||||
.map((setting) => setting.section),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4278,26 +4278,39 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Active Keys
|
||||
</h3>
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{activeKeys.map((key: AgentKey) => (
|
||||
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{key.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-3">
|
||||
Created {formatDate(key.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive text-xs"
|
||||
onClick={() => revokeKey.mutate(key.id)}
|
||||
disabled={revokeKey.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left">
|
||||
<thead className="border-b border-border text-xs font-medium text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Created</th>
|
||||
<th className="px-4 py-2 font-medium">Last used</th>
|
||||
<th className="px-4 py-2"><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{activeKeys.map((key: AgentKey) => (
|
||||
<tr key={key.id}>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-sm font-medium">{key.name}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-xs text-muted-foreground">{formatDate(key.createdAt)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-xs font-mono text-muted-foreground" title={key.lastUsedAt ? formatDate(key.lastUsedAt) : undefined}>
|
||||
{key.lastUsedAt ? relativeTime(key.lastUsedAt) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive text-xs"
|
||||
onClick={() => revokeKey.mutate(key.id)}
|
||||
disabled={revokeKey.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user