diff --git a/src/backend/services/permission/PermissionService.test.ts b/src/backend/services/permission/PermissionService.test.ts index 6a53aaa7a..1202f675a 100644 --- a/src/backend/services/permission/PermissionService.test.ts +++ b/src/backend/services/permission/PermissionService.test.ts @@ -1712,6 +1712,32 @@ describe('PermissionService — scan paths', () => { expect(await permService.check(actor, permission)).toBe(false); }); + // The traversal used to run beside the flat read with nobody awaiting it. + it('answers a flat hit without running the linked traversal', async () => { + const { row, actor } = await makeGroupedUser(); + const permission = `zztest:flat-${uuidv4()}:ii:read`; + await server.stores.permission.setFlatUserPerm(row.id, permission, { + permission, + deleted: false, + issuer_user_id: row.id, + } as never); + + const linked = vi + .spyOn(server.stores.permission, 'readLinkedUserUserPerms') + .mockRejectedValue(new Error('db wobble')); + try { + const reading = await permService.validateUserPerms({ + actor, + permissions: [permission], + }); + expect(reading).toHaveLength(1); + expect(reading[0]).toMatchObject({ permission }); + expect(linked).not.toHaveBeenCalled(); + } finally { + linked.mockRestore(); + } + }); + it('returns nothing for an actor with no user id', async () => { expect( await permService.validateUserPerms({ diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index 9cf7df503..0d275c2d0 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -705,19 +705,20 @@ export class PermissionService extends PuterService { }): Promise { if (!actor.user?.id) return []; - const flatPromise = this.#flatValidateUserPerms(actor, permissions); - const linkedPromise = this.#linkedValidateUserPerms( + const flatReading = await this.#flatValidateUserPerms( actor, permissions, - state ?? { antiCycleActors: [actor] }, ); - - const flatReading = await flatPromise; if (flatReading.length > 0) { return flatReading[0].deleted ? [] : flatReading; } - const linkedReading = await linkedPromise; + // Only on a miss: started beside the flat read, nothing awaits its rejection. + const linkedReading = await this.#linkedValidateUserPerms( + actor, + permissions, + state ?? { antiCycleActors: [actor] }, + ); const flatOptions = PermissionUtil.readingToOptions(linkedReading); // Warm flat KV cache for future hits (fire-and-forget, don't block