mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10:39 +09:00
[codex] Add agent permissions and controls plan (#6386)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies by keeping task ownership, approvals, and operator control inside one control plane. > - Agent permissions and plugin-hosted company settings sit on the boundary between autonomy and governance. > - V1 needs scoped task assignment rules, plugin extension points, and clearer company access surfaces without weakening company boundaries. > - The branch builds the core authorization service, plugin SDK/host APIs, and UI simplifications needed to support those controls. > - Paperclip EE plugin surfaces were intentionally moved out of this core PR per review direction, so this PR now carries only the public core/plugin infrastructure work. > - The latest updates preserve the PAP-9937 branch changes that belong in this PR, remove the `design/` artifacts, and exclude the experimental `plugin-briefs` package. > - Greptile feedback was applied through the authorization/audit paths and the final cleanup commit was re-reviewed at 5/5 with no unresolved Greptile threads. > - The benefit is safer assignment control with extension hooks for richer permission products while preserving simple defaults for normal operators. ## What Changed - Added scoped task-assignment authorization decisions and routed issue/agent assignment mutations through the authorization service. - Added plugin SDK and host APIs for company settings slots, authorization policy/grant management, assignment previews, and bridge invocation scope propagation. - Simplified core company access UI and moved advanced controls behind plugin-provided settings surfaces. - Added retry-now affordances for blocked issue next-step notices. - Added protected-assignment enforcement for persisted agent/project/issue policies, including explicit-grant fallback behavior. - Added incremental principal-access compatibility backfill for active agent memberships and role-default human permission grants. - Added the Markdown code block wrap action fix from the latest branch changes. - Removed `design/` artifacts from the PR and removed `packages/plugins/plugin-briefs` from the final diff. - Addressed Greptile feedback for plugin actor sanitization, legacy membership handling, audit pagination, unknown grant-scope metadata, and startup test mocks. ## Verification - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54 tests passed. - `pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62 tests passed. - `pnpm exec vitest run server/src/__tests__/authorization-service.test.ts server/src/__tests__/plugin-access-authorization-host-services.test.ts server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files passed, 28 tests passed. - `pnpm --filter @paperclipai/server typecheck` -> passed. - `git diff --check` -> passed. - `node ./scripts/check-docker-deps-stage.mjs` -> passed. - `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed with no lockfile update. - `pnpm exec vitest run ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed. - `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0. - GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`. - Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0 comments/annotations added, 0 unresolved review threads. - Confirmed the PR diff contains no `design/`, `packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or `.github/workflows` changes. ## Risks - Medium: task assignment authorization paths are behaviorally stricter for protected/private policy data, so existing plugin-authored policies may block assignment until explicit grants or approval flows are configured. - Medium: plugin-host authorization APIs expand the surface area available to trusted plugins and need careful review for company scoping. - Low: startup now performs a principal-access compatibility backfill, but the migration and runtime backfill use conflict-tolerant inserts. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell, git, and GitHub CLI access. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [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
c91a062326
commit
38c185fb8b
102 changed files with 6744 additions and 395 deletions
167
server/src/__tests__/access-routes-permissions-upgrade.test.ts
Normal file
167
server/src/__tests__/access-routes-permissions-upgrade.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "vitest";
|
||||
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
async function createApp(db: Db, companyId: string, userId: string) {
|
||||
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
const { accessRoutes } = await import("../routes/access.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId,
|
||||
source: "local_implicit",
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
|
||||
isInstanceAdmin: true,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", accessRoutes(db, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createCompanyWithOwner(db: Db) {
|
||||
const company = await db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `Access Routes ${randomUUID()}`,
|
||||
issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const owner = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `owner-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
return { company, owner };
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => {
|
||||
let db!: Db;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("rejects owner self-lockout through the member route after the permissions upgrade", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
|
||||
const res = await request(await createApp(db, company.id, owner.principalId))
|
||||
.patch(`/api/companies/${company.id}/members/${owner.id}`)
|
||||
.send({ membershipRole: "admin" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("You cannot remove yourself");
|
||||
|
||||
const unchanged = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.id, owner.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(unchanged.membershipRole).toBe("owner");
|
||||
});
|
||||
|
||||
it("keeps custom grants when the role-only member route changes a member role", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const member = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `admin-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const customScope = { projectIds: ["project-1"] };
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: member.principalId,
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: customScope,
|
||||
grantedByUserId: owner.principalId,
|
||||
});
|
||||
|
||||
const res = await request(await createApp(db, company.id, owner.principalId))
|
||||
.patch(`/api/companies/${company.id}/members/${member.id}`)
|
||||
.send({ membershipRole: "operator" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.membershipRole).toBe("operator");
|
||||
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, company.id),
|
||||
eq(principalPermissionGrants.principalType, "user"),
|
||||
eq(principalPermissionGrants.principalId, member.principalId),
|
||||
),
|
||||
);
|
||||
expect(grants).toHaveLength(1);
|
||||
expect(grants[0]).toMatchObject({
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: customScope,
|
||||
grantedByUserId: owner.principalId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { grantsForHumanRole } from "../services/company-member-roles.js";
|
||||
import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => {
|
|||
await db.delete(issues);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(instanceUserRoles);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
|
@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => {
|
|||
access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }),
|
||||
).rejects.toThrow("Instance admins cannot be removed from company access");
|
||||
});
|
||||
|
||||
it("allows owner and admin role-default grants to manage environments", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
const roles = ["admin", "operator", "viewer"] as const;
|
||||
const members = await db
|
||||
.insert(companyMemberships)
|
||||
.values(
|
||||
roles.map((role) => ({
|
||||
companyId: company.id,
|
||||
principalType: "user" as const,
|
||||
principalId: `${role}-${randomUUID()}`,
|
||||
status: "active" as const,
|
||||
membershipRole: role,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
|
||||
await access.setPrincipalGrants(
|
||||
company.id,
|
||||
"user",
|
||||
owner.principalId,
|
||||
grantsForHumanRole("owner"),
|
||||
owner.principalId,
|
||||
);
|
||||
for (const member of members) {
|
||||
await access.setPrincipalGrants(
|
||||
company.id,
|
||||
"user",
|
||||
member.principalId,
|
||||
grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"),
|
||||
owner.principalId,
|
||||
);
|
||||
}
|
||||
|
||||
const admin = members.find((member) => member.membershipRole === "admin")!;
|
||||
const operator = members.find((member) => member.membershipRole === "operator")!;
|
||||
const viewer = members.find((member) => member.membershipRole === "viewer")!;
|
||||
|
||||
await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true);
|
||||
await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
|
||||
await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false);
|
||||
await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const scopedEnvironmentGrant = { environmentId: "env-1" };
|
||||
const humanRows = await db
|
||||
.insert(companyMemberships)
|
||||
.values([
|
||||
{
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `admin-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `operator-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `viewer-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "viewer",
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `legacy-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: null,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
const admin = humanRows[0]!;
|
||||
const operator = humanRows[1]!;
|
||||
const viewer = humanRows[2]!;
|
||||
const legacyMember = humanRows[3]!;
|
||||
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: owner.principalId,
|
||||
permissionKey: "environments:manage",
|
||||
scope: scopedEnvironmentGrant,
|
||||
grantedByUserId: "custom-author",
|
||||
});
|
||||
|
||||
const first = await backfillPrincipalAccessCompatibility(db);
|
||||
const second = await backfillPrincipalAccessCompatibility(db);
|
||||
|
||||
expect(first.humanGrantsInserted).toBeGreaterThan(0);
|
||||
expect(second.humanGrantsInserted).toBe(0);
|
||||
await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true);
|
||||
await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true);
|
||||
await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true);
|
||||
await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false);
|
||||
|
||||
const ownerEnvironmentGrants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, company.id),
|
||||
eq(principalPermissionGrants.principalId, owner.principalId),
|
||||
eq(principalPermissionGrants.permissionKey, "environments:manage"),
|
||||
),
|
||||
);
|
||||
expect(ownerEnvironmentGrants).toHaveLength(1);
|
||||
expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant);
|
||||
expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author");
|
||||
});
|
||||
|
||||
it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => {
|
||||
const { company } = await createCompanyWithOwner(db);
|
||||
const agentRows = await db
|
||||
.insert(agents)
|
||||
.values([
|
||||
{
|
||||
companyId: company.id,
|
||||
name: `Idle ${randomUUID()}`,
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
name: `Running ${randomUUID()}`,
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
name: `Pending ${randomUUID()}`,
|
||||
role: "engineer",
|
||||
status: "pending_approval",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
name: `Terminated ${randomUUID()}`,
|
||||
role: "engineer",
|
||||
status: "terminated",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
const idleAgent = agentRows[0]!;
|
||||
const runningAgent = agentRows[1]!;
|
||||
const pendingAgent = agentRows[2]!;
|
||||
const terminatedAgent = agentRows[3]!;
|
||||
|
||||
const first = await backfillPrincipalAccessCompatibility(db);
|
||||
const second = await backfillPrincipalAccessCompatibility(db);
|
||||
|
||||
expect(first.agentMembershipsInserted).toBe(2);
|
||||
expect(second.agentMembershipsInserted).toBe(0);
|
||||
const memberships = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.principalType, "agent"));
|
||||
expect(memberships.map((membership) => membership.principalId).sort()).toEqual([
|
||||
idleAgent.id,
|
||||
runningAgent.id,
|
||||
].sort());
|
||||
expect(memberships.every((membership) => membership.status === "active")).toBe(true);
|
||||
expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true);
|
||||
expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false);
|
||||
expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("copies active user memberships with role-default grants for safe company imports", async () => {
|
||||
const source = await createCompanyWithOwner(db);
|
||||
const target = await createCompanyWithOwner(db);
|
||||
const admin = await db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId: source.company.id,
|
||||
principalType: "user",
|
||||
principalId: `admin-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
const access = accessService(db);
|
||||
await access.copyActiveUserMemberships(source.company.id, target.company.id);
|
||||
|
||||
const copiedOwnerGrants = await access.listPrincipalGrants(
|
||||
target.company.id,
|
||||
"user",
|
||||
source.owner.principalId,
|
||||
);
|
||||
const copiedAdminGrants = await access.listPrincipalGrants(
|
||||
target.company.id,
|
||||
"user",
|
||||
admin.principalId,
|
||||
);
|
||||
expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual(
|
||||
grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(),
|
||||
);
|
||||
expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual(
|
||||
grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const scopedGrant = { environmentId: "env-1" };
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: owner.principalId,
|
||||
permissionKey: "environments:manage",
|
||||
scope: scopedGrant,
|
||||
grantedByUserId: "custom-grant-author",
|
||||
});
|
||||
|
||||
await db.execute(sql.raw(`
|
||||
INSERT INTO "principal_permission_grants" (
|
||||
"company_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission_key",
|
||||
"scope",
|
||||
"granted_by_user_id",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
"company_id",
|
||||
'user',
|
||||
"principal_id",
|
||||
'environments:manage',
|
||||
NULL,
|
||||
NULL,
|
||||
now(),
|
||||
now()
|
||||
FROM "company_memberships"
|
||||
WHERE "principal_type" = 'user'
|
||||
AND "status" = 'active'
|
||||
AND "membership_role" IN ('owner', 'admin')
|
||||
ON CONFLICT (
|
||||
"company_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission_key"
|
||||
) DO NOTHING
|
||||
`));
|
||||
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, company.id),
|
||||
eq(principalPermissionGrants.principalId, owner.principalId),
|
||||
eq(principalPermissionGrants.permissionKey, "environments:manage"),
|
||||
),
|
||||
);
|
||||
expect(grants).toHaveLength(1);
|
||||
expect(grants[0]?.scope).toEqual(scopedGrant);
|
||||
expect(grants[0]?.grantedByUserId).toBe("custom-grant-author");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
|
|
@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => {
|
|||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant",
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
|
|
@ -293,6 +294,17 @@ function resetMockDefaults() {
|
|||
revokedAt: new Date("2026-04-11T00:05:00.000Z"),
|
||||
}));
|
||||
mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser);
|
||||
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
|
||||
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
|
||||
? true
|
||||
: currentAccessCanUser;
|
||||
return {
|
||||
allowed,
|
||||
action: input.action,
|
||||
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
|
||||
};
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async () => false);
|
||||
mockAccessService.getMembership.mockImplementation(async () => null);
|
||||
mockAccessService.listPrincipalGrants.mockImplementation(async () => []);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => {
|
|||
vi.clearAllMocks();
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue(makeAgent());
|
||||
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeAgent(),
|
||||
|
|
|
|||
|
|
@ -51,7 +51,16 @@ function registerModuleMocks() {
|
|||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => ({}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
approvalService: () => ({}),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
|
|
@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => {
|
|||
mockAgentService.getChainOfCommand.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAccessService.decide.mockReset();
|
||||
mockAccessService.hasPermission.mockReset();
|
||||
mockAccessService.getMembership.mockReset();
|
||||
mockAccessService.ensureMembership.mockReset();
|
||||
|
|
@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => {
|
|||
mockAgentService.update.mockResolvedValue(baseAgent);
|
||||
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
|
||||
const allowed = Boolean(await mockAccessService.canUser());
|
||||
return {
|
||||
allowed,
|
||||
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`,
|
||||
};
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAccessService.getMembership.mockResolvedValue({
|
||||
id: "membership-1",
|
||||
|
|
@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => {
|
|||
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
|
||||
}, 15_000);
|
||||
|
||||
it("reports simple-mode task assignment as enabled for active company agent members", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("simple_default");
|
||||
}, 15_000);
|
||||
|
||||
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
|
||||
mockAgentService.updatePermissions.mockResolvedValue({
|
||||
...baseAgent,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
listPrincipalGrants: vi.fn(),
|
||||
|
|
@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => {
|
|||
);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant",
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockAccessService.getMembership.mockResolvedValue(null);
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
getMembership: vi.fn(async () => null),
|
||||
listPrincipalGrants: vi.fn(async () => []),
|
||||
|
|
@ -120,6 +121,11 @@ describe("agent test-environment route", () => {
|
|||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant",
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveViteHmrPort } from "../app.ts";
|
||||
import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts";
|
||||
|
||||
describe("resolveViteHmrPort", () => {
|
||||
it("uses serverPort + 10000 when the result stays in range", () => {
|
||||
|
|
@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => {
|
|||
expect(resolveViteHmrPort(9_000)).toBe(19_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveViteHmrHost", () => {
|
||||
it("omits wildcard bind hosts so Vite uses the browser hostname", () => {
|
||||
expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined();
|
||||
expect(resolveViteHmrHost("::")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps concrete bind hosts", () => {
|
||||
expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1");
|
||||
expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
547
server/src/__tests__/authorization-service.test.ts
Normal file
547
server/src/__tests__/authorization-service.test.ts
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
instanceUserRoles,
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
async function createCompany(db: ReturnType<typeof createDb>, label: string) {
|
||||
return db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `Authorization ${label} ${randomUUID()}`,
|
||||
issuePrefix: `AZ${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createAgent(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
input: { role?: string; reportsTo?: string | null; permissions?: Record<string, unknown> } = {},
|
||||
) {
|
||||
return db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Agent ${randomUUID()}`,
|
||||
role: input.role ?? "engineer",
|
||||
reportsTo: input.reportsTo ?? null,
|
||||
permissions: input.permissions ?? {},
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createProject(db: ReturnType<typeof createDb>, companyId: string, label: string) {
|
||||
return db
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Project ${label} ${randomUUID()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function grantAgentPermission(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
scope: Record<string, unknown> | null = null,
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
permissionKey,
|
||||
scope,
|
||||
grantedByUserId: null,
|
||||
});
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("authorization service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(instanceUserRoles);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("allows active user role grants and explains the grant source", async () => {
|
||||
const company = await createCompany(db, "UserGrant");
|
||||
const userId = `user-${randomUUID()}`;
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
permissionKey: "tasks:assign",
|
||||
grantedByUserId: "owner",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign",
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
grant: {
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
permissionKey: "tasks:assign",
|
||||
},
|
||||
});
|
||||
expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign");
|
||||
});
|
||||
|
||||
it("allows agent grants for agent configuration decisions", async () => {
|
||||
const company = await createCompany(db, "AgentGrant");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "agents:create",
|
||||
grantedByUserId: null,
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.grant?.permissionKey).toBe("agents:create");
|
||||
});
|
||||
|
||||
it("denies cross-company agent decisions before grant evaluation", async () => {
|
||||
const sourceCompany = await createCompany(db, "Source");
|
||||
const targetCompany = await createCompany(db, "Target");
|
||||
const actorAgent = await createAgent(db, sourceCompany.id);
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_jwt" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "company", companyId: targetCompany.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_company_boundary",
|
||||
});
|
||||
expect(decision.explanation).toContain("Agent key cannot access another company");
|
||||
});
|
||||
|
||||
it("allows simple-mode task assignment between same-company agents without explicit grants", async () => {
|
||||
const company = await createCompany(db, "AssignmentDefault");
|
||||
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_simple_company_member",
|
||||
});
|
||||
expect(decision.explanation).toContain("simple mode");
|
||||
});
|
||||
|
||||
it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => {
|
||||
const company = await createCompany(db, "ProtectedAssignment");
|
||||
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
const targetAgent = await createAgent(db, company.id, {
|
||||
role: "engineer",
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
assignmentPolicy: {
|
||||
mode: "protected",
|
||||
protectedAgentRequiresApproval: true,
|
||||
},
|
||||
protectedAgent: {
|
||||
requiresApproval: true,
|
||||
approvalReason: "Production deployment authority",
|
||||
},
|
||||
managedBy: "permissions-extension",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_policy_restricted",
|
||||
});
|
||||
expect(decision.explanation).toContain("requires approval");
|
||||
});
|
||||
|
||||
it("requires an explicit grant before assigning to a private target agent", async () => {
|
||||
const company = await createCompany(db, "PrivateAssignment");
|
||||
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
const targetAgent = await createAgent(db, company.id, {
|
||||
role: "engineer",
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
agentVisibility: {
|
||||
mode: "private",
|
||||
hiddenFromDefaultDirectory: true,
|
||||
},
|
||||
assignmentPolicy: {
|
||||
mode: "company_default",
|
||||
protectedAgentRequiresApproval: false,
|
||||
},
|
||||
protectedAgent: {
|
||||
requiresApproval: false,
|
||||
},
|
||||
managedBy: "permissions-extension",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const denied = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
|
||||
assigneeAgentId: targetAgent.id,
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(denied).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_policy_restricted",
|
||||
});
|
||||
expect(denied.explanation).toContain("private");
|
||||
expect(allowed).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
grant: { permissionKey: "tasks:assign_scope" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => {
|
||||
const company = await createCompany(db, "BoardAssignmentDefault");
|
||||
const userId = `user-${randomUUID()}`;
|
||||
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_simple_company_member",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies legacy board assignment context for viewers", async () => {
|
||||
const company = await createCompany(db, "BoardViewerAssignment");
|
||||
const userId = `user-${randomUUID()}`;
|
||||
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "viewer",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "board", userId, companyIds: [company.id], source: "session" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies simple-mode assignment to a target agent from another company", async () => {
|
||||
const sourceCompany = await createCompany(db, "AssignmentSource");
|
||||
const targetCompany = await createCompany(db, "AssignmentTarget");
|
||||
const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" });
|
||||
const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: sourceCompany.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_company_boundary",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy CEO agent creator authority", async () => {
|
||||
const company = await createCompany(db, "Legacy");
|
||||
const actorAgent = await createAgent(db, company.id, { role: "ceo" });
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" },
|
||||
action: "agents:create",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_legacy_agent_creator",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows scoped assignment inside a granted project and denies other projects", async () => {
|
||||
const company = await createCompany(db, "ProjectScope");
|
||||
const project = await createProject(db, company.id, "Allowed");
|
||||
const otherProject = await createProject(db, company.id, "Denied");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
|
||||
projectIds: [project.id],
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { projectId: project.id, assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
const denied = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(allowed).toMatchObject({
|
||||
allowed: true,
|
||||
grant: { permissionKey: "tasks:assign_scope" },
|
||||
});
|
||||
expect(denied).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
expect(denied.explanation).toContain("does not cover the requested scope");
|
||||
});
|
||||
|
||||
it("treats unknown grant scope metadata as unconstrained", async () => {
|
||||
const company = await createCompany(db, "UnknownScopeMetadata");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
|
||||
note: "CEO-approved",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
grant: { permissionKey: "tasks:assign_scope" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows scoped assignment to agents inside a managed subtree only", async () => {
|
||||
const company = await createCompany(db, "SubtreeScope");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const managerAgent = await createAgent(db, company.id);
|
||||
const childAgent = await createAgent(db, company.id, { reportsTo: managerAgent.id });
|
||||
const grandchildAgent = await createAgent(db, company.id, { reportsTo: childAgent.id });
|
||||
const outsideAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
|
||||
managedSubtreeAgentIds: [managerAgent.id],
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: grandchildAgent.id },
|
||||
});
|
||||
const denied = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: outsideAgent.id },
|
||||
});
|
||||
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(allowed.grant?.permissionKey).toBe("tasks:assign_scope");
|
||||
expect(denied).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_scope",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows scoped assignment to an explicit target-agent allowlist only", async () => {
|
||||
const company = await createCompany(db, "AllowlistScope");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const allowedTarget = await createAgent(db, company.id);
|
||||
const deniedTarget = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
|
||||
assigneeAgentIds: [allowedTarget.id],
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: allowedTarget.id },
|
||||
});
|
||||
const denied = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: deniedTarget.id },
|
||||
});
|
||||
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(denied.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves unscoped tasks:assign compatibility for assignment decisions", async () => {
|
||||
const company = await createCompany(db, "BroadAssign");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign");
|
||||
|
||||
const decision = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
grant: { permissionKey: "tasks:assign" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,13 +5,17 @@ import {
|
|||
buildBetterAuthAdvancedOptions,
|
||||
deriveAuthCookiePrefix,
|
||||
deriveAuthTrustedOrigins,
|
||||
shouldDisableSecureAuthCookies,
|
||||
} from "../auth/better-auth.js";
|
||||
|
||||
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID;
|
||||
if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL;
|
||||
else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
|
||||
});
|
||||
|
||||
describe("Better Auth cookie scoping", () => {
|
||||
|
|
@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => {
|
|||
expect(advanced).toEqual({
|
||||
cookiePrefix: "paperclip-sat-worktree",
|
||||
});
|
||||
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe(
|
||||
"paperclip-sat-worktree.session_token",
|
||||
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch(
|
||||
/paperclip-sat-worktree\.session_token$/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -42,6 +46,41 @@ describe("Better Auth cookie scoping", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("disables secure cookies when no canonical public auth URL is configured", () => {
|
||||
delete process.env.PAPERCLIP_PUBLIC_URL;
|
||||
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
deploymentMode: "authenticated",
|
||||
authBaseUrlMode: "auto",
|
||||
authPublicBaseUrl: undefined,
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("derives secure cookie behavior from the configured public auth URL", () => {
|
||||
delete process.env.PAPERCLIP_PUBLIC_URL;
|
||||
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
deploymentMode: "authenticated",
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "http://paperclip-dev:46259",
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
deploymentMode: "authenticated",
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "https://paperclip.example.test",
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("lets PAPERCLIP_PUBLIC_URL override the auth base URL for cookie security", () => {
|
||||
process.env.PAPERCLIP_PUBLIC_URL = "http://paperclip-dev:46259";
|
||||
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
deploymentMode: "authenticated",
|
||||
authBaseUrlMode: "explicit",
|
||||
authPublicBaseUrl: "https://paperclip.example.test",
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("adds hostname port variants for authenticated mode on non-default ports", () => {
|
||||
const trustedOrigins = deriveAuthTrustedOrigins({
|
||||
deploymentMode: "authenticated",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const agentSvc = {
|
|||
|
||||
const accessSvc = {
|
||||
ensureMembership: vi.fn(),
|
||||
ensureRoleDefaultGrants: vi.fn(),
|
||||
listActiveUserMemberships: vi.fn(),
|
||||
copyActiveUserMemberships: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
|
|
|
|||
100
server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
Normal file
100
server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
const readline = require("node:readline");
|
||||
|
||||
let nextRequestId = 1;
|
||||
const pendingNested = new Map();
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function sendNestedHostRequest(originalRequest, invocationId) {
|
||||
const nestedId = `nested-${nextRequestId++}`;
|
||||
const params = originalRequest.params?.params ?? {};
|
||||
const mode = params.mode;
|
||||
const requestedCompanyId = params.requestedCompanyId;
|
||||
const nestedRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: nestedId,
|
||||
method: "companies.get",
|
||||
params: {
|
||||
companyId: requestedCompanyId,
|
||||
},
|
||||
};
|
||||
|
||||
if (mode === "echo") {
|
||||
nestedRequest.paperclipInvocationId = invocationId;
|
||||
} else if (mode === "unknown") {
|
||||
nestedRequest.paperclipInvocationId = "unknown-invocation";
|
||||
}
|
||||
|
||||
pendingNested.set(nestedId, originalRequest.id);
|
||||
send(nestedRequest);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
const message = JSON.parse(line);
|
||||
|
||||
if (message.id && pendingNested.has(message.id)) {
|
||||
const originalId = pendingNested.get(message.id);
|
||||
pendingNested.delete(message.id);
|
||||
if (message.error) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: originalId,
|
||||
error: message.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: originalId,
|
||||
result: message.result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const method = message && typeof message.method === "string" ? message.method : null;
|
||||
|
||||
if (method === "initialize") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["getData"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "getData") {
|
||||
sendNestedHostRequest(message, message.paperclipInvocation?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "shutdown") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {},
|
||||
});
|
||||
setImmediate(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Unhandled method: ${method}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -68,6 +68,7 @@ describe("human invite roles", () => {
|
|||
it("maps owner to the full management grant set", () => {
|
||||
expect(grantsForHumanRole("owner")).toEqual([
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "users:manage_permissions", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
|
|
@ -75,6 +76,16 @@ describe("human invite roles", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("maps admin to management grants including environment management", () => {
|
||||
expect(grantsForHumanRole("admin")).toEqual([
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults legacy or missing roles to operator", () => {
|
||||
expect(normalizeHumanRole("member")).toBe("operator");
|
||||
expect(resolveHumanInviteRole(null)).toBe("operator");
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -275,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAccessService.decide.mockReset();
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "tasks:assign",
|
||||
action: input.action,
|
||||
reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.",
|
||||
}));
|
||||
mockAccessService.hasPermission.mockReset();
|
||||
mockAgentService.getById.mockReset();
|
||||
mockAgentService.list.mockReset();
|
||||
|
|
@ -682,12 +690,12 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
});
|
||||
|
||||
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
principalId: string,
|
||||
permissionKey: string,
|
||||
) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts");
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "tasks:manage_active_checkouts",
|
||||
action: input.action,
|
||||
reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.",
|
||||
}));
|
||||
|
||||
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
|
||||
|
||||
|
|
@ -828,4 +836,37 @@ describe("agent issue mutation checkout ownership", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the authorization decision path for assignment changes", async () => {
|
||||
const decide = vi.fn(async () => ({
|
||||
allowed: false,
|
||||
action: "tasks:assign",
|
||||
reason: "deny_policy_restricted",
|
||||
explanation: "Target agent requires approval before task assignment.",
|
||||
}));
|
||||
(mockAccessService as any).decide = decide;
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
agent: makeAgent(peerAgentId),
|
||||
});
|
||||
|
||||
const app = await createApp(ownerActor());
|
||||
const res = await request(app)
|
||||
.patch(`/api/issues/${issueId}`)
|
||||
.send({ assigneeAgentId: peerAgentId });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("requires approval");
|
||||
expect(decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "tasks:assign",
|
||||
resource: expect.objectContaining({
|
||||
type: "issue",
|
||||
companyId,
|
||||
issueId,
|
||||
assigneeAgentId: peerAgentId,
|
||||
}),
|
||||
}));
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -229,6 +230,7 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
mockIssueService.listWakeableBlockedDependents.mockReset();
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAccessService.decide.mockReset();
|
||||
mockAccessService.hasPermission.mockReset();
|
||||
mockHeartbeatService.wakeup.mockReset();
|
||||
mockHeartbeatService.reportRunActivity.mockReset();
|
||||
|
|
@ -307,6 +309,15 @@ describe.sequential("issue comment reopen routes", () => {
|
|||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
|
||||
const allowed = input.action !== "tasks:manage_active_checkouts";
|
||||
return {
|
||||
allowed,
|
||||
action: input.action,
|
||||
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: allowed ? "Allowed by test grant." : "Missing active checkout override.",
|
||||
};
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockResolvedValue(null);
|
||||
mockAgentService.list.mockResolvedValue([
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
|||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
|
|
@ -160,6 +161,17 @@ describe("issue execution policy routes", () => {
|
|||
parentBlockerAdded: false,
|
||||
});
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
|
||||
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
|
||||
? true
|
||||
: Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission());
|
||||
return {
|
||||
allowed,
|
||||
action: input.action,
|
||||
reason: allowed ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`,
|
||||
};
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ function registerModuleMocks() {
|
|||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ vi.mock("../services/index.js", () => ({
|
|||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
|
|
@ -95,6 +101,12 @@ function registerModuleMocks() {
|
|||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
|
|
|
|||
348
server/src/__tests__/permissions-upgrade-boundary-routes.test.ts
Normal file
348
server/src/__tests__/permissions-upgrade-boundary-routes.test.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documents,
|
||||
heartbeatRuns,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home";
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "vitest";
|
||||
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
});
|
||||
|
||||
vi.mock("../services/issue-assignment-wakeup.js", () => ({
|
||||
queueIssueAssignmentWakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId,
|
||||
companyId,
|
||||
runId: null,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "false";
|
||||
const [{ activityRoutes }, { issueRoutes }] = await Promise.all([
|
||||
import("../routes/activity.js"),
|
||||
import("../routes/issues.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use("/api", activityRoutes(db));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedCompany(db: Db, label: string) {
|
||||
return db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `Permissions Boundary ${label}`,
|
||||
issuePrefix: `PB${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function seedAgent(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
input: { role?: string; permissions?: Record<string, unknown>; status?: "active" | "idle" } = {},
|
||||
) {
|
||||
return db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Agent ${randomUUID()}`,
|
||||
role: input.role ?? "engineer",
|
||||
status: input.status ?? "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: input.permissions ?? {},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("permissions upgrade visibility and route boundaries", () => {
|
||||
let db!: Db;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-permissions-boundary-routes-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueAttachments);
|
||||
await db.delete(assets);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueWorkProducts);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("keeps V1 private agent visibility from becoming issue, comment, document, attachment, activity, or work product privacy", async () => {
|
||||
const company = await seedCompany(db, "Visibility");
|
||||
const readerAgent = await seedAgent(db, company.id);
|
||||
const privateTargetAgent = await seedAgent(db, company.id, {
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
agentVisibility: {
|
||||
mode: "private",
|
||||
hiddenFromDefaultDirectory: true,
|
||||
},
|
||||
assignmentPolicy: { mode: "protected" },
|
||||
protectedAgent: { requiresApproval: false },
|
||||
managedBy: "permissions-extension",
|
||||
},
|
||||
},
|
||||
});
|
||||
const issue = await db
|
||||
.insert(issues)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
identifier: `${company.issuePrefix}-1`,
|
||||
title: "Visible work for a private target agent",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: privateTargetAgent.id,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const comment = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
authorAgentId: privateTargetAgent.id,
|
||||
body: "Private target agent status is still company-visible.",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const doc = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
title: "Plan",
|
||||
latestBody: "Shared plan body",
|
||||
createdByAgentId: privateTargetAgent.id,
|
||||
updatedByAgentId: privateTargetAgent.id,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
documentId: doc.id,
|
||||
key: "plan",
|
||||
});
|
||||
const asset = await db
|
||||
.insert(assets)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
provider: "local_disk",
|
||||
objectKey: `attachments/${randomUUID()}.txt`,
|
||||
contentType: "text/plain",
|
||||
byteSize: 12,
|
||||
sha256: "abc123",
|
||||
originalFilename: "note.txt",
|
||||
createdByAgentId: privateTargetAgent.id,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
await db.insert(issueAttachments).values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
issueCommentId: comment.id,
|
||||
assetId: asset.id,
|
||||
});
|
||||
await db.insert(issueWorkProducts).values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
type: "url",
|
||||
provider: "test",
|
||||
title: "Preview",
|
||||
url: "https://example.test/preview",
|
||||
status: "ready",
|
||||
});
|
||||
await db.insert(activityLog).values({
|
||||
companyId: company.id,
|
||||
actorType: "agent",
|
||||
actorId: privateTargetAgent.id,
|
||||
agentId: privateTargetAgent.id,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { source: "test" },
|
||||
});
|
||||
|
||||
const app = await createApp(db, agentActor(company.id, readerAgent.id));
|
||||
|
||||
const [issueList, comments, docs, docDetail, attachments, activity, workProducts] = await Promise.all([
|
||||
request(app).get(`/api/companies/${company.id}/issues`),
|
||||
request(app).get(`/api/issues/${issue.id}/comments`),
|
||||
request(app).get(`/api/issues/${issue.id}/documents`),
|
||||
request(app).get(`/api/issues/${issue.id}/documents/plan`),
|
||||
request(app).get(`/api/issues/${issue.id}/attachments`),
|
||||
request(app).get(`/api/issues/${issue.id}/activity`),
|
||||
request(app).get(`/api/issues/${issue.id}/work-products`),
|
||||
]);
|
||||
|
||||
expect(issueList.status, JSON.stringify(issueList.body)).toBe(200);
|
||||
expect(issueList.body.items ?? issueList.body).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: issue.id })]),
|
||||
);
|
||||
expect(comments.status, JSON.stringify(comments.body)).toBe(200);
|
||||
expect(comments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: comment.id })]));
|
||||
expect(docs.status, JSON.stringify(docs.body)).toBe(200);
|
||||
expect(docs.body).toEqual(expect.arrayContaining([expect.objectContaining({ key: "plan" })]));
|
||||
expect(docDetail.status, JSON.stringify(docDetail.body)).toBe(200);
|
||||
expect(docDetail.body.body ?? docDetail.body.latestBody).toContain("Shared plan body");
|
||||
expect(attachments.status, JSON.stringify(attachments.body)).toBe(200);
|
||||
expect(attachments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]));
|
||||
expect(activity.status, JSON.stringify(activity.body)).toBe(200);
|
||||
expect(activity.body).toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })]));
|
||||
expect(workProducts.status, JSON.stringify(workProducts.body)).toBe(200);
|
||||
expect(workProducts.body).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Preview" })]));
|
||||
});
|
||||
|
||||
it("denies cross-company issue reads before private-agent grant evaluation can matter", async () => {
|
||||
const sourceCompany = await seedCompany(db, "Source");
|
||||
const targetCompany = await seedCompany(db, "Target");
|
||||
const sourceAgent = await seedAgent(db, sourceCompany.id);
|
||||
const privateTargetAgent = await seedAgent(db, targetCompany.id, {
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
|
||||
assignmentPolicy: { mode: "company_default" },
|
||||
protectedAgent: { requiresApproval: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
const issue = await db
|
||||
.insert(issues)
|
||||
.values({
|
||||
companyId: targetCompany.id,
|
||||
title: "Other company work",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: privateTargetAgent.id,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
const res = await request(await createApp(db, agentActor(sourceCompany.id, sourceAgent.id)))
|
||||
.get(`/api/issues/${issue.id}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Agent key cannot access another company");
|
||||
});
|
||||
|
||||
it("allows same-company route assignment after upgrade but keeps private target assignment grant constrained", async () => {
|
||||
const company = await seedCompany(db, "Assignment");
|
||||
const actorAgent = await seedAgent(db, company.id);
|
||||
const openTargetAgent = await seedAgent(db, company.id);
|
||||
const privateTargetAgent = await seedAgent(db, company.id, {
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
|
||||
assignmentPolicy: { mode: "company_default" },
|
||||
protectedAgent: { requiresApproval: false },
|
||||
managedBy: "permissions-extension",
|
||||
},
|
||||
},
|
||||
});
|
||||
const app = await createApp(db, agentActor(company.id, actorAgent.id));
|
||||
|
||||
const openAssignment = await request(app)
|
||||
.post(`/api/companies/${company.id}/issues`)
|
||||
.send({ title: "Assignable after upgrade", assigneeAgentId: openTargetAgent.id });
|
||||
expect(openAssignment.status, JSON.stringify(openAssignment.body)).toBe(201);
|
||||
|
||||
const deniedPrivateAssignment = await request(app)
|
||||
.post(`/api/companies/${company.id}/issues`)
|
||||
.send({ title: "Private target needs scope", assigneeAgentId: privateTargetAgent.id });
|
||||
expect(deniedPrivateAssignment.status).toBe(403);
|
||||
expect(deniedPrivateAssignment.body.error).toContain("private");
|
||||
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentIds: [privateTargetAgent.id] },
|
||||
grantedByUserId: null,
|
||||
});
|
||||
|
||||
const allowedPrivateAssignment = await request(app)
|
||||
.post(`/api/companies/${company.id}/issues`)
|
||||
.send({ title: "Private target has explicit scope", assigneeAgentId: privateTargetAgent.id });
|
||||
expect(allowedPrivateAssignment.status, JSON.stringify(allowedPrivateAssignment.body)).toBe(201);
|
||||
|
||||
const otherPrivateTargetAgent = await seedAgent(db, company.id, {
|
||||
permissions: privateTargetAgent.permissions as Record<string, unknown>,
|
||||
});
|
||||
const deniedOutsideScope = await request(app)
|
||||
.post(`/api/companies/${company.id}/issues`)
|
||||
.send({ title: "Different private target stays denied", assigneeAgentId: otherPrivateTargetAgent.id });
|
||||
expect(deniedOutsideScope.status).toBe(403);
|
||||
expect(deniedOutsideScope.body.error).toContain("private");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
invites,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const pluginId = "plugin-record-id";
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function createCompany(db: ReturnType<typeof createDb>, prefix: string) {
|
||||
return db
|
||||
.insert(companies)
|
||||
.values({
|
||||
name: `${prefix} ${randomUUID()}`,
|
||||
issuePrefix: `${prefix}${randomUUID().slice(0, 6).toUpperCase()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin access and authorization host services", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-access-authz-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(invites);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("rejects grant writes for principals outside the requested company", async () => {
|
||||
const targetCompany = await createCompany(db, "PAX");
|
||||
const otherCompany = await createCompany(db, "PAY");
|
||||
const otherAgent = await db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId: otherCompany.id,
|
||||
name: "Other agent",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
permissions: {},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
|
||||
|
||||
await expect(
|
||||
services.authorization.setGrants({
|
||||
companyId: targetCompany.id,
|
||||
principalType: "agent",
|
||||
principalId: otherAgent.id,
|
||||
grants: [{ permissionKey: "tasks:assign" }],
|
||||
}),
|
||||
).rejects.toThrow("Agent not found");
|
||||
|
||||
const rows = await db.select().from(principalPermissionGrants);
|
||||
expect(rows).toEqual([]);
|
||||
services.dispose();
|
||||
});
|
||||
|
||||
it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => {
|
||||
const company = await createCompany(db, "PAZ");
|
||||
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
|
||||
|
||||
const created = await services.access.createInvite({
|
||||
companyId: company.id,
|
||||
allowedJoinTypes: "human",
|
||||
defaultsPayload: {
|
||||
human: { role: "operator", apiKey: "secret-value" },
|
||||
secret: "top-secret",
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.token).toMatch(/^pcp_invite_/);
|
||||
expect("tokenHash" in created).toBe(false);
|
||||
expect(created.defaultsPayload).toMatchObject({
|
||||
human: { role: "operator", apiKey: "***REDACTED***" },
|
||||
secret: "***REDACTED***",
|
||||
});
|
||||
|
||||
const listed = await services.access.listInvites({ companyId: company.id });
|
||||
expect(listed.invites).toHaveLength(1);
|
||||
expect("token" in listed.invites[0]!).toBe(false);
|
||||
expect("tokenHash" in listed.invites[0]!).toBe(false);
|
||||
services.dispose();
|
||||
});
|
||||
|
||||
it("filters authorization audit entries by allow or deny decision details", async () => {
|
||||
const company = await createCompany(db, "PAU");
|
||||
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
|
||||
await db.insert(activityLog).values([
|
||||
{
|
||||
companyId: company.id,
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "authorization.assignment_preview",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
details: { decision: "allow", secret: "do-not-leak" },
|
||||
createdAt: new Date("2026-01-02T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "authorization.assignment_preview",
|
||||
entityType: "issue",
|
||||
entityId: "issue-2",
|
||||
details: { reason: "deny_scope" },
|
||||
createdAt: new Date("2026-01-03T00:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const [allowed, denied] = await Promise.all([
|
||||
services.authorization.searchAudit({
|
||||
companyId: company.id,
|
||||
action: "authorization.assignment_preview",
|
||||
decision: "allow",
|
||||
limit: 1,
|
||||
}),
|
||||
services.authorization.searchAudit({
|
||||
companyId: company.id,
|
||||
action: "authorization.assignment_preview",
|
||||
decision: "deny",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(allowed).toHaveLength(1);
|
||||
expect(allowed[0]!.entityId).toBe("issue-1");
|
||||
expect(allowed[0]!.details).toMatchObject({ decision: "allow", secret: "***REDACTED***" });
|
||||
expect(denied).toHaveLength(1);
|
||||
expect(denied[0]!.entityId).toBe("issue-2");
|
||||
services.dispose();
|
||||
});
|
||||
|
||||
it("uses persisted agent policy for plugin assignment preview and explanation", async () => {
|
||||
const company = await createCompany(db, "PAP");
|
||||
const [actorAgent, targetAgent] = await db
|
||||
.insert(agents)
|
||||
.values([
|
||||
{
|
||||
companyId: company.id,
|
||||
name: "Actor agent",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
name: "Protected target",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent!.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
|
||||
const updatedPolicy = await services.authorization.updatePolicy({
|
||||
companyId: company.id,
|
||||
resourceType: "agent",
|
||||
resourceId: targetAgent!.id,
|
||||
policy: {
|
||||
assignmentPolicy: {
|
||||
mode: "protected",
|
||||
protectedAgentRequiresApproval: true,
|
||||
},
|
||||
protectedAgent: {
|
||||
requiresApproval: true,
|
||||
approvalReason: "Needs board approval",
|
||||
},
|
||||
managedBy: "permissions-extension",
|
||||
},
|
||||
});
|
||||
const input = {
|
||||
companyId: company.id,
|
||||
actor: {
|
||||
type: "agent" as const,
|
||||
agentId: actorAgent!.id,
|
||||
companyId: company.id,
|
||||
source: "agent_key" as const,
|
||||
},
|
||||
target: { assigneeAgentId: targetAgent!.id },
|
||||
};
|
||||
const [policy, preview, explanation] = await Promise.all([
|
||||
Promise.resolve(updatedPolicy),
|
||||
services.authorization.previewAssignment(input),
|
||||
services.authorization.explainAssignment(input),
|
||||
]);
|
||||
|
||||
expect(policy.policy).toMatchObject({
|
||||
protectedAgent: { requiresApproval: true },
|
||||
});
|
||||
expect(preview).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_policy_restricted",
|
||||
});
|
||||
expect(explanation).toMatchObject(preview);
|
||||
|
||||
const injectedBoardPreview = await services.authorization.previewAssignment({
|
||||
companyId: company.id,
|
||||
actor: {
|
||||
type: "board",
|
||||
userId: "operator",
|
||||
companyIds: [company.id],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
} as any,
|
||||
target: { assigneeAgentId: targetAgent!.id },
|
||||
});
|
||||
expect(injectedBoardPreview).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_policy_restricted",
|
||||
});
|
||||
services.dispose();
|
||||
});
|
||||
|
||||
it("sanitizes plugin authorization policy updates and records audit activity", async () => {
|
||||
const company = await createCompany(db, "PAS");
|
||||
const targetAgent = await db
|
||||
.insert(agents)
|
||||
.values({
|
||||
companyId: company.id,
|
||||
name: "Policy target",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
permissions: {},
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
|
||||
|
||||
const updatedPolicy = await services.authorization.updatePolicy({
|
||||
companyId: company.id,
|
||||
resourceType: "agent",
|
||||
resourceId: targetAgent.id,
|
||||
policy: {
|
||||
assignmentPolicy: { mode: "protected" },
|
||||
apiKey: "sk-test-secret",
|
||||
nested: {
|
||||
authorization: "Bearer should-not-persist",
|
||||
safeLabel: "kept",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedPolicy.policy).toMatchObject({
|
||||
assignmentPolicy: { mode: "protected" },
|
||||
apiKey: "***REDACTED***",
|
||||
nested: {
|
||||
authorization: "***REDACTED***",
|
||||
safeLabel: "kept",
|
||||
},
|
||||
});
|
||||
|
||||
const rows = await db.select().from(activityLog);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
companyId: company.id,
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
action: "authorization.policy_updated_by_plugin",
|
||||
entityType: "agent",
|
||||
entityId: targetAgent.id,
|
||||
});
|
||||
expect(rows[0]!.details).toMatchObject({
|
||||
hasPolicy: true,
|
||||
sourcePluginId: pluginId,
|
||||
sourcePluginKey: "permissions-extension",
|
||||
});
|
||||
expect(JSON.stringify(rows[0]!.details)).not.toContain("sk-test-secret");
|
||||
expect(JSON.stringify(rows[0]!.details)).not.toContain("should-not-persist");
|
||||
services.dispose();
|
||||
});
|
||||
});
|
||||
|
|
@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => {
|
|||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards authorized bridge company scope to the plugin worker", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { app } = await createApp(boardActor(), {}, {
|
||||
bridgeDeps: {
|
||||
workerManager: { call },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/data/health`)
|
||||
.send({ companyId: companyA, params: { view: "compact" } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith(pluginId, "getData", {
|
||||
key: "health",
|
||||
companyId: companyA,
|
||||
params: { view: "compact" },
|
||||
renderEnvironment: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
|
||||
readyPlugin();
|
||||
const call = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
|
|
|||
|
|
@ -82,4 +82,29 @@ describe("plugin SDK test harness", () => {
|
|||
"missing required capability 'skills.managed'",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires access and authorization capabilities for permission SDK calls", async () => {
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.test-missing-access-authz-capability",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Missing Access Capability",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
};
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow(
|
||||
"missing required capability 'access.members.read'",
|
||||
);
|
||||
await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow(
|
||||
"missing required capability 'authorization.grants.read'",
|
||||
);
|
||||
await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow(
|
||||
"missing required capability 'authorization.audit.read'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
createHostClientHandlers,
|
||||
JsonRpcCallError,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
type HostServices,
|
||||
type HostToWorkerMethods,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
|
|
@ -14,6 +17,10 @@ import {
|
|||
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs");
|
||||
const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-invocation-scope.cjs",
|
||||
);
|
||||
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
|
||||
|
||||
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
||||
|
|
@ -178,4 +185,86 @@ describe("plugin-worker-manager stderr failure context", () => {
|
|||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes echoed invocation scope to worker-to-host handlers", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-1" }));
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers: {
|
||||
"companies.get": companiesGet,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
await expect(handle.call("getData", {
|
||||
key: "probe",
|
||||
companyId: "company-1",
|
||||
params: {
|
||||
mode: "echo",
|
||||
requestedCompanyId: "company-1",
|
||||
},
|
||||
} as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" });
|
||||
|
||||
expect(companiesGet).toHaveBeenCalledWith(
|
||||
{ companyId: "company-1" },
|
||||
{ invocationScope: { companyId: "company-1" } },
|
||||
);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing or unknown invocation ids while a company invocation is active", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-2" }));
|
||||
const hostHandlers = createHostClientHandlers({
|
||||
pluginId: "test.plugin",
|
||||
capabilities: ["companies.read"],
|
||||
services: {
|
||||
companies: {
|
||||
get: companiesGet,
|
||||
},
|
||||
} as unknown as HostServices,
|
||||
});
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: {
|
||||
instanceId: "instance-1",
|
||||
hostVersion: "1.0.0",
|
||||
},
|
||||
apiVersion: 1,
|
||||
hostHandlers,
|
||||
});
|
||||
|
||||
try {
|
||||
await handle.start();
|
||||
|
||||
for (const mode of ["omit", "unknown"]) {
|
||||
await expect(handle.call("getData", {
|
||||
key: "probe",
|
||||
companyId: "company-1",
|
||||
params: {
|
||||
mode,
|
||||
requestedCompanyId: "company-2",
|
||||
},
|
||||
} as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
|
||||
});
|
||||
}
|
||||
|
||||
expect(companiesGet).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
backfillPrincipalAccessCompatibility: vi.fn(async () => ({
|
||||
agentMembershipsInserted: 0,
|
||||
humanGrantsInserted: 0,
|
||||
})),
|
||||
feedbackService: feedbackServiceFactoryMock,
|
||||
heartbeatService: vi.fn(() => ({
|
||||
reapOrphanedRuns: vi.fn(async () => undefined),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue