mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Pipelines was an unused experiment with its own UI, API, CLI, and automation. > - Keeping it leaves unsupported product surfaces and extra task-detail queries. > - Removing the experiment must preserve data that users already saved. > - This pull request removes the feature and keeps its database schema and migration history. > - Cases and Routines remain available as separate features. ## Linked Issues or Issue Description **What existing behavior does this improve?** Remove the unused Pipelines experiment across the application. **Current behavior** The experiment includes pipeline boards, stage settings, case automation, review queues, learnings, permissions, and CLI commands. Task detail also queries pipeline links. **Proposed behavior** Remove all these product surfaces and their implementation. Preserve existing tables, records, and documents. Ignore the retired boolean in older managed configurations so upgrades still start. **Breaking changes** Pipeline URLs, API endpoints, CLI commands, and `pipelines:write` are removed. Task detail no longer includes the old `linkedCases` field. The independent Cases API remains available. No database migration or cleanup is included. Related feature work: #7907, #9110, #10905, #11375, #13004, #14671, #16133. Related issues: #9663, #10310, #10312. This removal supersedes the feature; it does not implement those pending enhancements or delete their saved data. ## What Changed - Remove Pipeline UI pages, navigation, gates, clients, query keys, helpers, and unused design tokens. - Remove Pipeline REST routes, services, automation dispatch, task-detail queries, and OpenAPI discovery. - Remove the CLI commands, shared contracts, experimental flag, catalog entry, and permission defaults. - Remove feature-specific tests, tutorials, smoke scripts, maps, and Storybook examples. - Preserve database schema, migration history, and the reserved document key used by old records. - Keep Cases routes independent and cover obsolete stored and managed settings. ## Verification - Passed: `pnpm build` and `pnpm -r typecheck`. - Passed: targeted settings, managed configuration, sidebar, settings UI, routing, and feature-catalog tests. A first sidebar run caught a shared Workspaces icon import; the import was restored and the sidebar rerun passed. - Passed: 40 tests covering Cases routes, OpenAPI discovery, retained Pipeline table constraints, and schema snapshot drift. - Passed: token gates, map validation, generated API metadata check, Storybook catalog validation, and 28 map/catalog tests. - Passed: isolated local browser verification of navigation and Experimental settings with Pipelines absent; Cases and Routines still open normally. Live GET/POST requests to retired Pipeline endpoints return 404, and the retired setting is absent. CLI help has no Pipeline commands. - Passed: 22 access-service and agent-default-grant tests after restoring unrelated permission assertions flagged in review. - Local full run: `pnpm test:run` reported one failure in the unchanged workspace-runtime port-allocation test. That test passed in an isolated rerun. The long local run was stopped after full CI passed; it is not reported as a complete local pass. - Passed: all 56 checks on `def9181f3a4491d95fca1d38e9739f5b821aae3e` (2 conditional checks skipped), including build, typecheck, test shards, and browser suites. Eleven interrupted CI jobs reported “The operation was canceled”; their one-time retry passed. [CI run](https://github.com/paperclipai/paperclip/actions/runs/39255211006). - Greptile: 5/5 on the final commit, with the only review finding fixed and resolved. - Sandbox socket restrictions affected the first build/API test attempts. The same commands ran with local sockets available. ## Risks - Existing Pipeline clients must stop using the removed endpoints and commands. - Existing data remains in the database and backups, but the removed UI no longer displays it. - Historical boolean `enablePipelines` values are ignored. They cannot restore the feature. - Cases and Routines must retain their separate behavior. Targeted route tests cover this boundary. ## Model Used OpenAI Codex, GPT-6, with repository inspection, editing, shell execution, and browser verification. The exact deployed model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1203 lines
45 KiB
TypeScript
1203 lines
45 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
agents,
|
|
companySecretBindings,
|
|
companySecrets,
|
|
companies,
|
|
companyMemberships,
|
|
createDb,
|
|
connectionGrantDelegations,
|
|
connectionGrantMembers,
|
|
connectionGrants,
|
|
instanceUserRoles,
|
|
issues,
|
|
principalPermissionGrants,
|
|
toolAccessAuditEvents,
|
|
toolApplications,
|
|
toolConnections,
|
|
userSecretDeclarations,
|
|
userSecretDefinitions,
|
|
} 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(toolAccessAuditEvents);
|
|
await db.delete(userSecretDeclarations);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(connectionGrantDelegations);
|
|
await db.delete(connectionGrantMembers);
|
|
await db.delete(connectionGrants);
|
|
await db.delete(toolConnections);
|
|
await db.delete(toolApplications);
|
|
await db.delete(companySecrets);
|
|
await db.delete(userSecretDefinitions);
|
|
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("sweeps personal grants when instance-level company access is removed", 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: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const agent = await db.insert(agents).values({
|
|
companyId: company.id,
|
|
name: "Instance access delegated agent",
|
|
role: "worker",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}).returning().then((rows) => rows[0]!);
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `instance-access-${randomUUID()}`,
|
|
name: "Instance access app",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Instance access connection",
|
|
uid: `instance-access-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
credentialPolicy: "per_user",
|
|
status: "active",
|
|
enabled: true,
|
|
}).returning().then((rows) => rows[0]!);
|
|
const grant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: member.principalId,
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(connectionGrantDelegations).values({
|
|
companyId: company.id,
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
createdByUserId: member.principalId,
|
|
});
|
|
|
|
await accessService(db).setUserCompanyAccess(member.principalId, [], {
|
|
actorUserId: owner.principalId,
|
|
});
|
|
|
|
expect(await db.select().from(companyMemberships).where(eq(companyMemberships.id, member.id)))
|
|
.toEqual([expect.objectContaining({ status: "archived" })]);
|
|
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
|
.toEqual([expect.objectContaining({ status: "revoked" })]);
|
|
});
|
|
|
|
it("revokes personal connection access and destroys user-scoped secrets when membership is archived", 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: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const agent = await db.insert(agents).values({
|
|
companyId: company.id,
|
|
name: "Delegated agent",
|
|
role: "worker",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}).returning().then((rows) => rows[0]!);
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `app-${randomUUID()}`,
|
|
name: "Personal mail",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Mail",
|
|
uid: `mail-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
credentialPolicy: "per_user",
|
|
status: "active",
|
|
enabled: true,
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definition = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: "Personal OAuth token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const secret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: member.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: `OAuth ${randomUUID()}`,
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.update(toolConnections).set({
|
|
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
|
}).where(eq(toolConnections.id, connection.id));
|
|
const grant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: member.principalId,
|
|
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(connectionGrantDelegations).values({
|
|
companyId: company.id,
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
createdByUserId: member.principalId,
|
|
});
|
|
await accessService(db).archiveMember(company.id, member.id, {
|
|
reassignment: { assigneeUserId: owner.principalId },
|
|
});
|
|
|
|
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
|
expect(await db.select().from(connectionGrantMembers)).toHaveLength(0);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
|
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
|
.toEqual([expect.objectContaining({
|
|
status: "draft",
|
|
enabled: false,
|
|
healthStatus: "missing_secret",
|
|
lastError: "oauth_reauthorization_required",
|
|
credentialSecretRefs: [],
|
|
})]);
|
|
expect(await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.reasonCode, "membership_removed")))
|
|
.toHaveLength(1);
|
|
});
|
|
|
|
it("retains a personal credential while another member grant still uses it", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const [departing, surviving] = await db.insert(companyMemberships).values([
|
|
{
|
|
companyId: company.id,
|
|
principalType: "user" as const,
|
|
principalId: `departing-${randomUUID()}`,
|
|
status: "active" as const,
|
|
membershipRole: "member" as const,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
principalType: "user" as const,
|
|
principalId: `surviving-${randomUUID()}`,
|
|
status: "active" as const,
|
|
membershipRole: "member" as const,
|
|
},
|
|
]).returning();
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `shared-personal-${randomUUID()}`,
|
|
name: "Shared personal app",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Shared personal connection",
|
|
uid: `shared-personal-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
credentialPolicy: "per_user",
|
|
status: "active",
|
|
enabled: true,
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definition = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: `shared-personal-${randomUUID()}`,
|
|
name: "Shared personal token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const secret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: departing!.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `shared-personal-${randomUUID()}`,
|
|
name: "Shared personal token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const credentialSecretRefs = [{ secretId: secret.id, configPath: "oauth.access_token" }];
|
|
await db.update(toolConnections).set({ credentialSecretRefs }).where(eq(toolConnections.id, connection.id));
|
|
const [departingGrant, survivingGrant] = await db.insert(connectionGrants).values([
|
|
{
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user" as const,
|
|
subjectUserId: departing!.principalId,
|
|
status: "active" as const,
|
|
credentialSecretRefs,
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user" as const,
|
|
subjectUserId: surviving!.principalId,
|
|
status: "active" as const,
|
|
credentialSecretRefs,
|
|
},
|
|
]).returning();
|
|
|
|
await accessService(db).archiveMember(company.id, departing!.id, {
|
|
reassignment: { assigneeUserId: owner.principalId },
|
|
});
|
|
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))
|
|
.toHaveLength(1);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, departingGrant!.id)))
|
|
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, survivingGrant!.id)))
|
|
.toEqual([expect.objectContaining({ status: "active", credentialSecretRefs })]);
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
|
.toEqual([expect.objectContaining({ status: "active", enabled: true, credentialSecretRefs })]);
|
|
});
|
|
|
|
it("keeps a mixed connection active when an unaffected organization credential survives", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const departing = await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `departing-mixed-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `mixed-${randomUUID()}`,
|
|
name: "Mixed credential app",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definition = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: `mixed-personal-${randomUUID()}`,
|
|
name: "Mixed personal token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const personalSecret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: departing.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `mixed-personal-${randomUUID()}`,
|
|
name: "Mixed personal token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const organizationSecret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "company",
|
|
key: `mixed-organization-${randomUUID()}`,
|
|
name: "Mixed organization token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const personalRef = { secretId: personalSecret.id, configPath: "credentials.personal" };
|
|
const organizationRef = { secretId: organizationSecret.id, configPath: "credentials.organization" };
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Mixed credential connection",
|
|
uid: `mixed-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "api_key",
|
|
credentialPolicy: "per_user_with_fallback",
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: "healthy",
|
|
credentialSecretRefs: [personalRef, organizationRef],
|
|
}).returning().then((rows) => rows[0]!);
|
|
const personalGrant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: departing.principalId,
|
|
credentialSecretRefs: [personalRef],
|
|
}).returning().then((rows) => rows[0]!);
|
|
const organizationGrant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
status: "active",
|
|
isDefault: true,
|
|
credentialSecretRefs: [organizationRef],
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
await accessService(db).archiveMember(company.id, departing.id, {
|
|
reassignment: { assigneeUserId: owner.principalId },
|
|
});
|
|
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, personalSecret.id)))
|
|
.toHaveLength(0);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, organizationSecret.id)))
|
|
.toHaveLength(1);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, personalGrant.id)))
|
|
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
|
.toEqual([expect.objectContaining({ status: "active", credentialSecretRefs: [organizationRef] })]);
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
|
.toEqual([expect.objectContaining({
|
|
status: "active",
|
|
enabled: true,
|
|
healthStatus: "healthy",
|
|
lastError: null,
|
|
credentialSecretRefs: [organizationRef],
|
|
})]);
|
|
});
|
|
|
|
it("keeps a sole organization audience dormant until its member is reactivated", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const departing = await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `departing-org-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `shared-org-${randomUUID()}`,
|
|
name: "Shared organization app",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Shared organization connection",
|
|
uid: `shared-org-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
credentialPolicy: "shared",
|
|
status: "active",
|
|
enabled: true,
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definition = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: `shared-org-${randomUUID()}`,
|
|
name: "Shared organization token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const secret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: departing.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `shared-org-${randomUUID()}`,
|
|
name: "Shared organization token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const credentialSecretRefs = [{ secretId: secret.id, configPath: "oauth.access_token" }];
|
|
await db.update(toolConnections).set({ credentialSecretRefs }).where(eq(toolConnections.id, connection.id));
|
|
await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: departing.principalId,
|
|
status: "active",
|
|
credentialSecretRefs,
|
|
});
|
|
const organizationGrant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
status: "active",
|
|
isDefault: true,
|
|
credentialSecretRefs,
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(connectionGrantMembers).values({
|
|
companyId: company.id,
|
|
grantId: organizationGrant.id,
|
|
subjectType: "user",
|
|
subjectId: departing.principalId,
|
|
});
|
|
|
|
await accessService(db).archiveMember(company.id, departing.id, {
|
|
reassignment: { assigneeUserId: owner.principalId },
|
|
});
|
|
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))
|
|
.toHaveLength(1);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
|
.toEqual([expect.objectContaining({ status: "active", isDefault: true, credentialSecretRefs })]);
|
|
expect(await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id)))
|
|
.toEqual([expect.objectContaining({ subjectId: departing.principalId })]);
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
|
.toEqual([expect.objectContaining({ status: "active", enabled: true, credentialSecretRefs })]);
|
|
|
|
await accessService(db).setUserCompanyAccess(departing.principalId, [company.id]);
|
|
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))
|
|
.toHaveLength(1);
|
|
expect(await db.select().from(companyMemberships).where(eq(companyMemberships.id, departing.id)))
|
|
.toEqual([expect.objectContaining({ status: "active" })]);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
|
.toEqual([expect.objectContaining({ status: "active", credentialSecretRefs })]);
|
|
});
|
|
|
|
it("revokes delegated personal connection access when membership is suspended", async () => {
|
|
const { company } = await createCompanyWithOwner(db);
|
|
const member = await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `member-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const agent = await db.insert(agents).values({
|
|
companyId: company.id,
|
|
name: "Delegated agent",
|
|
role: "worker",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}).returning().then((rows) => rows[0]!);
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `app-${randomUUID()}`,
|
|
name: "Personal mail",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Mail",
|
|
uid: `mail-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "oauth",
|
|
credentialPolicy: "per_user",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definition = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: "Personal OAuth token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const secret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: member.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: `OAuth ${randomUUID()}`,
|
|
}).returning().then((rows) => rows[0]!);
|
|
const grant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: member.principalId,
|
|
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(connectionGrantDelegations).values({
|
|
companyId: company.id,
|
|
grantId: grant.id,
|
|
agentId: agent.id,
|
|
createdByUserId: member.principalId,
|
|
});
|
|
|
|
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
|
|
|
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
|
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
|
});
|
|
|
|
it("preserves personal grant secrets used by surviving declarations and bindings", async () => {
|
|
const { company } = await createCompanyWithOwner(db);
|
|
const member = await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `member-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const application = await db.insert(toolApplications).values({
|
|
companyId: company.id,
|
|
applicationKey: `app-${randomUUID()}`,
|
|
name: "Personal shared credentials",
|
|
type: "mcp",
|
|
status: "active",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definitions = await db.insert(userSecretDefinitions).values([
|
|
{ companyId: company.id, key: `agent-declared-${randomUUID()}`, name: "Agent-declared credential" },
|
|
{ companyId: company.id, key: `environment-declared-${randomUUID()}`, name: "Environment-declared credential" },
|
|
{ companyId: company.id, key: `bound-${randomUUID()}`, name: "Bound credential" },
|
|
]).returning();
|
|
const secrets = await db.insert(companySecrets).values(definitions.map((definition, index) => ({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: member.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `shared-${index}-${randomUUID()}`,
|
|
name: `Shared credential ${index}`,
|
|
}))).returning();
|
|
const connection = await db.insert(toolConnections).values({
|
|
companyId: company.id,
|
|
applicationId: application.id,
|
|
name: "Shared personal connection",
|
|
uid: `shared-${randomUUID()}`,
|
|
connectionKind: "managed",
|
|
ownership: "customer",
|
|
transport: "mcp_remote",
|
|
authKind: "api_key",
|
|
credentialPolicy: "per_user",
|
|
status: "active",
|
|
enabled: true,
|
|
credentialSecretRefs: secrets.map((secret, index) => ({
|
|
secretId: secret.id,
|
|
configPath: `credentials.shared_${index}`,
|
|
})),
|
|
}).returning().then((rows) => rows[0]!);
|
|
const grant = await db.insert(connectionGrants).values({
|
|
companyId: company.id,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: member.principalId,
|
|
credentialSecretRefs: secrets.map((secret, index) => ({
|
|
secretId: secret.id,
|
|
configPath: `credentials.shared_${index}`,
|
|
})),
|
|
}).returning().then((rows) => rows[0]!);
|
|
await db.insert(userSecretDeclarations).values([
|
|
{
|
|
companyId: company.id,
|
|
userSecretDefinitionId: definitions[0]!.id,
|
|
targetType: "agent",
|
|
targetId: `surviving-agent-${randomUUID()}`,
|
|
configPath: "env.AGENT_TOKEN",
|
|
envKey: "AGENT_TOKEN",
|
|
},
|
|
{
|
|
companyId: company.id,
|
|
userSecretDefinitionId: definitions[1]!.id,
|
|
targetType: "environment",
|
|
targetId: `surviving-environment-${randomUUID()}`,
|
|
configPath: "env.ENVIRONMENT_TOKEN",
|
|
envKey: "ENVIRONMENT_TOKEN",
|
|
},
|
|
]);
|
|
await db.insert(companySecretBindings).values([
|
|
...secrets.map((secret, index) => ({
|
|
companyId: company.id,
|
|
secretId: secret.id,
|
|
targetType: "tool_connection",
|
|
targetId: connection.id,
|
|
configPath: `credentials.shared_${index}`,
|
|
})),
|
|
{
|
|
companyId: company.id,
|
|
secretId: secrets[2]!.id,
|
|
targetType: "environment",
|
|
targetId: `surviving-environment-${randomUUID()}`,
|
|
configPath: "env.BOUND_TOKEN",
|
|
},
|
|
]);
|
|
|
|
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
|
|
|
expect(await db.select().from(companySecrets).where(inArray(companySecrets.id, secrets.map((secret) => secret.id))))
|
|
.toHaveLength(3);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
|
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
|
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
|
.toEqual([expect.objectContaining({
|
|
status: "draft",
|
|
enabled: false,
|
|
credentialSecretRefs: [],
|
|
})]);
|
|
expect(await db.select().from(companySecretBindings)).toEqual([
|
|
expect.objectContaining({
|
|
secretId: secrets[2]!.id,
|
|
targetType: "environment",
|
|
}),
|
|
]);
|
|
expect(await db.select().from(userSecretDeclarations)).toHaveLength(2);
|
|
});
|
|
|
|
it("preserves unrelated user-scoped secrets when membership is suspended", async () => {
|
|
const { company } = await createCompanyWithOwner(db);
|
|
const member = await db.insert(companyMemberships).values({
|
|
companyId: company.id,
|
|
principalType: "user",
|
|
principalId: `member-${randomUUID()}`,
|
|
status: "active",
|
|
membershipRole: "member",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const definition = await db.insert(userSecretDefinitions).values({
|
|
companyId: company.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: "Personal OAuth token",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const unrelatedSecret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: member.principalId,
|
|
userSecretDefinitionId: definition.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: "Environment API key",
|
|
}).returning().then((rows) => rows[0]!);
|
|
const otherUserSecret = await db.insert(companySecrets).values({
|
|
companyId: company.id,
|
|
scope: "user",
|
|
ownerUserId: "another-user",
|
|
userSecretDefinitionId: definition.id,
|
|
key: `oauth-${randomUUID()}`,
|
|
name: "Another user's credential",
|
|
}).returning().then((rows) => rows[0]!);
|
|
|
|
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
|
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, unrelatedSecret.id)))
|
|
.toHaveLength(1);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, otherUserSecret.id)))
|
|
.toHaveLength(1);
|
|
});
|
|
|
|
it("allows owner, admin, and operator 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(true);
|
|
await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false);
|
|
});
|
|
|
|
it("allows default operators to edit agents and company work without administering membership", async () => {
|
|
const { company, owner } = await createCompanyWithOwner(db);
|
|
const access = accessService(db);
|
|
const operator = await access.ensureMembership(company.id, "user", `operator-${randomUUID()}`, "operator");
|
|
const agent = await db.insert(agents).values({
|
|
companyId: company.id,
|
|
name: "Operator-editable agent",
|
|
role: "engineer",
|
|
adapterType: "process",
|
|
adapterConfig: {},
|
|
}).returning().then((rows) => rows[0]!);
|
|
await access.setPrincipalGrants(
|
|
company.id,
|
|
"user",
|
|
operator.principalId,
|
|
grantsForHumanRole("operator"),
|
|
owner.principalId,
|
|
);
|
|
|
|
for (const permissionKey of [
|
|
"agents:create", "agents:configure", "skills:create", "environments:manage",
|
|
"users:invite", "tasks:assign", "tools:manage_connections",
|
|
"tools:manage_profiles", "tools:manage_runtime", "tools:use", "tools:admin",
|
|
"tools:view_audit", "audit:view_agent_actions",
|
|
] as const) {
|
|
await expect(access.canUser(company.id, operator.principalId, permissionKey)).resolves.toBe(true);
|
|
}
|
|
for (const permissionKey of ["joins:approve", "users:manage_permissions"] as const) {
|
|
await expect(access.canUser(company.id, operator.principalId, permissionKey)).resolves.toBe(false);
|
|
}
|
|
await expect(access.decide({
|
|
actor: { type: "board", userId: operator.principalId, source: "session" },
|
|
action: "agent_config:update",
|
|
resource: { type: "agent", companyId: company.id, agentId: agent.id },
|
|
})).resolves.toMatchObject({ allowed: true, reason: "allow_direct_change" });
|
|
|
|
const otherCompany = await createCompanyWithOwner(db);
|
|
await expect(access.canUser(otherCompany.company.id, operator.principalId, "agents:configure")).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");
|
|
});
|
|
});
|