|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#26597] A field a permission set has NO entry for is constrained only by the |
| 5 | + * set's object grant — measured through a real `ObjectQL` over a real SQL |
| 6 | + * driver, with the real `SecurityPlugin` middleware in front of it. |
| 7 | + * |
| 8 | + * A permission set constrains only the fields it names: a field the set does |
| 9 | + * not name passes the field write gate and the read mask |
| 10 | + * (`FieldMasker.detectForbiddenWrites` / `maskResults`: "Fields without a |
| 11 | + * permission entry pass through"; ADR-0066 ⑧), so the object grant decides. |
| 12 | + * |
| 13 | + * This is the runtime half of a pair. Studio's field-permission editor draws a |
| 14 | + * field row from the same table (`fieldRuleCell` in `@object-ui/app-shell`'s |
| 15 | + * metadata-admin `permission-slice.ts`, pinned by its |
| 16 | + * `permission-slice.fieldRule-26597.test.ts`): change the two together. |
| 17 | + * |
| 18 | + * The table: three field rules (no entry / read-only entry / editable entry) on |
| 19 | + * two objects (one whose object grant includes Edit, one without), each written |
| 20 | + * by a caller holding ONLY this set. |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 24 | +import { ObjectQL } from '@objectstack/objectql'; |
| 25 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 26 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 27 | + |
| 28 | +import { SecurityPlugin } from './security-plugin.js'; |
| 29 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 30 | +import { installDefaultOrganization } from './default-organization.testkit.js'; |
| 31 | + |
| 32 | +const SYS = { context: { isSystem: true } } as never; |
| 33 | +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; |
| 34 | + |
| 35 | +const EDIT_ON = 'qa_fr_edit_on'; |
| 36 | +const EDIT_OFF = 'qa_fr_edit_off'; |
| 37 | +const SET = 'qa_fr_editor'; |
| 38 | +const CALLER = { userId: 'usr_fr_member', positions: [], permissions: [SET], posture: 'MEMBER' }; |
| 39 | + |
| 40 | +/** The field each rule is declared on, per object. */ |
| 41 | +const FIELD_FOR = { absent: 'notes', readOnly: 'locked', editable: 'open' } as const; |
| 42 | +type Rule = keyof typeof FIELD_FOR; |
| 43 | + |
| 44 | +/** |
| 45 | + * May a holder of this set ALONE edit the field? Studio's editor draws exactly |
| 46 | + * this (`RUNTIME_EDIT_VERDICT` in `permission-slice.fieldRule-26597.test.ts`). |
| 47 | + */ |
| 48 | +const EDIT_VERDICT: Record<string, Record<Rule, boolean>> = { |
| 49 | + [EDIT_ON]: { absent: true, readOnly: false, editable: true }, |
| 50 | + [EDIT_OFF]: { absent: false, readOnly: false, editable: false }, |
| 51 | +}; |
| 52 | + |
| 53 | +interface Refusal { code?: string; status?: number; details?: { forbiddenFields?: string[] } } |
| 54 | +type Outcome = { kind: 'landed' } | ({ kind: 'refused' } & Refusal); |
| 55 | + |
| 56 | +interface Rig { |
| 57 | + engine: ObjectQL; |
| 58 | + update: (object: string, data: Record<string, unknown>) => Promise<Outcome>; |
| 59 | + stored: (object: string) => Promise<Record<string, unknown> | null>; |
| 60 | + teardown: () => Promise<void>; |
| 61 | +} |
| 62 | +const rigs: Rig[] = []; |
| 63 | +afterEach(async () => { |
| 64 | + for (const rig of rigs.splice(0)) await rig.teardown(); |
| 65 | +}); |
| 66 | + |
| 67 | +function outcome(p: Promise<unknown>): Promise<Outcome> { |
| 68 | + return p.then( |
| 69 | + () => ({ kind: 'landed' as const }), |
| 70 | + (e: any) => ({ kind: 'refused' as const, code: e?.code, status: e?.statusCode ?? e?.status, details: e?.details }), |
| 71 | + ); |
| 72 | +} |
| 73 | + |
| 74 | +const objectFields = { |
| 75 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 76 | + name: { name: 'name', type: 'text' }, |
| 77 | + notes: { name: 'notes', type: 'text' }, |
| 78 | + locked: { name: 'locked', type: 'text' }, |
| 79 | + open: { name: 'open', type: 'text' }, |
| 80 | +}; |
| 81 | + |
| 82 | +async function boot(): Promise<Rig> { |
| 83 | + const engine = new ObjectQL(); |
| 84 | + engine.registerDriver( |
| 85 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, |
| 86 | + true, |
| 87 | + ); |
| 88 | + await engine.init(); |
| 89 | + engine.registerApp({ |
| 90 | + id: 'com.objectstack.qa.field-rule-object-grant', |
| 91 | + name: 'Field rule: a field without an entry follows the object grant', |
| 92 | + version: '1.0.0', |
| 93 | + type: 'plugin', |
| 94 | + scope: 'system', |
| 95 | + objects: [ |
| 96 | + { name: EDIT_ON, label: 'Edit granted', sharingModel: 'public_read_write', fields: objectFields }, |
| 97 | + { name: EDIT_OFF, label: 'Edit not granted', sharingModel: 'public_read_write', fields: objectFields }, |
| 98 | + ], |
| 99 | + } as never); |
| 100 | + await engine.syncSchemas(); |
| 101 | + await installDefaultOrganization(engine); |
| 102 | + const row = { id: 'r1', name: 'row', notes: 'n0', locked: 'l0', open: 'o0' }; |
| 103 | + await engine.insert(EDIT_ON, [row], SYS); |
| 104 | + await engine.insert(EDIT_OFF, [row], SYS); |
| 105 | + |
| 106 | + const set = PermissionSetSchema.parse({ |
| 107 | + name: SET, |
| 108 | + objects: { |
| 109 | + [EDIT_ON]: { allowRead: true, allowCreate: true, allowEdit: true }, |
| 110 | + [EDIT_OFF]: { allowRead: true, allowCreate: true }, |
| 111 | + }, |
| 112 | + // No entry for `notes` on either object. |
| 113 | + fields: { |
| 114 | + [`${EDIT_ON}.locked`]: { readable: true, editable: false }, |
| 115 | + [`${EDIT_ON}.open`]: { readable: true, editable: true }, |
| 116 | + [`${EDIT_OFF}.locked`]: { readable: true, editable: false }, |
| 117 | + [`${EDIT_OFF}.open`]: { readable: true, editable: true }, |
| 118 | + }, |
| 119 | + }); |
| 120 | + const services: Record<string, unknown> = { |
| 121 | + manifest: { register: vi.fn() }, |
| 122 | + objectql: engine, |
| 123 | + metadata: { |
| 124 | + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, |
| 125 | + list: async () => [MEMBER_DEFAULT, set], |
| 126 | + }, |
| 127 | + }; |
| 128 | + const ctx = { |
| 129 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 130 | + registerService: vi.fn(), |
| 131 | + hook: () => undefined, |
| 132 | + getService: (name: string) => { |
| 133 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 134 | + return services[name]; |
| 135 | + }, |
| 136 | + }; |
| 137 | + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); |
| 138 | + await plugin.init(ctx as never); |
| 139 | + await plugin.start(ctx as never); |
| 140 | + |
| 141 | + const rig: Rig = { |
| 142 | + engine, |
| 143 | + update: (object, data) => outcome(engine.update(object, data, { where: { id: 'r1' }, context: { ...CALLER } } as never)), |
| 144 | + stored: async (object) => |
| 145 | + ((await engine.findOne(object, { where: { id: 'r1' }, context: { isSystem: true } } as never)) ?? null) as Record<string, unknown> | null, |
| 146 | + teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, |
| 147 | + }; |
| 148 | + rigs.push(rig); |
| 149 | + return rig; |
| 150 | +} |
| 151 | + |
| 152 | +describe('a field without an entry follows the object grant (#26597)', () => { |
| 153 | + for (const object of [EDIT_ON, EDIT_OFF]) { |
| 154 | + for (const rule of ['absent', 'readOnly', 'editable'] as const) { |
| 155 | + const field = FIELD_FOR[rule]; |
| 156 | + const allowed = EDIT_VERDICT[object][rule]; |
| 157 | + it(`${object} × ${rule}: an update naming the field is ${allowed ? 'written' : 'refused'}`, async () => { |
| 158 | + const r = await boot(); |
| 159 | + const got = await r.update(object, { [field]: 'changed' }); |
| 160 | + if (allowed) { |
| 161 | + expect(got).toEqual({ kind: 'landed' }); |
| 162 | + expect((await r.stored(object))?.[field]).toBe('changed'); |
| 163 | + return; |
| 164 | + } |
| 165 | + expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); |
| 166 | + // On the Edit-granting object the refusal is the FIELD gate's, naming the field. |
| 167 | + if (object === EDIT_ON) expect(got).toMatchObject({ details: { forbiddenFields: [field] } }); |
| 168 | + // Refused means untouched. |
| 169 | + expect((await r.stored(object))?.[field]).not.toBe('changed'); |
| 170 | + }); |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + it('a field without an entry is served on read wherever the object grants Read', async () => { |
| 175 | + const r = await boot(); |
| 176 | + for (const object of [EDIT_ON, EDIT_OFF]) { |
| 177 | + const seen = (await r.engine.findOne(object, { where: { id: 'r1' }, context: { ...CALLER } } as never)) as Record<string, unknown> | null; |
| 178 | + expect(seen?.notes).toBe('n0'); |
| 179 | + } |
| 180 | + }); |
| 181 | +}); |
0 commit comments