From 2b427e7ed3a36397fe6218ee658150f2fd28f061 Mon Sep 17 00:00:00 2001 From: cryppadotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:14:13 +0000 Subject: [PATCH] fix(auth): enforce legacy grant role ceiling Co-Authored-By: Paperclip --- .../__tests__/authorization-service.test.ts | 51 +++++++++++++++++++ server/src/services/authorization.ts | 18 ++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index b76683562e..fedc2bfcfa 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -227,6 +227,57 @@ describeEmbeddedPostgres("authorization service", () => { expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign"); }); + it("limits ambiguous legacy user grants to the active membership role", async () => { + const company = await createCompany(db, "LegacyGrantRoleCeiling"); + const userId = `user-${randomUUID()}`; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "admin", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + permissionKey: "tools:admin", + grantOrigin: "legacy_unknown", + }); + + const service = authorizationService(db); + await expect(service.decidePrincipalGrant({ + companyId: company.id, + principalType: "user", + principalId: userId, + action: "tools:admin", + permissionKey: "tools:admin", + })).resolves.toMatchObject({ allowed: true, reason: "allow_role_default" }); + + await db + .update(companyMemberships) + .set({ membershipRole: "operator" }) + .where(and( + eq(companyMemberships.companyId, company.id), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + )); + + await expect(service.decidePrincipalGrant({ + companyId: company.id, + principalType: "user", + principalId: userId, + action: "tools:admin", + permissionKey: "tools:admin", + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" }); + await expect(db.select().from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, userId), + ))).resolves.toEqual([ + expect.objectContaining({ permissionKey: "tools:admin", grantOrigin: "legacy_unknown" }), + ]); + }); + it("allows suggest grants to read peer agent configuration", async () => { const company = await createCompany(db, "AgentReadGrant"); const actorAgent = await createAgent(db, company.id); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 70b1d2032c..04d1685cb6 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -697,6 +697,18 @@ export function authorizationService(db: Db | DbTransaction) { }); } + const legacyGrantMatchesCurrentRole = grant.grantOrigin !== "legacy_unknown" + || input.principalType !== "user" + || grantsForHumanRole(normalizeHumanRole(membership.membershipRole, "operator")) + .some((defaultGrant) => defaultGrant.permissionKey === input.permissionKey); + if (!legacyGrantMatchesCurrentRole) { + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: `Missing permission: ${input.permissionKey}.`, + }); + } + if ( !(await scopeAllows(db, input.companyId, grant.scope, input.scope, { requireStructuredScope: input.permissionKey === "tasks:assign_scope", @@ -717,8 +729,10 @@ export function authorizationService(db: Db | DbTransaction) { return allow({ action: input.action, - reason: "allow_explicit_grant", - explanation: `Allowed by explicit grant ${input.permissionKey}.`, + reason: grant.grantOrigin === "legacy_unknown" ? "allow_role_default" : "allow_explicit_grant", + explanation: grant.grantOrigin === "legacy_unknown" + ? `Allowed by the ${membership.membershipRole ?? "operator"} membership role.` + : `Allowed by explicit grant ${input.permissionKey}.`, grant: { principalType: input.principalType, principalId: input.principalId,