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, }; }