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
16 changes: 16 additions & 0 deletions .changeset/security-fls-unknown-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/lint": minor
---

New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`.

`security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `<object>.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set.

The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind.

- **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages.
- **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`.
- **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not.
- **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors.

**What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero.
2 changes: 1 addition & 1 deletion packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1410,7 +1410,7 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// rule therefore stays behind WHOLE (#8310's explicit call), as its own
// entry.
//
// This entry remains the rest of the D7 block (12 rule ids) as ONE
// This entry remains the rest of the D7 block (14 rule ids) as ONE
// registration, not a per-rule split: the baseline/candidate differential is
// what keeps a write of one declared type from leaking the other rules'
// whole-stack findings — every finding derived from a sibling collection is
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ export {
SECURITY_PRIVATE_NO_READSCOPE,
SECURITY_MASTER_DETAIL_UNGRANTED,
SECURITY_FLS_UNQUALIFIED_KEY,
SECURITY_FLS_UNKNOWN_FIELD,
SECURITY_GRANT_EXPIRED_AT_AUTHORING,
SECURITY_DELEGATION_MISSING_REASON,
SECURITY_CBP_NO_RELATION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,15 @@ describe('validateSecurityPosture at the runtime publish surface (#7576 → #830
expect(stackKeyForType('flow')).toBe('flows');
});

it('[#8310] seed / permission / book / object all cross — the completed flip, on the whole 12-rule entry', () => {
it('[#8310] seed / permission / book / object all cross — the completed flip, on the whole 14-rule entry', () => {
// The registration the #7891 programme was for: the
// `validateSecurityPosture` entry (12 rule ids — `security-role-word` is
// `validateSecurityPosture` entry (14 rule ids — `security-role-word` is
// its own entry now, see below) declares all four mapped types.
// ⚠️ The count was written as 12 and had already drifted: it missed
// `security-cbp-ambiguous-relation` (#14747). Re-measured by counting the
// distinct `rule:` constants this function emits — 13 before
// `security-fls-unknown-field` (#16108), 14 with it. The number is prose,
// not an assertion; the pins below are what hold.
// `permission`/`book` measured ZERO refusals when they crossed (PR
// #8546); `object` crosses under the #8310 maintainer ruling with the
// red suites repaired honestly (fixtures author their `sharingModel`).
Expand Down
Loading
Loading