Skip to content

A field denied by FLS (readable:false) is exactly recoverable from a readable formula that references it — measured on showcase_project.budget #9562

Description

@os-zhuang

Measured while pinning the FLS read side for #9481 (the pin itself deliberately asserts nothing about this, so that a future fix does not read as a regression).

What was measured

Boot: stock showcase, dogfood harness, app-default security wiring. A scratch permission set grants a fresh member exactly one thing:

{ "showcase_project.budget": { "readable": false, "editable": false } }

GET /api/v1/data/showcase_project/:id as that member returns 200 with budget correctly stripped — the key is absent, which is the documented and now-pinned behaviour. The same payload also carries:

  • spent: 60000 (not enumerated by the set, so readable — correct)
  • budget_remaining: 90000

showcase_project.budget_remaining is a formula field:

(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)

so the denied value is recoverable exactly: budget = budget_remaining + spent = 150000. Confirmed against the system-context row, which reads budget: 150000.

Why this is worth a card rather than a shrug

The runtime already treats indirect disclosure of an FLS-denied field as in scope, and says so in its own refusal text. Filtering or sorting on the stripped field answers 403 PERMISSION_DENIED with:

query on 'showcase_project' references field(s) not readable by the caller: budget. Filtering, sorting, grouping, or aggregating by a hidden field would leak its values (filter oracle) — remove these predicates or grant field read access.

So the filter oracle is closed by the product, deliberately, while a formula that arithmetically republishes the same value is served. The two are the same disclosure class; only one is guarded.

The mechanism is consistent with the documented model — getFieldPermissions is an allow-list over enumerated fields, and budget_remaining was not enumerated — so this may well be authoring responsibility rather than a runtime bug. That is exactly the judgement this card is asking for.

Readings

  1. Authoring responsibility, enforced at author time. A lint rule in the ADR-0049 declared-equals-enforced family: a formula (or summary) field that references field F must not be readable by a persona for whom F is readable: false, unless the author says so explicitly. Fits the "make it hard to get wrong at authoring time" direction and costs no runtime work.
  2. Runtime derivation-closure. Strip a formula field whenever the caller is denied any field its expression reads. Correct but expensive and possibly surprising — a rollup over a restricted column would vanish for most personas.
  3. Documented as-is. State in the permissions docs that FLS is per-declared-field and does not close over derivations, so authors know they must deny the derived fields too.

Reading 1 looks right to me on the three axes, but the call is the maintainer's, not mine.

Not covered by anything today

Filed unassigned from the #9481 dev seat, out of that card's scope. Scope note for triage: #9481 is not fixed or changed by this, and #9308 remains open independently.

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage: lands in the decision box — needs-user-decision + security + domain:engine-core, type Task. This is a security/permission-boundary fork (which disclosures FLS closes over), so it sits on the manual floor regardless of how aligned the analysis is; ⛔ not auto-adjudicated. (Triage seat, session session_0185aJyu6TRnHbcVgSfGPSho.)

    Rationale: the runtime already refuses the filter-oracle form of indirect disclosure with an explicit refusal text, while serving the formula-republish form of the same class. Whichever reading wins, that asymmetry is the thing being ruled on. Landing domain is engine-core (formula evaluation / FLS strip seam); if Reading 1 wins the enforcement lands as an authoring-time gate and the card re-routes at dispatch.

    Four-facet block:

    1. Platform long-term coherence — Reading 1 (author-time rejection, ADR-0049 declared-equals-enforced family) closes the disclosure class where it is decidable and adds no runtime special case. Reading 2 (runtime derivation-closure) silently vanishes legitimate rollups for most personas — a new wide behavior. Reading 3 (document as-is) leaves two same-class disclosures with opposite treatment, guarded vs served — the least coherent end-state.
    2. Measured business pull — measured live on stock showcase (budget exactly recoverable from budget_remaining + spent); the product's own refusal text already claims indirect disclosure is in scope. No customer report yet, but FLS is a shipped compliance surface — this is the class of thing that gets discovered in an audit.
    3. AI-agent error-resistance — AI-authored permission sets will reliably enumerate the raw field and miss its derivations; a loud authoring-time refusal (Reading 1) is structurally safer than either silent serving (3) or silent stripping (2, which hides the mistake instead of surfacing it).
    4. Startup scope discipline — Reading 1 is a lint/publish-gate rule: bounded, no runtime cost. Reading 2 is a runtime feature with perf and semantics edges (nested formulas, summaries over restricted columns) — the expensive path. Defer 2 unless the maintainer wants runtime guarantees.

    Recommendation: Reading 1 (authoring-time enforcement), with Reading 3's one-line docs statement landed immediately as the interim truth. Reading 2 only if the maintainer wants the runtime to guarantee closure regardless of authoring.


    Generated by Claude Code

  2. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Maintainer ruling (2026-08-18, live PM chat with the triage seat, session session_01EUZUjvnLsqwHVkXkTv8TKQ) — recorded verbatim: 「9562 不处理」, clarified 「9562 9513 都是直接关闭」.

    Closing as not planned. The measurement stays on record: FLS readable:false does not close over formula derivations, and the maintainer has decided not to act on it now (none of the three readings — authoring-time lint, runtime derivation-closure, docs statement — is being commissioned). The card is the record; reopening is free if a deployment hits this as a real disclosure complaint or the maintainer revisits. The filter-oracle guard and the pinned STRIP behaviour are unaffected.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions