From d15b33c8225832f5d768e13531bd3e609bb75a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:08:17 +0000 Subject: [PATCH 1/9] fix(metadata-core): the object-schema field mask removes a denied field's references too (ADR-0106 D1) Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../src/object-schema-fls-contract.ts | 197 ++++++++- .../src/object-schema-fls-references.test.ts | 269 ++++++++++++ .../src/object-schema-fls-references.ts | 387 ++++++++++++++++++ .../metadata-core/src/object-schema-fls.ts | 16 +- 4 files changed, 852 insertions(+), 17 deletions(-) create mode 100644 packages/metadata-core/src/object-schema-fls-references.test.ts create mode 100644 packages/metadata-core/src/object-schema-fls-references.ts diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index 4f9b2438225..365e242666a 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -24,13 +24,66 @@ * contract, several implementations, one table. */ -/** The object schema every exit serves while the contract runs. */ +/** + * The object schema every exit serves while the contract runs. + * + * Besides the four fields, it names them in every KIND of position an object + * document has outside `fields` (ADR-0106 D1 removes a field WHOLE, so a + * reference is part of the field): a rule entry whose condition reads one, a + * rule entry that names one only through its `fields` pointer list, role + * pointers, name lists, an expression, a field-group predicate, an index — + * and, inside the READABLE fields, a name list, a `dependsOn`, a predicate and + * a formula that read a sibling. Each position also carries a reference to a + * field EVERY restricted case can read, so an over-eager mask that deletes the + * position wholesale fails the `retained` half of the case. + */ export const FLS_CONTRACT_OBJECT = { name: 'account', label: 'Account', + nameField: 'name', + stageField: 'salary_grade', + titleFormat: '{name} ({salary_grade})', + highlightFields: ['name', 'salary_grade', 'bonus_formula'], + searchableFields: ['name', 'salary_grade'], + fieldGroups: [ + { key: 'compensation', label: 'Compensation', visibleWhen: 'record.salary_grade != null' }, + { key: 'general', label: 'General', visibleWhen: 'record.name != null' }, + ], + indexes: [ + { fields: ['salary_grade', 'name'] }, + { fields: ['name'] }, + ], + validations: [ + { + type: 'script', + name: 'graded_account_needs_name', + condition: { dialect: 'cel', source: 'record.salary_grade != null && record.name == null' }, + message: 'A graded account needs a name.', + }, + { + type: 'cross_field', + name: 'bonus_needs_name', + fields: ['bonus_formula', 'name'], + condition: 'record.name == null', + message: 'A bonus needs a name.', + }, + { + type: 'script', + name: 'name_not_blank', + condition: 'record.name == ""', + message: 'Name must not be blank.', + }, + ], fields: { id: { type: 'text', label: 'Id' }, - name: { type: 'text', label: 'Name' }, + name: { + type: 'text', + label: 'Name', + relatedListColumns: ['name', 'salary_grade'], + dependsOn: ['id', 'salary_grade'], + readonlyWhen: 'record.bonus_formula != null', + requiredWhen: 'record.id != null', + }, // Everything ADR-0106's Context section names as leaking with the field: // a sensitive enumeration, the capability guarding it, and a formula // that is itself business IP. @@ -55,10 +108,86 @@ export const FLS_CONTRACT_ALL_FIELDS = ['id', 'name', 'salary_grade', 'bonus_for /** What the exit's `security` double answers, or that it throws. */ export type FlsContractReadable = readonly string[] | undefined | 'throw'; +/** + * One thing a masked document must STILL say — the control half of a + * projection case. Without it a mask that deleted every position wholesale + * (all validations, every pointer) would pass the absence checks while serving + * a schema stripped of everything the caller IS entitled to. + */ +export interface FlsContractRetention { + /** What is kept, in words — this is what a failure names. */ + readonly what: string; + /** Holds on the served document. */ + readonly holds: (document: any) => boolean; +} + +/** Names of the validation rules a served document carries. */ +const ruleNames = (document: any): string[] => (Array.isArray(document?.validations) + ? document.validations.map((rule: any) => rule?.name) + : []); + +const sameList = (actual: unknown, expected: readonly unknown[]): boolean => + JSON.stringify(actual) === JSON.stringify(expected); + +/** + * What a caller who reads `id` and `name` only is still served: every + * reference to those two survives, every position keeps its non-denied part. + */ +const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [ + { what: 'the `nameField` pointer to a readable field', holds: (d) => d?.nameField === 'name' }, + { what: 'the validation rule over readable fields only', holds: (d) => sameList(ruleNames(d), ['name_not_blank']) }, + { what: '`highlightFields`, minus the denied entries', holds: (d) => sameList(d?.highlightFields, ['name']) }, + { what: '`searchableFields`, minus the denied entry', holds: (d) => sameList(d?.searchableFields, ['name']) }, + { what: 'the index over readable fields only', holds: (d) => sameList(d?.indexes, [{ fields: ['name'] }]) }, + { + what: 'both field groups, and the predicate that reads a readable field', + holds: (d) => sameList(d?.fieldGroups?.map((g: any) => g?.key), ['compensation', 'general']) + && d?.fieldGroups?.[1]?.visibleWhen === 'record.name != null', + }, + { + what: 'the readable field\'s own name lists, minus the denied entries', + holds: (d) => sameList(d?.fields?.name?.relatedListColumns, ['name']) + && sameList(d?.fields?.name?.dependsOn, ['id']), + }, + { what: 'the readable field\'s predicate over a readable sibling', holds: (d) => d?.fields?.name?.requiredWhen === 'record.id != null' }, +]; + +/** + * What a caller who reads everything but `salary_grade` is still served — + * including the readable formula field, minus only the formula that reads the + * denied one. + */ +const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [ + { + what: 'the validation rules that do not read the denied field', + holds: (d) => sameList(ruleNames(d), ['bonus_needs_name', 'name_not_blank']), + }, + { what: '`highlightFields`, minus the denied entry', holds: (d) => sameList(d?.highlightFields, ['name', 'bonus_formula']) }, + { what: 'the readable field\'s predicate over a readable sibling', holds: (d) => d?.fields?.name?.readonlyWhen === 'record.bonus_formula != null' }, + { + what: 'the readable formula field, minus its formula — its other facets stay', + holds: (d) => d?.fields?.bonus_formula?.label === 'Bonus' + && d?.fields?.bonus_formula?.visibleWhen === 'record.status == "active"' + && !('formula' in (d?.fields?.bonus_formula ?? {})), + }, +]; + +/** An unmasked answer is the whole fixture — every reference in every position. */ +const RETAINED_UNMASKED: readonly FlsContractRetention[] = [ + { what: 'every validation rule', holds: (d) => ruleNames(d).length === 3 }, + { what: 'every role pointer', holds: (d) => d?.nameField === 'name' && d?.stageField === 'salary_grade' }, + { what: 'every name-list entry', holds: (d) => d?.highlightFields?.length === 3 && d?.indexes?.length === 2 }, + { what: 'the formula', holds: (d) => typeof d?.fields?.bonus_formula?.formula === 'string' }, +]; + /** The one verdict every exit must reach for a case. */ export type FlsContractVerdict = - /** These field names are present; those are COMPLETELY absent. */ - | { kind: 'fields'; present: readonly string[]; absent: readonly string[] } + /** + * These field names are present; those are COMPLETELY absent — from + * `fields` AND from every other position, expressions included — and the + * `retained` facts still hold. + */ + | { kind: 'fields'; present: readonly string[]; absent: readonly string[]; retained?: readonly FlsContractRetention[] } /** Every declared field survives — the passthrough tiers (D4 exemptions, D6 tier 1/2, D8). */ | { kind: 'unmasked' } /** D6 tier 3 — the exit refuses: 5xx, no body carrying `fields`. */ @@ -87,17 +216,17 @@ export interface ObjectSchemaMaskCase { export const OBJECT_SCHEMA_MASK_CASES: readonly ObjectSchemaMaskCase[] = [ { id: 'restricted-caller/field-vanishes-whole', - why: 'D1 — an unreadable field is removed whole: name, label, type, options, formula, visibleWhen and requiredPermissions all go with it.', + why: 'D1 — an unreadable field is removed whole: name, label, type, options, formula, visibleWhen and requiredPermissions all go with it, and so does every reference to it elsewhere in the document — the rules that read it, the pointers and lists that name it, the readable fields\' predicates over it.', context: { userId: 'u_portal', systemPermissions: [] }, readable: ['id', 'name'], - expect: { kind: 'fields', present: ['id', 'name'], absent: ['salary_grade', 'bonus_formula'] }, + expect: { kind: 'fields', present: ['id', 'name'], absent: ['salary_grade', 'bonus_formula'], retained: RETAINED_FOR_ID_AND_NAME }, }, { id: 'restricted-caller/required-permissions-cause', - why: 'D1 — the two causes of unreadability (an explicit `readable:false` and a missing `requiredPermissions` capability) are already folded together by `getReadableFields`, so an exit sees one answer and must not distinguish them.', + why: 'D1 — the two causes of unreadability (an explicit `readable:false` and a missing `requiredPermissions` capability) are already folded together by `getReadableFields`, so an exit sees one answer and must not distinguish them. The READABLE formula field stays, but not the formula that reads the denied one.', context: { userId: 'u_portal', systemPermissions: [] }, readable: ['id', 'name', 'bonus_formula'], - expect: { kind: 'fields', present: ['id', 'name', 'bonus_formula'], absent: ['salary_grade'] }, + expect: { kind: 'fields', present: ['id', 'name', 'bonus_formula'], absent: ['salary_grade'], retained: RETAINED_WITH_BONUS_READABLE }, }, { id: 'unrestricted-caller/byte-identical', @@ -160,7 +289,7 @@ export const OBJECT_SCHEMA_MASK_CASES: readonly ObjectSchemaMaskCase[] = [ why: 'D7 — a caller resolving to zero permission sets goes through the fallback set rather than the everything-default; the exit sees whatever that resolution answers and projects it like any other. (The resolution itself is pinned in plugin-security; a truly ANONYMOUS caller never reaches an exit on a requireAuth deployment, which D7 says in as many words.)', context: { userId: 'u_guest', positions: [], permissions: [], systemPermissions: [] }, readable: ['id', 'name'], - expect: { kind: 'fields', present: ['id', 'name'], absent: ['salary_grade', 'bonus_formula'] }, + expect: { kind: 'fields', present: ['id', 'name'], absent: ['salary_grade', 'bonus_formula'], retained: RETAINED_FOR_ID_AND_NAME }, }, { id: 'masking-disabled/D8', @@ -229,7 +358,7 @@ export function assertObjectSchemaMaskCase( } const expected = testCase.expect.kind === 'unmasked' - ? { present: [...FLS_CONTRACT_ALL_FIELDS], absent: [] as readonly string[] } + ? { present: [...FLS_CONTRACT_ALL_FIELDS], absent: [] as readonly string[], retained: RETAINED_UNMASKED } : testCase.expect; for (const name of expected.present) { @@ -243,17 +372,57 @@ export function assertObjectSchemaMaskCase( `${where}: field '${name}' must be COMPLETELY ABSENT for this caller, but the exit served it. ${testCase.why}`, ); } - // The whole-field rule: no residue anywhere in the serialized document. + // The whole-field rule: no residue anywhere in the served document. // A partial redaction (a name kept, the details stripped) still leaks - // existence, which D1 rules out in as many words. - if (JSON.stringify(outcome.document).includes(`"${name}"`)) { + // existence, which D1 rules out in as many words — and so does a + // REFERENCE: a rule whose condition reads the field, a formula over it, + // a pointer naming it. Judged by identifier token, so a name embedded + // in an expression (`record. > 0`) is caught, not only a quoted one. + const residue = findIdentifierResidue(outcome.document, name); + if (residue) { + throw new Error( + `${where}: '${name}' is gone from \`fields\` but is still referenced at ${residue} — D1 removes the field WHOLE.`, + ); + } + } + for (const fact of expected.retained ?? []) { + if (!fact.holds(outcome.document)) { throw new Error( - `${where}: '${name}' is gone from \`fields\` but still appears elsewhere in the served document — D1 removes the field WHOLE.`, + `${where}: the mask over-reached — the served document no longer carries ${fact.what}. ${testCase.why}`, ); } } } +const IDENTIFIER_TOKEN = /[A-Za-z_][A-Za-z0-9_]*/g; + +/** + * Where `name` occurs as an identifier token — in any string leaf or object + * key of `value` — as a dotted path, or `undefined` when it occurs nowhere. + * + * Deliberately independent of the mask's own reference detector: a matcher + * that shared it would go blind exactly where the mask does. + */ +function findIdentifierResidue(value: unknown, name: string, path = '$'): string | undefined { + const tokenIs = (text: string) => (text.match(IDENTIFIER_TOKEN) ?? []).includes(name); + if (typeof value === 'string') return tokenIs(value) ? path : undefined; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const hit = findIdentifierResidue(value[i], name, `${path}[${i}]`); + if (hit) return hit; + } + return undefined; + } + if (value && typeof value === 'object') { + for (const [key, inner] of Object.entries(value as Record)) { + if (tokenIs(key)) return `${path}.${key} (key)`; + const hit = findIdentifierResidue(inner, name, `${path}.${key}`); + if (hit) return hit; + } + } + return undefined; +} + /** * The `security` service double a case implies. * diff --git a/packages/metadata-core/src/object-schema-fls-references.test.ts b/packages/metadata-core/src/object-schema-fls-references.test.ts new file mode 100644 index 00000000000..5019dae4d01 --- /dev/null +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 D1] A denied field is removed WHOLE — its references elsewhere in + * the served object document go with its `fields` entry. These pins drive the + * real projection (`applyObjectSchemaMask`) over synthetic documents, one + * position kind at a time, each with its control: the reference to a READABLE + * field in the same position survives. + */ + +import { describe, it, expect } from 'vitest'; +import { FieldSchema, ObjectSchema } from '@objectstack/spec/data'; +import { applyObjectSchemaMask, type ObjectSchemaMaskPosture } from './object-schema-fls.js'; +import { + FIELD_REFERENCE_POSITIONS, + OBJECT_REFERENCE_POSITIONS, + mentionsDenied, +} from './object-schema-fls-references.js'; +import { + FLS_CONTRACT_OBJECT, + OBJECT_SCHEMA_MASK_CASES, + assertObjectSchemaMaskCase, +} from './object-schema-fls-contract.js'; + +const project = (readable: string[]): ObjectSchemaMaskPosture => ({ kind: 'project', readable: new Set(readable) }); + +/** Three fields; `secret_score` is the one a restricted caller cannot read. */ +const fieldsWith = (extra: Record = {}) => ({ + id: { type: 'text' }, + title: { type: 'text', ...extra }, + secret_score: { type: 'number' }, +}); + +const mask = (document: Record) => + applyObjectSchemaMask(document, project(['id', 'title'])).document as Record; + +describe('[ADR-0106 D1] references to a denied field — object level', () => { + it('drops a validation rule whose condition reads the denied field, and keeps the one that does not', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + validations: [ + { type: 'script', name: 'score_cap', condition: { dialect: 'cel', source: 'record.secret_score > 10' }, message: 'Too high.' }, + { type: 'script', name: 'title_set', condition: 'record.title == ""', message: 'Title required.' }, + ], + }); + expect(served.validations.map((r: any) => r.name)).toEqual(['title_set']); + }); + + it('drops a rule that names the denied field only through a pointer (`field` / `fields[]`)', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + validations: [ + { type: 'state_machine', name: 'score_flow', field: 'secret_score', transitions: {}, message: 'x' }, + { type: 'cross_field', name: 'pair', fields: ['secret_score', 'title'], condition: 'true', message: 'x' }, + ], + }); + expect(served).not.toHaveProperty('validations'); + }); + + it('deletes role pointers naming the denied field, keeps pointers naming readable ones', () => { + const served = mask({ name: 'thing', fields: fieldsWith(), nameField: 'title', stageField: 'secret_score', imageField: 'secret_score' }); + expect(served.nameField).toBe('title'); + expect(served).not.toHaveProperty('stageField'); + expect(served).not.toHaveProperty('imageField'); + }); + + it('filters name lists, and deletes a list left empty', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + highlightFields: ['secret_score', 'title'], + searchableFields: ['secret_score'], + }); + expect(served.highlightFields).toEqual(['title']); + expect(served).not.toHaveProperty('searchableFields'); + }); + + it('deletes an expression that reads the denied field (template, field-group predicate)', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + titleFormat: '{title} / {secret_score}', + fieldGroups: [ + { key: 'scores', label: 'Scores', visibleWhen: 'record.secret_score != null' }, + { key: 'main', label: 'Main', visibleWhen: 'record.title != null' }, + ], + }); + expect(served).not.toHaveProperty('titleFormat'); + expect(served.fieldGroups).toEqual([ + { key: 'scores', label: 'Scores' }, + { key: 'main', label: 'Main', visibleWhen: 'record.title != null' }, + ]); + }); + + it('drops indexes and milestones over the denied field', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + indexes: [{ fields: ['secret_score'] }, { fields: ['title'] }], + activityMilestones: [{ field: 'secret_score', value: 1, summary: 'Scored' }], + }); + expect(served.indexes).toEqual([{ fields: ['title'] }]); + expect(served).not.toHaveProperty('activityMilestones'); + }); + + it('scrubs the nested policy blocks: tenancy, lifecycle, publicSharing, external, userActions', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + tenancy: { enabled: true, tenantField: 'secret_score' }, + lifecycle: { class: 'record', ttl: { field: 'secret_score', expireAfter: '30d' } }, + publicSharing: { enabled: true, redactFields: ['secret_score', 'title'], eligibility: 'record.secret_score > 0' }, + external: { remoteName: 't', columnMap: { SCORE: 'secret_score', TITLE: 'title' } }, + userActions: { edit: { enabled: true, disabledWhen: 'record.secret_score > 5' }, delete: false }, + }); + expect(served.tenancy).toEqual({ enabled: true }); + expect(served.lifecycle).toEqual({ class: 'record' }); + expect(served.publicSharing).toEqual({ enabled: true, redactFields: ['title'] }); + expect(served.external).toEqual({ remoteName: 't', columnMap: { TITLE: 'title' } }); + expect(served.userActions).toEqual({ edit: { enabled: true }, delete: false }); + }); + + it('filters a list view\'s columns, and drops a view whose filter reads the denied field', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + listViews: { + all: { label: 'All', columns: ['title', 'secret_score'] }, + high: { label: 'High', columns: ['title'], filter: [['secret_score', '>', 5]] }, + }, + }); + expect(served.listViews).toEqual({ all: { label: 'All', columns: ['title'] } }); + }); + + it('drops an action whose predicate reads the denied field — but not for a word in its prose', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + actions: [ + { name: 'rescore', label: 'Rescore', visible: 'record.secret_score > 0' }, + { name: 'retitle', label: 'Retitle', description: 'Ignores secret_score entirely.' }, + ], + }); + expect(served.actions.map((a: any) => a.name)).toEqual(['retitle']); + }); + + it('deletes an UNCLASSIFIED key that mentions the denied field — fail-safe, never a leak', () => { + const served = mask({ name: 'thing', fields: fieldsWith(), futureKey: { pick: 'record.secret_score' }, otherKey: 'title' }); + expect(served).not.toHaveProperty('futureKey'); + expect(served.otherKey).toBe('title'); + }); +}); + +describe('[ADR-0106 D1] references to a denied field — inside a READABLE field', () => { + it('filters `relatedListColumns` and `dependsOn` (both entry shapes)', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ + relatedListColumns: ['title', 'secret_score'], + dependsOn: ['id', { field: 'secret_score', param: 's' }], + }), + }); + expect(served.fields.title.relatedListColumns).toEqual(['title']); + expect(served.fields.title.dependsOn).toEqual(['id']); + }); + + it('deletes the formula and predicates that read the denied field; the field and its other facets stay', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ + label: 'Title', + expression: { dialect: 'cel', source: 'record.secret_score * 2' }, + visibleWhen: 'record.secret_score > 0', + requiredWhen: 'record.id != null', + relatedListFilter: { secret_score: { $gt: 0 } }, + options: [{ value: 'a', label: 'A', visibleWhen: 'record.secret_score > 1' }], + }), + }); + expect(served.fields.title).toEqual({ + type: 'text', + label: 'Title', + requiredWhen: 'record.id != null', + options: [{ value: 'a', label: 'A' }], + }); + }); + + it('leaves names of ANOTHER object\'s fields alone — they are that object\'s projection', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ type: 'lookup', reference: 'other', lookupColumns: ['secret_score'], displayField: 'secret_score' }), + }); + expect(served.fields.title.lookupColumns).toEqual(['secret_score']); + expect(served.fields.title.displayField).toBe('secret_score'); + }); +}); + +describe('[ADR-0106 D1/D3] the projection stays pure', () => { + const SOURCE = { + name: 'thing', + fields: fieldsWith({ visibleWhen: 'record.secret_score > 0' }), + validations: [{ type: 'script', name: 'cap', condition: 'record.secret_score > 1', message: 'x' }], + }; + + it('never mutates the shared cache entry', () => { + const source = JSON.parse(JSON.stringify(SOURCE)); + const before = JSON.stringify(source); + mask(source); + expect(JSON.stringify(source)).toBe(before); + }); + + it('serves the SAME reference to a caller who denies nothing', () => { + expect(applyObjectSchemaMask(SOURCE, project(['id', 'title', 'secret_score'])).document).toBe(SOURCE); + }); + + it('keeps the document\'s key order', () => { + expect(Object.keys(mask({ ...SOURCE, label: 'Thing' }))).toEqual(['name', 'fields', 'label']); + }); +}); + +describe('mentionsDenied is an identifier-token test', () => { + const denied = new Set(['score']); + it('matches a name embedded in an expression, a template and a filter key', () => { + expect(mentionsDenied('record.score > 1', denied)).toBe(true); + expect(mentionsDenied('{score}', denied)).toBe(true); + expect(mentionsDenied({ score: { $gt: 1 } }, denied)).toBe(true); + }); + it('does not match a longer identifier or a capitalised word', () => { + expect(mentionsDenied('record.score_band', denied)).toBe(false); + expect(mentionsDenied('Score', denied)).toBe(false); + }); +}); + +/** The keys a Zod object schema declares. */ +function declaredKeys(schema: unknown): string[] { + const shape = (schema as { shape?: Record }).shape; + if (!shape || typeof shape !== 'object') throw new Error('schema exposes no `.shape` — the pin cannot read it'); + return Object.keys(shape); +} + +describe('[ADR-0106 D1] every position is classified — closed against the live spec', () => { + it('classifies every top-level ObjectSchema key (`fields` is the projection itself)', () => { + const unclassified = declaredKeys(ObjectSchema).filter((key) => key !== 'fields' && !(key in OBJECT_REFERENCE_POSITIONS)); + expect(unclassified, 'classify each new ObjectSchema key in OBJECT_REFERENCE_POSITIONS').toEqual([]); + }); + + it('classifies every FieldSchema key', () => { + const unclassified = declaredKeys(FieldSchema).filter((key) => !(key in FIELD_REFERENCE_POSITIONS)); + expect(unclassified, 'classify each new FieldSchema key in FIELD_REFERENCE_POSITIONS').toEqual([]); + }); + + it('classifies nothing the spec no longer declares', () => { + const objectKeys = new Set(declaredKeys(ObjectSchema)); + const fieldKeys = new Set(declaredKeys(FieldSchema)); + expect(Object.keys(OBJECT_REFERENCE_POSITIONS).filter((key) => !objectKeys.has(key))).toEqual([]); + expect(Object.keys(FIELD_REFERENCE_POSITIONS).filter((key) => !fieldKeys.has(key))).toEqual([]); + }); +}); + +describe('[ADR-0106] the shared contract table, driven through the bare projection', () => { + 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])); + 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 new file mode 100644 index 00000000000..3886032606b --- /dev/null +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -0,0 +1,387 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0106 D1] The second half of "remove an unreadable field WHOLE": the + * field's REFERENCES elsewhere in the served object document. + * + * {@link applyObjectSchemaMask} deletes a denied field's entry from `fields`. + * That alone is not D1's promise — "a field the caller cannot read does not + * exist for that caller, on any plane" — because an object schema names its + * fields in many more places than the `fields` map: object-level rule entries + * (`validations`, `indexes`, `activityMilestones`), role pointers + * (`nameField`, `stageField`, …), name lists (`highlightFields`, …), + * expressions (`titleFormat`, field-group `visibleWhen`, …), and the + * definitions of the fields that STAY (`relatedListColumns`, `dependsOn`, the + * formula `expression`, `visibleWhen` / `readonlyWhen` / `requiredWhen`, …). + * This module removes those references, for the one caller being served. + * + * ## The four dispositions + * + * Every position is classified once, in the two tables below, by what a + * reference there MEANS — so the disposition is the one that keeps the served + * document truthful for a caller who cannot see the field: + * + * | Position kind | What a reference to a denied field does | + * |---|---| + * | **pointer** (a string naming one field) | the key is deleted — the role falls back to its default, derived from the fields the caller IS served | + * | **name list** (`string[]`, or `{ field }` entries) | the entry is filtered out; an emptied list is deleted | + * | **expression** (CEL / template / filter) | the key is deleted — the caller cannot evaluate it meaningfully (the denied value is stripped from every record it reads), and the platform still evaluates the stored one server-side | + * | **rule entry** (`validations[i]`, `indexes[i]`, …) | the entry is dropped — a rule over a field the caller cannot read is server policy the caller cannot evaluate, and its text describes the field | + * + * A position that names fields of ANOTHER object (`lookupColumns`, + * `displayField`, `summaryOperations`, …) is `foreign`: those names are + * governed by that object's own projection, and filtering them against THIS + * object's denied set would delete unrelated columns that merely share a name. + * + * ## Fail-safe by construction + * + * A key neither table classifies is treated as an expression: it is served + * unless it mentions a denied name, and deleted if it does. A new spec key + * therefore over-masks rather than leaks until it is classified, and + * `object-schema-fls-references.test.ts` pins both tables against the live + * `ObjectSchema` / `FieldSchema` shapes so a new key is classified on the day + * it lands. + * + * "Mentions" is an identifier-token test, not a substring test: a string + * mentions `budget` when one of its `[A-Za-z_][A-Za-z0-9_]*` tokens IS + * `budget` — so `record.budget > 0` and `{budget}` do, while `budget_code` + * and `Budget` do not. Object keys are tested the same way (a filter + * condition keys on the field name). + * + * Pure and non-mutating: the input is the shared cache's single full copy + * (ADR-0106 D3), so every changed branch is a fresh object and every unchanged + * branch is the same reference. + */ + +/** 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; + +const IDENTIFIER = /[A-Za-z_][A-Za-z0-9_]*/g; + +/** Does this string carry a denied name as an identifier token? */ +function stringMentions(text: string, denied: ReadonlySet): boolean { + for (const token of text.match(IDENTIFIER) ?? []) { + if (denied.has(token)) return true; + } + return false; +} + +/** + * Keys whose values are display prose, skipped by {@link mentionsDenied} in + * `prose: 'skip'` mode. A label or confirmation sentence is not a field + * reference — a capitalised "Budget" never matches anyway, and an English + * word that happens to equal a field name must not cost a caller a whole + * list view or action. + */ +const PROSE_KEYS: ReadonlySet = new Set([ + 'label', 'pluralLabel', 'description', 'title', 'message', 'helpText', 'inlineHelpText', + 'placeholder', 'confirmText', 'successMessage', 'errorMessage', 'outcomeMessages', + 'relatedListTitle', 'inlineTitle', 'icon', +]); + +/** + * Does `value` reference any denied field — in any string leaf or object key? + * + * `prose: 'include'` (rule entries) reads everything, because a rule's message + * describes the field it guards; `prose: 'skip'` (list views, actions) ignores + * {@link PROSE_KEYS}. + */ +export function mentionsDenied( + value: unknown, + denied: ReadonlySet, + prose: 'include' | 'skip' = 'include', +): boolean { + if (typeof value === 'string') return stringMentions(value, denied); + if (Array.isArray(value)) return value.some((entry) => mentionsDenied(entry, denied, prose)); + if (value && typeof value === 'object') { + for (const [key, inner] of Object.entries(value as Record)) { + if (prose === 'skip' && PROSE_KEYS.has(key)) continue; + if (stringMentions(key, denied)) return true; + if (mentionsDenied(inner, denied, prose)) return true; + } + } + return false; +} + +// ── Building blocks ─────────────────────────────────────────────────────────── + +/** Served as is — the position carries no reference to a field of this object. */ +const keep: Scrub = (value) => value; + +/** A string naming ONE field of this object. */ +const pointer: Scrub = (value, denied) => (typeof value === 'string' && denied.has(value) ? REMOVE : value); + +/** A CEL predicate / formula / template / filter condition. */ +const expression: Scrub = (value, denied) => (mentionsDenied(value, denied) ? REMOVE : value); + +/** The field a name-list entry names: a bare string, or `{ field }` / `{ name }`. */ +function entryFieldName(entry: unknown): string | undefined { + if (typeof entry === 'string') return entry; + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + const rec = entry as Record; + if (typeof rec.field === 'string') return rec.field; + if (typeof rec.name === 'string') return rec.name; + } + return undefined; +} + +/** A list of field names of this object; an emptied list is deleted (every such key is optional). */ +const names: Scrub = (value, denied) => { + if (!Array.isArray(value)) return pointer(value, denied); + const kept = value.filter((entry) => { + const name = entryFieldName(entry); + return name === undefined || !denied.has(name); + }); + if (kept.length === value.length) return value; + return kept.length === 0 ? REMOVE : kept; +}; + +/** An array whose elements are scrubbed one by one; `REMOVE` drops the element. */ +function arrayOf(element: Scrub): Scrub { + return (value, denied) => { + if (!Array.isArray(value)) return expression(value, denied); + let changed = false; + const out: unknown[] = []; + for (const entry of value) { + const next = element(entry, denied); + if (next !== entry) changed = true; + if (next !== REMOVE) out.push(next); + } + if (!changed) return value; + return out.length === 0 ? REMOVE : out; + }; +} + +/** + * Rule entries: an entry that mentions a denied field ANYWHERE — its pointers, + * its condition, its message — is dropped whole. + */ +const ruleEntries: Scrub = arrayOf((entry, denied) => (mentionsDenied(entry, denied, 'include') ? REMOVE : entry)); + +/** An object block scrubbed key by key; an unclassified key is an {@link expression}. */ +function block(table: Readonly>): Scrub { + return (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return expression(value, denied); + return scrubRecord(value as Record, table, denied); + }; +} + +/** A `Record` whose values are scrubbed; `REMOVE` deletes the entry. */ +function recordOf(entry: Scrub): Scrub { + return (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return expression(value, denied); + let changed = false; + const out: Record = {}; + for (const [key, inner] of Object.entries(value as Record)) { + const next = entry(inner, denied); + if (next !== inner) changed = true; + if (next !== REMOVE) out[key] = next; + } + if (!changed) return value; + return Object.keys(out).length === 0 ? REMOVE : out; + }; +} + +/** + * 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. + */ +function presentationEntry(listKeys: readonly string[]): Scrub { + return (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return expression(value, denied); + const rec = value as Record; + let changed = false; + const out: Record = {}; + for (const [key, inner] of Object.entries(rec)) { + if (listKeys.includes(key)) { + const next = names(inner, denied); + if (next !== inner) changed = true; + if (next !== REMOVE) out[key] = next; + continue; + } + if (!PROSE_KEYS.has(key) && (stringMentions(key, denied) || mentionsDenied(inner, denied, 'skip'))) { + return REMOVE; + } + out[key] = inner; + } + return changed ? out : value; + }; +} + +/** Apply `table` to every key of `rec`; same reference when nothing changed. */ +function scrubRecord( + rec: Record, + table: Readonly>, + denied: ReadonlySet, +): 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]! : expression; + const next = scrub(inner, denied); + if (next !== inner) changed = true; + if (next !== REMOVE) out[key] = next; + } + return changed ? out : rec; +} + +// ── The ADR-0010 protection envelope — present on objects and fields alike ─── + +const PROTECTION_ENVELOPE: Readonly> = { + _lock: keep, _lockReason: keep, _lockSource: keep, _provenance: keep, + _packageId: keep, _packageVersion: keep, _lockDocsUrl: keep, +}; + +// ── Field level: the definition of a field the caller IS served ────────────── + +/** A select option: its own `visibleWhen` is a predicate over sibling fields. */ +const option = block({ label: keep, value: keep, description: keep, color: keep, default: keep, visibleWhen: expression, icon: keep }); + +/** + * [ADR-0106 D1] Every `FieldSchema` key, classified. Pinned against the live + * schema shape by `object-schema-fls-references.test.ts`. + */ +export const FIELD_REFERENCE_POSITIONS: Readonly> = { + // Identity, type and value-shape facets of the field itself. + name: keep, label: keep, type: keep, description: keep, format: keep, required: keep, + storage: keep, searchable: keep, multiple: keep, unique: keep, maxLength: keep, + minLength: keep, valueDomain: keep, rows: keep, precision: keep, scale: keep, min: keep, + max: keep, useGrouping: keep, accept: keep, maxSize: keep, picklist: keep, reference: keep, + deleteBehavior: keep, inlineEdit: keep, inlineTitle: keep, relatedList: keep, + relatedListTitle: keep, lookupPageSize: keep, allowCreate: keep, returnType: keep, + language: keep, step: keep, currencyConfig: keep, dimensions: keep, trackHistory: keep, + group: keep, widget: keep, hidden: keep, internal: keep, readonly: keep, + requiredPermissions: keep, maskingRule: keep, ackPlaintextMasking: keep, system: keep, + sortable: keep, inlineHelpText: keep, placeholder: keep, externalId: keep, + conditionalRequired: keep, + // Names of ANOTHER object's fields — that object's projection governs them. + inlineColumns: keep, inlineAmountField: keep, displayField: keep, descriptionField: keep, + lookupColumns: keep, lookupFilters: keep, summaryOperations: keep, + // A sibling field of THIS object. + referenceVia: pointer, + relatedListColumns: names, + dependsOn: names, + // Evaluated against this object's record. + expression, + visibleWhen: expression, + readonlyWhen: expression, + requiredWhen: expression, + relatedListFilter: expression, + defaultValue: expression, + autonumberFormat: expression, + options: arrayOf(option), + ...PROTECTION_ENVELOPE, +}; + +const readableField = block(FIELD_REFERENCE_POSITIONS); + +// ── Object level ────────────────────────────────────────────────────────────── + +/** A row-CRUD override flag: `boolean`, or `{ enabled, visibleWhen, disabledWhen }`. */ +const crudFlag: Scrub = (value, denied) => ( + value && typeof value === 'object' && !Array.isArray(value) + ? block({ enabled: keep, visibleWhen: expression, disabledWhen: expression })(value, denied) + : value +); + +/** `lifecycle.ttl` measures from ONE field; a denied one leaves no ttl the caller can be told about. */ +const ttl: Scrub = (value, denied) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const rec = value as Record; + if (typeof rec.field === 'string' && denied.has(rec.field)) return REMOVE; + } + return block({ field: pointer, expireAfter: keep, onlyWhen: expression })(value, denied); +}; + +/** `external.columnMap` is remote column → LOCAL field name; an entry mapping onto a denied field goes. */ +const columnMap = recordOf(pointer); + +/** The list-view keys that are column-style lists of this object's field names. */ +const LIST_VIEW_NAME_LISTS = ['columns', 'hiddenFields', 'fieldOrder', 'searchableFields', 'filterableFields'] as const; + +/** + * [ADR-0106 D1] Every top-level `ObjectSchema` key, classified. `fields` is + * absent on purpose — {@link maskDeniedFieldReferences} owns it. Pinned against + * the live schema shape by `object-schema-fls-references.test.ts`. + */ +export const OBJECT_REFERENCE_POSITIONS: Readonly> = { + // Identity and posture — no field names. + name: keep, label: keep, pluralLabel: keep, description: keep, icon: keep, isSystem: keep, + managedBy: keep, ownership: keep, systemFields: keep, datasource: keep, access: keep, + requiredPermissions: keep, fileAccessDelegate: keep, editMode: keep, enable: keep, + sharingModel: keep, externalSharingModel: keep, protection: keep, + // Role pointers. + nameField: pointer, + displayNameField: pointer, + imageField: pointer, + stageField: pointer, + // Name lists. + highlightFields: names, + searchableFields: names, + // Expressions. + titleFormat: expression, + // Rule entries — dropped whole when they mention a denied field. + validations: ruleEntries, + indexes: ruleEntries, + activityMilestones: ruleEntries, + // Blocks. + userActions: block({ create: crudFlag, import: crudFlag, edit: crudFlag, delete: crudFlag, exportCsv: keep }), + external: block({ + remoteName: keep, remoteSchema: keep, writable: keep, introspectedAt: keep, ignoreColumns: keep, + columnMap, + }), + fieldGroups: arrayOf(block({ + key: keep, label: keep, icon: keep, description: keep, collapse: keep, + defaultExpanded: keep, collapsible: keep, collapsed: keep, visibleWhen: expression, + })), + tenancy: block({ enabled: keep, tenantField: pointer }), + lifecycle: block({ + class: keep, storage: keep, archive: keep, reclaim: keep, + retention: block({ maxAge: keep, onlyWhen: expression }), + ttl, + }), + publicSharing: block({ + enabled: keep, allowedAudiences: keep, allowedPermissions: keep, maxExpiryDays: keep, + redactFields: names, eligibility: expression, + }), + // Presentation entries. + listViews: recordOf(presentationEntry(LIST_VIEW_NAME_LISTS)), + actions: arrayOf(presentationEntry([])), + ...PROTECTION_ENVELOPE, +}; + +/** + * 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. + */ +export function maskDeniedFieldReferences( + document: Record, + denied: ReadonlySet, +): Record { + if (denied.size === 0) return document; + const { fields, ...rest } = document; + const scrubbedRest = scrubRecord(rest, OBJECT_REFERENCE_POSITIONS, denied); + + let scrubbedFields = fields; + if (fields && typeof fields === 'object' && !Array.isArray(fields)) { + const scrubbed = recordOf(readableField)(fields, denied); + // A field definition is never REMOVEd by its own scrub (only keys inside + // it are), so the map keeps exactly the projected field set. + scrubbedFields = scrubbed === REMOVE ? {} : scrubbed; + } + + if (scrubbedRest === rest && scrubbedFields === fields) return document; + // Rebuilt in the document's own key order, so a masked body differs from + // the full one only where a reference was removed. + const out: Record = {}; + for (const key of Object.keys(document)) { + if (key === 'fields') out.fields = scrubbedFields; + else if (Object.prototype.hasOwnProperty.call(scrubbedRest, key)) out[key] = scrubbedRest[key]; + } + return out; +} diff --git a/packages/metadata-core/src/object-schema-fls.ts b/packages/metadata-core/src/object-schema-fls.ts index d840fe79b3d..af3c4e5e663 100644 --- a/packages/metadata-core/src/object-schema-fls.ts +++ b/packages/metadata-core/src/object-schema-fls.ts @@ -43,7 +43,7 @@ * | masking disabled (D8 escape hatch) / no `security` service / exempt caller (D4) | `passthrough` | serve unmasked, byte-identical to pre-ADR | * | `getReadableFields` → `undefined` (field universe unresolvable) | `undetermined` | serve unmasked + structured warn + metric + `Cache-Control: private, no-store`, **no shared ETag** | * | `getReadableFields` throws | throws {@link ObjectSchemaMaskEvaluationError} | the exit answers 5xx — never the unmasked body, never an empty-fields 200 | - * | otherwise | `project` | fields ∉ readable are deleted whole | + * | otherwise | `project` | fields ∉ readable are deleted whole, references included | * * ## D3 — mask AFTER the cache, fingerprint the ETag * @@ -58,6 +58,8 @@ * self-invalidates the stale 304. */ +import { maskDeniedFieldReferences } from './object-schema-fls-references.js'; + /** * [#6603 / ADR-0066 D1] The capabilities that let a caller **write** an object * schema — the authoritative set, from which the D4 read exemption below is @@ -346,7 +348,10 @@ export interface ObjectSchemaMaskResult { /** * Project a metadata document's `fields` onto the caller's readable set - * (ADR-0106 D1) — remove an unreadable field **whole**. + * (ADR-0106 D1) — remove an unreadable field **whole**: its `fields` entry AND + * every reference to it elsewhere in the document (object-level rules, role + * pointers, name lists, expressions, and the definitions of the fields that + * stay), per {@link maskDeniedFieldReferences}. * * Pure and total, with the same tolerance contract as * {@link applyAuditFieldGovernance}: any input may be handed to it, including a @@ -380,8 +385,13 @@ export function applyObjectSchemaMask(document: T, posture: ObjectSchemaMaskP if (posture.readable.has(name)) kept[name] = def; } + // 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)); + return { - document: { ...rec, fields: kept } as unknown as T, + document: projected as unknown as T, denied, fingerprint: objectFieldVisibilityFingerprint(denied), emptied: Object.keys(kept).length === 0, From f1af28166dc9e3dff0f01ea786b3b1a79c518ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:10:59 +0000 Subject: [PATCH 2/9] fix(metadata-core): type the residue matcher's token list Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- packages/metadata-core/src/object-schema-fls-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index 365e242666a..bdc0c926b84 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -404,7 +404,7 @@ const IDENTIFIER_TOKEN = /[A-Za-z_][A-Za-z0-9_]*/g; * that shared it would go blind exactly where the mask does. */ function findIdentifierResidue(value: unknown, name: string, path = '$'): string | undefined { - const tokenIs = (text: string) => (text.match(IDENTIFIER_TOKEN) ?? []).includes(name); + const tokenIs = (text: string): boolean => (text.match(IDENTIFIER_TOKEN) ?? ([] as string[])).includes(name); if (typeof value === 'string') return tokenIs(value) ? path : undefined; if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { From 4ab5f92b939a5b80ae0dbc7f8ee9127693ccd72a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:25:33 +0000 Subject: [PATCH 3/9] chore(changeset): object-schema mask removes denied-field references Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../21723-object-schema-mask-references.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/21723-object-schema-mask-references.md diff --git a/.changeset/21723-object-schema-mask-references.md b/.changeset/21723-object-schema-mask-references.md new file mode 100644 index 00000000000..a1f7bd81e51 --- /dev/null +++ b/.changeset/21723-object-schema-mask-references.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-core': patch +--- + +The object-schema field mask (ADR-0106 D1) now removes a denied field's references from the rest of the served object document, not only its `fields` entry. + +Clause-②: no + +A caller who cannot read a field was served a document without that field's definition, but other parts of the document could still name the field. Those parts are now projected too, on every exit that serves an object schema to a restricted caller: the by-name read, the list read and the layered view. What happens to each kind of position: + +- **Object-level rule entries** (`validations`, `indexes`, `activityMilestones`). An entry that names or reads a denied field is dropped whole. The platform still evaluates the stored rule on every write. +- **Role pointers** (`nameField`, `displayNameField`, `imageField`, `stageField`, `tenancy.tenantField`, `lifecycle.ttl.field`). A pointer to a denied field is deleted, so the role falls back to its default. +- **Name lists** (`highlightFields`, `searchableFields`, `publicSharing.redactFields`, list-view column lists, `external.columnMap` entries, and a readable field's `relatedListColumns` / `dependsOn`). The denied entries are filtered out. A list left empty is deleted. +- **Expressions** (`titleFormat`, field-group `visibleWhen`, row-CRUD `visibleWhen` / `disabledWhen`, `publicSharing.eligibility`, lifecycle `onlyWhen`, and a readable field's formula `expression`, `visibleWhen` / `readonlyWhen` / `requiredWhen`, `relatedListFilter`, `defaultValue`, `autonumberFormat` and per-option `visibleWhen`). An expression that reads a denied field is deleted. The readable field itself stays. +- **List views and actions.** An entry whose filter, sort, predicate or params read a denied field is dropped. + +Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema` and `FieldSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers. + +The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well. From bbaec097c3c79fadc45d147ec7e8aa55a2b471fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:10:28 +0000 Subject: [PATCH 4/9] docs(metadata-core): neutral example in the reference-mask docblock Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude --- .../metadata-core/src/object-schema-fls-references.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 3886032606b..9d4b38b113b 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -43,9 +43,9 @@ * it lands. * * "Mentions" is an identifier-token test, not a substring test: a string - * mentions `budget` when one of its `[A-Za-z_][A-Za-z0-9_]*` tokens IS - * `budget` — so `record.budget > 0` and `{budget}` do, while `budget_code` - * and `Budget` do not. Object keys are tested the same way (a filter + * mentions `score` when one of its `[A-Za-z_][A-Za-z0-9_]*` tokens IS + * `score` — so `record.score > 0` and `{score}` do, while `score_band` + * and `Score` do not. Object keys are tested the same way (a filter * condition keys on the field name). * * Pure and non-mutating: the input is the shared cache's single full copy @@ -71,7 +71,7 @@ function stringMentions(text: string, denied: ReadonlySet): boolean { /** * Keys whose values are display prose, skipped by {@link mentionsDenied} in * `prose: 'skip'` mode. A label or confirmation sentence is not a field - * reference — a capitalised "Budget" never matches anyway, and an English + * reference — a capitalised "Score" never matches anyway, and an English * word that happens to equal a field name must not cost a caller a whole * list view or action. */ From 59d4a4173c3c9ab986659fea80ec6208718622ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:58:21 +0000 Subject: [PATCH 5/9] fix(metadata-core): the object-schema reference mask reads the inline grid as this object's, and keys only where keys are fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `inlineColumns` / `inlineAmountField` are declared on the child's own master_detail field and name the child's own columns: a column that is a denied field, or is computed from one, is dropped; a denied amount field is deleted. - In a classified position an object key is a reference only inside a field-keyed block (FilterCondition, lifecycle onlyWhen, action patch), and closed-vocabulary values (rule type, envelope dialect, …) are not read, so a denied field named like a schema word no longer drops every rule, view, action or CEL envelope. The unclassified fail-safe path still reads every key. - The reference walk guards its path, so a cyclic document terminates. - A list view keyed by a denied field's name is dropped. - The contract fixture uses the real `expression` key and covers list views, actions and the inline grid. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/object-schema-fls-contract.ts | 53 +++- .../src/object-schema-fls-references.test.ts | 169 +++++++++++- .../src/object-schema-fls-references.ts | 244 +++++++++++++++--- 3 files changed, 422 insertions(+), 44 deletions(-) diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index bdc0c926b84..b4591ada36a 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -31,9 +31,15 @@ * document has outside `fields` (ADR-0106 D1 removes a field WHOLE, so a * reference is part of the field): a rule entry whose condition reads one, a * rule entry that names one only through its `fields` pointer list, role - * pointers, name lists, an expression, a field-group predicate, an index — - * and, inside the READABLE fields, a name list, a `dependsOn`, a predicate and - * a formula that read a sibling. Each position also carries a reference to a + * 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), + * actions (a visibility predicate) — 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 + * restricted case can read carries it — the mask does not read `type`, and a + * fifth field would change the field set every exit's suite counts. Each + * position also carries a reference to a * field EVERY restricted case can read, so an over-eager mask that deletes the * position wholesale fails the `retained` half of the case. */ @@ -74,6 +80,15 @@ export const FLS_CONTRACT_OBJECT = { message: 'Name must not be blank.', }, ], + listViews: { + all: { label: 'All', type: 'grid', columns: ['name', 'salary_grade'] }, + graded: { label: 'Graded', type: 'grid', columns: ['name'], filter: [{ field: 'salary_grade', operator: 'is_not_null' }] }, + salary_grade: { label: 'By grade', type: 'grid', columns: ['name'] }, + }, + actions: [ + { name: 'regrade', label: 'Regrade', type: 'script', visible: 'record.salary_grade != null' }, + { name: 'rename', label: 'Rename', type: 'script', visible: 'record.name != null' }, + ], fields: { id: { type: 'text', label: 'Id' }, name: { @@ -83,6 +98,13 @@ export const FLS_CONTRACT_OBJECT = { dependsOn: ['id', 'salary_grade'], readonlyWhen: 'record.bonus_formula != null', requiredWhen: 'record.id != null', + inlineColumns: [ + { name: 'id' }, + { name: 'salary_grade', label: 'Grade' }, + { name: 'name', label: 'Name', readonlyWhen: 'record.salary_grade != null' }, + { name: 'id', label: 'Bonus x2', computed: true, expr: 'bonus_formula * 2' }, + ], + inlineAmountField: 'bonus_formula', }, // Everything ADR-0106's Context section names as leaking with the field: // a sensitive enumeration, the capability guarding it, and a formula @@ -96,7 +118,7 @@ export const FLS_CONTRACT_OBJECT = { bonus_formula: { type: 'formula', label: 'Bonus', - formula: 'salary_grade == "band_a" ? 0.2 : 0.1', + expression: 'salary_grade == "band_a" ? 0.2 : 0.1', visibleWhen: 'record.status == "active"', }, }, @@ -126,7 +148,7 @@ const ruleNames = (document: any): string[] => (Array.isArray(document?.validati ? document.validations.map((rule: any) => rule?.name) : []); -const sameList = (actual: unknown, expected: readonly unknown[]): boolean => +const sameList = (actual: unknown, expected: readonly unknown[] | Readonly>): boolean => JSON.stringify(actual) === JSON.stringify(expected); /** @@ -150,6 +172,12 @@ const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [ && sameList(d?.fields?.name?.dependsOn, ['id']), }, { what: 'the readable field\'s predicate over a readable sibling', holds: (d) => d?.fields?.name?.requiredWhen === 'record.id != null' }, + { + what: 'the inline grid\'s readable columns, minus a predicate over the denied field', + holds: (d) => sameList(d?.fields?.name?.inlineColumns, [{ name: 'id' }, { name: 'name', label: 'Name' }]), + }, + { what: 'the list view over readable fields, minus the denied column', holds: (d) => sameList(d?.listViews, { all: { label: 'All', type: 'grid', columns: ['name'] } }) }, + { what: 'the action whose predicate reads a readable field', holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename']) }, ]; /** @@ -168,7 +196,13 @@ const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [ what: 'the readable formula field, minus its formula — its other facets stay', holds: (d) => d?.fields?.bonus_formula?.label === 'Bonus' && d?.fields?.bonus_formula?.visibleWhen === 'record.status == "active"' - && !('formula' in (d?.fields?.bonus_formula ?? {})), + && !('expression' in (d?.fields?.bonus_formula ?? {})), + }, + { + what: 'the inline grid\'s column computed from the readable formula field, and its amount field', + holds: (d) => d?.fields?.name?.inlineColumns?.length === 3 + && d?.fields?.name?.inlineColumns?.[2]?.expr === 'bonus_formula * 2' + && d?.fields?.name?.inlineAmountField === 'bonus_formula', }, ]; @@ -177,7 +211,12 @@ const RETAINED_UNMASKED: readonly FlsContractRetention[] = [ { what: 'every validation rule', holds: (d) => ruleNames(d).length === 3 }, { what: 'every role pointer', holds: (d) => d?.nameField === 'name' && d?.stageField === 'salary_grade' }, { what: 'every name-list entry', holds: (d) => d?.highlightFields?.length === 3 && d?.indexes?.length === 2 }, - { what: 'the formula', holds: (d) => typeof d?.fields?.bonus_formula?.formula === 'string' }, + { what: 'the formula', holds: (d) => typeof d?.fields?.bonus_formula?.expression === 'string' }, + { + what: 'every inline-grid column, every list view and every action', + holds: (d) => d?.fields?.name?.inlineColumns?.length === 4 + && Object.keys(d?.listViews ?? {}).length === 3 && d?.actions?.length === 2, + }, ]; /** The one verdict every exit must reach for a case. */ 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 5019dae4d01..c3906a4e8e8 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -9,10 +9,11 @@ */ import { describe, it, expect } from 'vitest'; -import { FieldSchema, ObjectSchema } from '@objectstack/spec/data'; +import { FieldSchema, InlineGridColumnSchema, ObjectSchema } from '@objectstack/spec/data'; import { applyObjectSchemaMask, type ObjectSchemaMaskPosture } from './object-schema-fls.js'; import { FIELD_REFERENCE_POSITIONS, + INLINE_COLUMN_POSITIONS, OBJECT_REFERENCE_POSITIONS, mentionsDenied, } from './object-schema-fls-references.js'; @@ -146,6 +147,40 @@ describe('[ADR-0106 D1] references to a denied field — object level', () => { expect(served.actions.map((a: any) => a.name)).toEqual(['retitle']); }); + it('drops a list view KEYED by the denied field\'s name; the others stay', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + listViews: { + secret_score: { label: 'By score', columns: ['title'] }, + all: { label: 'All', columns: ['title'] }, + }, + }); + expect(served.listViews).toEqual({ all: { label: 'All', columns: ['title'] } }); + }); + + it('drops an action whose `patch` writes the denied field (a field-keyed block)', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + actions: [ + { name: 'zero', label: 'Zero', type: 'script', operation: 'update', patch: { secret_score: 0 } }, + { name: 'clear', label: 'Clear', type: 'script', operation: 'update', patch: { title: '' } }, + ], + }); + expect(served.actions.map((a: any) => a.name)).toEqual(['clear']); + }); + + it('serves a field group keyed like the denied field — a group name is not a field reference', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ group: 'secret_score' }), + fieldGroups: [{ key: 'secret_score', label: 'Scores' }], + }); + expect(served.fieldGroups).toEqual([{ key: 'secret_score', label: 'Scores' }]); + expect(served.fields.title.group).toBe('secret_score'); + }); + it('deletes an UNCLASSIFIED key that mentions the denied field — fail-safe, never a leak', () => { const served = mask({ name: 'thing', fields: fieldsWith(), futureKey: { pick: 'record.secret_score' }, otherKey: 'title' }); expect(served).not.toHaveProperty('futureKey'); @@ -186,6 +221,40 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field }); }); + it('scrubs the inline master-detail grid — its columns are THIS (child) object\'s own fields', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ + type: 'master_detail', + reference: 'parent_thing', + inlineColumns: [ + { name: 'id' }, + { name: 'secret_score', label: 'Score' }, + { name: 'title', label: 'Title', type: 'lookup', reference: 'other', displayField: 'secret_score', readonlyWhen: 'record.secret_score > 0' }, + { name: 'id', computed: true, expr: 'secret_score * 2' }, + { name: 'title', computed: true, expr: 'id + 1' }, + ], + inlineAmountField: 'secret_score', + }), + }); + expect(served.fields.title.inlineColumns).toEqual([ + { name: 'id' }, + // `displayField` names a field of the lookup's own target — kept. + { name: 'title', label: 'Title', type: 'lookup', reference: 'other', displayField: 'secret_score' }, + { name: 'title', computed: true, expr: 'id + 1' }, + ]); + expect(served.fields.title).not.toHaveProperty('inlineAmountField'); + }); + + it('keeps a readable inline amount field, and deletes a grid left with no column', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ type: 'master_detail', inlineColumns: [{ name: 'secret_score' }], inlineAmountField: 'id' }), + }); + expect(served.fields.title).not.toHaveProperty('inlineColumns'); + expect(served.fields.title.inlineAmountField).toBe('id'); + }); + it('leaves names of ANOTHER object\'s fields alone — they are that object\'s projection', () => { const served = mask({ name: 'thing', @@ -196,6 +265,77 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field }); }); +describe('[ADR-0106 D1] a denied field named like a schema word costs only its own references', () => { + /** `type` and `source` are both denied; `title` is the readable control. */ + const maskWords = (document: Record) => + applyObjectSchemaMask(document, project(['id', 'title'])).document as Record; + const wordFields = (extra: Record = {}) => ({ + id: { type: 'text' }, + title: { type: 'text', ...extra }, + type: { type: 'text' }, + source: { type: 'text' }, + }); + + it('keeps every rule that does not read `type` / `source`, though each carries a `type` key and a `{ dialect, source }` envelope', () => { + const served = maskWords({ + name: 'thing', + fields: wordFields(), + validations: [ + { type: 'script', name: 'title_set', condition: { dialect: 'cel', source: 'record.title == ""' }, message: 'x' }, + { type: 'format', name: 'title_url', field: 'title', format: 'url', message: 'x' }, + { type: 'script', name: 'typed', condition: { dialect: 'cel', source: 'record.type == "a"' }, message: 'x' }, + { type: 'cross_field', name: 'pair', fields: ['source', 'title'], condition: 'true', message: 'x' }, + ], + indexes: [{ name: 'by_title', fields: ['title'], unique: 'global' }, { fields: ['source'] }], + }); + expect(served.validations.map((r: any) => r.name)).toEqual(['title_set', 'title_url']); + expect(served.indexes).toEqual([{ name: 'by_title', fields: ['title'], unique: 'global' }]); + }); + + it('keeps every list view and action that does not read them, though each carries a `type` key', () => { + const served = maskWords({ + name: 'thing', + fields: wordFields(), + listViews: { + all: { label: 'All', type: 'grid', columns: ['title', 'type'], filter: [{ field: 'title', operator: 'is_not_null' }] }, + typed: { label: 'Typed', type: 'grid', columns: ['title'], filter: [{ field: 'type', operator: 'equals', value: 'a' }] }, + }, + actions: [ + { name: 'retitle', label: 'Retitle', type: 'script', visible: { dialect: 'cel', source: 'record.title != null' } }, + { name: 'resource', label: 'Re-source', type: 'script', visible: 'record.source != null' }, + ], + }); + expect(served.listViews).toEqual({ + all: { label: 'All', type: 'grid', columns: ['title'], filter: [{ field: 'title', operator: 'is_not_null' }] }, + }); + expect(served.actions.map((a: any) => a.name)).toEqual(['retitle']); + }); + + it('keeps a readable field\'s CEL envelopes; a field-keyed filter still keys on the field name', () => { + const served = maskWords({ + name: 'thing', + fields: wordFields({ + visibleWhen: { dialect: 'cel', source: 'record.id != null' }, + expression: { dialect: 'cel', source: 'record.id * 2' }, + relatedListFilter: { type: { $eq: 'a' } }, + }), + lifecycle: { class: 'record', retention: { maxAge: '30d', onlyWhen: { source: 'import' } } }, + }); + expect(served.fields.title.visibleWhen).toEqual({ dialect: 'cel', source: 'record.id != null' }); + expect(served.fields.title.expression).toEqual({ dialect: 'cel', source: 'record.id * 2' }); + expect(served.fields.title).not.toHaveProperty('relatedListFilter'); + expect(served.lifecycle).toEqual({ class: 'record', retention: { maxAge: '30d' } }); + }); + + it('a field-keyed filter does not read a `$`-operator as a field: denied `and` keeps `$and` over readable fields', () => { + const served = applyObjectSchemaMask({ + name: 'thing', + fields: { id: { type: 'text' }, title: { type: 'text', relatedListFilter: { $and: [{ id: { $ne: null } }] } }, and: { type: 'text' } }, + }, project(['id', 'title'])).document as Record; + expect(served.fields.title.relatedListFilter).toEqual({ $and: [{ id: { $ne: null } }] }); + }); +}); + describe('[ADR-0106 D1/D3] the projection stays pure', () => { const SOURCE = { name: 'thing', @@ -214,6 +354,19 @@ describe('[ADR-0106 D1/D3] the projection stays pure', () => { expect(applyObjectSchemaMask(SOURCE, project(['id', 'title', 'secret_score'])).document).toBe(SOURCE); }); + it('is total on a cyclic document: it terminates, and still removes the reference', () => { + const cyclic: Record = { name: 'thing', fields: fieldsWith() }; + const loop: Record = { pick: 'record.secret_score' }; + loop.self = loop; + cyclic.futureKey = loop; + cyclic.otherKey = { back: cyclic }; + cyclic.validations = [{ type: 'script', name: 'cap', condition: 'record.title == ""', message: 'x' }]; + cyclic.validations[0].again = cyclic.validations; + const served = mask(cyclic); + expect(served).not.toHaveProperty('futureKey'); + expect(served.validations.map((r: any) => r.name)).toEqual(['cap']); + }); + it('keeps the document\'s key order', () => { expect(Object.keys(mask({ ...SOURCE, label: 'Thing' }))).toEqual(['name', 'fields', 'label']); }); @@ -226,6 +379,13 @@ describe('mentionsDenied is an identifier-token test', () => { expect(mentionsDenied('{score}', denied)).toBe(true); expect(mentionsDenied({ score: { $gt: 1 } }, denied)).toBe(true); }); + it('reads keys only where the caller says they are field names', () => { + expect(mentionsDenied({ score: 1 }, denied, 'include', 'classified')).toBe(false); + expect(mentionsDenied({ filter: { score: 1 } }, denied, 'include', 'classified')).toBe(true); + expect(mentionsDenied({ score: 1 }, denied, 'include', 'field-keyed')).toBe(true); + expect(mentionsDenied({ type: 'score' }, denied, 'include', 'classified')).toBe(false); + expect(mentionsDenied({ type: 'score' }, denied, 'include', 'all')).toBe(true); + }); it('does not match a longer identifier or a capitalised word', () => { expect(mentionsDenied('record.score_band', denied)).toBe(false); expect(mentionsDenied('Score', denied)).toBe(false); @@ -250,9 +410,16 @@ describe('[ADR-0106 D1] every position is classified — closed against the live expect(unclassified, 'classify each new FieldSchema key in FIELD_REFERENCE_POSITIONS').toEqual([]); }); + it('classifies every InlineGridColumnSchema key', () => { + const unclassified = declaredKeys(InlineGridColumnSchema).filter((key) => !(key in INLINE_COLUMN_POSITIONS)); + expect(unclassified, 'classify each new InlineGridColumnSchema key in INLINE_COLUMN_POSITIONS').toEqual([]); + }); + it('classifies nothing the spec no longer declares', () => { const objectKeys = new Set(declaredKeys(ObjectSchema)); const fieldKeys = new Set(declaredKeys(FieldSchema)); + const columnKeys = new Set(declaredKeys(InlineGridColumnSchema)); + expect(Object.keys(INLINE_COLUMN_POSITIONS).filter((key) => !columnKeys.has(key))).toEqual([]); expect(Object.keys(OBJECT_REFERENCE_POSITIONS).filter((key) => !objectKeys.has(key))).toEqual([]); expect(Object.keys(FIELD_REFERENCE_POSITIONS).filter((key) => !fieldKeys.has(key))).toEqual([]); }); diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 9d4b38b113b..48d2c49e87c 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -29,9 +29,12 @@ * | **rule entry** (`validations[i]`, `indexes[i]`, …) | the entry is dropped — a rule over a field the caller cannot read is server policy the caller cannot evaluate, and its text describes the field | * * A position that names fields of ANOTHER object (`lookupColumns`, - * `displayField`, `summaryOperations`, …) is `foreign`: those names are + * `displayField`, `summaryOperations`, …) is kept as is: those names are * governed by that object's own projection, and filtering them against THIS * object's denied set would delete unrelated columns that merely share a name. + * The inline master-detail grid (`inlineColumns`, `inlineAmountField`) is NOT + * 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. * * ## Fail-safe by construction * @@ -45,8 +48,33 @@ * "Mentions" is an identifier-token test, not a substring test: a string * mentions `score` when one of its `[A-Za-z_][A-Za-z0-9_]*` tokens IS * `score` — so `record.score > 0` and `{score}` do, while `score_band` - * and `Score` do not. Object keys are tested the same way (a filter - * condition keys on the field name). + * and `Score` do not. In a classified position an object KEY is a reference + * only where keys ARE field names — a `FilterCondition` (`relatedListFilter`, + * a list view's `filter`), a `lifecycle` `onlyWhen` map, an action's `patch`; + * elsewhere a key is a schema word, so a denied field called `type` or + * `source` does not cost the caller every rule or every CEL envelope. The + * unclassified path tests every key. + * + * ## Accepted limitations + * + * - A string LITERAL equal to a denied name in a classified position — a + * `defaultValue`, a filter comparand, a quoted CEL string — reads as a + * reference, so the position is deleted (over-masking, never a leak). + * - A composite identifier (`score_positive`) is one token and is not + * matched, even where an author meant it to evoke the field. + * - A list view or action that reads a denied field anywhere outside its + * column lists (a nested `kanban.groupByField`, a sort, a param) is dropped + * whole rather than partially rewritten. + * - A `fieldGroups[].key` is a group name, not a field reference — the + * readable fields join a group through their own `group: ` — so a group + * keyed like a denied field is served; dropping it would regroup fields the + * caller IS entitled to. A `listViews` key, by contrast, is the view's whole + * identity and nothing else points at it, so a view keyed like a denied field + * is dropped. + * + * Total: a cyclic document terminates (the reference walk guards its path), + * and every table is finite, so no input overflows the stack by recursion + * through the tables. * * Pure and non-mutating: the input is the shared cache's single full copy * (ADR-0106 D3), so every changed branch is a fresh object and every unchanged @@ -82,27 +110,107 @@ const PROSE_KEYS: ReadonlySet = new Set([ ]); /** - * Does `value` reference any denied field — in any string leaf or object key? + * Keys whose values are a closed vocabulary or a non-field namespace — a rule's + * `type` / `severity` / `events`, an expression envelope's `dialect`, a filter + * rule's `operator`, a sort's `order`, a state machine's state values, a + * `format` rule's enum, a `regex` pattern, a `json_schema` rule's JSON Schema. + * In a CLASSIFIED position their values never name a field of this object, so + * a denied field that happens to be called `script`, `email` or `cel` must not + * cost the caller every rule of that kind. Not consulted on the unclassified + * fail-safe path, nor inside a field-keyed block (where `type` IS a field). + */ +const VOCABULARY_KEYS: ReadonlySet = new Set([ + 'type', 'dialect', 'severity', 'events', 'format', 'operator', 'order', 'direction', + 'transitions', 'initialStates', 'regex', 'schema', +]); + +/** + * Keys whose value is a FIELD-KEYED block: a Query-DSL `FilterCondition` + * (`{ status: { $ne: 'x' }, $and: [...] }`), a `lifecycle.*.onlyWhen` map, an + * action's `patch` (field → static value). Only there is an object KEY a field + * reference; everywhere else in a classified position a key is a schema word + * (`type`, `source`, `name`, …) and is never tested. + */ +const FIELD_KEYED_KEYS: ReadonlySet = new Set(['filter', 'where', 'relatedListFilter', 'onlyWhen', 'patch']); + +/** How {@link mentionsDenied} reads object keys. */ +export type KeyReading = + /** Every key is tested — the UNCLASSIFIED fail-safe path, where nothing is known about the shape. */ + | 'all' + /** Keys are tested only inside a {@link FIELD_KEYED_KEYS} block; {@link VOCABULARY_KEYS} values are skipped. */ + | 'classified' + /** The value itself is a field-keyed block (`relatedListFilter`, `onlyWhen`). */ + | 'field-keyed'; + +/** + * Does `value` reference any denied field? * * `prose: 'include'` (rule entries) reads everything, because a rule's message * describes the field it guards; `prose: 'skip'` (list views, actions) ignores - * {@link PROSE_KEYS}. + * {@link PROSE_KEYS}. `keys` says which object keys are field references — + * see {@link KeyReading}; the default is the fail-safe `'all'`. + * + * Total on any input, cyclic ones included: a value already on the walk's + * path is not re-entered (its answer is the one being computed above it), so a + * self-referencing document terminates instead of overflowing the stack. The + * guard is the current PATH, not every value ever seen, so a sub-object shared + * by two positions is still read in each position's own key reading. */ export function mentionsDenied( value: unknown, denied: ReadonlySet, prose: 'include' | 'skip' = 'include', + keys: KeyReading = 'all', ): boolean { - if (typeof value === 'string') return stringMentions(value, denied); - if (Array.isArray(value)) return value.some((entry) => mentionsDenied(entry, denied, prose)); - if (value && typeof value === 'object') { - for (const [key, inner] of Object.entries(value as Record)) { - if (prose === 'skip' && PROSE_KEYS.has(key)) continue; - if (stringMentions(key, denied)) return true; - if (mentionsDenied(inner, denied, prose)) return true; + const onPath = new WeakSet(); + + const enter = (node: object, read: () => boolean): boolean => { + if (onPath.has(node)) return false; + onPath.add(node); + try { + return read(); + } finally { + onPath.delete(node); } - } - return false; + }; + + /** A `FilterCondition` / field → value map: non-`$` keys are field names. */ + const fieldKeyed = (node: unknown): boolean => { + if (typeof node === 'string') return stringMentions(node, denied); + if (!node || typeof node !== 'object') return false; + if (Array.isArray(node)) return enter(node, () => node.some(fieldKeyed)); + const rec = node as Record; + // A list-view filter RULE (`{ field, operator, value }`) keys on schema + // words, not field names: its field is the `field` string. + if (typeof rec.field === 'string') return walk(rec, false); + return enter(rec, () => Object.entries(rec).some(([key, inner]) => ( + key.startsWith('$') + ? fieldKeyed(inner) // `$and` / `$or` / `$not` nest conditions; `$gt: 5` is a literal + : denied.has(key) || walk(inner, false) + ))); + }; + + const walk = (node: unknown, testKeys: boolean): boolean => { + if (typeof node === 'string') return stringMentions(node, denied); + if (!node || typeof node !== 'object') return false; + if (Array.isArray(node)) return enter(node, () => node.some((entry) => walk(entry, testKeys))); + return enter(node, () => { + for (const [key, inner] of Object.entries(node as Record)) { + if (prose === 'skip' && PROSE_KEYS.has(key)) continue; + if (testKeys) { + if (stringMentions(key, denied) || walk(inner, true)) return true; + continue; + } + if (VOCABULARY_KEYS.has(key)) continue; + if (FIELD_KEYED_KEYS.has(key) ? fieldKeyed(inner) : walk(inner, false)) return true; + } + return false; + }); + }; + + if (keys === 'all') return walk(value, true); + if (keys === 'field-keyed') return fieldKeyed(value); + return walk(value, false); } // ── Building blocks ─────────────────────────────────────────────────────────── @@ -113,8 +221,22 @@ const keep: Scrub = (value) => value; /** A string naming ONE field of this object. */ const pointer: Scrub = (value, denied) => (typeof value === 'string' && denied.has(value) ? REMOVE : value); -/** A CEL predicate / formula / template / filter condition. */ -const expression: Scrub = (value, denied) => (mentionsDenied(value, denied) ? REMOVE : value); +/** + * A CEL predicate / formula / template, bare or in its `{ dialect, source }` + * envelope: deleted when a string in it names a denied field. Its object keys + * are schema words, not field references (see {@link KeyReading}). + */ +const expression: Scrub = (value, denied) => (mentionsDenied(value, denied, 'include', 'classified') ? REMOVE : value); + +/** A field-keyed block — a Query-DSL `FilterCondition`, an `onlyWhen` map: its KEYS are field names. */ +const fieldKeyed: Scrub = (value, denied) => (mentionsDenied(value, denied, 'include', 'field-keyed') ? REMOVE : value); + +/** + * A key no table classifies, or a classified key holding a value of the wrong + * shape: nothing is known about it, so every string AND every key is read as a + * possible reference — the fail-safe path over-masks rather than leaks. + */ +const unclassified: Scrub = (value, denied) => (mentionsDenied(value, denied, 'include', 'all') ? REMOVE : value); /** The field a name-list entry names: a bare string, or `{ field }` / `{ name }`. */ function entryFieldName(entry: unknown): string | undefined { @@ -141,7 +263,7 @@ const names: Scrub = (value, denied) => { /** An array whose elements are scrubbed one by one; `REMOVE` drops the element. */ function arrayOf(element: Scrub): Scrub { return (value, denied) => { - if (!Array.isArray(value)) return expression(value, denied); + if (!Array.isArray(value)) return unclassified(value, denied); let changed = false; const out: unknown[] = []; for (const entry of value) { @@ -156,25 +278,36 @@ function arrayOf(element: Scrub): Scrub { /** * Rule entries: an entry that mentions a denied field ANYWHERE — its pointers, - * its condition, its message — is dropped whole. + * its condition, its message — is dropped whole. Its keys (`type`, `name`, + * `severity`, …) and its vocabulary values are not references; see + * {@link VOCABULARY_KEYS}. */ -const ruleEntries: Scrub = arrayOf((entry, denied) => (mentionsDenied(entry, denied, 'include') ? REMOVE : entry)); +const ruleEntries: Scrub = arrayOf((entry, denied) => (mentionsDenied(entry, denied, 'include', 'classified') ? REMOVE : entry)); -/** An object block scrubbed key by key; an unclassified key is an {@link expression}. */ +/** An object block scrubbed key by key; an unclassified key goes the {@link unclassified} way. */ function block(table: Readonly>): Scrub { return (value, denied) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return expression(value, denied); + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); return scrubRecord(value as Record, table, denied); }; } -/** A `Record` whose values are scrubbed; `REMOVE` deletes the entry. */ -function recordOf(entry: Scrub): Scrub { +/** + * A `Record` whose values are scrubbed; `REMOVE` deletes the + * entry. With `keyIsName`, an entry whose KEY names a denied field is deleted + * too — for maps keyed by an author-chosen name the caller is shown + * (`listViews`), where a key spelling a denied field discloses it. + */ +function recordOf(entry: Scrub, keyIsName = false): Scrub { return (value, denied) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return expression(value, denied); + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); let changed = false; const out: Record = {}; for (const [key, inner] of Object.entries(value as Record)) { + if (keyIsName && stringMentions(key, denied)) { + changed = true; + continue; + } const next = entry(inner, denied); if (next !== inner) changed = true; if (next !== REMOVE) out[key] = next; @@ -192,7 +325,7 @@ function recordOf(entry: Scrub): Scrub { */ function presentationEntry(listKeys: readonly string[]): Scrub { return (value, denied) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return expression(value, denied); + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); const rec = value as Record; let changed = false; const out: Record = {}; @@ -203,9 +336,9 @@ function presentationEntry(listKeys: readonly string[]): Scrub { if (next !== REMOVE) out[key] = next; continue; } - if (!PROSE_KEYS.has(key) && (stringMentions(key, denied) || mentionsDenied(inner, denied, 'skip'))) { - return REMOVE; - } + // 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; out[key] = inner; } return changed ? out : value; @@ -221,7 +354,7 @@ function scrubRecord( let changed = false; const out: Record = {}; for (const [key, inner] of Object.entries(rec)) { - const scrub = Object.prototype.hasOwnProperty.call(table, key) ? table[key]! : expression; + const scrub = Object.prototype.hasOwnProperty.call(table, key) ? table[key]! : unclassified; const next = scrub(inner, denied); if (next !== inner) changed = true; if (next !== REMOVE) out[key] = next; @@ -241,6 +374,37 @@ const PROTECTION_ENVELOPE: Readonly> = { /** A select option: its own `visibleWhen` is a predicate over sibling fields. */ const option = block({ label: keep, value: keep, description: keep, color: keep, default: keep, visibleWhen: expression, icon: keep }); +/** + * [ADR-0106 D1] Every `InlineGridColumnSchema` key, classified. A column's + * `name` is a field of THIS object (the child the grid edits); its lookup + * `reference` / `displayField` / `idField` name the referenced object and its + * fields; its `readonlyWhen` / `requiredWhen` read the row as `record`. + * Pinned against the live schema shape by `object-schema-fls-references.test.ts`. + */ +export const INLINE_COLUMN_POSITIONS: Readonly> = { + name: keep, label: keep, type: keep, options: keep, prefix: keep, step: keep, + reference: keep, displayField: keep, idField: keep, multiple: keep, accept: keep, + defaultHidden: keep, computed: keep, scale: keep, autofill: keep, width: keep, required: keep, + // Judged by `inlineColumn` before the table runs: a denied one drops the column. + expr: keep, + readonlyWhen: expression, + requiredWhen: expression, +}; + +/** + * One inline-grid column: dropped whole when it IS a denied field (`name`) or + * is computed from one (`expr` — a computed cell recomputed from sibling cells, + * so serving it without the expression would show a different number). Its + * other facets are scrubbed by {@link INLINE_COLUMN_POSITIONS}. + */ +const inlineColumn: Scrub = (value, denied) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); + const rec = value as Record; + if (typeof rec.name === 'string' && denied.has(rec.name)) return REMOVE; + if (rec.expr !== undefined && mentionsDenied(rec.expr, denied, 'include', 'classified')) return REMOVE; + return scrubRecord(rec, INLINE_COLUMN_POSITIONS, denied); +}; + /** * [ADR-0106 D1] Every `FieldSchema` key, classified. Pinned against the live * schema shape by `object-schema-fls-references.test.ts`. @@ -259,10 +423,17 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { sortable: keep, inlineHelpText: keep, placeholder: keep, externalId: keep, conditionalRequired: keep, // Names of ANOTHER object's fields — that object's projection governs them. - inlineColumns: keep, inlineAmountField: keep, displayField: keep, descriptionField: keep, - lookupColumns: keep, lookupFilters: keep, summaryOperations: keep, - // A sibling field of THIS object. + // `displayField` / `descriptionField` / `lookupColumns` / `lookupFilters` + // name fields of the `reference` target (the lookup picker reads them); + // `summaryOperations` (`field`, `filter`) reads the CHILD object it rolls up. + displayField: keep, descriptionField: keep, lookupColumns: keep, lookupFilters: keep, + summaryOperations: keep, + // A sibling field of THIS object. The inline grid is declared on the + // child's `master_detail` field and its columns ARE the child's own fields — + // this object, not the parent the field points at. referenceVia: pointer, + inlineAmountField: pointer, + inlineColumns: arrayOf(inlineColumn), relatedListColumns: names, dependsOn: names, // Evaluated against this object's record. @@ -270,7 +441,7 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { visibleWhen: expression, readonlyWhen: expression, requiredWhen: expression, - relatedListFilter: expression, + relatedListFilter: fieldKeyed, defaultValue: expression, autonumberFormat: expression, options: arrayOf(option), @@ -294,7 +465,7 @@ const ttl: Scrub = (value, denied) => { const rec = value as Record; if (typeof rec.field === 'string' && denied.has(rec.field)) return REMOVE; } - return block({ field: pointer, expireAfter: keep, onlyWhen: expression })(value, denied); + return block({ field: pointer, expireAfter: keep, onlyWhen: fieldKeyed })(value, denied); }; /** `external.columnMap` is remote column → LOCAL field name; an entry mapping onto a denied field goes. */ @@ -341,7 +512,7 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { tenancy: block({ enabled: keep, tenantField: pointer }), lifecycle: block({ class: keep, storage: keep, archive: keep, reclaim: keep, - retention: block({ maxAge: keep, onlyWhen: expression }), + retention: block({ maxAge: keep, onlyWhen: fieldKeyed }), ttl, }), publicSharing: block({ @@ -349,7 +520,8 @@ export const OBJECT_REFERENCE_POSITIONS: Readonly> = { redactFields: names, eligibility: expression, }), // Presentation entries. - listViews: recordOf(presentationEntry(LIST_VIEW_NAME_LISTS)), + // 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([])), ...PROTECTION_ENVELOPE, }; From c0a2b9990bde6660ea3e0f9348172c3917c6a8af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:28:56 +0000 Subject: [PATCH 6/9] chore(changeset): object-schema mask scope covers the inline grid and field-keyed key reading Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/21723-object-schema-mask-references.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/21723-object-schema-mask-references.md b/.changeset/21723-object-schema-mask-references.md index a1f7bd81e51..44167f95845 100644 --- a/.changeset/21723-object-schema-mask-references.md +++ b/.changeset/21723-object-schema-mask-references.md @@ -11,9 +11,12 @@ A caller who cannot read a field was served a document without that field's defi - **Object-level rule entries** (`validations`, `indexes`, `activityMilestones`). An entry that names or reads a denied field is dropped whole. The platform still evaluates the stored rule on every write. - **Role pointers** (`nameField`, `displayNameField`, `imageField`, `stageField`, `tenancy.tenantField`, `lifecycle.ttl.field`). A pointer to a denied field is deleted, so the role falls back to its default. - **Name lists** (`highlightFields`, `searchableFields`, `publicSharing.redactFields`, list-view column lists, `external.columnMap` entries, and a readable field's `relatedListColumns` / `dependsOn`). The denied entries are filtered out. A list left empty is deleted. +- **The inline master-detail grid** (`inlineColumns`, `inlineAmountField`). These are declared on the child object's own `master_detail` field and name the child's own fields. A column that is a denied field, or is computed from one (`expr`), is dropped. A denied `inlineAmountField` is deleted. - **Expressions** (`titleFormat`, field-group `visibleWhen`, row-CRUD `visibleWhen` / `disabledWhen`, `publicSharing.eligibility`, lifecycle `onlyWhen`, and a readable field's formula `expression`, `visibleWhen` / `readonlyWhen` / `requiredWhen`, `relatedListFilter`, `defaultValue`, `autonumberFormat` and per-option `visibleWhen`). An expression that reads a denied field is deleted. The readable field itself stays. -- **List views and actions.** An entry whose filter, sort, predicate or params read a denied field is dropped. +- **List views and actions.** An entry whose filter, sort, predicate, params or `patch` read a denied field is dropped. So is a list view whose key is a denied field's name. -Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema` and `FieldSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers. +In these positions an object key counts as a field reference only where keys are field names: a `FilterCondition`, a lifecycle `onlyWhen` map, an action's `patch`. A denied field named like a schema word (`type`, `source`, `name`) no longer removes every rule, view, action or CEL envelope. The mask also terminates on a cyclic document. -The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well. +Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field in any string or key, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema`, `FieldSchema` and `InlineGridColumnSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers. + +The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions, including list views, actions and the inline grid, and its formula field uses the real `expression` key. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well. From 8a8f488ae5bffcb5b69050d773f1e7ce1e398e2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:34:26 +0000 Subject: [PATCH 7/9] chore(changeset): grade metadata-core minor for the new FlsContractRetention type on its ./testing entry Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/21723-object-schema-mask-references.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21723-object-schema-mask-references.md b/.changeset/21723-object-schema-mask-references.md index 44167f95845..b82e31d7cb7 100644 --- a/.changeset/21723-object-schema-mask-references.md +++ b/.changeset/21723-object-schema-mask-references.md @@ -1,5 +1,5 @@ --- -'@objectstack/metadata-core': patch +'@objectstack/metadata-core': minor --- The object-schema field mask (ADR-0106 D1) now removes a denied field's references from the rest of the served object document, not only its `fields` entry. From b0dbe2fe9857842de8399aeb4c48b1ac98b81ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:07:17 +0000 Subject: [PATCH 8/9] fix(metadata-core): an object-form list column goes when any facet names a denied field A list column in object form names fields of this object through its nested prefix and summary pointers as well as its own field; the name-list scrub read only the latter. Columns now go through a classified table pinned against the live column schemas, so a new nested pointer is classified before it ships. A dependsOn entry's param stays a remote key. A dotted path rooted at a denied field (in a field-keyed block, a name list or a pointer) is now read as a reference to that field. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .../src/object-schema-fls-contract.ts | 32 +++- .../src/object-schema-fls-references.test.ts | 80 ++++++++++ .../src/object-schema-fls-references.ts | 148 ++++++++++++++---- 3 files changed, 228 insertions(+), 32 deletions(-) diff --git a/packages/metadata-core/src/object-schema-fls-contract.ts b/packages/metadata-core/src/object-schema-fls-contract.ts index b4591ada36a..51385899287 100644 --- a/packages/metadata-core/src/object-schema-fls-contract.ts +++ b/packages/metadata-core/src/object-schema-fls-contract.ts @@ -32,7 +32,8 @@ * reference is part of the field): a rule entry whose condition reads one, a * rule entry that names one only through its `fields` pointer list, role * 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), + * 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 * list, a `dependsOn`, a predicate, a formula `expression` and an inline grid * (`inlineColumns` by name and by computed `expr`, `inlineAmountField`) that @@ -84,6 +85,17 @@ export const FLS_CONTRACT_OBJECT = { all: { label: 'All', type: 'grid', columns: ['name', 'salary_grade'] }, graded: { label: 'Graded', type: 'grid', columns: ['name'], filter: [{ field: 'salary_grade', operator: 'is_not_null' }] }, salary_grade: { label: 'By grade', type: 'grid', columns: ['name'] }, + // Object-form columns: a column's nested pointers (`prefix.field`, + // `summary.field`) name fields of THIS object as surely as its `field`. + compact: { + label: 'Compact', + type: 'grid', + columns: [ + { field: 'name', width: 200 }, + { field: 'name', prefix: { field: 'salary_grade', type: 'badge' } }, + { field: 'id', summary: { type: 'sum', field: 'bonus_formula' } }, + ], + }, }, actions: [ { name: 'regrade', label: 'Regrade', type: 'script', visible: 'record.salary_grade != null' }, @@ -176,7 +188,13 @@ const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [ what: 'the inline grid\'s readable columns, minus a predicate over the denied field', holds: (d) => sameList(d?.fields?.name?.inlineColumns, [{ name: 'id' }, { name: 'name', label: 'Name' }]), }, - { what: 'the list view over readable fields, minus the denied column', holds: (d) => sameList(d?.listViews, { all: { label: 'All', type: 'grid', columns: ['name'] } }) }, + { + what: 'the list views over readable fields, minus the denied columns — object-form columns included', + holds: (d) => sameList(d?.listViews, { + all: { label: 'All', type: 'grid', columns: ['name'] }, + 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']) }, ]; @@ -198,6 +216,13 @@ const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [ && d?.fields?.bonus_formula?.visibleWhen === 'record.status == "active"' && !('expression' in (d?.fields?.bonus_formula ?? {})), }, + { + what: 'the object-form column aggregating the readable formula field, minus the one prefixed by the denied field', + holds: (d) => sameList(d?.listViews?.compact?.columns, [ + { field: 'name', width: 200 }, + { field: 'id', summary: { type: 'sum', field: 'bonus_formula' } }, + ]), + }, { what: 'the inline grid\'s column computed from the readable formula field, and its amount field', holds: (d) => d?.fields?.name?.inlineColumns?.length === 3 @@ -215,7 +240,8 @@ 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 === 3 && d?.actions?.length === 2, + && Object.keys(d?.listViews ?? {}).length === 4 && d?.listViews?.compact?.columns?.length === 3 + && d?.actions?.length === 2, }, ]; 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 c3906a4e8e8..9d8257193a3 100644 --- a/packages/metadata-core/src/object-schema-fls-references.test.ts +++ b/packages/metadata-core/src/object-schema-fls-references.test.ts @@ -10,10 +10,14 @@ 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 { + COLUMN_PREFIX_POSITIONS, + COLUMN_SUMMARY_POSITIONS, FIELD_REFERENCE_POSITIONS, INLINE_COLUMN_POSITIONS, + LIST_COLUMN_POSITIONS, OBJECT_REFERENCE_POSITIONS, mentionsDenied, } from './object-schema-fls-references.js'; @@ -135,6 +139,46 @@ describe('[ADR-0106 D1] references to a denied field — object level', () => { expect(served.listViews).toEqual({ all: { label: 'All', columns: ['title'] } }); }); + it('drops an object-form column whose nested `prefix.field` / `summary.field` names the denied field', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + listViews: { + all: { + label: 'All', + columns: [ + { field: 'title', prefix: { field: 'secret_score', type: 'badge' } }, + { field: 'title', summary: { type: 'sum', field: 'secret_score' } }, + { field: 'id', prefix: { field: 'title', type: 'badge' }, summary: { type: 'count', field: 'title' } }, + { field: 'title', summary: 'sum', type: 'currency', action: 'secret_score' }, + ], + }, + only: { label: 'Only', columns: [{ field: 'title', prefix: { field: 'secret_score', type: 'text' } }] }, + }, + }); + // Control: nested pointers to a readable field, the bare summary + // vocabulary, a renderer type and an action id all stay. + expect(served.listViews).toEqual({ + all: { + label: 'All', + columns: [ + { field: 'id', prefix: { field: 'title', type: 'badge' }, summary: { type: 'count', field: 'title' } }, + { field: 'title', summary: 'sum', type: 'currency', action: 'secret_score' }, + ], + }, + only: { label: 'Only' }, + }); + }); + + it('drops an object-form column carrying a key the column table does not classify, when it mentions the denied field', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith(), + listViews: { all: { label: 'All', columns: [{ field: 'title', futureFacet: { field: 'secret_score' } }, { field: 'id', futureFacet: 'title' }] } }, + }); + expect(served.listViews.all.columns).toEqual([{ field: 'id', futureFacet: 'title' }]); + }); + it('drops an action whose predicate reads the denied field — but not for a word in its prose', () => { const served = mask({ name: 'thing', @@ -201,6 +245,28 @@ describe('[ADR-0106 D1] references to a denied field — inside a READABLE field expect(served.fields.title.dependsOn).toEqual(['id']); }); + it('reads a dotted path rooted at the denied field as a reference to it — keys, names and pointers alike', () => { + const served = mask({ + name: 'thing', + fields: fieldsWith({ relatedListFilter: { 'secret_score.city': 'x' }, relatedListColumns: ['title', 'secret_score.city'] }), + nameField: 'secret_score.city', + displayNameField: 'title.city', + lifecycle: { class: 'record', retention: { maxAge: '30d', onlyWhen: { 'secret_score.city': 'x' } } }, + listViews: { all: { label: 'All', columns: ['title.city', 'secret_score.city'] } }, + }); + expect(served.fields.title).not.toHaveProperty('relatedListFilter'); + expect(served.fields.title.relatedListColumns).toEqual(['title']); + expect(served).not.toHaveProperty('nameField'); + expect(served.displayNameField).toBe('title.city'); + expect(served.lifecycle).toEqual({ class: 'record', retention: { maxAge: '30d' } }); + expect(served.listViews.all.columns).toEqual(['title.city']); + }); + + it('reads a `dependsOn` entry\'s `param` as the lookup target\'s key, not a field of this object', () => { + const served = mask({ name: 'thing', fields: fieldsWith({ dependsOn: [{ field: 'id', param: 'secret_score' }] }) }); + expect(served.fields.title.dependsOn).toEqual([{ field: 'id', param: 'secret_score' }]); + }); + it('deletes the formula and predicates that read the denied field; the field and its other facets stay', () => { const served = mask({ name: 'thing', @@ -415,6 +481,20 @@ describe('[ADR-0106 D1] every position is classified — closed against the live expect(unclassified, 'classify each new InlineGridColumnSchema key in INLINE_COLUMN_POSITIONS').toEqual([]); }); + // A list column's nested pointers (`prefix.field`, `summary.field`) name + // fields of this object: a new facet must be classified before it ships. + for (const [label, schema, table] of [ + ['ListColumnSchema', ListColumnSchema, LIST_COLUMN_POSITIONS], + ['ColumnPrefixSchema', ColumnPrefixSchema, COLUMN_PREFIX_POSITIONS], + ['ColumnSummaryConfigSchema', ColumnSummaryConfigSchema, COLUMN_SUMMARY_POSITIONS], + ] as const) { + it(`classifies every ${label} key, and nothing it no longer declares`, () => { + const keys = declaredKeys(schema); + expect(keys.filter((key) => !(key in table)), `classify each new ${label} key`).toEqual([]); + expect(Object.keys(table).filter((key) => !keys.includes(key))).toEqual([]); + }); + } + it('classifies nothing the spec no longer declares', () => { const objectKeys = new Set(declaredKeys(ObjectSchema)); const fieldKeys = new Set(declaredKeys(FieldSchema)); diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 48d2c49e87c..ec90791746d 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -24,7 +24,7 @@ * | Position kind | What a reference to a denied field does | * |---|---| * | **pointer** (a string naming one field) | the key is deleted — the role falls back to its default, derived from the fields the caller IS served | - * | **name list** (`string[]`, or `{ field }` entries) | the entry is filtered out; an emptied list is deleted | + * | **name list** (`string[]`, or object entries where the spec admits them) | the entry is filtered out — an object entry when ANY facet reads the field (a list column's own `field`, its `prefix.field`, its `summary.field`); an emptied list is deleted | * | **expression** (CEL / template / filter) | the key is deleted — the caller cannot evaluate it meaningfully (the denied value is stripped from every record it reads), and the platform still evaluates the stored one server-side | * | **rule entry** (`validations[i]`, `indexes[i]`, …) | the entry is dropped — a rule over a field the caller cannot read is server policy the caller cannot evaluate, and its text describes the field | * @@ -65,6 +65,13 @@ * - A list view or action that reads a denied field anywhere outside its * column lists (a nested `kanban.groupByField`, a sort, a param) is dropped * whole rather than partially rewritten. + * - 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. + * - 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. * - A `fieldGroups[].key` is a group name, not a field reference — the * readable fields join a group through their own `group: ` — so a group * keyed like a denied field is served; dropping it would regroup fields the @@ -96,6 +103,16 @@ function stringMentions(text: string, denied: ReadonlySet): boolean { return false; } +/** + * Does `name` — a field name, or a dotted path ROOTED at one (`owner.city`) — + * name a denied field of this object? A path's root segment is this object's + * field (the rest walks the referenced record), so a path through a denied + * lookup names the denied field as surely as the bare name does. + */ +function namesDeniedField(name: string, denied: ReadonlySet): boolean { + return denied.has(name) || denied.has(name.split('.', 1)[0]!); +} + /** * Keys whose values are display prose, skipped by {@link mentionsDenied} in * `prose: 'skip'` mode. A label or confirmation sentence is not a field @@ -186,7 +203,7 @@ export function mentionsDenied( return enter(rec, () => Object.entries(rec).some(([key, inner]) => ( key.startsWith('$') ? fieldKeyed(inner) // `$and` / `$or` / `$not` nest conditions; `$gt: 5` is a literal - : denied.has(key) || walk(inner, false) + : namesDeniedField(key, denied) || walk(inner, false) ))); }; @@ -219,7 +236,7 @@ export function mentionsDenied( const keep: Scrub = (value) => value; /** A string naming ONE field of this object. */ -const pointer: Scrub = (value, denied) => (typeof value === 'string' && denied.has(value) ? REMOVE : value); +const pointer: Scrub = (value, denied) => (typeof value === 'string' && namesDeniedField(value, denied) ? REMOVE : value); /** * A CEL predicate / formula / template, bare or in its `{ dialect, source }` @@ -238,27 +255,52 @@ const fieldKeyed: Scrub = (value, denied) => (mentionsDenied(value, denied, 'inc */ const unclassified: Scrub = (value, denied) => (mentionsDenied(value, denied, 'include', 'all') ? REMOVE : value); -/** The field a name-list entry names: a bare string, or `{ field }` / `{ name }`. */ -function entryFieldName(entry: unknown): string | undefined { - if (typeof entry === 'string') return entry; - if (entry && typeof entry === 'object' && !Array.isArray(entry)) { - const rec = entry as Record; - if (typeof rec.field === 'string') return rec.field; - if (typeof rec.name === 'string') return rec.name; +/** + * Does one object-form name-list entry read a denied field through ANY facet? + * `table` classifies the entry's keys (its own `field`, and nested same-object + * pointers such as a list column's `prefix.field` / `summary.field`); a facet + * whose scrub would change it reads a denied field. A key the table does not + * classify goes the {@link unclassified} way, so a new nested pointer + * over-masks until it is classified. + */ +function entryReadsDenied( + entry: Record, + table: Readonly>, + denied: ReadonlySet, +): boolean { + for (const [key, inner] of Object.entries(entry)) { + const scrub = Object.prototype.hasOwnProperty.call(table, key) ? table[key]! : unclassified; + if (scrub(inner, denied) !== inner) return true; } - return undefined; + return false; } -/** A list of field names of this object; an emptied list is deleted (every such key is optional). */ -const names: Scrub = (value, denied) => { - if (!Array.isArray(value)) return pointer(value, denied); - const kept = value.filter((entry) => { - const name = entryFieldName(entry); - return name === undefined || !denied.has(name); - }); - if (kept.length === value.length) return value; - return kept.length === 0 ? REMOVE : kept; -}; +/** + * A list of field names of this object; an emptied list is deleted (every such + * key is optional). A string entry is judged as a name (or a path rooted at + * one). Where the spec also admits an OBJECT entry (`entryTable` given), the + * entry is dropped when any of its facets reads a denied field — its own + * `field` or a nested pointer — because serving the column without that facet + * would render a different column. An object entry in a strings-only list is + * the wrong shape and is read fail-safe: any mention drops it. + */ +function nameList(entryTable?: Readonly>): Scrub { + return (value, denied) => { + if (!Array.isArray(value)) return pointer(value, denied); + const kept = value.filter((entry) => { + if (typeof entry === 'string') return !namesDeniedField(entry, denied); + if (entryTable && entry && typeof entry === 'object' && !Array.isArray(entry)) { + return !entryReadsDenied(entry as Record, entryTable, denied); + } + return !mentionsDenied(entry, denied, 'include', 'all'); + }); + if (kept.length === value.length) return value; + return kept.length === 0 ? REMOVE : kept; + }; +} + +/** A strings-only list of field names of this object (see {@link nameList}). */ +const names: Scrub = nameList(); /** An array whose elements are scrubbed one by one; `REMOVE` drops the element. */ function arrayOf(element: Scrub): Scrub { @@ -323,15 +365,15 @@ function recordOf(entry: Scrub, keyIsName = false): Scrub { * sort, a visibility predicate, a param — drops the entry, because serving it * without that part would silently change what it does. */ -function presentationEntry(listKeys: readonly string[]): Scrub { +function presentationEntry(lists: Readonly>): Scrub { return (value, denied) => { 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 (listKeys.includes(key)) { - const next = names(inner, denied); + if (Object.prototype.hasOwnProperty.call(lists, key)) { + const next = lists[key]!(inner, denied); if (next !== inner) changed = true; if (next !== REMOVE) out[key] = next; continue; @@ -400,11 +442,21 @@ export const INLINE_COLUMN_POSITIONS: Readonly> = { const inlineColumn: Scrub = (value, denied) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return unclassified(value, denied); const rec = value as Record; - if (typeof rec.name === 'string' && denied.has(rec.name)) return REMOVE; + if (typeof rec.name === 'string' && namesDeniedField(rec.name, denied)) return REMOVE; if (rec.expr !== undefined && mentionsDenied(rec.expr, denied, 'include', 'classified')) return REMOVE; return scrubRecord(rec, INLINE_COLUMN_POSITIONS, denied); }; +/** + * [ADR-0106 D1] The `{ field, param }` form of a `dependsOn` entry. `field` is + * a sibling field of THIS object; `param` is the remote filter key on the + * lookup's TARGET object — that object's name, not a reference here. + */ +export const DEPENDS_ON_ENTRY_POSITIONS: Readonly> = { + field: pointer, + param: keep, +}; + /** * [ADR-0106 D1] Every `FieldSchema` key, classified. Pinned against the live * schema shape by `object-schema-fls-references.test.ts`. @@ -435,7 +487,7 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { inlineAmountField: pointer, inlineColumns: arrayOf(inlineColumn), relatedListColumns: names, - dependsOn: names, + dependsOn: nameList(DEPENDS_ON_ENTRY_POSITIONS), // Evaluated against this object's record. expression, visibleWhen: expression, @@ -463,7 +515,7 @@ const crudFlag: Scrub = (value, denied) => ( const ttl: Scrub = (value, denied) => { if (value && typeof value === 'object' && !Array.isArray(value)) { const rec = value as Record; - if (typeof rec.field === 'string' && denied.has(rec.field)) return REMOVE; + if (typeof rec.field === 'string' && namesDeniedField(rec.field, denied)) return REMOVE; } return block({ field: pointer, expireAfter: keep, onlyWhen: fieldKeyed })(value, denied); }; @@ -471,8 +523,46 @@ const ttl: Scrub = (value, denied) => { /** `external.columnMap` is remote column → LOCAL field name; an entry mapping onto a denied field goes. */ const columnMap = recordOf(pointer); +/** + * [ADR-0106 D1] Every `ColumnPrefixSchema` key, classified: `field` is a sibling + * field of this object rendered before the cell value. + */ +export const COLUMN_PREFIX_POSITIONS: Readonly> = { field: pointer, type: keep }; + +/** + * [ADR-0106 D1] Every `ColumnSummaryConfigSchema` key, classified: `field` is + * the field of this object the footer aggregates. + */ +export const COLUMN_SUMMARY_POSITIONS: Readonly> = { type: keep, field: pointer }; + +/** `summary`: the bare aggregation (a closed vocabulary) or `{ type, field }`. */ +const columnSummary: Scrub = (value, denied) => ( + typeof value === 'string' ? value : block(COLUMN_SUMMARY_POSITIONS)(value, denied) +); + +/** + * [ADR-0106 D1] Every `ListColumnSchema` key, classified. A column is dropped + * when any facet reads a denied field — its own `field`, or the nested + * pointers `prefix.field` / `summary.field` (see {@link nameList}). `action` + * is a registered action id, `type` a renderer name; neither names a field. + * Pinned against the live schema shape by `object-schema-fls-references.test.ts`. + */ +export const LIST_COLUMN_POSITIONS: Readonly> = { + field: pointer, + label: keep, width: keep, align: keep, hidden: keep, sortable: keep, resizable: keep, + wrap: keep, type: keep, pinned: keep, link: keep, action: keep, + summary: columnSummary, + prefix: block(COLUMN_PREFIX_POSITIONS), +}; + /** The list-view keys that are column-style lists of this object's field names. */ -const LIST_VIEW_NAME_LISTS = ['columns', 'hiddenFields', 'fieldOrder', 'searchableFields', 'filterableFields'] as const; +const LIST_VIEW_NAME_LISTS: Readonly> = { + columns: nameList(LIST_COLUMN_POSITIONS), + hiddenFields: names, + fieldOrder: names, + searchableFields: names, + filterableFields: names, +}; /** * [ADR-0106 D1] Every top-level `ObjectSchema` key, classified. `fields` is @@ -522,7 +612,7 @@ 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([])), + actions: arrayOf(presentationEntry({})), ...PROTECTION_ENVELOPE, }; From 80e5f775deba32e6be3a0ef8a1a5f438037b9701 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:08:36 +0000 Subject: [PATCH 9/9] chore(changeset): object-form list columns and dotted paths in the object-schema mask scope Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 --- .changeset/21723-object-schema-mask-references.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/21723-object-schema-mask-references.md b/.changeset/21723-object-schema-mask-references.md index b82e31d7cb7..bbf0704e240 100644 --- a/.changeset/21723-object-schema-mask-references.md +++ b/.changeset/21723-object-schema-mask-references.md @@ -10,13 +10,13 @@ A caller who cannot read a field was served a document without that field's defi - **Object-level rule entries** (`validations`, `indexes`, `activityMilestones`). An entry that names or reads a denied field is dropped whole. The platform still evaluates the stored rule on every write. - **Role pointers** (`nameField`, `displayNameField`, `imageField`, `stageField`, `tenancy.tenantField`, `lifecycle.ttl.field`). A pointer to a denied field is deleted, so the role falls back to its default. -- **Name lists** (`highlightFields`, `searchableFields`, `publicSharing.redactFields`, list-view column lists, `external.columnMap` entries, and a readable field's `relatedListColumns` / `dependsOn`). The denied entries are filtered out. A list left empty is deleted. +- **Name lists** (`highlightFields`, `searchableFields`, `publicSharing.redactFields`, list-view column lists, `external.columnMap` entries, and a readable field's `relatedListColumns` / `dependsOn`). The denied entries are filtered out. A list-view column in object form is dropped when any of its facets names a denied field: its own `field`, its `prefix.field` or its `summary.field`. A `dependsOn` entry's `param` is the lookup target's key and is not read as a field here. A list left empty is deleted. - **The inline master-detail grid** (`inlineColumns`, `inlineAmountField`). These are declared on the child object's own `master_detail` field and name the child's own fields. A column that is a denied field, or is computed from one (`expr`), is dropped. A denied `inlineAmountField` is deleted. - **Expressions** (`titleFormat`, field-group `visibleWhen`, row-CRUD `visibleWhen` / `disabledWhen`, `publicSharing.eligibility`, lifecycle `onlyWhen`, and a readable field's formula `expression`, `visibleWhen` / `readonlyWhen` / `requiredWhen`, `relatedListFilter`, `defaultValue`, `autonumberFormat` and per-option `visibleWhen`). An expression that reads a denied field is deleted. The readable field itself stays. - **List views and actions.** An entry whose filter, sort, predicate, params or `patch` read a denied field is dropped. So is a list view whose key is a denied field's name. -In these positions an object key counts as a field reference only where keys are field names: a `FilterCondition`, a lifecycle `onlyWhen` map, an action's `patch`. A denied field named like a schema word (`type`, `source`, `name`) no longer removes every rule, view, action or CEL envelope. The mask also terminates on a cyclic document. +In these positions an object key counts as a field reference only where keys are field names: a `FilterCondition`, a lifecycle `onlyWhen` map, an action's `patch`. A denied field named like a schema word (`type`, `source`, `name`) no longer removes every rule, view, action or CEL envelope. A dotted path whose root segment is a denied field counts as a reference to it, whether it is a field-keyed key, a name-list entry or a pointer. The mask also terminates on a cyclic document. -Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field in any string or key, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema`, `FieldSchema` and `InlineGridColumnSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers. +Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field in any string or key, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema`, `FieldSchema`, `InlineGridColumnSchema`, `ListColumnSchema`, `ColumnPrefixSchema` and `ColumnSummaryConfigSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers. -The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions, including list views, actions and the inline grid, and its formula field uses the real `expression` key. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well. +The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions, including list views (one with object-form columns), actions and the inline grid, and its formula field uses the real `expression` key. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well.