diff --git a/.changeset/21884-fls-mask-object-override.md b/.changeset/21884-fls-mask-object-override.md new file mode 100644 index 00000000000..cb99ac09860 --- /dev/null +++ b/.changeset/21884-fls-mask-object-override.md @@ -0,0 +1,24 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/rest': patch +'@objectstack/runtime': patch +--- + +The object-schema field mask (ADR-0106 D1) judges an action param that names another object's field through `objectOverride` against that object, not the one being served. + +Clause-②: yes (widening) + +**What a user saw.** A `delegated_admin` may invite members, and the invite door admits them, but `GET /meta/object/sys_user` served that principal no `invite_user` action. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`: it names `sys_member.role`. The mask read every param's `field` as a field of the served object, so a caller denied `sys_user.role` lost the whole action. A plain `member` lost it the same way. The member is now served the action too, and still not offered it: the action's `requiresMembershipReach` predicate excludes the member grade. + +**The rule.** A param whose `objectOverride` names another object reads that object's field. It is judged against the caller's readable fields on that object, and it is not a reference to the served object's fields. The action is still dropped when the caller cannot read the field there, and when that object's readable fields cannot be determined (no answer from the security service, a security service that throws, or an object that does not exist). Nothing about the other object is served on a guess. The rest of the param is still read against the served object: `visible`, an option's `visibleWhen`, `defaultValue`, and an explicit `name` that differs from `field`. A `name` that only repeats `field` is read as that field. With `defaultFromRow`, the param also reads `field` from the served object's row, so `field` is judged against the served object too. An exempt caller (platform admin, `isSystem`) is served the whole schema, as before. + +**The API (`@objectstack/metadata-core`), additive.** + +- `relateObjectSchemaMaskPosture(posture, ...documents)` completes a `project` posture for the documents it is about to mask. It reads the caller's readable fields on each other object their action params name through `objectOverride`. It runs after the fetch, because only the document names those objects. It returns every other posture, and any document with no such param, unchanged, and it never throws. +- The `project` member of `ObjectSchemaMaskPosture` gains two optional fields. `relate` asks the posture's question (same caller, same security service) about another object. `resolveObjectSchemaMaskPosture` sets it. `related` holds the answers. A `project` posture built without `related` gets no answers, so `applyObjectSchemaMask` drops every action with such a param. +- `applyObjectSchemaMask` folds each related read it withholds into the fingerprint, written as `object.field`. Two callers who are denied the same fields on the served object but differ on the other object get different validators. An unrestricted caller's ETag is unchanged. +- The shared contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) gains two actions whose params read `contact` fields through `objectOverride`. The contract's projection cases now require the readable one to be served and the denied one to be dropped. An exit that never relates its posture fails the contract by name. + +**Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** These exits relate the posture after the fetch, before the projection: the shared item, layered and list chains, `RestServer`'s cached read and published read, and the runtime dispatcher's mask. The `/meta` diff route masks `fields` only and needs no relate step. + +**Measured on a showcase boot.** We read every object schema (78 objects, by-name read and list read) as five principals: a platform admin, an org owner, an admin, a `delegated_admin` and a `member`. Before and after this change, the only served action that moved is `sys_user.invite_user`, which is now served to the `delegated_admin` and the `member`. This repository has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` and `sys_member.invite_user`'s `email` (on `sys_invitation`). The second was served to all five principals before and after. diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index 51385899287..2e4105ca33b 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -34,7 +34,9 @@ * pointers, name lists, an expression, a field-group predicate, an index, * list views (a column list, a filter, a view KEYED by a field's name, an * object-form column naming one through its nested `prefix` / `summary`), - * actions (a visibility predicate) — and, inside the READABLE fields, a name + * actions (a visibility predicate, and — #21884 — a param that reads a field of + * ANOTHER object through `objectOverride`, which every exit must relate after + * its fetch and judge against THAT object) — and, inside the READABLE fields, a name * list, a `dependsOn`, a predicate, a formula `expression` and an inline grid * (`inlineColumns` by name and by computed `expr`, `inlineAmountField`) that * read a sibling. The inline grid sits on `name` only so that a field every @@ -100,6 +102,12 @@ export const FLS_CONTRACT_OBJECT = { actions: [ { name: 'regrade', label: 'Regrade', type: 'script', visible: 'record.salary_grade != null' }, { name: 'rename', label: 'Rename', type: 'script', visible: 'record.name != null' }, + // [#21884] Params reading `contact`'s fields. The security double answers + // the same set for every object, so `contact.name` is readable wherever + // `account.name` is: an exit that never relates its posture withholds + // `invite` (fail closed) and fails the `retained` half by name. + { name: 'invite', label: 'Invite', type: 'script', params: [{ field: 'name', objectOverride: 'contact' }] }, + { name: 'escalate', label: 'Escalate', type: 'script', params: [{ field: 'bonus_formula', objectOverride: 'contact' }] }, ], fields: { id: { type: 'text', label: 'Id' }, @@ -195,7 +203,10 @@ const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [ compact: { label: 'Compact', type: 'grid', columns: [{ field: 'name', width: 200 }] }, }), }, - { what: 'the action whose predicate reads a readable field', holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename']) }, + { + what: 'the action whose predicate reads a readable field, and the one whose `objectOverride` param reads a field readable on THAT object', + holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite']), + }, ]; /** @@ -229,6 +240,10 @@ const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [ && d?.fields?.name?.inlineColumns?.[2]?.expr === 'bonus_formula * 2' && d?.fields?.name?.inlineAmountField === 'bonus_formula', }, + { + what: 'both actions whose `objectOverride` params read fields readable on THAT object', + holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite', 'escalate']), + }, ]; /** An unmasked answer is the whole fixture — every reference in every position. */ @@ -241,7 +256,7 @@ const RETAINED_UNMASKED: readonly FlsContractRetention[] = [ what: 'every inline-grid column, every list view and every action', holds: (d) => d?.fields?.name?.inlineColumns?.length === 4 && Object.keys(d?.listViews ?? {}).length === 4 && d?.listViews?.compact?.columns?.length === 3 - && d?.actions?.length === 2, + && d?.actions?.length === 4, }, ]; diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts index 9d8257193a3..8d8774ea3db 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -11,7 +11,12 @@ import { describe, it, expect } from 'vitest'; import { FieldSchema, InlineGridColumnSchema, ObjectSchema } from '@objectstack/spec/data'; import { ColumnPrefixSchema, ColumnSummaryConfigSchema, ListColumnSchema } from '@objectstack/spec/ui'; -import { applyObjectSchemaMask, type ObjectSchemaMaskPosture } from './object-schema-fls.js'; +import { + applyObjectSchemaMask, + relateObjectSchemaMaskPosture, + resolveObjectSchemaMaskPosture, + type ObjectSchemaMaskPosture, +} from './object-schema-fls.js'; import { COLUMN_PREFIX_POSITIONS, COLUMN_SUMMARY_POSITIONS, @@ -458,6 +463,146 @@ describe('mentionsDenied is an identifier-token test', () => { }); }); +describe('[ADR-0106 D1] an action param under `objectOverride` is judged against the object it names', () => { + // The shape of `sys_user.invite_user`: `role` is a field of BOTH objects, + // and the param names the member's — not the user's. + const SYS_USER = { + name: 'sys_user', + fields: { id: { type: 'text' }, email: { type: 'email' }, role: { type: 'text' } }, + actions: [ + { + name: 'invite_user', + label: 'Invite User', + type: 'api', + params: [ + { field: 'email', required: true }, + { field: 'role', objectOverride: 'sys_member', required: true }, + ], + }, + { name: 'set_role', label: 'Set Role', type: 'api', params: [{ field: 'role', required: true }] }, + { name: 'mail', label: 'Mail', type: 'api', params: [{ field: 'email' }] }, + ], + }; + + /** A security service answering per object, as plugin-security does. */ + const securityFor = (answers: Record) => ({ + getMetadataReadableFields: async (object: string) => { + const answer = answers[object]; + if (answer === 'throw') throw new Error(`security unhealthy for ${object} (test)`); + return answer === undefined ? undefined : [...answer]; + }, + }); + + /** The exit's sequence: resolve before the fetch, relate after it, mask. */ + const serve = async ( + answers: Record, + document: Record = SYS_USER, + context: Record = { userId: 'u_delegate', systemPermissions: [] }, + ) => { + const posture = await resolveObjectSchemaMaskPosture({ + objectName: String(document.name), context, security: securityFor(answers), enabled: true, + }); + return applyObjectSchemaMask(document, await relateObjectSchemaMaskPosture(posture, document)); + }; + const actionNames = (result: { document: unknown }) => ((result.document as any).actions ?? []).map((a: any) => a.name); + + 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 () => { + const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role', 'user_id'] }); + expect(result.denied).toEqual(['role']); + expect(actionNames(result)).toEqual(['invite_user', 'mail']); + // Served as authored — the param still names the member's `role`. + expect((result.document as any).actions[0].params[1]).toEqual({ field: 'role', objectOverride: 'sys_member', required: true }); + }); + + it('still drops an action whose param names a denied field of THIS object', async () => { + const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }); + expect(actionNames(result)).not.toContain('set_role'); + // An override naming this object IS this object: the same reading. + const self = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }, { + ...SYS_USER, + actions: [{ name: 'self_role', type: 'api', params: [{ field: 'role', objectOverride: 'sys_user' }] }], + }); + expect(actionNames(self)).toEqual([]); + }); + + it('still drops the action when the caller is denied the field on the object the override names', async () => { + const denied = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'user_id'] }); + expect(actionNames(denied)).toEqual(['mail']); + // Even when nothing of THIS object is denied: the read is the other object's. + const thisWhole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'user_id'] }); + expect(thisWhole.denied).toEqual([]); + expect(actionNames(thisWhole)).toEqual(['set_role', 'mail']); + }); + + it('fails closed when the named object\'s readable set cannot be determined — undetermined, throwing, or unknown', async () => { + for (const sysMember of [undefined, 'throw'] as const) { + expect(actionNames(await serve({ sys_user: ['id', 'email', 'role'], sys_member: sysMember }))).toEqual(['set_role', 'mail']); + } + const unknown = await serve({ sys_user: ['id', 'email', 'role'] }, { + ...SYS_USER, + actions: [{ name: 'ghost', type: 'api', params: [{ field: 'role', objectOverride: 'no_such_object' }] }], + }); + expect(actionNames(unknown)).toEqual([]); + // A posture nobody related (an exit that skipped the step) relates nothing: closed too. + const unrelated = applyObjectSchemaMask(SYS_USER, { kind: 'project', readable: new Set(['id', 'email', 'role']) }); + expect(actionNames(unrelated)).toEqual(['set_role', 'mail']); + }); + + it('leaves an exempt caller\'s schema whole, and never asks about the related object', async () => { + let asked = 0; + const posture = await resolveObjectSchemaMaskPosture({ + objectName: 'sys_user', + context: { userId: 'u_admin', systemPermissions: ['setup.access'] }, + security: { getMetadataReadableFields: async () => { asked++; return []; } }, + enabled: true, + }); + const related = await relateObjectSchemaMaskPosture(posture, SYS_USER); + expect(related).toBe(posture); + expect(applyObjectSchemaMask(SYS_USER, related).document).toBe(SYS_USER); + expect(asked).toBe(0); + }); + + it('reads a `name` restating `field` as that field; an explicit other `name`, and a `defaultFromRow` seed, as THIS object\'s', async () => { + const answers = { sys_user: ['id', 'email'], sys_member: ['id', 'role', 'title'] }; + const withParam = (param: Record) => ({ ...SYS_USER, actions: [{ name: 'act', type: 'api', params: [param] }] }); + // The default body key, spelled out, is the same read. + expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']); + // A body key that differs from the field keeps the existing reading: it + // spells a denied field of this object, so the action goes. + expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'title', objectOverride: 'sys_member' })))).toEqual([]); + expect(actionNames(await serve(answers, withParam({ name: 'member_role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']); + // `defaultFromRow` seeds the value from THIS object's row — a read here too. + expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', defaultFromRow: true })))).toEqual([]); + // The rest of the param is still this object's: a predicate over a denied field drops it. + expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', visible: 'record.role != null' })))).toEqual([]); + }); + + it('folds a withheld related read into the fingerprint, so two cohorts never share a validator for different bodies', async () => { + const reads = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }); + const cannot = await serve({ sys_user: ['id', 'email'], sys_member: ['id'] }); + expect(reads.denied).toEqual(cannot.denied); + expect(reads.fingerprint).not.toBe(cannot.fingerprint); + // An unrestricted caller on both objects keeps the empty fingerprint and the same reference. + const whole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'role'] }); + expect(whole.fingerprint).toBe(''); + expect(whole.document).toBe(SYS_USER); + }); + + it('relates each named object once, across documents, and leaves a document with no such read alone', async () => { + const asked: string[] = []; + const posture = await resolveObjectSchemaMaskPosture({ + objectName: 'sys_user', + context: { userId: 'u' }, + security: { getMetadataReadableFields: async (object: string) => { asked.push(object); return ['id', 'role']; } }, + enabled: true, + }); + expect(await relateObjectSchemaMaskPosture(posture, { name: 'sys_user', actions: [] })).toBe(posture); + const related = await relateObjectSchemaMaskPosture(posture, SYS_USER, SYS_USER); + expect(await relateObjectSchemaMaskPosture(related, SYS_USER)).toBe(related); + expect(asked).toEqual(['sys_user', 'sys_member']); + }); +}); + /** The keys a Zod object schema declares. */ function declaredKeys(schema: unknown): string[] { const shape = (schema as { shape?: Record }).shape; @@ -509,7 +654,12 @@ describe('[ADR-0106] the shared contract table, driven through the bare projecti for (const testCase of OBJECT_SCHEMA_MASK_CASES.filter((c) => c.expect.kind === 'fields')) { it(testCase.id, () => { const readable = testCase.readable as readonly string[]; - const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, project([...readable])); + // Related as an exit relates it: the contract's double answers the + // same set for every object, `contact` included (#21884). + const posture: ObjectSchemaMaskPosture = { + kind: 'project', readable: new Set(readable), related: new Map([['contact', new Set(readable)]]), + }; + const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, posture); assertObjectSchemaMaskCase('applyObjectSchemaMask', testCase, { kind: 'document', document }); }); } diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index ec90791746d..d7455eadbd5 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -36,6 +36,13 @@ * such a position: it is declared on the child's own `master_detail` field and * names the child's own columns, so it is scrubbed against this object. * + * An action param's `field` under `objectOverride` is the one such position + * that is still JUDGED rather than kept: it is a field of the object the + * override names, so it is read against the caller's readable set on THAT + * object ({@link MaskScope.related}), and the action is dropped when the field + * is not in it — or when that set could not be determined (fail closed). It is + * not a reference to this object's fields (see {@link actionParamReadsDenied}). + * * ## Fail-safe by construction * * A key neither table classifies is treated as an expression: it is served @@ -68,7 +75,12 @@ * - An action param whose `name` equals a denied field drops the action, * although a param name is a request-body key rather than a field. It is * kept a reference on purpose: it defaults to the param's `field`, and the - * body key it names is commonly the field the action writes. + * body key it names is commonly the field the action writes. Under + * `objectOverride` a `name` that restates `field` is that field — a field of + * the other object, judged there; an explicit `name` that differs from + * `field` is a body key whose owner nothing here can verify, so it keeps + * this reading. So does `field` itself when `defaultFromRow` seeds the + * param from THIS object's row, which reads it here too. * - A name, pointer or field-keyed key is matched on its root segment as * well as whole, so a dotted path through a denied lookup (`owner.city` * with `owner` denied) is a reference to it. @@ -91,7 +103,28 @@ /** A scrubber's answer meaning "delete this key / drop this entry". */ const REMOVE: unique symbol = Symbol('remove'); type Scrubbed = unknown | typeof REMOVE; -type Scrub = (value: unknown, denied: ReadonlySet) => Scrubbed; + +/** + * What a scrub needs beyond THIS object's denied set: the document's own object + * name, and the caller's readable field set on each OTHER object an action + * param reads through `objectOverride`. Only the action-param reading consults + * it; every other scrub ignores it. + */ +export interface MaskScope { + /** The served document's `name` — an `objectOverride` naming it is this object. */ + readonly objectName?: string; + /** + * The caller's readable fields on another object, by name. `undefined`, or + * an object missing from the map, means the set could not be determined — + * a param reading that object then drops its action (fail closed). + */ + readonly related: ReadonlyMap | undefined>; +} + +/** No related object resolved: every `objectOverride` read fails closed. */ +const NO_RELATED: MaskScope = { related: new Map() }; + +type Scrub = (value: unknown, denied: ReadonlySet, scope?: MaskScope) => Scrubbed; const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/g; @@ -304,12 +337,12 @@ const names: Scrub = nameList(); /** An array whose elements are scrubbed one by one; `REMOVE` drops the element. */ function arrayOf(element: Scrub): Scrub { - return (value, denied) => { + return (value, denied, scope) => { if (!Array.isArray(value)) return unclassified(value, denied); let changed = false; const out: unknown[] = []; for (const entry of value) { - const next = element(entry, denied); + const next = element(entry, denied, scope); if (next !== entry) changed = true; if (next !== REMOVE) out.push(next); } @@ -328,9 +361,9 @@ const ruleEntries: Scrub = arrayOf((entry, denied) => (mentionsDenied(entry, den /** An object block scrubbed key by key; an unclassified key goes the {@link unclassified} way. */ function block(table: Readonly>): Scrub { - return (value, denied) => { + return (value, denied, scope) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); - return scrubRecord(value as Record, table, denied); + return scrubRecord(value as Record, table, denied, scope); }; } @@ -341,7 +374,7 @@ function block(table: Readonly>): Scrub { * (`listViews`), where a key spelling a denied field discloses it. */ function recordOf(entry: Scrub, keyIsName = false): Scrub { - return (value, denied) => { + return (value, denied, scope) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); let changed = false; const out: Record = {}; @@ -350,7 +383,7 @@ function recordOf(entry: Scrub, keyIsName = false): Scrub { changed = true; continue; } - const next = entry(inner, denied); + const next = entry(inner, denied, scope); if (next !== inner) changed = true; if (next !== REMOVE) out[key] = next; } @@ -359,45 +392,129 @@ function recordOf(entry: Scrub, keyIsName = false): Scrub { }; } +/** Does a presentation entry's `key` read a denied field? */ +type EntryRead = (value: unknown, denied: ReadonlySet, scope: MaskScope) => boolean; + +/** The default reading of a non-list key: every non-prose mention, as {@link mentionsDenied} reads it. */ +const readsAsThisObject = (key: string): EntryRead => (value, denied) => ( + // Read as `{ [key]: value }` so the key's own kind applies: prose and + // vocabulary keys are skipped, `filter` / `patch` are field-keyed. + mentionsDenied({ [key]: value }, denied, 'skip', 'classified') +); + /** * A presentation entry (list view, action): its column-style name lists are * filtered, and any OTHER non-prose mention of a denied field — a filter, a * sort, a visibility predicate, a param — drops the entry, because serving it - * without that part would silently change what it does. + * without that part would silently change what it does. `reads` overrides how + * one key is read; every other key is read as this object's + * ({@link readsAsThisObject}). */ -function presentationEntry(lists: Readonly>): Scrub { - return (value, denied) => { +function presentationEntry( + lists: Readonly>, + reads: Readonly> = {}, +): Scrub { + return (value, denied, scope = NO_RELATED) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); const rec = value as Record; let changed = false; const out: Record = {}; for (const [key, inner] of Object.entries(rec)) { if (Object.prototype.hasOwnProperty.call(lists, key)) { - const next = lists[key]!(inner, denied); + const next = lists[key]!(inner, denied, scope); if (next !== inner) changed = true; if (next !== REMOVE) out[key] = next; continue; } - // Read as `{ [key]: inner }` so the key's own kind applies: prose and - // vocabulary keys are skipped, `filter` / `patch` are field-keyed. - if (mentionsDenied({ [key]: inner }, denied, 'skip', 'classified')) return REMOVE; + const read = Object.prototype.hasOwnProperty.call(reads, key) ? reads[key]! : readsAsThisObject(key); + if (read(inner, denied, scope)) return REMOVE; out[key] = inner; } return changed ? out : value; }; } +/** + * [#21884] Does one action param read a field the caller is denied? + * + * A param with no `objectOverride` (or one naming this object) is read as every + * other key of an action is: any non-prose mention of a denied field of THIS + * object, `field` and `name` included. + * + * With `objectOverride` naming ANOTHER object, its `field` is a field of that + * object. It is judged against the caller's readable set THERE + * ({@link MaskScope.related}) and is not a reference to this object's fields: a + * field absent from that set, or a set that could not be determined, drops the + * action — the ADR-0106 rule, applied to the object the field belongs to. The + * override's value is an object name, never a field. Everything else on the + * param is still read against this object — `visible`, `options[].visibleWhen`, + * `defaultValue`, an explicit `name` that differs from `field` (a body key whose + * owner cannot be verified here) — and so is `field` (with a `name` restating + * it) when `defaultFromRow` seeds it from this object's row, which is a second + * read of a field of this object. + */ +export function actionParamReadsDenied(param: unknown, denied: ReadonlySet, scope: MaskScope): boolean { + const asThisObject = (value: unknown): boolean => mentionsDenied({ params: [value] }, denied, 'skip', 'classified'); + if (!param || typeof param !== 'object' || Array.isArray(param)) return asThisObject(param); + const rec = param as Record; + const other = rec.objectOverride; + if (typeof other !== 'string' || other === scope.objectName) return asThisObject(rec); + + const rest: Record = { ...rec }; + delete rest.objectOverride; + if (typeof rec.field === 'string') { + if (!scope.related.get(other)?.has(rec.field)) return true; + if (rec.defaultFromRow !== true) { + delete rest.field; + if (rest.name === rec.field) delete rest.name; + } + } + return asThisObject(rest); +} + +/** An action's `params`: the action reads a denied field when any one param does. */ +const actionParams: EntryRead = (value, denied, scope) => ( + Array.isArray(value) + ? value.some((param) => actionParamReadsDenied(param, denied, scope)) + : readsAsThisObject('params')(value, denied, scope) +); + +/** + * [#21884] Every `(object, field)` the document's action params read through an + * `objectOverride` naming ANOTHER object — the reads {@link actionParamReadsDenied} + * judges against {@link MaskScope.related}. In document order, duplicates kept. + */ +export function objectOverrideReads(document: unknown): Array<{ object: string; field: string }> { + if (!document || typeof document !== 'object' || Array.isArray(document)) return []; + const rec = document as Record; + const reads: Array<{ object: string; field: string }> = []; + if (!Array.isArray(rec.actions)) return reads; + for (const action of rec.actions) { + const params = action && typeof action === 'object' ? (action as Record).params : undefined; + if (!Array.isArray(params)) continue; + for (const param of params) { + if (!param || typeof param !== 'object' || Array.isArray(param)) continue; + const { objectOverride, field } = param as Record; + if (typeof objectOverride === 'string' && objectOverride !== rec.name && typeof field === 'string') { + reads.push({ object: objectOverride, field }); + } + } + } + return reads; +} + /** Apply `table` to every key of `rec`; same reference when nothing changed. */ function scrubRecord( rec: Record, table: Readonly>, denied: ReadonlySet, + scope?: MaskScope, ): Record { let changed = false; const out: Record = {}; for (const [key, inner] of Object.entries(rec)) { const scrub = Object.prototype.hasOwnProperty.call(table, key) ? table[key]! : unclassified; - const next = scrub(inner, denied); + const next = scrub(inner, denied, scope); if (next !== inner) changed = true; if (next !== REMOVE) out[key] = next; } @@ -612,7 +729,9 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { // Presentation entries. // A view KEYED by a denied field's name goes too: the key is shown to the caller. listViews: recordOf(presentationEntry(LIST_VIEW_NAME_LISTS), true), - actions: arrayOf(presentationEntry({})), + // An action's `params` are read one by one: a param's `field` under + // `objectOverride` belongs to the object it names (see `actionParamReadsDenied`). + actions: arrayOf(presentationEntry({}, { params: actionParams })), ...PROTECTION_ENVELOPE, }; @@ -620,14 +739,24 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { * Remove every reference to a `denied` field from an object document whose * `fields` map has ALREADY been projected (so every field left in it is * readable). Returns the same reference when nothing referenced a denied field. + * + * [#21884] `related` is the caller's readable set on each OTHER object an + * action param reads through `objectOverride` (see {@link MaskScope.related}); + * a read of an object it does not resolve drops its action. So a document with + * such a read is walked even when nothing of THIS object is denied. */ export function maskDeniedFieldReferences( document: Record, denied: ReadonlySet, + related: MaskScope['related'] = NO_RELATED.related, ): Record { - if (denied.size === 0) return document; + if (denied.size === 0 && objectOverrideReads(document).length === 0) return document; + const scope: MaskScope = { + ...(typeof document.name === 'string' ? { objectName: document.name } : {}), + related, + }; const { fields, ...rest } = document; - const scrubbedRest = scrubRecord(rest, OBJECT_REFERENCE_POSITIONS, denied); + const scrubbedRest = scrubRecord(rest, OBJECT_REFERENCE_POSITIONS, denied, scope); let scrubbedFields = fields; if (fields && typeof fields === 'object' && !Array.isArray(fields)) { diff --git a/packages/metadata-core/src/object-schema-fls.test.ts b/packages/metadata-core/src/object-schema-fls.test.ts index 4401ab8446b..c24332b018e 100644 --- a/packages/metadata-core/src/object-schema-fls.test.ts +++ b/packages/metadata-core/src/object-schema-fls.test.ts @@ -278,7 +278,7 @@ describe('[ADR-0106 D7] the metadata-plane query is preferred when the service o getMetadataReadableFields: () => ['id'], }, }); - expect(posture).toEqual({ kind: 'project', readable: new Set(['id']) }); + expect(posture).toMatchObject({ kind: 'project', readable: new Set(['id']) }); }); it('falls back to `getReadableFields` on a service that predates D7', async () => { @@ -288,6 +288,6 @@ describe('[ADR-0106 D7] the metadata-plane query is preferred when the service o enabled: true, security: { getReadableFields: () => ['id'] }, }); - expect(posture).toEqual({ kind: 'project', readable: new Set(['id']) }); + expect(posture).toMatchObject({ kind: 'project', readable: new Set(['id']) }); }); }); diff --git a/packages/metadata-core/src/object-schema-fls.ts b/packages/metadata-core/src/object-schema-fls.ts index af3c4e5e663..e06a1f06692 100644 --- a/packages/metadata-core/src/object-schema-fls.ts +++ b/packages/metadata-core/src/object-schema-fls.ts @@ -32,9 +32,15 @@ * * ```ts * const posture = await resolveObjectSchemaMaskPosture({ objectName, context, security, enabled }); - * const masked = applyObjectSchemaMask(document, posture); // fetch → mask → send + * const masked = applyObjectSchemaMask(document, await relateObjectSchemaMaskPosture(posture, document)); + * // fetch → relate → mask → send * ``` * + * The relate step (#21884) asks the same caller's question about the OTHER + * objects the fetched document's action params read through `objectOverride` — + * only the document names them, so it runs after the fetch. It is a no-op for + * every posture but `project` and every document without such a read. + * * `resolveObjectSchemaMaskPosture` is where ADR-0106 D6's three-tier failure * posture is decided, ONCE, so no exit can invent a fourth answer: * @@ -58,7 +64,7 @@ * self-invalidates the stale 304. */ -import { maskDeniedFieldReferences } from './object-schema-fls-references.js'; +import { maskDeniedFieldReferences, objectOverrideReads } from './object-schema-fls-references.js'; /** * [#6603 / ADR-0066 D1] The capabilities that let a caller **write** an object @@ -155,7 +161,29 @@ export type ObjectSchemaMaskPosture = | { kind: 'passthrough'; reason: ObjectSchemaMaskPassthroughReason } /** ADR-0106 D6 tier 2 — `getReadableFields` could not answer. */ | { kind: 'undetermined' } - | { kind: 'project'; readable: ReadonlySet }; + | { + kind: 'project'; + readable: ReadonlySet; + /** + * [#21884] The same caller's readable fields on each OTHER object the + * document's action params read through `objectOverride` — filled AFTER + * the fetch by {@link relateObjectSchemaMaskPosture}, because only the + * document says which objects those are. `undefined` for an object + * whose set could not be determined. An object missing from the map + * (or no map at all) reads the same way: a param reading it drops its + * action — fail closed, never served on a guess. + */ + related?: ReadonlyMap | undefined>; + /** + * [#21884] This posture's own question — same caller, same service — + * asked about another object: its readable fields, or `undefined` when + * they cannot be determined. Set by + * {@link resolveObjectSchemaMaskPosture}; read by + * {@link relateObjectSchemaMaskPosture}. A posture without it relates + * nothing. + */ + relate?: (objectName: string) => Promise | undefined>; + }; /** A posture that serves the document unchanged and needs no fingerprint. */ export const OBJECT_SCHEMA_MASK_NOT_APPLICABLE: ObjectSchemaMaskPosture = @@ -323,7 +351,71 @@ export async function resolveObjectSchemaMaskPosture(input: { return { kind: 'undetermined' }; } - return { kind: 'project', readable: new Set(readable) }; + // [#21884] The same question about another object, for the action params + // that read one through `objectOverride`. Unlike this object's own D6 + // tiers, an answer it cannot get WITHHOLDS what depends on it — the + // actions reading that object — rather than opening the document or + // refusing it: nothing about the other object is served on a guess, and + // the rest of this document does not depend on it. Said once per object. + const relate = async (related: string): Promise | undefined> => { + let answer: string[] | undefined; + try { + answer = await ask(related, context); + } catch (error) { + telemetry?.warn?.( + '[ADR-0106] field visibility on a related object could not be evaluated — ' + + 'the actions whose params read it through `objectOverride` are withheld', + { object: objectName, related, decision: 'withhold-actions', error: String(error) }, + ); + return undefined; + } + if (!Array.isArray(answer)) { + telemetry?.warn?.( + '[ADR-0106] field visibility on a related object undetermined — ' + + 'the actions whose params read it through `objectOverride` are withheld', + { object: objectName, related, decision: 'withhold-actions' }, + ); + telemetry?.counter?.(OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, { object: related }); + return undefined; + } + return new Set(answer); + }; + + return { kind: 'project', readable: new Set(readable), relate }; +} + +/** + * [#21884] Complete a `project` posture for the document it is about to mask: + * resolve the caller's readable fields on every OTHER object the document's + * action params read through `objectOverride` (ADR-0106 D1 judges such a param + * against the object it names, not this one). + * + * Called by every exit AFTER its fetch and BEFORE {@link applyObjectSchemaMask} + * — only the document says which objects those are, so this half cannot ride + * the posture resolved before the fetch (D3). Any posture but `project`, and a + * document with no such read, comes back as given (same reference). Objects + * already related are not asked again, so one posture related over several + * documents (a layered read's layers) asks each object once. Never throws: an + * object it cannot resolve is related as `undefined`, which withholds the + * actions that read it. + */ +export async function relateObjectSchemaMaskPosture( + posture: ObjectSchemaMaskPosture, + ...documents: unknown[] +): Promise { + if (posture.kind !== 'project') return posture; + const targets = new Set(); + for (const document of documents) { + for (const read of objectOverrideReads(document)) { + if (!posture.related?.has(read.object)) targets.add(read.object); + } + } + if (targets.size === 0) return posture; + const related = new Map(posture.related ?? []); + for (const object of targets) { + related.set(object, posture.relate ? await posture.relate(object) : undefined); + } + return { ...posture, related }; } /** The result of projecting one document. */ @@ -332,7 +424,11 @@ export interface ObjectSchemaMaskResult { document: T; /** Field names removed, sorted. Empty for an unrestricted caller. */ denied: readonly string[]; - /** {@link objectFieldVisibilityFingerprint} over {@link denied}; `''` when nothing was removed. */ + /** + * {@link objectFieldVisibilityFingerprint} over {@link denied} and the + * `objectOverride` reads withheld (as `object.field`, #21884); `''` when + * nothing was removed. + */ fingerprint: string; /** * True when the projection would have left the schema with **no** fields at @@ -377,7 +473,15 @@ export function applyObjectSchemaMask(document: T, posture: ObjectSchemaMaskP for (const name of Object.keys(declared)) { if (!posture.readable.has(name)) denied.push(name); } - if (denied.length === 0) return unchanged; + // [#21884] The `objectOverride` reads this caller cannot make — each one + // withholds its action, so the served body varies with them as surely as + // with `denied`, and the fingerprint must too (D3: a cohort shares 304s + // only when it shares the body). Qualified `object.field`, which no field + // name can collide with. + const relatedDenied = [...new Set(objectOverrideReads(rec) + .filter((read) => !posture.related?.get(read.object)?.has(read.field)) + .map((read) => `${read.object}.${read.field}`))]; + if (denied.length === 0 && relatedDenied.length === 0) return unchanged; denied.sort(); const kept: Record = {}; @@ -388,12 +492,16 @@ export function applyObjectSchemaMask(document: T, posture: ObjectSchemaMaskP // D1's "whole" covers the field's references too: a validation rule over it, // a role pointer naming it, a readable field's formula reading it, … — see // `object-schema-fls-references.ts` for every position and its disposition. - const projected = maskDeniedFieldReferences({ ...rec, fields: kept }, new Set(denied)); + const projected = maskDeniedFieldReferences( + { ...rec, fields: kept }, + new Set(denied), + posture.related ?? new Map(), + ); return { document: projected as unknown as T, denied, - fingerprint: objectFieldVisibilityFingerprint(denied), + fingerprint: objectFieldVisibilityFingerprint([...denied, ...relatedDenied]), emptied: Object.keys(kept).length === 0, }; } @@ -405,7 +513,9 @@ export function applyObjectSchemaMask(document: T, posture: ObjectSchemaMaskP * * Empty denied set → empty string, which is what makes an unrestricted caller's * ETag byte-identical to the pre-ADR one (see - * {@link foldVisibilityFingerprintIntoEtag}). + * {@link foldVisibilityFingerprintIntoEtag}). {@link applyObjectSchemaMask} + * hands it the `objectOverride` reads it withheld too, qualified as + * `object.field` (#21884), since those also change the served body. * * FNV-1a/32, hex, order-independent (the input is sorted first): two callers in * the same cohort must hash equal whatever order their sets were computed in. diff --git a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts index 73db537b3f7..cb4d5ca9590 100644 --- a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts @@ -115,6 +115,8 @@ describe('org-admin affordances follow the membership grade (served metadata × const tokens = {} as Record; const sessionUser = {} as Record>; const served = {} as Record>; + /** The field names each grade is served per object — what the metadata-plane field mask left. */ + const servedFields = {} as Record>; let features: Record; /** A representative row per object — the binding a row action is evaluated against. */ const rowOf = {} as Record>; @@ -181,11 +183,13 @@ describe('org-admin affordances follow the membership grade (served metadata × expect(session.status).toBe(200); sessionUser[grade] = ((await session.json()) as { user: Record }).user; served[grade] = new Map(); + servedFields[grade] = {}; for (const object of OBJECTS) { const meta = await stack.apiAs(tokens[grade], 'GET', `/meta/object/${object}`); expect(meta.status, `${grade} reads /meta/object/${object}`).toBe(200); - const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + const body = (await meta.json()) as { item?: { actions?: ServedAction[]; fields?: Record } }; for (const action of body.item?.actions ?? []) served[grade].set(`${object}.${action.name}`, action); + servedFields[grade][object] = Object.keys(body.item?.fields ?? {}); } } }, 240_000); @@ -224,16 +228,6 @@ describe('org-admin affordances follow the membership grade (served metadata × return gateAdmits(grade, site); }; - /** - * The one site the metadata-plane field mask (ADR-0106) withholds below - * tenant-admin grade: `sys_user.invite_user`'s `role` param names - * `sys_user.role`, a field those callers cannot read, so the whole action is - * dropped from their `/meta/object/sys_user` — independently of this gate. - * Every other site must be served to every grade, or a verdict below would be - * the mask's rather than the gate's. - */ - const MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user']; - it('the session face carries each grade under its projected name, and no other grade', () => { // The capability half of every composed predicate is ON here, so each // verdict below is decided by the grade term alone. @@ -251,12 +245,30 @@ describe('org-admin affordances follow the membership grade (served metadata × expect(ALL.filter((site) => gateAdmits(grade, site))).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site))); }); + // Every site is served to every grade, so each verdict below is the gate's + // rather than the metadata-plane field mask's. it.each(Object.keys(EXPECTED) as Grade[])('%s is offered, on its own served metadata, exactly those it is served', (grade) => { const unserved = ALL.filter((site) => !served[grade].has(site)); - const tenantAdmin = grade === 'owner' || grade === 'admin'; - expect(unserved).toEqual(tenantAdmin ? [] : MASKED_BELOW_TENANT_ADMIN); + expect(unserved).toEqual([]); const shown = ALL.filter((site) => offered(grade, site)); - expect(shown).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site) && !unserved.includes(site))); + expect(shown).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site))); + }); + + it('a delegated_admin is offered Invite User on sys_user; a plain member is not — by the reach gate, not the field mask', () => { + // `invite_user`'s `role` param names `sys_member.role` through + // `objectOverride`. Neither grade is served `sys_user.role`, so a mask that + // read the param as THIS object's field withheld the action from both. Both + // ARE served `sys_member.role`, the field the param actually names. + for (const grade of ['delegated_admin', 'member'] as const) { + expect(servedFields[grade].sys_user, `${grade} is not served sys_user.role`).not.toContain('role'); + expect(servedFields[grade].sys_member, `${grade} is served sys_member.role`).toContain('role'); + expect(served[grade].has('sys_user.invite_user'), `${grade} is served sys_user.invite_user`).toBe(true); + } + expect(offered('delegated_admin', 'sys_user.invite_user')).toBe(true); + // The member is served the same action and is still not offered it: the + // served `requiresMembershipReach` predicate excludes its grade. + expect(gateAdmits('member', 'sys_user.invite_user')).toBe(false); + expect(offered('member', 'sys_user.invite_user')).toBe(false); }); it('a plain member sees none of them on the member, invitation and team lists', () => { diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index bfd21867c08..b9e94d4bd88 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -66,6 +66,7 @@ import { ObjectSchemaMaskEvaluationError, applyObjectSchemaMask, organizationIdForMetaRead, + relateObjectSchemaMaskPosture, type ObjectSchemaMaskPosture, } from '@objectstack/metadata-core'; import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; @@ -2329,6 +2330,11 @@ export const META_UNDETERMINED_CACHE_CONTROL = 'private, no-store'; * posture's schema went out with no `Cache-Control` at all, where `RestServer` * answers `private, no-store` — the header the ADR (and the posture's own * `warn` line) promises. + * + * [#21884] Hand it the posture RELATED to this document + * (`relateObjectSchemaMaskPosture`): an action param reading another object + * through `objectOverride` is judged against that object, and a posture nobody + * related withholds such an action (fail closed). */ export function projectMetaObjectSchema( posture: ObjectSchemaMaskPosture, @@ -2364,7 +2370,9 @@ async function maskMetaObjectList( if (maskError instanceof ObjectSchemaMaskEvaluationError) return { ok: false, object: objectName }; throw maskError; } - const masked = projectMetaObjectSchema(posture, item); + // [#21884] The params that read another object through `objectOverride` + // are judged against THAT object — related here, after the fetch. + const masked = projectMetaObjectSchema(await relateObjectSchemaMaskPosture(posture, item), item); if (!masked.ok) return { ok: false, object: objectName }; cacheControl ??= masked.cacheControl; projected.push(masked.document); @@ -2718,8 +2726,10 @@ export function createMetaItemAnswer( visible = resolveDocLocale(visible as any, sources.requestLocale()); } - // 4. [ADR-0106 D1/D5(1)] The mask, under the posture resolved before the fetch. - const masked = projectMetaObjectSchema(maskPosture, visible); + // 4. [ADR-0106 D1/D5(1)] The mask, under the posture resolved before the + // fetch — related [#21884] to the objects this document's action params + // read through `objectOverride`, which only the fetched document names. + const masked = projectMetaObjectSchema(await relateObjectSchemaMaskPosture(maskPosture, visible), visible); if (!masked.ok) return { kind: 'mask-fault', object: name }; visible = masked.document; @@ -2898,11 +2908,15 @@ export function createMetaLayeredAnswer( } } - // 3. [ADR-0106 D5(4)] The mask, on every layer. + // 3. [ADR-0106 D5(4)] The mask, on every layer — under one posture + // related [#21884] over every layer, so each object an `objectOverride` + // param names is asked about once. + const layerDocuments = META_ITEM_MASKED_LAYERS.map((layer) => (served.has(layer) ? served.get(layer) : layered?.[layer])); + const layerPosture = await relateObjectSchemaMaskPosture(maskPosture, ...layerDocuments); let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; for (const layer of META_ITEM_MASKED_LAYERS) { const document = served.has(layer) ? served.get(layer) : layered?.[layer]; - const masked = projectMetaObjectSchema(maskPosture, document); + const masked = projectMetaObjectSchema(layerPosture, document); if (!masked.ok) return { kind: 'mask-fault', object: name }; cacheControl ??= masked.cacheControl; if (masked.document !== document) served.set(layer, masked.document); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e49a3d11023..60fecbc93af 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -57,6 +57,7 @@ import { isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, normalizeIfNoneMatch, + relateObjectSchemaMaskPosture, resolveObjectSchemaMaskPosture, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, type ObjectSchemaMaskPosture, @@ -4014,6 +4015,11 @@ export class RestServer { * schema with no fields at all — `getReadableFields` answers `[]` only where * its own posture read failed closed (#3545), and D6 rules an empty-fields * `200` out ("silently wrong UI **and** cacheable poison"). + * + * [#21884] Hand it the posture related to `document` + * (`relateObjectSchemaMaskPosture`) wherever the document can carry + * actions: an action param reading another object through `objectOverride` + * is judged against that object, and an unrelated posture withholds it. */ private maskObjectDocument( res: any, @@ -6704,7 +6710,9 @@ export class RestServer { let cachedDocument: any = result.data; let visibilityFingerprint = ''; if (maskPosture.kind === 'project') { - const masked = this.maskObjectDocument(res, maskPosture, req.params.name, cachedDocument); + // [#21884] Related to the fetched document: its `objectOverride` params name other objects. + const related = await relateObjectSchemaMaskPosture(maskPosture, cachedDocument); + const masked = this.maskObjectDocument(res, related, req.params.name, cachedDocument); if (!masked) return; cachedDocument = masked.document; visibilityFingerprint = masked.fingerprint; @@ -8585,7 +8593,8 @@ export class RestServer { } let served = verdict.document; if (publishedMaskPosture.kind === 'project') { - const masked = this.maskObjectDocument(res, publishedMaskPosture, name, served); + const related = await relateObjectSchemaMaskPosture(publishedMaskPosture, served); // [#21884] + const masked = this.maskObjectDocument(res, related, name, served); if (!masked) return; served = masked.document; } else if (publishedMaskPosture.kind === 'undetermined') { diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index d0c56d1d38f..f3f2cb560cc 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -24,6 +24,7 @@ import { ObjectSchemaMaskEvaluationError, isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, + relateObjectSchemaMaskPosture, resolveObjectSchemaMaskPosture, type ObjectSchemaMaskPosture, // [#8805] Moved to `metadata-core` so the REST `/meta` write doors decide @@ -391,7 +392,8 @@ async function maskObjectSchema( if (error instanceof ObjectSchemaMaskEvaluationError) return { ok: false }; throw error; } - return projectMetaObjectSchema(posture, document); + // [#21884] Related to the fetched document: its `objectOverride` params name other objects. + return projectMetaObjectSchema(await relateObjectSchemaMaskPosture(posture, document), document); } /**