diff --git a/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql new file mode 100644 index 00000000000..4d05e171bfc --- /dev/null +++ b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql @@ -0,0 +1,29 @@ +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; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index a47bae7c2d1..79c31aad10b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -610,6 +610,13 @@ "when": 1778976000000, "tag": "0086_routine_env_runtime_contract", "breakpoints": true + }, + { + "idx": 87, + "version": "7", + "when": 1779360000000, + "tag": "0087_backfill_environment_manage_human_defaults", + "breakpoints": true } ] } diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index f9ac3e64c9b..3148ad8c593 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { companies, @@ -14,6 +14,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { accessService } from "../services/access.js"; +import { grantsForHumanRole } from "../services/company-member-roles.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -221,4 +222,107 @@ describeEmbeddedPostgres("access service", () => { access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), ).rejects.toThrow("Instance admins cannot be removed from company access"); }); + + it("allows owner and admin role-default grants to manage environments", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + const roles = ["admin", "operator", "viewer"] as const; + const members = await db + .insert(companyMemberships) + .values( + roles.map((role) => ({ + companyId: company.id, + principalType: "user" as const, + principalId: `${role}-${randomUUID()}`, + status: "active" as const, + membershipRole: role, + })), + ) + .returning(); + + await access.setPrincipalGrants( + company.id, + "user", + owner.principalId, + grantsForHumanRole("owner"), + owner.principalId, + ); + for (const member of members) { + await access.setPrincipalGrants( + company.id, + "user", + member.principalId, + grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"), + owner.principalId, + ); + } + + const admin = members.find((member) => member.membershipRole === "admin")!; + const operator = members.find((member) => member.membershipRole === "operator")!; + const viewer = members.find((member) => member.membershipRole === "viewer")!; + + await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true); + await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true); + await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false); + await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false); + }); + + it("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"); + }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index fae007e059d..6834f763e2c 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -68,6 +68,7 @@ describe("human invite roles", () => { it("maps owner to the full management grant set", () => { expect(grantsForHumanRole("owner")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, @@ -75,6 +76,16 @@ describe("human invite roles", () => { ]); }); + it("maps admin to management grants including environment management", () => { + expect(grantsForHumanRole("admin")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + it("defaults legacy or missing roles to operator", () => { expect(normalizeHumanRole("member")).toBe("operator"); expect(resolveHumanInviteRole(null)).toBe("operator"); diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 6e8513ee451..593770ca42e 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -28,6 +28,7 @@ export function grantsForHumanRole( case "owner": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, @@ -36,6 +37,7 @@ export function grantsForHumanRole( case "admin": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null },