Files
PaperClipAI/server/src/__tests__/workspace-authorization-query.test.ts
T
DottaandPaperclip c41e00bca4 fix(server): index workspace source task enumeration (#16342)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent startup checks access to every source retained by a workspace.
> - Repository admission repeats these checks while it holds a company
lock.
> - PR #16334 indexed the workspace privacy query. The next source-task
query still cast the task primary key to text.
> - That second query scanned company history for every workspace and
delayed other agent launches.
> - This pull request keeps the task column indexed and guards the
retained identifiers before conversion.
> - The benefit is faster startup with the same source validation and
access decisions.

## Linked Issues or Issue Description

Refs #16334.

**What happened?**

Agent launches still spent minutes preparing repositories after the
first authorization query fix. The source-task lookup scanned 14,191
task rows for one matching source.

**Expected behavior**

Source validation should use the task primary key and workspace-binding
index. It must still reject missing, malformed, noncanonical, and
foreign-company sources.

**Steps to reproduce**

1. Seed company task history and a workspace with attached and retained
source tasks.
2. Call `assertTaskWorkspaceAccess` and capture its source-task SQL.
3. Run `EXPLAIN (ANALYZE, FORMAT JSON)` on that query. The baseline
scans unrelated task history.

## What Changed

- Convert retained text identifiers with the existing canonical UUID
guard. Keep the indexed task column as a UUID.
- Exercise the real source-access service against PostgreSQL. Verify
complete source results, malformed and uppercase input behavior, and
rows examined by the plan.
- Document the second source-validation query and its regression
coverage.

## Verification

- Red: the new regression failed because the source lookup examined
1,202 rows for two sources.
- Green: four focused workspace suites passed, 147 tests total. The
final regression also checks malformed and uppercase query inputs.
- Live read-only comparison: two warmups, then five samples per variant
in alternating order. Median client query time changed from 4.88 ms to
0.35 ms. Complete result rows matched. Shared buffer hits changed from
3,984 to 5. These measurements cover this SQL statement, not total agent
startup.
- The complete source-access helper also passed for the same retained
reader in read-only transactions. Its median changed from 15.75 ms to
10.75 ms; repository admission still repeats other checks.
- `pnpm -r typecheck` and `pnpm build` passed locally. The staged Linux
build and all seven final regression cases passed on Node 24.20.0.
- All 55 current-head checks/statuses passed; two Storybook checks were
skipped. Greptile reviewed `6cfb755` at 5/5 with no findings.
- The full local test run encountered a tracing-test failure in
`native-session-executor.test.ts`. Both that test alone and the complete
file passed on rerun (698 tests). The remaining full-suite run is still
in progress.

## Risks

- This changes SQL planning on an authorization path. The canonical
guard preserves the former exact lowercase text match and avoids UUID
parse errors.
- Company filters, missing-source checks, project checks, and per-source
access decisions remain in place.
- No schema migration or new index is required. Repeated checks across
large repository histories remain a separate source of startup work.

## Model Used

- OpenAI Codex, GPT-6 family, with reasoning, shell tools, code
execution, and browser verification. The exact model ID and
context-window size are not exposed in 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 checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 18:15:04 -05:00

127 lines
7.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaces, issueAccessGrants,
issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
import { assertTaskWorkspaceAccess } from "../services/task-workspace-source-access.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("workspace authorization query", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), workspaceId = randomUUID();
const sourceId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId, agentId, onBehalfOfUserId: userId };
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
database = await startEmbeddedPostgresTestDatabase("workspace-authorization-query-");
db = createDb(database.connectionString);
await db.insert(companies).values({ id: companyId, name: "Workspace authorization", issuePrefix: "WAQ" });
await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "process" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, name: "Retained files", mode: "shared_workspace", strategyType: "operator_directory" });
await db.$client.unsafe(`insert into issues (company_id, title) select $1, 'Unrelated history ' || n from generate_series(1, 1200) n`, [companyId]);
await db.insert(issues).values({ id: sourceId, companyId, title: "Retained source" });
await db.$client.unsafe("ANALYZE");
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
vi.unstubAllEnvs();
await db?.$client.end({ timeout: 0 });
await database?.cleanup();
});
async function query() {
return db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId),
eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(db, actor))).toSQL();
}
async function read() {
const q = await query();
return db.$client.unsafe(q.sql, q.params as never[]);
}
async function retain(id: string) {
// Seed historical/corrupt provenance in this disposable database. Normal
// writes have a trigger that deliberately preserves the previous keys.
await db.transaction(async tx => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(executionWorkspaces).set({ metadata: { _issuePrivacySources: { [id]: true } } })
.where(eq(executionWorkspaces.id, workspaceId));
});
}
it("checks retained task IDs without evaluating privacy for unrelated company history", async () => {
await retain(sourceId);
expect(await read()).toHaveLength(1);
const q = await query();
const [result] = await db.$client.unsafe(`EXPLAIN (ANALYZE, FORMAT JSON) ${q.sql}`, q.params as never[]);
type Plan = { "Node Type": string; "Actual Loops": number; Plans?: Plan[] };
function privacyTraversals(plan: Plan): number {
return (plan["Node Type"] === "Recursive Union" ? plan["Actual Loops"] : 0)
+ (plan.Plans ?? []).reduce((sum, child) => sum + privacyTraversals(child), 0);
}
// A single source should not make admission authorize 1,200 unrelated tasks
// for both the agent and its responsible user under the company-wide lock.
expect(privacyTraversals(result["QUERY PLAN"][0].Plan)).toBeLessThan(100);
});
it("enumerates attached and retained source tasks without scanning unrelated history", async () => {
await retain(sourceId);
const [attached] = await db.insert(issues).values({ companyId, title: "Attached source", executionWorkspaceId: workspaceId })
.returning({ id: issues.id });
try {
const statements: Array<{ sql: string; params: unknown[] }> = [];
const recorded = drizzle(db.$client, {
schema: db._.fullSchema,
logger: { logQuery: (query, params) => statements.push({ sql: query, params }) },
}) as unknown as typeof db;
await assertTaskWorkspaceAccess(recorded, { type: "board", source: "local_implicit", userId: "local-board" }, companyId, workspaceId);
const query = statements.find(statement => statement.sql.startsWith('select "id" from "issues"')
&& statement.sql.includes('"execution_workspace_id"'));
expect(query).toBeDefined();
const rows = await db.$client.unsafe(query!.sql, query!.params as never[]);
expect(rows.map(row => row.id).sort()).toEqual([attached.id, sourceId].sort());
for (const invalidId of [sourceId.toUpperCase(), "not-a-uuid"]) {
const params = query!.params.map(value => value === sourceId ? invalidId : value);
const noncanonical = await db.$client.unsafe(query!.sql, params as never[]);
expect(noncanonical.map(row => row.id)).toEqual([attached.id]);
}
const [result] = await db.$client.unsafe(`EXPLAIN (ANALYZE, FORMAT JSON) ${query!.sql}`, query!.params as never[]);
type Plan = { "Relation Name"?: string; "Actual Rows": number; "Actual Loops": number; "Rows Removed by Filter"?: number; Plans?: Plan[] };
function issueRowsExamined(plan: Plan): number {
return (plan["Relation Name"] === "issues"
? (plan["Actual Rows"] + (plan["Rows Removed by Filter"] ?? 0)) * plan["Actual Loops"] : 0)
+ (plan.Plans ?? []).reduce((sum, child) => sum + issueRowsExamined(child), 0);
}
expect(issueRowsExamined(result["QUERY PLAN"][0].Plan)).toBeLessThan(100);
} finally {
await db.delete(issues).where(eq(issues.id, attached.id));
}
});
it.each(["missing", "malformed", "uppercase", "foreign"])("denies a %s retained source without throwing a UUID parse error", async kind => {
const id = kind === "malformed" ? "not-a-uuid" : kind === "uppercase" ? sourceId.toUpperCase() : randomUUID();
if (kind === "foreign") {
const [foreign] = await db.insert(companies).values({ name: "Foreign", issuePrefix: "OTHER" }).returning();
await db.insert(issues).values({ id, companyId: foreign.id, title: "Foreign source" });
}
await retain(id);
expect(await read()).toHaveLength(0);
});
it("requires both principals and rechecks revoked grants for retained private files", async () => {
await retain(sourceId);
await db.update(issues).set({ visibility: "private", privacyRootIssueId: sourceId }).where(eq(issues.id, sourceId));
expect(await read()).toHaveLength(0);
await db.insert(issueAccessGrants).values({ issueId: sourceId, subjectType: "agent", subjectId: agentId, source: "explicit" });
expect(await read()).toHaveLength(0);
const [grant] = await db.insert(issueAccessGrants).values({ issueId: sourceId, subjectType: "user", subjectId: userId, source: "explicit" }).returning();
expect(await read()).toHaveLength(1);
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
expect(await read()).toHaveLength(0);
});
});