mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 10:06:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Human company members receive permission grants from their role preset. > - Human invitations use the Operator role by default. > - The old Operator preset only granted task assignment, so ordinary members could not edit agents or manage connections. > - This pull request gives Operators company editing and audit access while keeping join approval and member-permission management separate. > - Members can configure their agents and accounts without an Owner role. ## Linked Issues or Issue Description **What existing behavior does this improve?** The default permission grants for human Operators and the invitation role description. **Subsystem affected** server/ permission presets and invitation authorization, plus ui/ invitation and tool access gates. **Current behavior** Operators receive only `tasks:assign` as an explicit default grant. Agent configuration and managed account setup fail their permission checks. **Proposed behavior** Operators receive agent creation/configuration, skills, environments, invitations, task assignment, pipelines, connections, tool management/use, and audit grants. The preset excludes `joins:approve` and `users:manage_permissions`. Operators can invite Operators and Viewers. Inviting a role with either excluded power requires that power, so invitations cannot bypass the restriction. **Reason and benefit** Ordinary company members can edit company work and connect accounts through the existing routes. **Breaking changes** The Operator preset grants more permissions. The existing startup and Cloud sign-in seeding paths can insert these missing grants for existing Operators. They retain custom grant scopes. Explicit invitation grants still take precedence. Inviting Admins now requires join approval; inviting Owners also requires member-permission management. This PR adds no migration or new backfill path. Related: #5945 describes missing role grants in another membership entry point. This PR changes the preset and does not change that endpoint. ## What Changed - Expanded the Operator preset to 14 company editing, invitation, tool, and audit grants. - Kept join approval and member-permission management out of the preset. - Required those powers when an invitation's selected human role includes them, preventing Operators from delegating the excluded powers through Owner/Admin invitations. - Updated the invitation role copy and the product specifications. - Allowed active Operators (including legacy Member roles) through the invite shortcut and advanced tool/profile UI gates. Kept inactive memberships, Viewers, and other companies excluded; API grants remain authoritative. - Added tests for the default Operator grants, both excluded actions, agent editing, company boundaries, and invitation role authorization. - Simplified adapter-route test imports so authorization errors and the error handler use one module graph; retained the upstream mock-initialization fix. - Kept Owner/Admin/Viewer presets and explicit invitation grants unchanged. Added no EE code or database migration. ## Verification - Permission and invitation tests: 38 passed across access service, invitation defaults, and invitation creation routes. - UI access tests: 61 passed across invitation shortcuts, Operator tool/profile access, and invitation UI. - Adapter route tests: 15 passed after resolving the upstream test setup conflict. - `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates`: passed on the final branch. - The initial local `pnpm test:run` general-server batch reported three tool-access failures and eight upload-helper timeouts. Both complete affected suites passed in isolation on the final branch: 403 tests. The initial full local run was not green. - Both complete tool-access and adapter route suites also passed on an untouched snapshot of base `88ff98b83`: 387 tests. The earlier tool failures did not reproduce there. - [All CI gates passed](https://github.com/paperclipai/paperclip/actions/runs/37527422346) for `068be153b9c2a064f2aaccc0627fdf458e73747e`, including the previously failing serialized adapter lane, general tests, E2E, typecheck, build, Runner checks, and canary dry run. - Greptile scored that exact commit 5/5. Superagent passed. Both invitation review threads are resolved. ## Risks - Operators gain broad company editing and invitation access by design. - Existing default seeding can add the new missing grants during startup or Cloud sign-in. It does not replace existing scopes. - Company boundaries and the two excluded permission checks still apply. - An Admin without member-permission management can no longer invite an Owner. Operators retain invitation access for Operator/Viewer roles and agent-only invites. - This PR changes company permissions. Cloud workspace invitation rules remain separate. ## Model Used OpenAI Codex, GPT-6. The exact deployment model ID and context window were not exposed in this session. Used repository inspection, code editing, shell execution, and test tools. ## 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 a 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", "pipelines:write", "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");
|
|
});
|
|
});
|