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
12 changes: 12 additions & 0 deletions .changeset/30303-fls-key-extension-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/lint': patch
---

fix(lint): a permission set's field key on a field that an `objectExtensions` entry adds, a sibling package's in the same artifact included, is no longer refused as `security-fls-unknown-field` (#30303)

Clause-②: yes (widening: the FLS-key check accepts extension-added fields)
Metadata-protocol: none

- **Before.** The FLS-key check resolved `object.field` against the object's declared `fields` only. On a multi-package artifact, an app package's permission set naming `crm_contract.o2c_probe_note`, a field its module package adds to `crm_contract` through `objectExtensions`, failed `os build`, `os validate` and `os lint` with "crm_contract declares no field o2c_probe_note", although the runtime serves the field and the key masks it. A single-package stack's own extension fields were refused the same way.
- **Now.** The key resolves against the object as the registry serves it: its declared fields plus every field an `objectExtensions` entry of any body of the artifact adds to it, folded as `@objectstack/objectql` folds them (fields by name; one entry per package per object, a later entry replacing an earlier one; `extend` naming the object verbatim). A field that no body declares or adds keeps the same refusal, with the same rule id, severity and message, and so does a field an entry only patches through `fieldPatches`, which adds no field. The hint's nearest name reads the served fields too.
- **One fold.** The controlled-by-parent merge (#23210), the membership-key column check and the FLS-key check now read extensions through one shared fold in `object-graph.ts`.
89 changes: 89 additions & 0 deletions packages/lint/src/object-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
nearestName,
listNames,
packagesOf,
objectExtensionsByTarget,
objectsAsServed,
RELATIONSHIP_FIELD_TYPES,
} from './object-graph.js';
import { walkFilterFieldKeys, type FilterFieldKey } from './filter-walk.js';
Expand Down Expand Up @@ -345,6 +347,93 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)'
});
});

/**
* [#30303] The shared extension fold on junk input, beside the
* `indexObjectGraph` and `packagesOf` cases above. Neither export refuses
* anything: a malformed `packages` is {@link packagesOf}'s refusal, met by a
* caller that reads the bodies through it (`validateSecurityPosture` does,
* before either fold runs). Each shape below reads as "nothing to fold" for
* the part that is junk, and never throws.
*/
describe('[#30303] object-graph — objectExtensionsByTarget / objectsAsServed on junk input', () => {
const OBJ = { name: 'crm_contract', fields: { contract_no: { type: 'text' } } };
const EXT = { extend: 'crm_contract', fields: { note: { type: 'text' } } };
const served = (stack: unknown) =>
objectsAsServed(stack).objects.map((o) => [o.name, (o.fields as Array<{ name: string }> | Record<string, unknown>)]);

it('a null or non-record stack: no extensions, no objects', () => {
for (const stack of [null, undefined, 'junk', 42, []]) {
expect(objectExtensionsByTarget(stack).size, String(stack)).toBe(0);
expect(objectsAsServed(stack), String(stack)).toEqual({ objects: [], tied: new Map() });
}
});

it('packages: [null] and other non-record entries or bodies contribute nothing; a record body beside them still does', () => {
expect(objectExtensionsByTarget({ packages: [null] }).size).toBe(0);
const stack = {
objects: [OBJ],
packages: [null, 'junk', 42, [], { manifest: null }, { manifest: 'junk' }, { manifest: { objectExtensions: [EXT] } }],
};
expect([...objectExtensionsByTarget(stack).entries()].map(([t, list]) => [t, list.map((e) => [e.carrier, e.path])])).toEqual([
['crm_contract', [[6, 'packages[6].manifest.objectExtensions[0]']]],
]);
expect(objectsAsServed(stack).objects[0].fields).toEqual([
{ name: 'contract_no', type: 'text' },
{ name: 'note', type: 'text' },
]);
});

it('a non-array objectExtensions folds nothing', () => {
for (const objectExtensions of [null, 'junk', 42, { crm_contract: EXT }]) {
const stack = { objects: [OBJ], objectExtensions };
expect(objectExtensionsByTarget(stack).size, JSON.stringify(objectExtensions)).toBe(0);
expect(objectsAsServed(stack).objects, JSON.stringify(objectExtensions)).toEqual([OBJ]);
}
});

it('a non-record entry, or one whose extend is not a string, is skipped; the record entry beside it is read', () => {
const stack = {
objects: [OBJ],
objectExtensions: [null, 'junk', 42, [], { extend: 7, fields: { x: { type: 'text' } } }, { fields: { y: {} } }, EXT],
};
expect([...objectExtensionsByTarget(stack).keys()]).toEqual(['crm_contract']);
expect(objectExtensionsByTarget(stack).get('crm_contract')!.map((e) => e.path)).toEqual(['objectExtensions[6]']);
expect(served(stack)).toEqual([['crm_contract', [{ name: 'contract_no', type: 'text' }, { name: 'note', type: 'text' }]]]);
});

it('junk fields add nothing: a non-collection, non-record members, a member with no name', () => {
for (const fields of [null, 'junk', 42, [null, 'x', 3, { type: 'text' }, { name: '', type: 'text' }]]) {
const stack = { objects: [OBJ], objectExtensions: [{ extend: 'crm_contract', fields }] };
expect(() => objectsAsServed(stack), JSON.stringify(fields)).not.toThrow();
expect(objectsAsServed(stack).objects[0].fields, JSON.stringify(fields)).toEqual([{ name: 'contract_no', type: 'text' }]);
}
// The array form is read by name, as the map form is.
const arrayForm = { objects: [OBJ], objectExtensions: [{ extend: 'crm_contract', fields: [{ name: 'note', type: 'text' }] }] };
expect(served(arrayForm)).toEqual([['crm_contract', [{ name: 'contract_no', type: 'text' }, { name: 'note', type: 'text' }]]]);
});

// `ObjectExtensionSchema.priority` is a number, so a parsed stack never
// carries this shape; the fold reads a non-number as absent (the default 200,
// as `SchemaRegistry.registerObjectExtension` does) rather than guessing.
it('a string priority reads as the default 200 and never throws', () => {
const stack = {
objects: [OBJ],
packages: [
{ manifest: { objectExtensions: [{ extend: 'crm_contract', priority: '100', fields: { note: { type: 'text', label: 'A' } } }] } },
{ manifest: { objectExtensions: [{ extend: 'crm_contract', priority: 150, fields: { note: { type: 'text', label: 'B' } } }] } },
],
};
expect(objectExtensionsByTarget(stack).get('crm_contract')!.map((e) => [e.carrier, e.priority])).toEqual([
[1, 150],
[0, 200],
]);
// So the string-priority entry folds LAST and its definition wins, untied.
const { objects, tied } = objectsAsServed(stack);
expect((objects[0].fields as Array<Record<string, unknown>>).find((f) => f.name === 'note')?.label).toBe('A');
expect(tied.size).toBe(0);
});
});

/**
* [#19289] A `{ type: 'user' }` field with no `reference` is TRAVERSABLE — the
* third defect found by the implicit-target census, and the widest-reaching of
Expand Down
135 changes: 135 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,141 @@ export function indexObjectGraph(stack: unknown): ObjectGraph {
return graph;
}

/**
* One `objectExtensions` entry as the registry registers it — see
* {@link objectExtensionsByTarget}.
*/
export interface VisibleObjectExtension {
/** The position of the package that ships it: registration order across packages. */
carrier: number;
/** The entry's path, e.g. `objectExtensions[0]` or `packages[1].manifest.objectExtensions[0]`. */
path: string;
/** `ext.priority ?? 200`, the registry's default. */
priority: number;
/** Its field records, name folded in ({@link recordsOf}). */
fields: AnyRec[];
}

/**
* [#30303] Every `objectExtensions` entry of a stack, by the object it
* `extend`s, as the registry registers and folds them. This is the ONE place
* this package reads extensions for an object's merged field set: the
* `controlled_by_parent` merge of `validate-security-posture.ts` (#23210),
* the membership-key columns of `validate-membership-key-declarations.ts`,
* and the served field set {@link objectsAsServed} hands the FLS-key check.
*
* Read as the registry reads a manifest, since `@objectstack/lint` imports no
* runtime (`ObjectQL.registerApp`'s `objectExtensions` loop,
* `SchemaRegistry.registerObject`'s `extend` arm and `foldExtenders`, in
* `@objectstack/objectql`):
*
* - Source: with `packages[]`, every body's `objectExtensions`
* (`packages[j].manifest`, `carrier` = `j`, the artifact's registration
* order); without it, the stack's own (`carrier` 0). An artifact's top level
* repeats its bodies' collections, so it is not read beside them.
* - `extend` names its target verbatim. ⚠️ The registry does one more thing
* this fold does not mirror (#28858, `SchemaRegistry.extensionTargetKey`):
* a short name that nothing is registered under but that `getObject` folds
* to a legacy `<ns>__<name>` key is filed under that key, with a warning.
* Lint reads the stack's own object names and knows no namespace, so such
* an entry folds onto nothing here: a key naming its field reads as
* unknown, the refusing direction, and the warning's remedy (write the key
* the object is registered under) is also what makes the fold see it.
* - ONE entry per package per target: registering an extension removes that
* package's earlier one, so a later entry of the same package replaces an
* earlier one whole, its fields included.
* - Fold order: ascending `priority` (default 200), equal priorities in
* package order. Each list is returned in that order.
*
* Which targets to judge is the caller's question: an entry is listed here
* whether or not the stack declares its target.
*/
export function objectExtensionsByTarget(stack: unknown): Map<string, VisibleObjectExtension[]> {
const byTarget = new Map<string, VisibleObjectExtension[]>();
if (!isRec(stack)) return byTarget;
// The receivers are named as `validate-security-posture.test.ts`'s key-read
// meta-test tables them (`stack`, `entry`, `body`, `ext`, `obj`, `f`): that
// scan reads this fold as part of the rules that read through it.
const collect = (entries: unknown, carrier: number, at: string) => {
if (!Array.isArray(entries)) return;
entries.forEach((ext: unknown, i: number) => {
if (!isRec(ext) || typeof ext.extend !== 'string') return;
const kept = (byTarget.get(ext.extend) ?? []).filter((other) => other.carrier !== carrier);
kept.push({
carrier,
path: `${at}objectExtensions[${i}]`,
priority: typeof ext.priority === 'number' ? ext.priority : 200,
fields: recordsOf(ext.fields),
});
byTarget.set(ext.extend, kept);
});
};
if (Array.isArray(stack.packages)) {
stack.packages.forEach((entry: unknown, j: number) => {
const body = isRec(entry) ? entry.manifest : undefined;
if (isRec(body)) collect(body.objectExtensions, j, `packages[${j}].manifest.`);
});
} else {
collect(stack.objectExtensions, 0, '');
}
for (const list of byTarget.values()) {
list.sort((left, right) => left.priority - right.priority || left.carrier - right.carrier);
}
return byTarget;
}

/**
* [#30303] A stack's `objects` as the registry SERVES them: each with every
* {@link objectExtensionsByTarget} entry that extends it folded in, the way
* `mergeObjectDefinitions` merges `fields` (by name, additively, a later
* writer replacing the whole field). An object nothing extends is returned as
* it is.
*
* Only the fields move. The registry plans an object's injected system
* columns over its base layer (`foldExtenders` reads
* `resolveInjectedSystemColumns(baseDefinition)`), and that plan reads no
* field, so {@link indexObjectGraph} over these objects injects what it
* injects over the declared ones. `fieldPatches` add no field (they patch the
* owner's own), so they are not read.
*
* A field two packages write at the priority that decides it is merged (its
* name is served whichever wins) and named in `tied`, for a caller whose
* verdict depends on which definition wins: registration order decides that,
* and a stack does not state it.
*/
export function objectsAsServed(stack: unknown): { objects: AnyRec[]; tied: Map<string, Set<string>> } {
const objects = isRec(stack) ? recordsOf(stack.objects) : [];
const tied = new Map<string, Set<string>>();
const extensions = objectExtensionsByTarget(stack);
if (extensions.size === 0) return { objects, tied };

const served = objects.map((obj) => {
const name = strName(obj.name);
const exts = name ? extensions.get(name) : undefined;
if (!name || !exts) return obj;
const fields = new Map<string, AnyRec>();
for (const f of recordsOf(obj.fields)) {
const key = strName(f.name);
if (key) fields.set(key, f);
}
const lastWriter = new Map<string, VisibleObjectExtension>();
const tiedKeys = new Set<string>();
for (const ext of exts) {
for (const f of ext.fields) {
const key = strName(f.name);
if (!key) continue;
if (lastWriter.get(key)?.priority === ext.priority) tiedKeys.add(key);
else tiedKeys.delete(key);
lastWriter.set(key, ext);
fields.set(key, f);
}
}
if (tiedKeys.size > 0) tied.set(name, tiedKeys);
return { ...obj, fields: [...fields.values()] };
});
return { objects: served, tied };
}

/** What a path resolved to, or precisely how it failed. */
export type FieldPathVerdict =
/**
Expand Down
3 changes: 2 additions & 1 deletion packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,8 @@ const SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = {
'Unlike an unqualified key (`security-fls-unqualified-key`) this one looks correct in review, and ' +
'it is exactly what a field rename leaves behind; an empty remainder is what a half-finished edit ' +
'leaves. A key with more dots is judged on its whole remainder, because FLS keys address columns, ' +
'never joins.',
'never joins. The columns are the object\'s as the registry serves it: the fields it declares and ' +
'every field an `objectExtensions` entry adds to it, from any package of the same artifact.',
'Not judged: an object this stack does not define, an object with no readable field map, and a ' +
'registry-injected system column, which is real and addressable. To fix it, point the key at a ' +
'field the object declares, or delete the entry: an entry that cannot match is not protection, ' +
Expand Down
72 changes: 2 additions & 70 deletions packages/lint/src/validate-membership-key-declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
import {
indexObjectGraph,
isUnjudgeable,
objectsAsServed,
recordsOf,
resolveFieldPath,
suggestName,
Expand Down Expand Up @@ -205,75 +206,6 @@ export function stackObjectNamesOf(stack: unknown): Set<string> {
return names;
}

/** One `objectExtensions` entry, as the registry folds it. */
interface VisibleExtension {
/** Package order: registration order across packages. */
carrier: number;
/** `priority ?? 200`, the registry's default. */
priority: number;
fields: AnyRec[];
}

/**
* The stack's objects with every `objectExtensions` entry folded in, as the
* registry merges them (this file's header). A field two packages write at the
* priority that decides it is left out of the merged map and named in
* `tied`, so its verdict is "not judged" rather than either writer's.
*/
function mergedObjectsOf(stack: AnyRec): { objects: AnyRec[]; tied: Map<string, Set<string>> } {
const objects = recordsOf(stack.objects);
const tied = new Map<string, Set<string>>();
const names = new Set(objects.map((obj) => str(obj.name)).filter(Boolean));
const extensions = new Map<string, VisibleExtension[]>();
const collect = (entries: unknown, carrier: number) => {
if (!Array.isArray(entries)) return;
for (const entry of entries) {
if (!isRec(entry) || typeof entry.extend !== 'string' || !names.has(entry.extend)) continue;
const list = extensions.get(entry.extend) ?? [];
// One per package per target: the later entry wins.
const kept = list.filter((ext) => ext.carrier !== carrier);
kept.push({
carrier,
priority: typeof entry.priority === 'number' ? entry.priority : 200,
fields: recordsOf(entry.fields),
});
extensions.set(entry.extend, kept);
}
};
if (Array.isArray(stack.packages)) {
stack.packages.forEach((pkg: unknown, j: number) => {
if (isRec(pkg) && isRec(pkg.manifest)) collect(pkg.manifest.objectExtensions, j);
});
} else {
collect(stack.objectExtensions, 0);
}
if (extensions.size === 0) return { objects, tied };

const merged = objects.map((obj) => {
const name = str(obj.name);
const exts = extensions.get(name);
if (!exts) return obj;
const fields = new Map<string, AnyRec>();
for (const f of recordsOf(obj.fields)) if (str(f.name)) fields.set(str(f.name), f);
const lastWriter = new Map<string, VisibleExtension>();
const tiedKeys = new Set<string>();
for (const ext of [...exts].sort((a, b) => a.priority - b.priority || a.carrier - b.carrier)) {
for (const f of ext.fields) {
const key = str(f.name);
if (!key) continue;
const previous = lastWriter.get(key);
if (previous && previous.priority === ext.priority) tiedKeys.add(key);
else tiedKeys.delete(key);
lastWriter.set(key, ext);
fields.set(key, f);
}
}
if (tiedKeys.size > 0) tied.set(name, tiedKeys);
return { ...obj, fields: [...fields.values()] };
});
return { objects: merged, tied };
}

/** U's verdict: `null` when it is a single-valued user reference, else the reason. */
function userFieldFault(meta: GraphField): string | null {
const isUserRef =
Expand Down Expand Up @@ -332,7 +264,7 @@ export function validateMembershipKeyDeclarations(
const declared = declaredMembershipKeysOf(stack);
if (declared.length === 0) return findings;

const { objects: merged, tied } = mergedObjectsOf(stack);
const { objects: merged, tied } = objectsAsServed(stack);
const graph = indexObjectGraph({ objects: merged });
const stackNames = stackObjectNamesOf(stack);
const owners = options.membershipKeyOwners;
Expand Down
Loading
Loading