|
11 | 11 | import { describe, it, expect } from 'vitest'; |
12 | 12 | import { FieldSchema, InlineGridColumnSchema, ObjectSchema } from '@objectstack/spec/data'; |
13 | 13 | import { ColumnPrefixSchema, ColumnSummaryConfigSchema, ListColumnSchema } from '@objectstack/spec/ui'; |
14 | | -import { applyObjectSchemaMask, type ObjectSchemaMaskPosture } from './object-schema-fls.js'; |
| 14 | +import { |
| 15 | + applyObjectSchemaMask, |
| 16 | + relateObjectSchemaMaskPosture, |
| 17 | + resolveObjectSchemaMaskPosture, |
| 18 | + type ObjectSchemaMaskPosture, |
| 19 | +} from './object-schema-fls.js'; |
15 | 20 | import { |
16 | 21 | COLUMN_PREFIX_POSITIONS, |
17 | 22 | COLUMN_SUMMARY_POSITIONS, |
@@ -458,6 +463,146 @@ describe('mentionsDenied is an identifier-token test', () => { |
458 | 463 | }); |
459 | 464 | }); |
460 | 465 |
|
| 466 | +describe('[ADR-0106 D1] an action param under `objectOverride` is judged against the object it names', () => { |
| 467 | + // The shape of `sys_user.invite_user`: `role` is a field of BOTH objects, |
| 468 | + // and the param names the member's — not the user's. |
| 469 | + const SYS_USER = { |
| 470 | + name: 'sys_user', |
| 471 | + fields: { id: { type: 'text' }, email: { type: 'email' }, role: { type: 'text' } }, |
| 472 | + actions: [ |
| 473 | + { |
| 474 | + name: 'invite_user', |
| 475 | + label: 'Invite User', |
| 476 | + type: 'api', |
| 477 | + params: [ |
| 478 | + { field: 'email', required: true }, |
| 479 | + { field: 'role', objectOverride: 'sys_member', required: true }, |
| 480 | + ], |
| 481 | + }, |
| 482 | + { name: 'set_role', label: 'Set Role', type: 'api', params: [{ field: 'role', required: true }] }, |
| 483 | + { name: 'mail', label: 'Mail', type: 'api', params: [{ field: 'email' }] }, |
| 484 | + ], |
| 485 | + }; |
| 486 | + |
| 487 | + /** A security service answering per object, as plugin-security does. */ |
| 488 | + const securityFor = (answers: Record<string, string[] | undefined | 'throw'>) => ({ |
| 489 | + getMetadataReadableFields: async (object: string) => { |
| 490 | + const answer = answers[object]; |
| 491 | + if (answer === 'throw') throw new Error(`security unhealthy for ${object} (test)`); |
| 492 | + return answer === undefined ? undefined : [...answer]; |
| 493 | + }, |
| 494 | + }); |
| 495 | + |
| 496 | + /** The exit's sequence: resolve before the fetch, relate after it, mask. */ |
| 497 | + const serve = async ( |
| 498 | + answers: Record<string, string[] | undefined | 'throw'>, |
| 499 | + document: Record<string, unknown> = SYS_USER, |
| 500 | + context: Record<string, unknown> = { userId: 'u_delegate', systemPermissions: [] }, |
| 501 | + ) => { |
| 502 | + const posture = await resolveObjectSchemaMaskPosture({ |
| 503 | + objectName: String(document.name), context, security: securityFor(answers), enabled: true, |
| 504 | + }); |
| 505 | + return applyObjectSchemaMask(document, await relateObjectSchemaMaskPosture(posture, document)); |
| 506 | + }; |
| 507 | + const actionNames = (result: { document: unknown }) => ((result.document as any).actions ?? []).map((a: any) => a.name); |
| 508 | + |
| 509 | + it('serves `invite_user` to a caller denied THIS object\'s `role` who may read the named object\'s `role` (the delegated_admin shape)', async () => { |
| 510 | + const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role', 'user_id'] }); |
| 511 | + expect(result.denied).toEqual(['role']); |
| 512 | + expect(actionNames(result)).toEqual(['invite_user', 'mail']); |
| 513 | + // Served as authored — the param still names the member's `role`. |
| 514 | + expect((result.document as any).actions[0].params[1]).toEqual({ field: 'role', objectOverride: 'sys_member', required: true }); |
| 515 | + }); |
| 516 | + |
| 517 | + it('still drops an action whose param names a denied field of THIS object', async () => { |
| 518 | + const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }); |
| 519 | + expect(actionNames(result)).not.toContain('set_role'); |
| 520 | + // An override naming this object IS this object: the same reading. |
| 521 | + const self = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }, { |
| 522 | + ...SYS_USER, |
| 523 | + actions: [{ name: 'self_role', type: 'api', params: [{ field: 'role', objectOverride: 'sys_user' }] }], |
| 524 | + }); |
| 525 | + expect(actionNames(self)).toEqual([]); |
| 526 | + }); |
| 527 | + |
| 528 | + it('still drops the action when the caller is denied the field on the object the override names', async () => { |
| 529 | + const denied = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'user_id'] }); |
| 530 | + expect(actionNames(denied)).toEqual(['mail']); |
| 531 | + // Even when nothing of THIS object is denied: the read is the other object's. |
| 532 | + const thisWhole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'user_id'] }); |
| 533 | + expect(thisWhole.denied).toEqual([]); |
| 534 | + expect(actionNames(thisWhole)).toEqual(['set_role', 'mail']); |
| 535 | + }); |
| 536 | + |
| 537 | + it('fails closed when the named object\'s readable set cannot be determined — undetermined, throwing, or unknown', async () => { |
| 538 | + for (const sysMember of [undefined, 'throw'] as const) { |
| 539 | + expect(actionNames(await serve({ sys_user: ['id', 'email', 'role'], sys_member: sysMember }))).toEqual(['set_role', 'mail']); |
| 540 | + } |
| 541 | + const unknown = await serve({ sys_user: ['id', 'email', 'role'] }, { |
| 542 | + ...SYS_USER, |
| 543 | + actions: [{ name: 'ghost', type: 'api', params: [{ field: 'role', objectOverride: 'no_such_object' }] }], |
| 544 | + }); |
| 545 | + expect(actionNames(unknown)).toEqual([]); |
| 546 | + // A posture nobody related (an exit that skipped the step) relates nothing: closed too. |
| 547 | + const unrelated = applyObjectSchemaMask(SYS_USER, { kind: 'project', readable: new Set(['id', 'email', 'role']) }); |
| 548 | + expect(actionNames(unrelated)).toEqual(['set_role', 'mail']); |
| 549 | + }); |
| 550 | + |
| 551 | + it('leaves an exempt caller\'s schema whole, and never asks about the related object', async () => { |
| 552 | + let asked = 0; |
| 553 | + const posture = await resolveObjectSchemaMaskPosture({ |
| 554 | + objectName: 'sys_user', |
| 555 | + context: { userId: 'u_admin', systemPermissions: ['setup.access'] }, |
| 556 | + security: { getMetadataReadableFields: async () => { asked++; return []; } }, |
| 557 | + enabled: true, |
| 558 | + }); |
| 559 | + const related = await relateObjectSchemaMaskPosture(posture, SYS_USER); |
| 560 | + expect(related).toBe(posture); |
| 561 | + expect(applyObjectSchemaMask(SYS_USER, related).document).toBe(SYS_USER); |
| 562 | + expect(asked).toBe(0); |
| 563 | + }); |
| 564 | + |
| 565 | + it('reads a `name` restating `field` as that field; an explicit other `name`, and a `defaultFromRow` seed, as THIS object\'s', async () => { |
| 566 | + const answers = { sys_user: ['id', 'email'], sys_member: ['id', 'role', 'title'] }; |
| 567 | + const withParam = (param: Record<string, unknown>) => ({ ...SYS_USER, actions: [{ name: 'act', type: 'api', params: [param] }] }); |
| 568 | + // The default body key, spelled out, is the same read. |
| 569 | + expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']); |
| 570 | + // A body key that differs from the field keeps the existing reading: it |
| 571 | + // spells a denied field of this object, so the action goes. |
| 572 | + expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'title', objectOverride: 'sys_member' })))).toEqual([]); |
| 573 | + expect(actionNames(await serve(answers, withParam({ name: 'member_role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']); |
| 574 | + // `defaultFromRow` seeds the value from THIS object's row — a read here too. |
| 575 | + expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', defaultFromRow: true })))).toEqual([]); |
| 576 | + // The rest of the param is still this object's: a predicate over a denied field drops it. |
| 577 | + expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', visible: 'record.role != null' })))).toEqual([]); |
| 578 | + }); |
| 579 | + |
| 580 | + it('folds a withheld related read into the fingerprint, so two cohorts never share a validator for different bodies', async () => { |
| 581 | + const reads = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }); |
| 582 | + const cannot = await serve({ sys_user: ['id', 'email'], sys_member: ['id'] }); |
| 583 | + expect(reads.denied).toEqual(cannot.denied); |
| 584 | + expect(reads.fingerprint).not.toBe(cannot.fingerprint); |
| 585 | + // An unrestricted caller on both objects keeps the empty fingerprint and the same reference. |
| 586 | + const whole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'role'] }); |
| 587 | + expect(whole.fingerprint).toBe(''); |
| 588 | + expect(whole.document).toBe(SYS_USER); |
| 589 | + }); |
| 590 | + |
| 591 | + it('relates each named object once, across documents, and leaves a document with no such read alone', async () => { |
| 592 | + const asked: string[] = []; |
| 593 | + const posture = await resolveObjectSchemaMaskPosture({ |
| 594 | + objectName: 'sys_user', |
| 595 | + context: { userId: 'u' }, |
| 596 | + security: { getMetadataReadableFields: async (object: string) => { asked.push(object); return ['id', 'role']; } }, |
| 597 | + enabled: true, |
| 598 | + }); |
| 599 | + expect(await relateObjectSchemaMaskPosture(posture, { name: 'sys_user', actions: [] })).toBe(posture); |
| 600 | + const related = await relateObjectSchemaMaskPosture(posture, SYS_USER, SYS_USER); |
| 601 | + expect(await relateObjectSchemaMaskPosture(related, SYS_USER)).toBe(related); |
| 602 | + expect(asked).toEqual(['sys_user', 'sys_member']); |
| 603 | + }); |
| 604 | +}); |
| 605 | + |
461 | 606 | /** The keys a Zod object schema declares. */ |
462 | 607 | function declaredKeys(schema: unknown): string[] { |
463 | 608 | const shape = (schema as { shape?: Record<string, unknown> }).shape; |
@@ -509,7 +654,12 @@ describe('[ADR-0106] the shared contract table, driven through the bare projecti |
509 | 654 | for (const testCase of OBJECT_SCHEMA_MASK_CASES.filter((c) => c.expect.kind === 'fields')) { |
510 | 655 | it(testCase.id, () => { |
511 | 656 | const readable = testCase.readable as readonly string[]; |
512 | | - const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, project([...readable])); |
| 657 | + // Related as an exit relates it: the contract's double answers the |
| 658 | + // same set for every object, `contact` included (#21884). |
| 659 | + const posture: ObjectSchemaMaskPosture = { |
| 660 | + kind: 'project', readable: new Set(readable), related: new Map([['contact', new Set(readable)]]), |
| 661 | + }; |
| 662 | + const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, posture); |
513 | 663 | assertObjectSchemaMaskCase('applyObjectSchemaMask', testCase, { kind: 'document', document }); |
514 | 664 | }); |
515 | 665 | } |
|
0 commit comments