|
1 | 1 | import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors'; |
2 | | -import { VirtruPDP } from './VirtruPDP'; |
| 2 | +import { getDeniedSubjects, VirtruPDP } from './VirtruPDP'; |
| 3 | +import type { Decision } from './types'; |
3 | 4 |
|
4 | 5 | const serverFetchMock = jest.fn(); |
5 | 6 | jest.mock('@rocket.chat/server-fetch', () => ({ serverFetch: (...a: unknown[]) => serverFetchMock(...a) })); |
@@ -88,6 +89,35 @@ describe('VirtruPDP.isAvailable', () => { |
88 | 89 | }); |
89 | 90 | }); |
90 | 91 |
|
| 92 | +describe('getDeniedSubjects', () => { |
| 93 | + const pair = (userId: string) => ({ user: { _id: userId }, room: { _id: 'r1' } }); |
| 94 | + const [a, b, c] = [pair('a'), pair('b'), pair('c')]; |
| 95 | + const resp = (...decisions: Decision[]) => ({ |
| 96 | + resourceDecisions: decisions.map((decision) => ({ ephemeralResourceId: 'r1', decision })), |
| 97 | + }); |
| 98 | + |
| 99 | + it('collects subjects with an explicit DENY decision', () => { |
| 100 | + const responses = [resp('DECISION_DENY'), resp('DECISION_PERMIT'), resp('DECISION_DENY')]; |
| 101 | + expect(getDeniedSubjects(responses, [a, b, c])).toEqual([a, c]); |
| 102 | + }); |
| 103 | + |
| 104 | + it('skips subjects when every decision is PERMIT', () => { |
| 105 | + expect(getDeniedSubjects([resp('DECISION_PERMIT')], [a])).toEqual([]); |
| 106 | + }); |
| 107 | + |
| 108 | + it('skips inconclusive subjects (UNSPECIFIED, empty decisions, missing response)', () => { |
| 109 | + expect(getDeniedSubjects([resp('DECISION_UNSPECIFIED'), resp(), undefined], [a, b, c])).toEqual([]); |
| 110 | + }); |
| 111 | + |
| 112 | + it('treats a DENY among PERMITs in one response as denied', () => { |
| 113 | + expect(getDeniedSubjects([resp('DECISION_PERMIT', 'DECISION_DENY')], [a])).toEqual([a]); |
| 114 | + }); |
| 115 | + |
| 116 | + it('ignores responses without a matching subject', () => { |
| 117 | + expect(getDeniedSubjects([resp('DECISION_DENY'), resp('DECISION_DENY')], [a])).toEqual([a]); |
| 118 | + }); |
| 119 | +}); |
| 120 | + |
91 | 121 | describe('VirtruPDP.canAccessObject', () => { |
92 | 122 | const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] }; |
93 | 123 |
|
@@ -284,13 +314,39 @@ describe('VirtruPDP.onRoomAttributesChanged', () => { |
284 | 314 | expect(result).toEqual([u2]); |
285 | 315 | }); |
286 | 316 |
|
287 | | - it('treats empty resourceDecisions as non-compliant', async () => { |
| 317 | + it('does not evict on empty resourceDecisions (inconclusive)', async () => { |
288 | 318 | const u = user(); |
289 | 319 | usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u])); |
290 | 320 | const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [] }] }); |
291 | 321 | const pdp = new VirtruPDP(mkClient({ apiCall })); |
292 | 322 | const result = await pdp.onRoomAttributesChanged(room, newAttrs); |
293 | | - expect(result).toEqual([u]); |
| 323 | + expect(result).toEqual([]); |
| 324 | + }); |
| 325 | + |
| 326 | + it('does not evict on DECISION_UNSPECIFIED (inconclusive)', async () => { |
| 327 | + usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()])); |
| 328 | + const apiCall = jest.fn().mockResolvedValue({ |
| 329 | + decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }], |
| 330 | + }); |
| 331 | + const pdp = new VirtruPDP(mkClient({ apiCall })); |
| 332 | + const result = await pdp.onRoomAttributesChanged(room, newAttrs); |
| 333 | + expect(result).toEqual([]); |
| 334 | + }); |
| 335 | + |
| 336 | + it('still evicts entity-keyless users alongside DENY-only filtering of decided users', async () => { |
| 337 | + const keyless = user({ _id: 'u0', emails: [] }); |
| 338 | + const denied = user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com' }] }); |
| 339 | + const unspecified = user({ _id: 'u3', username: 'carol', emails: [{ address: 'c@x.com' }] }); |
| 340 | + usersFindActiveByRoomIds.mockReturnValue(asyncIterable([keyless, denied, unspecified])); |
| 341 | + const apiCall = jest.fn().mockResolvedValue({ |
| 342 | + decisionResponses: [ |
| 343 | + { resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] }, |
| 344 | + { resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }, |
| 345 | + ], |
| 346 | + }); |
| 347 | + const pdp = new VirtruPDP(mkClient({ apiCall })); |
| 348 | + const result = await pdp.onRoomAttributesChanged(room, newAttrs); |
| 349 | + expect(result).toEqual([keyless, denied]); |
294 | 350 | }); |
295 | 351 |
|
296 | 352 | it('returns only entity-keyless users when ALL users lack entity keys (no API call)', async () => { |
@@ -346,6 +402,17 @@ describe('VirtruPDP.onSubjectAttributesChanged', () => { |
346 | 402 | expect(result).toEqual([rooms[1]]); |
347 | 403 | }); |
348 | 404 |
|
| 405 | + it('still treats DECISION_UNSPECIFIED as non-compliant (LDAP-driven path cannot yield inconclusive decisions)', async () => { |
| 406 | + const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }]; |
| 407 | + roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms)); |
| 408 | + const apiCall = jest.fn().mockResolvedValue({ |
| 409 | + decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }], |
| 410 | + }); |
| 411 | + const pdp = new VirtruPDP(mkClient({ apiCall })); |
| 412 | + const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, []); |
| 413 | + expect(result).toEqual(rooms); |
| 414 | + }); |
| 415 | + |
349 | 416 | it('splits >200 rooms into multiple decision batches', async () => { |
350 | 417 | const total = 401; |
351 | 418 | const rooms = Array.from({ length: total }, (_, i) => ({ _id: `r${i}`, abacAttributes: [{ key: 'k', values: ['v'] }] })); |
@@ -414,13 +481,51 @@ describe('VirtruPDP.evaluateUserRooms', () => { |
414 | 481 | expect(result).toEqual([{ user: u, room: rooms[1] }]); |
415 | 482 | }); |
416 | 483 |
|
417 | | - it('treats empty resourceDecisions as non-compliant', async () => { |
| 484 | + it('does not evict on empty resourceDecisions (inconclusive)', async () => { |
418 | 485 | const u = user(); |
419 | 486 | const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }]; |
420 | 487 | const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [] }] }); |
421 | 488 | const pdp = new VirtruPDP(mkClient({ apiCall })); |
422 | 489 | const result = await pdp.evaluateUserRooms([{ user: u, rooms }]); |
423 | | - expect(result).toEqual([{ user: u, room: rooms[0] }]); |
| 490 | + expect(result).toEqual([]); |
| 491 | + }); |
| 492 | + |
| 493 | + it('does not evict on DECISION_UNSPECIFIED (inconclusive)', async () => { |
| 494 | + const u = user(); |
| 495 | + const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }]; |
| 496 | + const apiCall = jest.fn().mockResolvedValue({ |
| 497 | + decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }], |
| 498 | + }); |
| 499 | + const pdp = new VirtruPDP(mkClient({ apiCall })); |
| 500 | + const result = await pdp.evaluateUserRooms([{ user: u, rooms }]); |
| 501 | + expect(result).toEqual([]); |
| 502 | + }); |
| 503 | + |
| 504 | + it('evicts only explicit DENY among mixed PERMIT/UNSPECIFIED/DENY decisions', async () => { |
| 505 | + const u = user(); |
| 506 | + const rooms = [ |
| 507 | + { _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] }, |
| 508 | + { _id: 'rU', abacAttributes: [{ key: 'k', values: ['v'] }] }, |
| 509 | + { _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] }, |
| 510 | + ]; |
| 511 | + const apiCall = jest.fn().mockResolvedValue({ |
| 512 | + decisionResponses: [ |
| 513 | + { resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] }, |
| 514 | + { resourceDecisions: [{ ephemeralResourceId: 'rU', decision: 'DECISION_UNSPECIFIED' }] }, |
| 515 | + { resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] }, |
| 516 | + ], |
| 517 | + }); |
| 518 | + const pdp = new VirtruPDP(mkClient({ apiCall })); |
| 519 | + const result = await pdp.evaluateUserRooms([{ user: u, rooms }]); |
| 520 | + expect(result).toEqual([{ user: u, room: rooms[2] }]); |
| 521 | + }); |
| 522 | + |
| 523 | + it('rejects when the decision call fails (no evictions, sweep retried next run)', async () => { |
| 524 | + const apiCall = jest.fn().mockRejectedValue(new Error('pdp down')); |
| 525 | + const pdp = new VirtruPDP(mkClient({ apiCall })); |
| 526 | + const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }]; |
| 527 | + await expect(pdp.evaluateUserRooms([{ user: user(), rooms }])).rejects.toThrow('pdp down'); |
| 528 | + expect(apiCall).toHaveBeenCalled(); |
424 | 529 | }); |
425 | 530 | }); |
426 | 531 |
|
@@ -460,14 +565,6 @@ describe('VirtruPDP — PDP unreachable (decision call rejects)', () => { |
460 | 565 | await expect(pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, [])).rejects.toThrow('pdp down'); |
461 | 566 | expect(apiCall).toHaveBeenCalled(); |
462 | 567 | }); |
463 | | - |
464 | | - it('evaluateUserRooms rejects when the decision call fails', async () => { |
465 | | - const apiCall = pdpDown(); |
466 | | - const pdp = new VirtruPDP(mkClient({ apiCall })); |
467 | | - const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }]; |
468 | | - await expect(pdp.evaluateUserRooms([{ user: user(), rooms } as any])).rejects.toThrow('pdp down'); |
469 | | - expect(apiCall).toHaveBeenCalled(); |
470 | | - }); |
471 | 568 | }); |
472 | 569 |
|
473 | 570 | describe('reevaluateUsers', () => { |
|
0 commit comments