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
46 changes: 46 additions & 0 deletions .changeset/18306-role-word-field-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/lint': minor
---

fix(lint)!: the ADR-0090 D3 vocabulary freeze visits `objects[].fieldGroups[]` (#18306)

<!-- adr-0087: not-required (no-migration-prescription) an authoring-time lint rule reports a word on one more declaration surface; no key, symbol, enum member or stored value moves, so a stored metadata row is structurally identical before and after and `objectstack migrate meta` has nothing to rewrite -->

**BREAKING** in the accept-set sense — a declaration that passes today can fail tomorrow.
Landing in the launch window as `minor` (the lockstep convention: `major` is refused by
`check-changeset-no-major`, and breaking-ness is carried by this banner plus the ADR-0087
disposition above).

**Clause-②: no (narrowing)** — the rule refuses more than it did; no key is added to any
published payload and no public surface grows, so `lanes/spec.md`'s widening test is not
met. Narrowing is still a semantic-surface change, which is why it is declared here rather
than shipped silently.

`security-role-word` (ADR-0090 D3) judged an object's name, field names and labels, action
names and labels, permission sets, positions, apps and books — and not the field-group
heading that renders directly above the fields it was already judging. So on one record page
a field labelled `Role Of Record` was refused while the group header above it,
`Account & Role`, was admitted: the author renames the field and the heading keeps the word.
That is the exact "refused on one surface, admitted on another" shape (#7220) that this
rule's own split was made to avoid, one grain finer.

Both halves of the group declaration are judged, as on every other surface: `key` is an
identifier (`Field.group` assigns membership by it, and a layout section's `group` inherits
the group by it, ADR-0085 §5), `label` is the header an admin reads. ADR-0090 D3 bans the
word in "identifiers, UI copy, and documentation", and a field group declares both.

Pages, views and components stay out, unchanged: `role` there is the HTML/ARIA attribute — a
machine word with a fixed foreign meaning, not a word the author picked. `listViews`,
`recordTypes` and the other label-bearing surfaces are deliberately not swept in with this;
each needs its own reading first.

**What an author does.** Nothing is renamed for you and nothing is auto-rewritten: the
platform vocabulary is `permission_set` (capability), `position` (distribution),
`business_unit` (hierarchy), and the refusal itself names it at the exact path
(`objects[i].fieldGroups[j].key` / `.label`). A group heading reading `Account & Role`
becomes `Account & Assignment`; a group keyed `role_info` becomes `assignment`, and the
member fields' `group` pointers move with it.

Unaffected: a system object (`sys_*` / `isSystem: true`) keeps the better-auth exemption on
its field groups exactly as it keeps it on its fields, and a group carrying no reserved word
is silent.
112 changes: 111 additions & 1 deletion packages/lint/src/validate-security-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,89 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => {
).toEqual([]);
});

// ── The field-group surface (#18306) ────────────────────────────────
// The gap this closed was the #7220 shape one grain finer than the one
// `validateSecurityRoleWord`'s own split exists to avoid: the field label
// below is refused, so the group HEADING above it has to be refused too, or
// the author renames the field and the heading keeps the word. The pair is
// asserted together, in one stack, because apart they are two passing tests
// that say nothing about the thing that was wrong.
it('refuses the reserved word on a field-group heading, beside the field it heads', () => {
const findings = validateSecurityRoleWord({
objects: [
{
name: 'showcase_contact',
label: 'Contact',
sharingModel: 'private',
fields: { duty: { name: 'duty', label: 'Role Of Record', group: 'assignment' } },
fieldGroups: [{ key: 'assignment', label: 'Account & Role' }],
},
],
});
expect(findings.map((f) => f.path)).toEqual([
'objects[0].fields.duty.label',
'objects[0].fieldGroups[0].label',
]);
expect(findings.every((f) => f.severity === 'error' && f.rule === SECURITY_ROLE_WORD)).toBe(true);
});

it('refuses the reserved token in a field-group KEY, and names `key` (not `name`) in the fix-it', () => {
// `ObjectFieldGroupSchema` spells the identifier `key` and REJECTS `name`
// as an alias, so a message naming `name` would point the author at a key
// the schema refuses.
const findings = validateSecurityRoleWord({
objects: [
{
name: 'showcase_contact',
sharingModel: 'private',
fieldGroups: [{ key: 'role_info', label: 'Assignment' }],
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'error',
rule: SECURITY_ROLE_WORD,
path: 'objects[0].fieldGroups[0].key',
where: 'field group "showcase_contact.role_info"',
});
expect(findings[0]!.message).toContain('field group key "role_info"');
expect(findings[0]!.message).not.toContain('field group name');
});

it('stays silent on a field group with no reserved word — the showcase shape', () => {
// The four groups `examples/app-showcase` authors on `showcase_contact`,
// plus the near-miss the sibling test pins one grain up (`payroll`).
expect(
validateSecurityRoleWord({
objects: [
{
name: 'showcase_contact',
label: 'Contact',
sharingModel: 'private',
fieldGroups: [
{ key: 'contact', label: 'Contact' },
{ key: 'work', label: 'Work' },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
{ key: 'payroll', label: 'Payroll — Controlled Rollout' },
],
},
],
}),
).toEqual([]);
});

it('exempts a field group on a system object, exactly as it exempts the fields under it', () => {
// The visit lives INSIDE the `isSystemObject` guard on purpose: a platform
// object whose fields are exempt cannot have a gated heading above them.
expect(
validateSecurityRoleWord({
objects: [{ name: 'sys_member', fieldGroups: [{ key: 'role_info', label: 'Organization Role' }] }],
}),
).toEqual([]);
});

// ── Rule: security-private-no-readscope (info) ──────────────────────
it('emits info when a set grants plain read on a private object without depth', () => {
const findings = validateSecurityPosture({
Expand Down Expand Up @@ -1262,7 +1345,10 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
receiver: 'obj',
// `security` is absent, and that is the #5017 fix: `ObjectSchema` declares
// the OWD dials FLAT and has no `security` envelope to nest them under.
expected: ['actions', 'externalSharingModel', 'fields', 'isSystem', 'label', 'name', 'sharingModel'],
// [#18306] `fieldGroups` joined when the ADR-0090 D3 vocabulary freeze
// started visiting the group headings that sit above the fields it already
// judged.
expected: ['actions', 'externalSharingModel', 'fieldGroups', 'fields', 'isSystem', 'label', 'name', 'sharingModel'],
declaredBy: 'ObjectSchema',
keys: () => Object.keys(ObjectSchema.shape),
},
Expand Down Expand Up @@ -1296,6 +1382,17 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
declaredBy: 'ObjectSchema.actions[]',
keys: () => shapeKeysOf(ObjectSchema.shape.actions),
},
// [#18306] The field-group heading surface. `key`, not `name`: the schema
// spells the identifier `key` and declares `name` as a REJECTED alias, so
// reading `group.name` here would be the #5017 shape — a consumer tolerating
// a spelling its own schema refuses by name. This entry is what makes that
// regression fail before review rather than after.
{
receiver: 'group',
expected: ['key', 'label'],
declaredBy: 'ObjectSchema.fieldGroups[]',
keys: () => shapeKeysOf(ObjectSchema.shape.fieldGroups),
},
{
receiver: 'app',
expected: ['label', 'name'],
Expand Down Expand Up @@ -1528,6 +1625,19 @@ const REACHABILITY_CORPUS: Array<{ label: string; stack: Record<string, unknown>
},
{ label: 'role-word (identifier)', stack: { objects: [objectFixture({ name: 'user_role', sharingModel: 'private' })] } },
{ label: 'role-word (label)', stack: { objects: [objectFixture({ name: 'user_duty', label: 'User Role', sharingModel: 'private' })] } },
// [#18306] Not a new push site — the same two branches, reached through the
// field-group surface. It earns its place in THIS corpus for the other
// guarantee the corpus makes: that the keys the rule reads are keys an
// author can legally write, which is the claim `group.key` / `group.label`
// rests on.
{
label: 'role-word (field-group key)',
stack: { objects: [objectFixture({ name: 'user_duty', sharingModel: 'private', fieldGroups: [{ key: 'role_info', label: 'Assignment' }] })] },
},
{
label: 'role-word (field-group label)',
stack: { objects: [objectFixture({ name: 'user_duty', sharingModel: 'private', fieldGroups: [{ key: 'assignment', label: 'Account & Role' }] })] },
},
{
label: 'book-audience-unknown-set',
stack: { books: [{ name: 'guide', label: 'Guide', slug: 'guide', groups: [], audience: { permissionSet: 'nobody_declares_this' } }] },
Expand Down
65 changes: 61 additions & 4 deletions packages/lint/src/validate-security-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,13 +1045,42 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
* [ADR-0090 D3] The `security-role-word` vocabulary freeze, as its own rule.
*
* Scope: security-relevant identifiers/labels across SIX collections — objects
* (names, field names, action names), permission sets, positions, apps, books.
* (names, field names, action names, field-group keys and labels), permission
* sets, positions, apps, books.
* Pages/views/components are NOT scanned — `role` there is HTML/ARIA
* semantics, not permission vocabulary. The sole platform exception
* (better-auth `sys_member.role`) is a system object, which app stacks never
* author. Books entered the security-relevant set when `book.audience` became
* a permission-model reference (ADR-0046 §6.7 / ADR-0090).
*
* ## Why `objects[].fieldGroups[]` IS scanned, and a page section is not
*
* [#18306] Measured, not assumed — the question is which fact separates the
* scanned surfaces from the excluded ones, and it is NOT "does this key carry
* permission semantics". `object.label`, `field.label` and `action.label`
* carry none either, and all three are scanned: the ban ADR-0090 D3 writes is
* on the WORD, in "identifiers, UI copy, and documentation". A field group
* declares both halves — `key` is an identifier (`Field.group` assigns
* membership by it, and a layout section's `group` inherits the whole group by
* it, ADR-0085 §5), and `label` is the section header an admin reads on the
* record page. So it is inside the ban by the ban's own terms.
*
* What excludes pages/views/components is a different fact: `role` there is
* the HTML/ARIA attribute — a machine word with a fixed foreign meaning, not a
* word the author picked. No such collision exists on a group header.
*
* Leaving the surface out built the #7220 shape one grain FINER than the one
* this function's own split exists to avoid: on a single record page a field
* labelled `Role Of Record` was refused while the group header directly above
* it, `Account & Role`, walked through — the author renames the field and the
* heading keeps the word. `key` is visited beside `label` for the same reason
* the other six surfaces visit name beside label: refusing `fields: { role }`
* while admitting `fieldGroups: [{ key: 'role' }]` is that shape again.
*
* Deliberately NOT widened with it: `listViews`, `recordTypes` and the other
* label-bearing surfaces. They are unmeasured here, not judged — each needs
* the same reading this one got before it is in or out.
*
* ## Why this is a separate function from {@link validateSecurityPosture}
*
* [#8310] Not taste — a surface boundary. When the rest of the D7 block
Expand All @@ -1076,15 +1105,30 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] {
const objects = recordsOf(stack.objects);
const permissionSets = recordsOf(stack.permissions);

const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => {
/**
* `idKey` is the spelling of the IDENTIFIER slot on the surface being
* flagged. Six of the seven spell it `name`; `ObjectFieldGroupSchema` spells
* it `key` and REJECTS `name` as an alias, so a fix-it naming `name` there
* would point the author at a key the schema refuses — and the derived
* sibling-label path would address a child of the identifier rather than the
* identifier's neighbour.
*/
const flagRole = (
kind: string,
name: unknown,
label: unknown,
where: string,
path: string,
idKey: 'name' | 'key' = 'name',
) => {
if (identifierHasRoleToken(name)) {
findings.push({
severity: 'error',
rule: SECURITY_ROLE_WORD,
where,
path,
message:
`${kind} name "${String(name)}" uses the reserved word "role" — the platform vocabulary ` +
`${kind} ${idKey} "${String(name)}" uses the reserved word "role" — the platform vocabulary ` +
`is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`,
hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`,
});
Expand All @@ -1093,7 +1137,7 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] {
severity: 'error',
rule: SECURITY_ROLE_WORD,
where,
path: `${path.replace(/\.name$/, '')}.label`,
path: `${path.replace(/\.(?:name|key)$/, '')}.label`,
message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`,
hint: `Relabel with 'Position' (distribution) or a domain word — admins must meet ONE vocabulary.`,
});
Expand All @@ -1111,6 +1155,19 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] {
for (const [ai, action] of recordsOf(obj.actions).entries()) {
flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`);
}
// [#18306] Field groups sit INSIDE this loop deliberately: the system-object
// exemption above (better-auth `sys_member`) has to cover a group header on
// a platform object exactly as it covers the fields under it.
for (const [gi, group] of recordsOf(obj.fieldGroups).entries()) {
flagRole(
'field group',
group.key,
group.label,
`field group "${objName}.${String(group.key ?? '?')}"`,
`objects[${i}].fieldGroups[${gi}].key`,
'key',
);
}
}
for (let i = 0; i < permissionSets.length; i++) {
const ps = permissionSets[i];
Expand Down
Loading