forked from farhoodlabs/paperclip
38c185fb8b
## 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>
510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
instanceUserRoles,
|
|
issues,
|
|
principalPermissionGrants,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
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;
|
|
|
|
async function createCompanyWithOwner(db: ReturnType<typeof createDb>) {
|
|
const company = await db
|
|
.insert(companies)
|
|
.values({
|
|
name: `Access Service ${randomUUID()}`,
|
|
issuePrefix: `AS${randomUUID().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 service", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-service-");
|
|
db = createDb(tempDb.connectionString);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issues);
|
|
await db.delete(principalPermissionGrants);
|
|
await db.delete(instanceUserRoles);
|
|
await db.delete(agents);
|
|
await db.delete(companyMemberships);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("rejects combined access updates that would demote the last active owner", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const access = accessService(db);
|
|
|
|
await expect(
|
|
access.updateMemberAndPermissions(
|
|
company.id,
|
|
owner.id,
|
|
{ membershipRole: "admin", grants: [] },
|
|
"admin-user",
|
|
),
|
|
).rejects.toThrow("Cannot remove the last active owner");
|
|
|
|
const unchanged = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, owner.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(unchanged.membershipRole).toBe("owner");
|
|
});
|
|
|
|
it("rejects role-only updates that would suspend the last active owner", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const access = accessService(db);
|
|
|
|
await expect(
|
|
access.updateMember(company.id, owner.id, { status: "suspended" }),
|
|
).rejects.toThrow("Cannot remove the last active owner");
|
|
|
|
const unchanged = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, owner.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(unchanged.status).toBe("active");
|
|
});
|
|
|
|
it("archives members, clears grants, and reassigns open issues without deleting history", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const member = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `member-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: member.principalId,
|
|
permissionKey: "tasks:assign",
|
|
grantedByUserId: owner.principalId,
|
|
});
|
|
const openIssue = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId: company.id,
|
|
title: "Open assigned issue",
|
|
status: "in_progress",
|
|
assigneeUserId: member.principalId,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
const doneIssue = await db
|
|
.insert(issues)
|
|
.values({
|
|
companyId: company.id,
|
|
title: "Historical assigned issue",
|
|
status: "done",
|
|
assigneeUserId: member.principalId,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
|
|
const access = accessService(db);
|
|
const result = await access.archiveMember(company.id, member.id, {
|
|
reassignment: { assigneeUserId: owner.principalId },
|
|
});
|
|
|
|
expect(result?.reassignedIssueCount).toBe(1);
|
|
const archived = await db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.id, member.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(archived.status).toBe("archived");
|
|
|
|
const remainingGrants = await db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(eq(principalPermissionGrants.principalId, member.principalId));
|
|
expect(remainingGrants).toHaveLength(0);
|
|
|
|
const reassignedIssue = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, openIssue.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(reassignedIssue.assigneeUserId).toBe(owner.principalId);
|
|
expect(reassignedIssue.status).toBe("todo");
|
|
|
|
const historicalIssue = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, doneIssue.id))
|
|
.then((rows) => rows[0]!);
|
|
expect(historicalIssue.assigneeUserId).toBe(member.principalId);
|
|
});
|
|
|
|
it("rejects instance-level company access removal for self and protected users", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const access = accessService(db);
|
|
|
|
await expect(
|
|
access.setUserCompanyAccess(owner.principalId, [], { actorUserId: owner.principalId }),
|
|
).rejects.toThrow("You cannot remove yourself");
|
|
|
|
const admin = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `admin-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "admin",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
|
|
await expect(
|
|
access.setUserCompanyAccess(admin.principalId, [], { actorUserId: owner.principalId }),
|
|
).rejects.toThrow("Owners and admins cannot be removed from company access");
|
|
|
|
const operator = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `operator-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
await db.insert(instanceUserRoles).values({
|
|
userId: operator.principalId,
|
|
role: "instance_admin",
|
|
});
|
|
|
|
await expect(
|
|
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");
|
|
});
|
|
});
|