From 4f3c8c13e0a4dbe79ae7f1fc38153268e543a8d8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 4 Apr 2026 03:15:18 +1100 Subject: [PATCH] PM-4211: merge M2M member scopes across auth layers What was broken M2M POST, PATCH, and DELETE requests for project members could still return 403 even when the raw token payload carried project-member write scope. Root cause (if identifiable) The permission layer preferred user.scopes when they were present, while the route guard evaluated scopes from the raw token payload. If those two scope sources drifted, the guard could admit the request and the project-member service could still reject it. What was changed Merged normalized scopes from both user.scopes and the raw token payload in PermissionService so downstream permission checks use the same effective M2M grants that the auth guard sees. Added a regression that covers create, update, and delete project-member permissions when the raw token payload is broader than user.scopes. Any added/updated tests Updated src/shared/services/permission.service.spec.ts with create, update, and delete project-member M2M regression coverage for mismatched scope sources. --- .../services/permission.service.spec.ts | 21 +++++++++++++++++++ src/shared/services/permission.service.ts | 18 ++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/shared/services/permission.service.spec.ts b/src/shared/services/permission.service.spec.ts index a203341..e286c67 100644 --- a/src/shared/services/permission.service.spec.ts +++ b/src/shared/services/permission.service.spec.ts @@ -488,6 +488,27 @@ describe('PermissionService', () => { expect(allowed).toBe(true); }); + it.each([ + Permission.CREATE_PROJECT_MEMBER_NOT_OWN, + Permission.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, + Permission.DELETE_PROJECT_MEMBER_TOPCODER, + ])( + 'allows %s when raw M2M token scopes are broader than user.scopes', + (permission) => { + const allowed = service.hasNamedPermission(permission, { + scopes: [Scope.PROJECTS_READ], + isMachine: false, + tokenPayload: { + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + sub: 'svc-projects@clients', + }, + }); + + expect(allowed).toBe(true); + }, + ); + it('allows reading other users project invites for machine token with invite read scope', () => { const allowed = service.hasNamedPermission( Permission.READ_PROJECT_INVITE_NOT_OWN, diff --git a/src/shared/services/permission.service.ts b/src/shared/services/permission.service.ts index 0537428..d7a6bb2 100644 --- a/src/shared/services/permission.service.ts +++ b/src/shared/services/permission.service.ts @@ -633,6 +633,11 @@ export class PermissionService { * Resolves machine-token status and effective scopes from the normalized user * and the raw token payload so guard and permission checks stay aligned. * + * Merges both scope sources because upstream auth middleware can populate + * `user.scopes` differently from the raw token payload used by + * `TokenRolesGuard`. Keeping the union here avoids false 403s when one source + * is stale or incomplete but the other still carries the granted M2M scopes. + * * @param user authenticated JWT user context * @returns machine classification and the scopes to evaluate */ @@ -643,13 +648,18 @@ export class PermissionService { const payloadMachineContext = this.m2mService.validateMachineToken( user.tokenPayload, ); + const userScopes = Array.isArray(user.scopes) + ? user.scopes + .map((scope) => String(scope).trim()) + .filter((scope) => scope.length > 0) + : []; + const mergedScopes = Array.from( + new Set([...userScopes, ...payloadMachineContext.scopes]), + ); return { isMachine: Boolean(user.isMachine || payloadMachineContext.isMachine), - scopes: - Array.isArray(user.scopes) && user.scopes.length > 0 - ? user.scopes - : payloadMachineContext.scopes, + scopes: mergedScopes, }; }