Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/21884-fls-mask-object-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/metadata-core': minor
'@objectstack/rest': patch
'@objectstack/runtime': patch
---

The object-schema field mask (ADR-0106 D1) judges an action param that names another object's field through `objectOverride` against that object, not the one being served.

Clause-②: yes (widening)

**What a user saw.** A `delegated_admin` may invite members, and the invite door admits them, but `GET /meta/object/sys_user` served that principal no `invite_user` action. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`: it names `sys_member.role`. The mask read every param's `field` as a field of the served object, so a caller denied `sys_user.role` lost the whole action. A plain `member` lost it the same way. The member is now served the action too, and still not offered it: the action's `requiresMembershipReach` predicate excludes the member grade.

**The rule.** A param whose `objectOverride` names another object reads that object's field. It is judged against the caller's readable fields on that object, and it is not a reference to the served object's fields. The action is still dropped when the caller cannot read the field there, and when that object's readable fields cannot be determined (no answer from the security service, a security service that throws, or an object that does not exist). Nothing about the other object is served on a guess. The rest of the param is still read against the served object: `visible`, an option's `visibleWhen`, `defaultValue`, and an explicit `name` that differs from `field`. A `name` that only repeats `field` is read as that field. With `defaultFromRow`, the param also reads `field` from the served object's row, so `field` is judged against the served object too. An exempt caller (platform admin, `isSystem`) is served the whole schema, as before.

**The API (`@objectstack/metadata-core`), additive.**

- `relateObjectSchemaMaskPosture(posture, ...documents)` completes a `project` posture for the documents it is about to mask. It reads the caller's readable fields on each other object their action params name through `objectOverride`. It runs after the fetch, because only the document names those objects. It returns every other posture, and any document with no such param, unchanged, and it never throws.
- The `project` member of `ObjectSchemaMaskPosture` gains two optional fields. `relate` asks the posture's question (same caller, same security service) about another object. `resolveObjectSchemaMaskPosture` sets it. `related` holds the answers. A `project` posture built without `related` gets no answers, so `applyObjectSchemaMask` drops every action with such a param.
- `applyObjectSchemaMask` folds each related read it withholds into the fingerprint, written as `object.field`. Two callers who are denied the same fields on the served object but differ on the other object get different validators. An unrestricted caller's ETag is unchanged.
- The shared contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) gains two actions whose params read `contact` fields through `objectOverride`. The contract's projection cases now require the readable one to be served and the denied one to be dropped. An exit that never relates its posture fails the contract by name.

**Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** These exits relate the posture after the fetch, before the projection: the shared item, layered and list chains, `RestServer`'s cached read and published read, and the runtime dispatcher's mask. The `/meta` diff route masks `fields` only and needs no relate step.

**Measured on a showcase boot.** We read every object schema (78 objects, by-name read and list read) as five principals: a platform admin, an org owner, an admin, a `delegated_admin` and a `member`. Before and after this change, the only served action that moved is `sys_user.invite_user`, which is now served to the `delegated_admin` and the `member`. This repository has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` and `sys_member.invite_user`'s `email` (on `sys_invitation`). The second was served to all five principals before and after.
21 changes: 18 additions & 3 deletions packages/metadata-core/src/object-schema-fls-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
* pointers, name lists, an expression, a field-group predicate, an index,
* list views (a column list, a filter, a view KEYED by a field's name, an
* object-form column naming one through its nested `prefix` / `summary`),
* actions (a visibility predicate) — and, inside the READABLE fields, a name
* actions (a visibility predicate, and — #21884 — a param that reads a field of
* ANOTHER object through `objectOverride`, which every exit must relate after
* its fetch and judge against THAT object) — and, inside the READABLE fields, a name
* list, a `dependsOn`, a predicate, a formula `expression` and an inline grid
* (`inlineColumns` by name and by computed `expr`, `inlineAmountField`) that
* read a sibling. The inline grid sits on `name` only so that a field every
Expand Down Expand Up @@ -100,6 +102,12 @@ export const FLS_CONTRACT_OBJECT = {
actions: [
{ name: 'regrade', label: 'Regrade', type: 'script', visible: 'record.salary_grade != null' },
{ name: 'rename', label: 'Rename', type: 'script', visible: 'record.name != null' },
// [#21884] Params reading `contact`'s fields. The security double answers
// the same set for every object, so `contact.name` is readable wherever
// `account.name` is: an exit that never relates its posture withholds
// `invite` (fail closed) and fails the `retained` half by name.
{ name: 'invite', label: 'Invite', type: 'script', params: [{ field: 'name', objectOverride: 'contact' }] },
{ name: 'escalate', label: 'Escalate', type: 'script', params: [{ field: 'bonus_formula', objectOverride: 'contact' }] },
],
fields: {
id: { type: 'text', label: 'Id' },
Expand Down Expand Up @@ -195,7 +203,10 @@ const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [
compact: { label: 'Compact', type: 'grid', columns: [{ field: 'name', width: 200 }] },
}),
},
{ what: 'the action whose predicate reads a readable field', holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename']) },
{
what: 'the action whose predicate reads a readable field, and the one whose `objectOverride` param reads a field readable on THAT object',
holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite']),
},
];

/**
Expand Down Expand Up @@ -229,6 +240,10 @@ const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [
&& d?.fields?.name?.inlineColumns?.[2]?.expr === 'bonus_formula * 2'
&& d?.fields?.name?.inlineAmountField === 'bonus_formula',
},
{
what: 'both actions whose `objectOverride` params read fields readable on THAT object',
holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite', 'escalate']),
},
];

/** An unmasked answer is the whole fixture — every reference in every position. */
Expand All @@ -241,7 +256,7 @@ const RETAINED_UNMASKED: readonly FlsContractRetention[] = [
what: 'every inline-grid column, every list view and every action',
holds: (d) => d?.fields?.name?.inlineColumns?.length === 4
&& Object.keys(d?.listViews ?? {}).length === 4 && d?.listViews?.compact?.columns?.length === 3
&& d?.actions?.length === 2,
&& d?.actions?.length === 4,
},
];

Expand Down
154 changes: 152 additions & 2 deletions packages/metadata-core/src/object-schema-fls-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
import { describe, it, expect } from 'vitest';
import { FieldSchema, InlineGridColumnSchema, ObjectSchema } from '@objectstack/spec/data';
import { ColumnPrefixSchema, ColumnSummaryConfigSchema, ListColumnSchema } from '@objectstack/spec/ui';
import { applyObjectSchemaMask, type ObjectSchemaMaskPosture } from './object-schema-fls.js';
import {
applyObjectSchemaMask,
relateObjectSchemaMaskPosture,
resolveObjectSchemaMaskPosture,
type ObjectSchemaMaskPosture,
} from './object-schema-fls.js';
import {
COLUMN_PREFIX_POSITIONS,
COLUMN_SUMMARY_POSITIONS,
Expand Down Expand Up @@ -458,6 +463,146 @@ describe('mentionsDenied is an identifier-token test', () => {
});
});

describe('[ADR-0106 D1] an action param under `objectOverride` is judged against the object it names', () => {
// The shape of `sys_user.invite_user`: `role` is a field of BOTH objects,
// and the param names the member's — not the user's.
const SYS_USER = {
name: 'sys_user',
fields: { id: { type: 'text' }, email: { type: 'email' }, role: { type: 'text' } },
actions: [
{
name: 'invite_user',
label: 'Invite User',
type: 'api',
params: [
{ field: 'email', required: true },
{ field: 'role', objectOverride: 'sys_member', required: true },
],
},
{ name: 'set_role', label: 'Set Role', type: 'api', params: [{ field: 'role', required: true }] },
{ name: 'mail', label: 'Mail', type: 'api', params: [{ field: 'email' }] },
],
};

/** A security service answering per object, as plugin-security does. */
const securityFor = (answers: Record<string, string[] | undefined | 'throw'>) => ({
getMetadataReadableFields: async (object: string) => {
const answer = answers[object];
if (answer === 'throw') throw new Error(`security unhealthy for ${object} (test)`);
return answer === undefined ? undefined : [...answer];
},
});

/** The exit's sequence: resolve before the fetch, relate after it, mask. */
const serve = async (
answers: Record<string, string[] | undefined | 'throw'>,
document: Record<string, unknown> = SYS_USER,
context: Record<string, unknown> = { userId: 'u_delegate', systemPermissions: [] },
) => {
const posture = await resolveObjectSchemaMaskPosture({
objectName: String(document.name), context, security: securityFor(answers), enabled: true,
});
return applyObjectSchemaMask(document, await relateObjectSchemaMaskPosture(posture, document));
};
const actionNames = (result: { document: unknown }) => ((result.document as any).actions ?? []).map((a: any) => a.name);

it('serves `invite_user` to a caller denied THIS object\'s `role` who may read the named object\'s `role` (the delegated_admin shape)', async () => {
const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role', 'user_id'] });
expect(result.denied).toEqual(['role']);
expect(actionNames(result)).toEqual(['invite_user', 'mail']);
// Served as authored — the param still names the member's `role`.
expect((result.document as any).actions[0].params[1]).toEqual({ field: 'role', objectOverride: 'sys_member', required: true });
});

it('still drops an action whose param names a denied field of THIS object', async () => {
const result = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] });
expect(actionNames(result)).not.toContain('set_role');
// An override naming this object IS this object: the same reading.
const self = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] }, {
...SYS_USER,
actions: [{ name: 'self_role', type: 'api', params: [{ field: 'role', objectOverride: 'sys_user' }] }],
});
expect(actionNames(self)).toEqual([]);
});

it('still drops the action when the caller is denied the field on the object the override names', async () => {
const denied = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'user_id'] });
expect(actionNames(denied)).toEqual(['mail']);
// Even when nothing of THIS object is denied: the read is the other object's.
const thisWhole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'user_id'] });
expect(thisWhole.denied).toEqual([]);
expect(actionNames(thisWhole)).toEqual(['set_role', 'mail']);
});

it('fails closed when the named object\'s readable set cannot be determined — undetermined, throwing, or unknown', async () => {
for (const sysMember of [undefined, 'throw'] as const) {
expect(actionNames(await serve({ sys_user: ['id', 'email', 'role'], sys_member: sysMember }))).toEqual(['set_role', 'mail']);
}
const unknown = await serve({ sys_user: ['id', 'email', 'role'] }, {
...SYS_USER,
actions: [{ name: 'ghost', type: 'api', params: [{ field: 'role', objectOverride: 'no_such_object' }] }],
});
expect(actionNames(unknown)).toEqual([]);
// A posture nobody related (an exit that skipped the step) relates nothing: closed too.
const unrelated = applyObjectSchemaMask(SYS_USER, { kind: 'project', readable: new Set(['id', 'email', 'role']) });
expect(actionNames(unrelated)).toEqual(['set_role', 'mail']);
});

it('leaves an exempt caller\'s schema whole, and never asks about the related object', async () => {
let asked = 0;
const posture = await resolveObjectSchemaMaskPosture({
objectName: 'sys_user',
context: { userId: 'u_admin', systemPermissions: ['setup.access'] },
security: { getMetadataReadableFields: async () => { asked++; return []; } },
enabled: true,
});
const related = await relateObjectSchemaMaskPosture(posture, SYS_USER);
expect(related).toBe(posture);
expect(applyObjectSchemaMask(SYS_USER, related).document).toBe(SYS_USER);
expect(asked).toBe(0);
});

it('reads a `name` restating `field` as that field; an explicit other `name`, and a `defaultFromRow` seed, as THIS object\'s', async () => {
const answers = { sys_user: ['id', 'email'], sys_member: ['id', 'role', 'title'] };
const withParam = (param: Record<string, unknown>) => ({ ...SYS_USER, actions: [{ name: 'act', type: 'api', params: [param] }] });
// The default body key, spelled out, is the same read.
expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']);
// A body key that differs from the field keeps the existing reading: it
// spells a denied field of this object, so the action goes.
expect(actionNames(await serve(answers, withParam({ name: 'role', field: 'title', objectOverride: 'sys_member' })))).toEqual([]);
expect(actionNames(await serve(answers, withParam({ name: 'member_role', field: 'role', objectOverride: 'sys_member' })))).toEqual(['act']);
// `defaultFromRow` seeds the value from THIS object's row — a read here too.
expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', defaultFromRow: true })))).toEqual([]);
// The rest of the param is still this object's: a predicate over a denied field drops it.
expect(actionNames(await serve(answers, withParam({ field: 'role', objectOverride: 'sys_member', visible: 'record.role != null' })))).toEqual([]);
});

it('folds a withheld related read into the fingerprint, so two cohorts never share a validator for different bodies', async () => {
const reads = await serve({ sys_user: ['id', 'email'], sys_member: ['id', 'role'] });
const cannot = await serve({ sys_user: ['id', 'email'], sys_member: ['id'] });
expect(reads.denied).toEqual(cannot.denied);
expect(reads.fingerprint).not.toBe(cannot.fingerprint);
// An unrestricted caller on both objects keeps the empty fingerprint and the same reference.
const whole = await serve({ sys_user: ['id', 'email', 'role'], sys_member: ['id', 'role'] });
expect(whole.fingerprint).toBe('');
expect(whole.document).toBe(SYS_USER);
});

it('relates each named object once, across documents, and leaves a document with no such read alone', async () => {
const asked: string[] = [];
const posture = await resolveObjectSchemaMaskPosture({
objectName: 'sys_user',
context: { userId: 'u' },
security: { getMetadataReadableFields: async (object: string) => { asked.push(object); return ['id', 'role']; } },
enabled: true,
});
expect(await relateObjectSchemaMaskPosture(posture, { name: 'sys_user', actions: [] })).toBe(posture);
const related = await relateObjectSchemaMaskPosture(posture, SYS_USER, SYS_USER);
expect(await relateObjectSchemaMaskPosture(related, SYS_USER)).toBe(related);
expect(asked).toEqual(['sys_user', 'sys_member']);
});
});

/** The keys a Zod object schema declares. */
function declaredKeys(schema: unknown): string[] {
const shape = (schema as { shape?: Record<string, unknown> }).shape;
Expand Down Expand Up @@ -509,7 +654,12 @@ describe('[ADR-0106] the shared contract table, driven through the bare projecti
for (const testCase of OBJECT_SCHEMA_MASK_CASES.filter((c) => c.expect.kind === 'fields')) {
it(testCase.id, () => {
const readable = testCase.readable as readonly string[];
const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, project([...readable]));
// Related as an exit relates it: the contract's double answers the
// same set for every object, `contact` included (#21884).
const posture: ObjectSchemaMaskPosture = {
kind: 'project', readable: new Set(readable), related: new Map([['contact', new Set(readable)]]),
};
const { document } = applyObjectSchemaMask(FLS_CONTRACT_OBJECT, posture);
assertObjectSchemaMaskCase('applyObjectSchemaMask', testCase, { kind: 'document', document });
});
}
Expand Down
Loading
Loading