Skip to content

Commit a9d36d5

Browse files
fix(plugin-security)!: a caller who resolves no permission set is served a masked field masked and may not query on it (#21051)
Fixes #20995 Clause-②: no (narrowing) ## What this changes A field that declares `maskingRule` is masked for every non-system caller unless the caller holds all of the field's `requiredPermissions` (the describe on `maskingRule` in `packages/spec/src/data/field.zod.ts`). A caller who carries a principal but resolves no permission set holds no capability, so the rule applies to it. The runtime served that caller the stored value and let it filter, sort, group and aggregate on the field. It now serves the masked value and refuses those queries, as it already did for every other masked caller. **Correction to the card's source-read, measured.** The card read the result masker as already masking for this caller. It does not: for a caller who resolves no set, the middleware stood a posture stand-in in for the object, and the stand-in carried no masking rule. The result masker, the predicate guard and the field projections therefore all agreed on "stored and queryable". The disagreement was between that one answer and the `maskingRule` describe, and the explain engine, which reads the real posture and reports the field partially masked. **Reach (triage step 1), measured on real boots.** Doors that serve this caller class with a masked field in reach exist at public entry points: an anonymous public-form door that serves a referenced object's declared display fields, on a deployment that registers no profile for public forms, and every data door for a signed-in user on a deployment whose baseline is switched off. The readings, by door and class, are in the seat's private scratch file `issue-20995/reach-readings.txt`. Nothing here states a request. ## One derivation (triage step 2) - `resolveCallerPosture` is the posture every gate and both field projections read. For a caller who resolves a set it is `getObjectSecurityMeta`, unchanged. For a caller who resolves none it is the stand-in with two things carried from the posture: the object's masking rules, and whether the posture resolved. Every rule then applies (`computePartialMaskRules` holds nothing to lift it), and an unreadable posture fails closed for this caller as for any other. - The masked-echo write refusal (step 2.5a) and the aggregate-input guard (step 2.5b) are no longer gated on a resolved set. The predicate guard (step 2.9) never was. Without 2.5a a caller now served the mask could save it back over the stored value. - `isPrincipalLessContext` is the one predicate for the hand-off the middleware makes before it resolves anything (no position, no named set, no user id). The middleware and `resolveProjectionFieldMask` both read it, so the boundary of the class has one answer too: that context is still served stored and the projections still answer the full set. - The projection asks the on-behalf-of delegator only when the caller resolved a set, as the middleware does. - ⛔ Not carried into the stand-in: the per-field capability fold and the object's capability contract. `getReadableFields` still answers the full set for this caller (a masked field is a served column), which is what the service contract in `packages/spec/src/contracts/security-service.ts` states. No spec file is touched. ## Per door, by class | Door, by class | Caller class | Before | After | |:--|:--|:--|:--| | Anonymous public-form door serving a referenced object's declared display fields | visitor with no session, on a deployment with no public-form profile (resolves no set) | masked field served stored; a search or sort on it answered | masked field served masked; a search or sort on it refused, 403 `PERMISSION_DENIED` | | Record, list and query doors | signed-in user on a deployment with the baseline switched off, holding no grant (resolves no set) | stored; a filter on it answered | masked; filter, sort, group and aggregate refused, 403 | | Doors that compile their own query and read `getQueryableFields` | a caller who resolves no set | the field queryable, its value served stored as a group key | the field not queryable, refused 403 | | Engine middleware, any operation | a context that names only sets resolving to nothing, or holds only an audience anchor no set is named after | stored and queryable | masked and not queryable | | Engine middleware | principal-less context (internal) | stored and queryable | unchanged | | Engine middleware | a caller who resolves at least one set | unchanged | unchanged | `ISecurityService.getQueryableFields` for this caller drops the masked fields. `getReadableFields` and `getWritableFields` answer as before. ## Pins, red then green - `packages/plugins/plugin-security/src/zero-set-masking.test.ts` (new). Three caller classes, each by what resolution answers for it: a user id on a deployment with no baseline, no user id with only unresolvable named sets, and no user id with only an audience anchor. Each case first asserts the premise (zero sets). Then the query projection equals the middleware's query guards field for field across four positions and excludes the masked fields; result masking serves them masked and the read projection keeps them; and a masked-echo write is refused with 400 `VALIDATION_ERROR`. A last block pins the boundary: a principal-less context is handed through untouched and the projections agree. - `packages/plugins/plugin-security/src/get-queryable-fields.test.ts`: the one case that pinned the full field set for this caller now expects the masked fields excluded. - `packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts` (new). A real boot, two doors: the anonymous public-form door and the record door for a signed-in user with no baseline. Each asserts the scene is real (a system read holds the stored value), the masked field is served masked beside a field served as stored, and on the record door a predicate on it is refused. - Red on the pins commit `4b7105c437` (tree equal to HEAD for the plugin source): 10 failed, 14 passed. These are the 9 zero-set cases and the updated `getQueryableFields` case. Premises and boundary green. - Green on the fix commit `21a8de2f92`: the two files 24 of 24. With the masking, readable and writable suites beside them, 76 of 76. ## Ablation The fix reverted by writing the pre-fix blob of `security-plugin.ts` (from `4b7105c437`) into the tree at `075fa174e6`. Landing proven by blob hash (`58bf1990…`, the pre-fix blob) and by the marker `resolveCallerPosture` counting 8, then 0. Then: - unit pins: 10 failed, 14 passed, the same ten as the red run; - `plugin-security` rebuilt, and `ablation-dist-preflight --absent` passed: the marker is in none of the 6 built files; - dogfood pin: 2 of 2 failed, each on the stored value served where the mask was expected. Restored with `git checkout HEAD --` on the absolute path inside an `EXIT INT TERM` trap. Restore proven byte-identical: the blob hash equals HEAD's (`2a1f47aa…`), `git diff HEAD` is empty and the status is clean. Then rebuilt, and the preflight found the marker in 4 built files with the tree clean. Unit pins 24 of 24 and the dogfood pin 2 of 2. ## Consumers (Z3), suites run, not edited - `@objectstack/plugin-approvals` test: 52 files, 804 passed. Its snapshot redaction reads the read projection intersected with the query one, so a masked field is now dropped from the snapshot for an approver who resolves no set, where it was served stored. - `@objectstack/service-analytics` test: 150 files, 3458 passed. Its field gate reads `getQueryableFields`, so for this caller a masked field is now refused as a group key, aggregate input or filter. ## Local verification, at `075fa174e6` - `@objectstack/plugin-security` test: 151 files, 3276 passed, 23 skipped. Typecheck passed, including the test layer (`check:test-typecheck` OK). - `@objectstack/dogfood` typecheck: passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 67 commands, all run in one locked sequential script, every exit 0. `--ran` reconciliation: 67 derived, 67 run, 0 not measured. - `eslint --no-inline-config --format json` on the 4 touched TypeScript files: 4 files linted, 0 errors, 0 warnings. The population is `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its never-linted build directories, and all 4 files are in it. The config enables no type-aware linting (its own header states this), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. - `check:adr-0087-registration`: the changeset reads `[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`. ## Acceptance notes - **Not measured: an approval inbox read on a real boot by an approver who resolves no set.** The consumer suites cover that seam, and the field answers it reads are pinned here. - **Not measured: CI's whole-root and workflow-valued families.** `dispatch-gates` names 11 whole-root families and 6 workflow-valued ones as not placeable locally. They run on this PR. - **The gate scripts were read on the base tree.** After this branch was cut, `origin/main` moved by 7 commits. None of them touches this PR's files, so nothing was merged. Two of those commits changed gate scripts (`scripts/check-test-typecheck.mts`, `scripts/cross-package-test-inputs.mjs`), and CI runs the new copies. - **The narrowing reaches one authoring shape.** A public-form lookup whose first declared display field carries a masking rule now answers 403 for every request from a caller who resolves no set. That door sorts, and searches, by its first display field. The changeset states the class. Whether authoring should refuse that shape is a separate question, reported to the seat. - **Contract docblock, for the spec owner.** `getMetadataReadableFields`' docblock in `packages/spec/src/contracts/security-service.ts` says the middleware "skips its whole field gate" for a caller with no permission sets. That now holds for the grant-based gates and not for masking. The answers it states are unchanged: the full read set on the data plane. This PR leaves that file alone, by the claim's surface. The plugin's own docblock is updated. - **Unchanged by design, and outside this surface:** the per-field capability fold for this caller, admission and row scope for this caller, and the public-form submit path, which never resolves sets. Further findings in the same caller class went to the seat privately, by class, under the disclosure discipline. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94990a2 commit a9d36d5

5 files changed

Lines changed: 542 additions & 36 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: a field whose masking rule applies is served masked to a caller who resolves no permission set, and that caller may not filter, sort, group or aggregate on it (#20995)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author wrote is rewritten or changes meaning: every maskingRule already declared that it applies to every non-system caller who does not hold all of the field's requiredPermissions, and this change makes the runtime honour that declaration for the one caller class it skipped. No stored metadata, schema key or published type moves. -->
10+
11+
**BREAKING for callers who resolve no permission set.**
12+
13+
**What changed.** A field that declares `maskingRule` is masked for every
14+
non-system caller unless the caller holds all of the field's
15+
`requiredPermissions`. A caller who carries a principal but resolves no
16+
permission set holds no capability, so the rule applies to it, but the runtime
17+
served that caller the stored value and let it filter, sort, group and
18+
aggregate on the field. That caller is now served the masked value. A filter,
19+
sort key, group key or aggregate that names the field is refused with
20+
`403 PERMISSION_DENIED`, as it already was for any other masked caller. A write
21+
that sends the masked placeholder back is refused with `400 VALIDATION_ERROR`, so
22+
a client that saves the record it was served cannot overwrite the stored value
23+
with its mask.
24+
25+
The published field answers agree with what is served.
26+
`ISecurityService.getQueryableFields` no longer lists such a field for this
27+
caller, so a door that compiles its own query refuses it the same way.
28+
`getReadableFields` still lists it, because a masked field is a served column.
29+
30+
**Who this reaches.** A caller who resolves no permission set but carries a
31+
position, a named permission set or a user id. A caller with none of the three
32+
is handed through untouched, as before, and the field projections say so. A
33+
system context is unaffected.
34+
35+
**One more refusal, by the same rule.** If the object's security posture cannot
36+
be read, this caller's request is now refused, as every other caller's already
37+
is. The masking rules come from that posture, so they cannot be known without
38+
it.
39+
40+
**What to do.** Nothing, unless such a caller needs the stored value. A field's
41+
`requiredPermissions` are the gate that lifts its mask, so give the caller a
42+
permission set that holds all of them, or drop the `maskingRule`. A query that
43+
must sort or search on the field needs the same.

‎packages/plugins/plugin-security/src/get-queryable-fields.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,11 @@ describe('[#20935] getQueryableFields — the answers the contract names', () =>
178178
expect(await plugin.getQueryableFields('ledger', { isSystem: true })).toEqual(FIELDS);
179179
});
180180

181-
it('no permission sets resolved: the full field set, as the middleware skips both guards', async () => {
181+
it('no permission sets resolved: every field but the masked ones — a caller holding nothing lifts no rule (#20995)', async () => {
182182
const { plugin } = await boot([], { noBaseline: true });
183-
expect(await plugin.getQueryableFields('ledger', MEMBER_CTX)).toEqual(FIELDS);
183+
// `denied_masked`'s explicit deny comes from a set this caller does not
184+
// resolve, so here it is simply a field whose rule applies.
185+
expect(await plugin.getQueryableFields('ledger', MEMBER_CTX)).toEqual(['id', 'title', 'secret']);
184186
});
185187

186188
it('an unresolvable object is no answer (undefined), not an empty one', async () => {

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 114 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,24 @@ const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze(
327327
all: [], read: [], create: [], update: [], delete: [],
328328
}) as NormalizedRequiredPermissions;
329329

330+
/**
331+
* [#20995] A context that carries NO principal: no position, no named
332+
* permission set and no user id. The engine middleware hands an operation
333+
* under such a context straight to `next()` before it resolves anything, and
334+
* the field projections answer the full field set for it — both read THIS
335+
* predicate, so they cannot disagree about who is handed through.
336+
*
337+
* Every other non-system context reaches the gates, including one whose
338+
* positions or named sets resolve to nothing: that caller resolves no
339+
* permission set, holds no capability, and is the one
340+
* {@link SecurityPlugin.resolveCallerPosture} gives the object's masking rules.
341+
*/
342+
function isPrincipalLessContext(context: any): boolean {
343+
const positions = context?.positions ?? [];
344+
const explicitPermissionSets = context?.permissions ?? [];
345+
return positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId;
346+
}
347+
330348
/**
331349
* [#5492] Knobs on the layered RLS computation. Both are COMPOSITION
332350
* instructions rather than policy switches: the caller has already consulted an
@@ -2139,12 +2157,9 @@ export class SecurityPlugin implements Plugin {
21392157

21402158
// Skip security checks if no positions AND no explicit permission sets
21412159
// AND no userId (anonymous/unauthenticated). The auth middleware
2142-
// should handle authentication separately.
2143-
if (
2144-
positions.length === 0 &&
2145-
explicitPermissionSets.length === 0 &&
2146-
!opCtx.context?.userId
2147-
) {
2160+
// should handle authentication separately. [#20995] The field
2161+
// projections read the same predicate for the same hand-off.
2162+
if (isPrincipalLessContext(opCtx.context)) {
21482163
return next();
21492164
}
21502165

@@ -2219,15 +2234,10 @@ export class SecurityPlugin implements Plugin {
22192234

22202235
// [ADR-0066 D2/D3] Resolve the object's security posture (private flag,
22212236
// platform-global flag, capability contract) once for the checks below.
2222-
const secMeta =
2223-
permissionSets.length > 0
2224-
? await this.getObjectSecurityMeta(opCtx.object)
2225-
// [#10401] `unresolvedCause` is spelled out rather than omitted so this
2226-
// stand-in and the real posture share one readable shape — the throw
2227-
// site below reads the key off the union. `undefined` is correct here:
2228-
// this branch declares `unresolved: false` by fiat (no permission sets
2229-
// were resolved, so no posture was read), and there is no cause.
2230-
: { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, fieldRequiredPermissions: {} as Record<string, string[]>, fieldMaskingRules: {} as Record<string, FieldMaskingRule>, unresolved: false, unresolvedCause: undefined as UnresolvedPostureCause | undefined };
2237+
// [#20995] For a caller who resolved NO permission set this is the
2238+
// stand-in that carries the object's masking rules and nothing else —
2239+
// see resolveCallerPosture, which the field projections read too.
2240+
const secMeta = await this.resolveCallerPosture(opCtx.object, permissionSets);
22312241

22322242
// [#3545] Fail CLOSED when the object's own posture could not be resolved.
22332243
// #3545 accepted the API-exposure gate's fail-open on unresolvable metadata
@@ -2244,8 +2254,10 @@ export class SecurityPlugin implements Plugin {
22442254
//
22452255
// Blast radius is bounded to exactly the risky case: system/boot writes
22462256
// (`isSystem`) and principal-less/anonymous contexts short-circuited above,
2247-
// so reaching here means an AUTHENTICATED principal with resolved grants
2248-
// asking for an object whose declaration is missing. Cold start therefore
2257+
// so reaching here means a principal asking for an object whose
2258+
// declaration is missing — one with resolved grants, or [#20995] one
2259+
// resolving none, whose masking rules come from this same posture and are
2260+
// unknown while it is unreadable. Cold start therefore
22492261
// does NOT trip this — that window is served by the earlier short-circuits,
22502262
// not by the permissive default — which is why the tiered decision recorded
22512263
// for the exposure gate (transient unavailability → fail open) can stay
@@ -2858,11 +2870,12 @@ export class SecurityPlugin implements Plugin {
28582870
// as 2.5: silent drops hide the boundary from honest clients). Callers
28592871
// who hold the field's unmask capabilities are exempt by construction
28602872
// (computePartialMaskRules returns nothing for them), so a privileged
2861-
// import of literally-starred data stays possible.
2873+
// import of literally-starred data stays possible. [#20995] Not gated on
2874+
// a resolved set: a caller who resolves none is served the mask too, so
2875+
// its echo is refused like anyone's.
28622876
if (
28632877
(opCtx.operation === 'insert' || opCtx.operation === 'update') &&
2864-
opCtx.data &&
2865-
permissionSets.length > 0
2878+
opCtx.data
28662879
) {
28672880
const echoRules = this.computePartialMaskRules(secMeta, permissionSets, delegatorSets);
28682881
if (Object.keys(echoRules).length > 0) {
@@ -2885,7 +2898,9 @@ export class SecurityPlugin implements Plugin {
28852898
// FLS-unreadable field is rejected fail-closed with the offending names
28862899
// (mirrors the write gate in 2.5). `where`-filter probing is a
28872900
// platform-wide class shared with find() and is not widened here.
2888-
if (opCtx.operation === 'aggregate' && permissionSets.length > 0) {
2901+
// [#20995] Not gated on a resolved set, as step 2.9 never was: for a
2902+
// caller who resolves none the map is its masked fields alone.
2903+
if (opCtx.operation === 'aggregate') {
28892904
// The field map (ADR-0066 D3 `requiredPermissions` AND-gate, ADR-0090
28902905
// D10 delegator intersection — a field the agent may read but the
28912906
// delegator may not stays forbidden) with every masked-for-this-caller
@@ -5420,10 +5435,12 @@ export class SecurityPlugin implements Plugin {
54205435
* `member_default`) instead of falling open to the full field set.
54215436
*
54225437
* Why the two differ rather than converge. `getReadableFields` mirrors the
5423-
* engine middleware, which skips its whole gate for a caller with no
5438+
* engine middleware, which skips its grant-based gates for a caller with no
54245439
* permission sets — reporting a narrowing the data path would not enforce is
54255440
* its own kind of drift, so on the DATA plane falling open is the correct,
5426-
* drift-free answer. The metadata plane has no such symmetry to preserve: the
5441+
* drift-free answer. ([#20995] The object's masking rules do reach that
5442+
* caller, but a masked field is a served column, so the read answer is still
5443+
* the full set.) The metadata plane has no such symmetry to preserve: the
54275444
* question there is disclosure, and ADR-0106 D7 rules that a public/guest
54285445
* deployment's schema exposure must be a deliberate permission-set decision
54295446
* rather than an accidental everything-default. Anonymous callers on a
@@ -5495,9 +5512,12 @@ export class SecurityPlugin implements Plugin {
54955512
*
54965513
* The settled answers are the projection's ({@link resolveProjectionFieldMask}):
54975514
* `undefined` when the schema cannot be resolved; the full set for a system
5498-
* context and for a caller with no permission sets (the middleware then
5499-
* skips both guards, and no masking rule reaches it); `[]` on an
5500-
* unresolvable posture or a dangling delegator (fail closed).
5515+
* context and for a principal-less one (the middleware hands both straight
5516+
* through); `[]` on an unresolvable posture or a dangling delegator (fail
5517+
* closed). [#20995] A caller who resolves NO permission set is not settled
5518+
* early: it holds no capability, so every masking rule reaches it and its
5519+
* masked fields are not queryable, as both guards refuse them
5520+
* ({@link resolveCallerPosture}).
55015521
*/
55025522
async getQueryableFields(object: string, context?: any): Promise<string[] | undefined> {
55035523
const mask = await this.resolveProjectionFieldMask(object, context, { fallbackOnEmptySets: false });
@@ -5508,7 +5528,8 @@ export class SecurityPlugin implements Plugin {
55085528

55095529
/**
55105530
* The derivation both field projections share: the schema's field universe,
5511-
* the caller's permission sets, the evaluator's field map with the ADR-0066
5531+
* the caller's permission sets, the posture its gates read
5532+
* ({@link resolveCallerPosture}), the evaluator's field map with the ADR-0066
55125533
* D3 `requiredPermissions` fold, and the ADR-0090 D10 delegator intersection
55135534
* — the steps, in order, that the middleware's read mask and its step 2.5
55145535
* write gate each take. A case that settles the answer before any mask
@@ -5548,26 +5569,35 @@ export class SecurityPlugin implements Plugin {
55485569
// the same two-step `/auth/me/permissions` performs.
55495570
permissionSets = await this.resolveFallbackPermissionSets(context);
55505571
}
5551-
// No sets resolved (e.g. unauthenticated) → no field mask applies, exactly
5552-
// as the middleware (getFieldPermissions([]) === {} → nothing deleted).
5553-
if (permissionSets.length === 0) return { kind: 'answer', fields: allFields };
5572+
// [#20995] A principal-less context is handed straight through by the
5573+
// middleware, so no field gate reaches it: the full set, read off the SAME
5574+
// predicate. A caller who resolved no set but carries a principal is NOT
5575+
// this case — it reaches the gates, and the object's masking rules reach it
5576+
// (resolveCallerPosture), so it falls through to the mask below.
5577+
if (permissionSets.length === 0 && isPrincipalLessContext(context)) {
5578+
return { kind: 'answer', fields: allFields };
5579+
}
55545580

5555-
const secMeta = await this.getObjectSecurityMeta(objectName);
5581+
const secMeta = await this.resolveCallerPosture(objectName, permissionSets);
55565582
// [#3545] Posture unresolvable → expose no columns, the same fail-closed
55575583
// stance this method already takes on a dangling delegator below. The
55585584
// per-field capability contract (`fieldRequiredPermissions`) would otherwise
5559-
// default to empty and silently unmask every capability-gated column.
5585+
// default to empty and silently unmask every capability-gated column, and
5586+
// [#20995] the masking rules a caller with no set is held to are unknown.
55605587
if (secMeta.unresolved) return { kind: 'answer', fields: [] };
55615588
const basePerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets);
55625589
let fieldPerms = this.foldFieldRequiredPermissions(basePerms, secMeta.fieldRequiredPermissions, permissionSets);
55635590

55645591
// [ADR-0090 D10] On an on-behalf-of request the projection must NOT widen
55655592
// past the DELEGATOR's — intersect the delegator's field mask too. A
55665593
// dangling delegator fails CLOSED (expose no columns), the same fail-closed
5567-
// stance the CRUD middleware takes on a 'missing' delegator.
5594+
// stance the CRUD middleware takes on a 'missing' delegator. [#20995] Asked
5595+
// only when the caller resolved a set, as the middleware asks it: a caller
5596+
// who resolved none reaches here only for its masking rules, and every one
5597+
// of them already applies to it.
55685598
let delBasePerms: Record<string, { readable: boolean; editable: boolean }> | null = null;
55695599
let delegatorSets: PermissionSet[] | null = null;
5570-
if (context?.onBehalfOf?.userId) {
5600+
if (permissionSets.length > 0 && context?.onBehalfOf?.userId) {
55715601
const del = await resolveDelegatorContext(this.ql, context);
55725602
if (del.kind === 'missing') return { kind: 'answer', fields: [] };
55735603
if (del.kind === 'resolved') {
@@ -5591,6 +5621,56 @@ export class SecurityPlugin implements Plugin {
55915621
};
55925622
}
55935623

5624+
/**
5625+
* [#20995] The object posture a caller's gates read — ONE place, read by the
5626+
* engine middleware and by {@link resolveProjectionFieldMask} alike, so the
5627+
* result masker, the two query guards, the masked-echo refusal and the
5628+
* published field projections cannot answer one caller two ways.
5629+
*
5630+
* A caller who resolved at least one permission set reads the posture as it
5631+
* is ({@link getObjectSecurityMeta}). A caller who resolved NONE — and still
5632+
* carries a principal; a principal-less context ({@link isPrincipalLessContext})
5633+
* never reaches a gate — reads a stand-in: every grant-based narrowing at
5634+
* rest (no capability contract, no capability-gated field, `isPrivate` and
5635+
* the tenancy flags `false`), EXCEPT two things carried from the posture:
5636+
*
5637+
* - **The masking rules.** `maskingRule` applies to "every non-system caller
5638+
* unless the field's `requiredPermissions` are ALL held", and this caller
5639+
* holds nothing, so {@link computePartialMaskRules} applies every rule to
5640+
* it: the field is served masked and is not queryable. The stand-in used
5641+
* to carry no rule at all, so every reader answered "stored and
5642+
* queryable" for this caller.
5643+
* - **Whether the posture resolved.** The rules come from it, so an
5644+
* unreadable posture leaves them unknown and fails closed for this caller
5645+
* exactly as for any other (#3545).
5646+
*
5647+
* ⛔ Deliberately NOT carried: the per-field capability fold and the object's
5648+
* capability contract. Those narrow by what a caller holds through its sets,
5649+
* and the data-plane read projection's answer for a caller with no set — the
5650+
* full field set — is stated by the service contract. Masking does not move
5651+
* that answer (a masked field is a served column); the capability fold would.
5652+
*/
5653+
private async resolveCallerPosture(
5654+
object: string,
5655+
permissionSets: PermissionSet[],
5656+
): Promise<ObjectSecurityMeta> {
5657+
const posture = await this.getObjectSecurityMeta(object);
5658+
if (permissionSets.length > 0) return posture;
5659+
return {
5660+
isPrivate: false,
5661+
tenancyDisabled: false,
5662+
isBetterAuthManaged: false,
5663+
tenantAnchorIsPhantom: false,
5664+
owdOpensRowWrites: false,
5665+
requiredPermissions: EMPTY_REQUIRED_PERMISSIONS,
5666+
fieldRequiredPermissions: {},
5667+
fieldMaskingRules: posture.fieldMaskingRules,
5668+
unresolved: posture.unresolved,
5669+
// [#10401] Explanation only, and present only on the refusing path.
5670+
unresolvedCause: posture.unresolvedCause,
5671+
};
5672+
}
5673+
55945674
/**
55955675
* Whether `context` may READ `object` at all — the OBJECT-level admission,
55965676
* exposed for the read doors that bypass the engine middleware.

0 commit comments

Comments
 (0)