Repository navigation
Expand file tree
/
Copy pathexplain-engine.ts
More file actions
2695 lines (2606 loc) · 143 KB
/
Copy pathexplain-engine.ts
File metadata and controls
2695 lines (2606 loc) · 143 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [ADR-0090 D6] Access-explanation engine — `explain(principal, object,
* operation)` as a first-class API.
*
* "Explained by construction": every layer below calls the SAME functions the
* enforcement middleware calls — the shared permission-set resolution, the
* shared `PermissionEvaluator`, the shared RLS compiler — injected from
* `SecurityPlugin` so the report can never drift from enforcement. The engine
* adds no evaluation logic of its own; it only records what each pipeline
* layer contributed:
*
* principal → required_permissions → object_crud → fls → owd_baseline →
* depth → sharing → vama_bypass → rls
*
* The dual use (D6): admins ask "why can 张三 PATCH 李四's leave_request?",
* and the AI-safety story gets its audit substrate — a publish gate can show
* the SEMANTIC impact of a grant change instead of a JSON diff.
*/
import {
isGrantActive,
isGrantExpired,
derivePosture as deriveAdminPosture,
readDisabledCatalogNames,
resolveUserAuthzGrants,
securityCatalogReaderOf,
} from '@objectstack/core';
import {
crossFieldClassRefusalCarriedBy,
findCrossFieldClassRefusal,
matchesFilterCondition,
type CrossFieldClassRefusal,
type MatchesFilterOptions,
} from '@objectstack/formula';
import { ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec';
import type {
AuthoredRowWriteOperation,
AuthoredRowWriteVerdict,
ControlledByParentWriteDenialLeg,
ControlledByParentWriteOutcome,
ControlledByParentWriteUnresolvedReason,
} from '@objectstack/spec/contracts';
import type { FieldMaskingRule } from '@objectstack/spec/data';
import type { PermissionSet } from '@objectstack/spec/security';
import type {
AuthzPosture,
ExplainDecision,
ExplainLayer,
ExplainMatchedRule,
ExplainOperation,
ExplainRecordAttribution,
} from '@objectstack/spec/security';
import type { PermissionEvaluator } from './permission-evaluator.js';
import { superuserBypassBitForOperation } from './permission-evaluator.js';
import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js';
import { ExplainObjectNotFoundError } from './errors.js';
import { RLS_DENY_FILTER, compiledObjectDefaultRowScopeOf, compiledPolicyNameOf } from './rls-compiler.js';
import { declaredComparisonColumns } from './declared-comparison-columns.js';
import {
declaredJsonStoredColumns,
findJsonColumnCheckRefusal,
jsonColumnCheckRefusalError,
type DeclaredJsonStoredColumns,
type JsonColumnCheckRefusal,
} from './rls-check-stored-form.js';
import {
unresolvedPostureExplainDetail,
type UnresolvedPostureCause,
} from './unresolved-posture.js';
import { grantSetNameOf } from './grant-permission-set-name.js';
const SYSTEM_CTX = { isSystem: true } as const;
/** Owner field convention — mirrors plugin-sharing's `OWNER_FIELD` (single-owner MVP). */
const OWNER_FIELD = 'owner_id';
/**
* [C2 / ADR-0095 D1] Which kernel tier a pipeline layer belongs to: the
* always-first tenant wall (`layer_0_tenant`) vs. everything downstream of it
* (business RLS / sharing / ownership / the capability gates), all
* `layer_1_business`. The binary mirrors the enforcement split — Layer 0 has its
* own code path and is AND-composed BEFORE any business rule.
*/
function kernelTierOf(layer: ExplainLayer['layer']): 'layer_0_tenant' | 'layer_1_business' {
return layer === 'tenant_isolation' ? 'layer_0_tenant' : 'layer_1_business';
}
const AUTHZ_POSTURES: ReadonlySet<string> = new Set<AuthzPosture>([
'PLATFORM_ADMIN',
'TENANT_ADMIN',
'MEMBER',
'EXTERNAL',
]);
function isAuthzPosture(v: unknown): v is AuthzPosture {
return typeof v === 'string' && AUTHZ_POSTURES.has(v);
}
/**
* [C2 / ADR-0095 D2/D3] Resolve the principal's posture rung, using the SAME
* evidence enforcement uses so the explain panel's tier can never sit HIGHER
* than the runtime's (label-drift elimination — security review low-severity).
*
* Order of preference:
*
* 1. **guest / anonymous → EXTERNAL.** Explain layers one thing on top the
* enforcement resolver deliberately does NOT: a guest principal is EXTERNAL
* for the debugger. The enforcement floor is MEMBER (no external principal
* type exists yet — ADR-0095 D2), so this mapping lives here, and it wins
* first.
* 2. **Reuse `ctx.posture` verbatim when present.** A principal resolved through
* the full `resolveAuthzContext` already carries the enforcement-derived
* rung; consuming it directly makes drift structurally impossible.
* 3. **Fallback — re-derive from capability-grant evidence.** Reached only by a
* HAND-BUILT context (tests, an internal caller assembling `{ userId,
* positions, permissions }` itself). Since #6352 the explain API's
* `buildContextForUser` resolves through `resolveUserAuthzGrants` and
* therefore carries the enforcement-derived `posture`, so it lands on (2) —
* structurally, not by agreement. This branch derives from the SAME evidence
* `resolveAuthzContext` uses — NOT the previous loose permission-set-NAME
* match:
* - `PLATFORM_ADMIN` ← the **unscoped `admin_full_access` USER grant**
* (`hasPlatformAdminGrant`, which `buildContextForUser` READS OFF the
* resolver's own verdict rather than recomputing), and nothing else. A
* merely-SCOPED `admin_full_access` grant (name present in
* `permissions`, not held unscoped) does not over-label.
*
* ⛔ [#15981] It is NOT ALSO the `platform_admin` built-in POSITION.
* That arm rested on the position being "only ever PROJECTED from that
* same grant", which stopped being true when `positions[]` became the
* security axis: it now carries ADR-0057 D4 `sys_user_position` names,
* and that table is `apiEnabled` with unconstrained `position` values,
* so a tenant could mint a row spelling the built-in. ⚠️ Reachability
* here was NARROWER than at #15981's three sibling sites, and it is
* worth stating precisely rather than overclaiming: preference (2)
* above returns first for any principal `buildContextForUser` builds,
* so the SHIPPING path never reached this line and a D4 row never moved
* it. What the name-read did reach was a posture-less HAND-BUILT
* context — and there it made the panel REPORT `PLATFORM_ADMIN` for a
* principal enforcement treats as a MEMBER. That is a misreport, not an
* admission — this engine explains, it does not admit — but it is a
* misreport in the tool an administrator opens to check exactly this,
* so it answered reassuringly wrong at the worst moment.
* - `TENANT_ADMIN` ← the `organization_admin` **capability** grant, exactly
* like enforcement (ADR-0095 D3). The better-auth `org_owner`/`org_admin`
* role positions are a provisioning source only and are no longer read
* as posture evidence — closing the same #2836 dual-track explain-side.
*/
function derivePosture(context: any): AuthzPosture {
if (!context?.userId || context?.principalKind === 'guest') return 'EXTERNAL';
if (isAuthzPosture(context?.posture)) return context.posture;
const permissions: string[] = Array.isArray(context?.permissions) ? context.permissions : [];
return deriveAdminPosture({
isPlatformAdmin: context?.hasPlatformAdminGrant === true,
isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)),
});
}
/**
* The `explain`/sharing zero-rows deny sentinel — the one named in the PUBLISHED
* `readFilter` contract (`ExplainDecisionSchema.readFilter`,
* `ExplainRecordAttributionSchema.rowFilter`), produced by plugin-sharing's
* `buildReadFilter`/`buildWriteFilter` and by this engine's own
* `computeRlsFilter` catch fallback in §9 below.
*/
const DENY_ALL_SENTINEL_ID = '__deny_all__';
/**
* True iff `filter` is THAT sentinel specifically — the narrow question, kept
* apart from {@link isDenyAll} because §9's payload collapse is keyed to this
* one value and must NOT follow the widened recognition (see below).
*/
function isDenyAllSentinel(filter: unknown): boolean {
return !!filter && typeof filter === 'object' && (filter as { id?: unknown }).id === DENY_ALL_SENTINEL_ID;
}
/**
* True iff `filter` is plugin-security's fail-closed RLS sentinel
* (`RLS_DENY_FILTER`) — an `id` equality against a UUID-shaped string no record
* can carry, so the SQL layer returns zero rows without raising.
*
* Identity is decided on the sentinel's own VALUE, not on its shape — the same
* rule `isTenantWallDenial` states (ADR-0123 D2), and for the same reason:
* producers SPREAD the frozen constant (`{ ...RLS_DENY_FILTER }`), so a
* reference check would answer `false` for every real denial.
*/
function isRlsDenySentinel(filter: unknown): boolean {
return !!filter && typeof filter === 'object' && (filter as { id?: unknown }).id === RLS_DENY_FILTER.id;
}
/**
* [#13639] True iff a composed filter denies EVERY row — whichever of the two
* sentinels the platform composed. ⭐ This is THE question the engine asks, and
* every deny-recognition site routes through it.
*
* It used to recognise `__deny_all__` alone, and the object-level `rls` verdict
* in §9 compared the same literal a second time, INLINE. So a fail-closed RLS
* denial — the "no active organization" path, which composes `RLS_DENY_FILTER`
* and is guaranteed to return zero rows — was reported as verdict `narrows`
* with `decision.allowed: TRUE`. That is not an imprecise label; it is an
* affirmatively wrong answer about a request that cannot return a row, handed
* to the operator debugging exactly "why does this user see nothing?".
*
* ⛔ Recognition is deliberately all this widening does. The two sentinels are
* NOT merged (`__deny_all__` is in the published spec schema and docs;
* `__rls_deny__` is pinned as a bound SQL parameter by service-analytics and
* dispatched on by value by `isTenantWallDenial`), and §9's payload
* replacement is NOT extended to `__rls_deny__` — a deployment that receives
* `{ id: '__rls_deny__:…' }` in `readFilter` today keeps receiving it. Both of
* those are deployment-facing changes recorded on #13639 as the maintainer's.
*/
function isDenyAll(filter: unknown): boolean {
return isDenyAllSentinel(filter) || isRlsDenySentinel(filter);
}
/**
* A dependency call that REJECTED, kept apart from every value it could have
* resolved to.
*
* The engine calls the functions enforcement calls, and enforcement runs them
* un-caught: when one rejects, the request being explained FAILS. Folding that
* rejection into an answer — `null` read as "no filter", `undefined` read as
* "no gate wired", `{ kind: 'none' }` read as "no delegation" — explains a
* request that fails as one that succeeds. {@link settle} hands back this
* sentinel instead; each site reports its layer `not_evaluated` and its
* verdict fail-closed, and never as an admission.
*/
const DEPENDENCY_FAULT: unique symbol = Symbol('explain.dependency-fault');
function settle<T>(call: Promise<T>): Promise<T | typeof DEPENDENCY_FAULT> {
return call.then((value) => value, () => DEPENDENCY_FAULT);
}
/** Explain-operation → engine-operation (the middleware's vocabulary). */
const EXPLAIN_TO_ENGINE_OP: Record<ExplainOperation, string> = {
read: 'find',
create: 'insert',
update: 'update',
delete: 'delete',
transfer: 'transfer',
restore: 'restore',
purge: 'purge',
// [#3544] Not a copy-paste slip: `export` IS its own evaluator operation
// (`read ∧ the export grant`). It is also the only entry whose data path
// differs from its gate — see {@link dataOpOf}.
export: 'export',
};
/**
* [#3544] The operation every DATA-shaped layer must be computed as.
*
* `export` is gated as its own operation but performs an ordinary `find` to
* fetch its rows, so requiredPermissions, OWD/depth/sharing, RLS and record
* attribution all have to resolve as a READ. Asking the RLS compiler about an
* `export` operation would match no policy and report "no RLS applies" for a
* principal whose rows are in fact filtered — an explanation that contradicts
* the export it is explaining. Only `object_crud` uses the gate operation,
* because that is the only layer the axis changes.
*/
function dataOpOf(operation: ExplainOperation, engineOp: string): string {
return operation === 'export' ? 'find' : engineOp;
}
export interface ExplainEngineDeps {
ql: any;
/** The middleware's own set resolution (baseline + everyone semantics included). */
resolveSets: (context: any) => Promise<PermissionSet[]>;
evaluator: PermissionEvaluator;
getObjectSecurityMeta: (object: string) => Promise<{
isPrivate: boolean;
requiredPermissions: any;
fieldRequiredPermissions: Record<string, string[]>;
/** [#3545] Posture could not be read — the middleware denies (fail-closed). */
unresolved?: boolean;
/**
* [#10401] Which condition that is — an unpublished draft, or a declaration
* that genuinely cannot be read. Optional: a deps bag wired without it (or a
* deployment whose probe could not run) explains as `'unknown'`, the wording
* that covers both. Never an input to the verdict — only to the prose.
*/
unresolvedCause?: UnresolvedPostureCause;
}>;
/** The middleware's requiredPermissions AND-gate resolution for an operation. */
requiredCaps: (meta: any, engineOperation: string) => string[];
/**
* The middleware's RLS filter composition (same inputs, same output). Asked
* for the operation the door composes the explained verb's row-level
* security for ({@link rlsOperationForVerb}): a lifecycle verb arrives as its
* write class, every other verb as itself.
*/
computeRlsFilter: (
sets: PermissionSet[],
object: string,
engineOperation: string,
context: any,
) => Promise<Record<string, unknown> | null | undefined>;
/** The middleware's merged FLS mask (field requiredPermissions folded in). */
getFieldMask: (
sets: PermissionSet[],
object: string,
fieldRequiredPermissions: Record<string, string[]>,
) => Record<string, { readable?: boolean; editable?: boolean }>;
/**
* [#9127] The middleware's EFFECTIVE partial-mask set for this caller — the
* `partialRules` argument enforcement hands `FieldMasker.maskResults`, after
* the explicit-deny exclusion. Keyed by field, valued by the rule that will
* be applied.
*
* REQUIRED, deliberately. The field-mask decision has three outcomes
* (deleted / partially masked / served whole) and the binary
* {@link getFieldMask} can express only two, so an engine wired without this
* would report a partially-masked field as fully hidden and a gate-less
* rule field as fully readable — the exact misreport this dep exists to
* close. An optional dep would have let a new embedder re-open it silently;
* a required one makes the omission a compile error.
*
* Supply the enforcement composition, never a re-derivation of it: explain's
* module contract is that it "matches enforcement by construction".
*/
getPartialMaskRules: (
sets: PermissionSet[],
object: string,
delegatorSets: PermissionSet[] | null,
) => Promise<Record<string, FieldMaskingRule>>;
/**
* Configured additive baseline set NAMES (default `['member_default']`), for
* attribution.
*
* [#7555] A list, not a name: the baseline is the app-declared set COMPOSED
* with the platform's `member_default`, so a report that attributed only one
* of them would label the other "resolved" — the vaguest bucket `viaOf` has,
* on the grant most likely to be the answer to "why can this member read
* anything at all".
*/
baselinePermissionSets: string[];
// ── [C2 / ADR-0095] Record-grained deps. All OPTIONAL: absent → the engine
// stays object-level and byte-compatible. Present → the sharing / rls / owd /
// tenant_isolation layers gain per-record attribution. Every one reuses an
// enforcement code path so the row story cannot drift from execution. ──
/**
* The middleware's RLS composition, SPLIT into its two independent kernel
* layers (the same `Layer0(tenant) AND Layer1(business)` the effective filter
* is built from). Lets the tenant wall (Layer 0) and business RLS (Layer 1) be
* attributed to a record separately. `undefined` return = engine cannot split.
* Asked for the same operation as {@link computeRlsFilter} (the explained
* verb through {@link rlsOperationForVerb}), so a `transfer`'s row story is
* composed as the update the door composes it as.
*/
computeLayeredRlsFilter?: (
sets: PermissionSet[],
object: string,
engineOperation: string,
context: any,
) => Promise<{ layer0: Record<string, unknown> | null; layer1: Record<string, unknown> | null }>;
/** Fetch the one record under a system context (organization_id / owner_id + fields for filter matching). */
fetchRecord?: (object: string, recordId: string, engineOperation: string) => Promise<Record<string, unknown> | null>;
/** The sharing service's own read-filter contribution for the object (owner-match OR granted-ids), same as enforcement AND-s in. */
sharingReadFilter?: (object: string, context: any) => Promise<unknown | null>;
/**
* The concrete `sys_record_share` rows attached to the record (for `rules[]`
* attribution).
*
* `access_level` stays wider than the authorable `read`/`edit`: explain
* REPORTS stored rows, and a `full` row written before #3865 retired that
* level (normalised to `edit` by the sharing plugin's boot backfill) must
* still be explainable rather than crash the panel.
*/
listRecordShares?: (
object: string,
recordId: string,
context: any,
) => Promise<Array<{ id?: string; recipient_type?: string; recipient_id?: string; access_level?: 'read' | 'edit' | 'full'; source?: string; source_id?: string }>>;
/** The sharing service's per-record UPDATE gate (`canEdit`) — the by-construction verdict for update operations. */
canEditRecord?: (object: string, recordId: string, context: any) => Promise<boolean>;
/**
* [ADR-0111 D3] The sharing service's per-record DELETE gate (`canDelete`) —
* the by-construction verdict for a delete operation. Narrower than
* `canEditRecord`: an edit-level share opens update but not delete, so the
* explanation for a `delete` must consult this rather than the update gate.
*/
canDeleteRecord?: (object: string, recordId: string, context: any) => Promise<boolean>;
/**
* [#5493 / ADR-0105 D3] The by-id write gate's DEFERRAL: whether an
* APP-AUTHORED row-level policy admits the row for the refusing gate's verb
* (`ISecurityService.checkAuthoredRowWrite`, reached through the sharing
* service's own probe). The sharing middleware asks it only after its
* per-record gate refused the row, and only the literal `admit` lifts that
* refusal: the row then goes on to the pre-image gate, which is the
* row-level security this report's `rls` layer explains.
*
* Asked on the same condition here: for a by-id `update` or `delete` whose
* gate above ({@link canEditRecord} / {@link canDeleteRecord}) answered
* `false`, with that gate's verb and the context being EXPLAINED, and for a
* `transfer` as an `update` (its door is the by-id update of `owner_id`,
* which the update gate judges). Every
* other answer leaves the refusal standing, and so does a rejection: the
* door's probe catches its own failure and keeps its refusal, so a failing
* probe is not a failing request and is not reported as one.
*
* Optional: a deps bag wired without it reports the gate's refusal as the
* write verdict, as before.
*/
probeAuthoredRowWrite?: (
object: string,
recordId: string,
operation: AuthoredRowWriteOperation,
context: any,
) => Promise<AuthoredRowWriteVerdict | string>;
/**
* [ADR-0055] The master-detail write check's own answer for a by-id UPDATE
* of `recordId` by `context`: `ISecurityService.checkControlledByParentWrite`,
* the composition step 2.8 of the write path runs. On a
* `controlled_by_parent` record the per-record sharing gates above abstain
* (the object's effective model is `public`), so they cannot be a write's
* verdict there: this check is, and the door refuses on every outcome but
* `allow` and `not_applicable`.
*
* Asked for every by-id write step 2.8 runs the check on
* ({@link MASTER_CHECKED_BY_ID_WRITES}: `update`, `delete`, `transfer`,
* `restore`, `purge`) of a record that exists, with the context being
* EXPLAINED. The update's answer is each of those writes' answer: the check
* judges EDIT access to the master whatever the record's own verb, and reads
* the verb only to pick its `insert` branch. The member holds the
* `controlled_by_parent` predicate, so the engine keeps no second copy of it:
* `not_applicable` leaves the report exactly as it was. A rejection (a
* context the write path refuses, or a store fault) is a dependency fault,
* reported fail-closed.
*
* Optional: a deps bag wired without it explains each write from the sharing
* gates alone, as before, and claims nothing about a master.
*/
checkControlledByParentWrite?: (
object: string,
recordId: string,
context: any,
) => Promise<ControlledByParentWriteOutcome>;
/**
* [#21771] Enforcement's own read question for an addressed by-id write:
* would the read door, asked by this principal, NOT return the record?
* `true` only for absence; a read-time policy refusal answers `false`, and a
* store fault propagates — exactly as the write path asks it, so the
* explanation cannot drift from the answer the write gets.
*
* [#22571] Asked for every explained verb whose door is a by-id update or
* delete, read through {@link rlsOperationForVerb}: `update`, `delete`, and
* the lifecycle verbs that map onto them (`transfer` and `restore` onto
* update, `purge` onto delete).
*
* [ADR-0055] Asked for a record-grained read too, of a
* `controlled_by_parent` record the rest of the read's row story admits: the
* read door also scopes such a record by its master, a leg no other
* dependency here answers, so the door's own answer is what the read verdict
* takes ({@link applyRecordAttribution}).
*
* [#22792] Asked first of the ASKER, for every record-grained
* self-explanation: whether the explanation may say the record exists is
* decided by the read access of whoever asked for it, not by the explained
* principal's (the asker gate in the engine's record pass). [#22851] An
* asker explaining ANOTHER principal is asked the same caller-context by-id
* read through the security service's own binding, where a declared refusal
* counts as "cannot read" too (ruling C); this dependency keeps the write
* path's notion, which a principal explaining itself is asked.
*/
recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise<boolean>;
}
export interface ExplainInput {
object: string;
operation: ExplainOperation;
/** Execution context of the principal being EXPLAINED (not the caller). */
context: any;
/**
* [C2 / ADR-0095] Optional id of ONE concrete record to explain at row
* granularity. Omitted → object-level (the report is byte-identical to the
* pre-C2 engine). Present → the row-scoped layers gain a `record` attribution,
* the tenant wall surfaces as the `tenant_isolation` Layer 0, every layer is
* tagged with its `kernelTier`, the principal's `posture` is resolved, and the
* decision carries a top-level `record` verdict.
*/
recordId?: string;
}
/** The ADR-0091 `valid_until` of a grant row, as the panel prints it. */
function untilOfGrantRow(r: any): string | undefined {
const v = r?.valid_until ?? r?.validUntil;
return v == null || v === '' ? undefined : String(v);
}
/**
* [commit 42b05af89] One entry of the shared "held but not resolving, because X"
* vocabulary (the internal counterpart of the spec contract's
* `contributors[].state`): a grant row the principal HOLDS that the resolver
* fail-closed DROPPED, with the closed reason enumeration naming why —
* `expired` (ADR-0091 D2 validity window) or `deactivated` (ADR-0049 / #8613
* switch, read from the `sys_metadata_activation` ledger). `until` is the window bound, present only for
* the expired reason. One array, one reason discriminant — deliberately NOT a
* sibling array per cause (maintainer ruling 2026-08-18).
*/
export interface DroppedGrant {
kind: 'position' | 'permission_set';
name: string;
state: 'expired' | 'deactivated';
until?: string;
}
/**
* [#6352 / ADR-0091 D2/D3 / commit 42b05af89] The explain-ONLY provenance pass: the
* annotations the panel prints that the authorization resolver, correctly,
* throws away.
*
* This is presentation, not aggregation. It decides nothing about who is
* authorized — `resolveUserAuthzGrants` has already decided that, and this pass
* never feeds `positions` / `permissions` / the `platform_admin` derivation. It
* only re-reads the same rows to answer questions the resolver's output
* cannot express, because the resolver's output is by construction the set of
* grants that DID resolve:
*
* - **expired** — a row whose `valid_until` has passed, so the panel can say
* "held until … — expired" instead of silently omitting a grant the admin
* knows they granted. This is "why did access DISAPPEAR", and only a dropped
* row can answer it.
* - **deactivated** (commit 42b05af89) — a row whose position or permission
* set is switched off (ADR-0049 / #8613), so the grant stopped resolving
* for everyone holding it. The switch is the activation ledger's
* (`sys_metadata_activation`, ADR-0126 §3 regime C as ADR-0131 D6 amends
* it), asked through the resolver's own read (`readDisabledCatalogNames`),
* so explain and enforce cannot disagree; a catalog row's `active` column
* is not read. Deactivation is an incident-response control with no date on the
* user's own grant row, so without this state the set is simply ABSENT and
* explain answers like the grant never existed — the exact silence the
* contributor-attribution feature exists to prevent.
* - **delegated** — the `delegated_from` provenance of a row that DID resolve,
* so a position can be attributed "via delegation from X, until Y".
*
* All verdicts come from the SAME shared reads the resolver uses
* (`isGrantActive` / `isGrantExpired` for the ADR-0091 window,
* `readDisabledCatalogNames` for the ADR-0049 switch, the security catalog
* (`securityCatalogReaderOf`) for whether a set exists — all
* `@objectstack/core`) — one implementation of each rule, consulted twice,
* never re-derived here.
*
* A row that is BOTH expired and deactivated reports `expired`: the window
* verdict is judged on the user's own grant row, the same order the resolver
* drops in, and each dropped row carries exactly one reason (closed enum).
*
* Boundary (the ruling's cheaper sketch, deliberately): deactivation is
* re-read for the grants this pass already walks — `sys_user_position` rows
* and direct `sys_user_permission_set` grants. A deactivated position held via
* role projection, or a deactivated SET reached only through an active
* position's `permissionSets`, is not re-derived here — that would replicate
* the resolver's aggregation, which is the design this pass exists to avoid.
*/
async function collectGrantProvenance(
ql: any,
userId: string,
nowMs: number,
): Promise<{
droppedGrants: DroppedGrant[];
delegatedPositions: Array<{ name: string; from: string; until?: string }>;
}> {
const droppedGrants: DroppedGrant[] = [];
const delegatedPositions: Array<{ name: string; from: string; until?: string }> = [];
try {
const rows = await ql.find('sys_user_position', { where: { user_id: userId }, limit: 500, context: SYSTEM_CTX });
// Window-ACTIVE position names — held now, so the only thing that can
// still drop them is the catalogue `active` switch, checked below.
const heldPositionNames = new Set<string>();
for (const r of Array.isArray(rows) ? rows : []) {
const p = String((r as any)?.position ?? '');
if (!p) continue;
if (isGrantActive(r, nowMs)) {
heldPositionNames.add(p);
const from = (r as any)?.delegated_from;
if (from != null && from !== '') {
delegatedPositions.push({ name: p, from: String(from), until: untilOfGrantRow(r) });
}
} else if (isGrantExpired(r, nowMs)) {
// Pending (future `valid_from`) rows are inactive but NOT expired — they
// are not reported, because nothing was lost yet.
droppedGrants.push({ kind: 'position', name: p, state: 'expired', until: untilOfGrantRow(r) });
}
}
// [commit 42b05af89 / ADR-0049] A held position the activation ledger
// switched off was dropped by the resolver (step 6a) — report it instead of
// letting it vanish. The resolver's own ledger read, so a name with no
// ledger row is in effect here exactly as it is there.
if (heldPositionNames.size > 0) {
const disabled = await readDisabledCatalogNames(ql, Array.from(heldPositionNames), []);
for (const n of heldPositionNames) {
if (disabled.positions.has(n)) droppedGrants.push({ kind: 'position', name: n, state: 'deactivated' });
}
}
} catch { /* table unavailable → no provenance to report */ }
try {
const rows = await ql.find('sys_user_permission_set', { where: { user_id: userId }, limit: 500, context: SYSTEM_CTX });
// [ADR-0131 D4] A grant names its set (`permission_set`); one that names
// nothing ({@link grantSetNameOf}) reports nothing — it resolves no set.
const grantRows = (Array.isArray(rows) ? rows : []).filter((g: any) => grantSetNameOf(g) !== undefined);
const expiredRows = grantRows.filter((g: any) => !isGrantActive(g, nowMs) && isGrantExpired(g, nowMs));
// Window-ACTIVE direct grants: resolvable unless the SET's catalogue row is
// deactivated — the one remaining reason the resolver drops them (step 6b).
const activeRows = grantRows.filter((g: any) => isGrantActive(g, nowMs));
if (expiredRows.length > 0 || activeRows.length > 0) {
// [ADR-0131 D3/D4] A grant's set is the security catalog's definition of
// the name it carries — the one the resolver grants (step 6). A name the
// catalog does not hold resolves nothing either way, so it is reported
// neither as expired nor as deactivated: there is no set to lose.
const catalog = securityCatalogReaderOf(ql);
const held = new Set<string>();
for (const g of [...expiredRows, ...activeRows]) {
const n = grantSetNameOf(g) as string;
if (!held.has(n) && catalog && (await catalog.resolve('permission', n))) held.add(n);
}
for (const g of expiredRows) {
const n = grantSetNameOf(g) as string;
if (held.has(n)) droppedGrants.push({ kind: 'permission_set', name: n, state: 'expired', until: untilOfGrantRow(g) });
}
// [commit 42b05af89 / ADR-0049] Whether a held set is switched off is the
// activation ledger's answer, read once for every name a window-active
// grant carries — the resolver's own read. Several grant rows can name
// one deactivated set; the fact is reported once.
const activeNames = [...new Set(activeRows.map((g: any) => grantSetNameOf(g) as string))].filter((n) => held.has(n));
if (activeNames.length > 0) {
const disabled = await readDisabledCatalogNames(ql, [], activeNames);
for (const n of activeNames) {
if (disabled.permissions.has(n)) droppedGrants.push({ kind: 'permission_set', name: n, state: 'deactivated' });
}
}
}
} catch { /* table unavailable → no provenance to report */ }
return { droppedGrants, delegatedPositions };
}
/**
* Reconstruct an evaluation context for an arbitrary user, for the explain API's
* `userId` parameter. The caller-facing authorization for explaining OTHERS
* lives in the route/service wrapper (`explainAccessForCaller`), not here.
*
* [#6352] **The authorization aggregation is not implemented here.** It is
* `@objectstack/core`'s `resolveUserAuthzGrants` — the userId-driven core of
* `resolveAuthzContext`, i.e. the exact function every inbound request resolves
* through, called with the exact arguments (`ql`, `userId`, the ADR-0091 clock).
* So the positions (`sys_member` role projection + `sys_user_position` + the
* ADR-0090 D5 `everyone` anchor), the permission-set names (user-bound AND
* position-bound), the ADR-0091 validity windows, the ADR-0068 D2 `platform_admin`
* derivation, the ADR-0095 posture rung and the ADR-0105 D2 `accessible_org_ids`
* are all ONE implementation, not two kept in step by comment.
*
* This used to be a hand-written mirror whose only guarantee was two comments
* saying it matched. It did not: measured over identical rows it dropped the
* `sys_member` role positions, every position-bound permission set
* (`sys_position_permission_set`), the `ai_seat` synthesis, `systemPermissions`
* and the posture rung — so a user whose grants arrive through a POSITION was
* explained as holding nothing, and the panel reported a deny that enforcement
* did not make. That is the failure mode the panel exists to prevent, so the
* mirror is gone rather than pinned.
*
* What stays explain-side is presentation only, and additive: the dropped-grant
* (expired / deactivated) and delegated row annotations
* ({@link collectGrantProvenance}), and `hasPlatformAdminGrant`, which is now
* READ OFF the resolver's own posture verdict instead of being recomputed from
* the grant rows.
*
* [#20515] `tenantId` is the organization the user is resolved IN, handed to
* the resolver exactly as enforcement hands it — the resolver applies an
* organization-scoped grant only while its organization is the active tenant,
* and with no tenant only the global grants. There is no "every organization"
* reading: the explainer used to get one by passing no tenant, which is the
* very resolution that kept a removed member's grants. Each caller passes the
* organization it is explaining in — the live principal's for a delegator
* ({@link resolveDelegatorContext}), the caller's own for the explain API.
* [#20580] The explain API passes the caller's organization only once
* `vetOrganizationClaim` (`@objectstack/core`) has let it stand for the
* explained user, as enforcement does for that user's session claim: this
* function passes on what it is handed and vets nothing itself.
* Omitted, the context is the user with NO active organization. The returned
* context still carries no `tenantId` of its own; a caller that needs one sets
* it, as {@link resolveDelegatorContext} does, and as the explain API does
* (#20604) with the organization it resolved the user in.
*/
export async function buildContextForUser(
ql: any,
userId: string,
nowMs: number = Date.now(),
tenantId?: string,
): Promise<any> {
// [#11971] ⭐ Ruled bypass of the #11633 leg-B grants cache (maintainer
// acceptance 2026-08-25): the explainer is the tool an administrator uses to
// VERIFY that a revocation took effect. An explainer answering from cache
// would explain a state that no longer exists — and would do it at exactly
// the moment someone is checking. `explain` therefore takes the force-fresh
// path unconditionally, whatever `OS_AUTHZ_GRANTS_CACHE_TTL_MS` says.
const grants = await resolveUserAuthzGrants(ql, userId, { tenantId, nowMs, bypassGrantsCache: true });
const { droppedGrants, delegatedPositions } = await collectGrantProvenance(ql, userId, nowMs);
return {
userId,
positions: grants.positions,
permissions: grants.permissions,
systemPermissions: grants.systemPermissions,
org_user_ids: grants.org_user_ids,
accessible_org_ids: grants.accessible_org_ids,
...(grants.tabPermissions ? { tabPermissions: grants.tabPermissions } : {}),
...(grants.email != null ? { email: grants.email } : {}),
...(grants.posture ? { posture: grants.posture } : {}),
droppedGrants,
delegatedPositions,
// [ADR-0068 D2] Not a second derivation: `derivePosture` returns
// PLATFORM_ADMIN if and only if the resolver saw the unscoped
// `admin_full_access` USER grant, so this reads that one verdict back.
hasPlatformAdminGrant: grants.posture === 'PLATFORM_ADMIN',
};
}
/**
* [ADR-0090 D10] Result of resolving the delegator behind an on-behalf-of
* principal. `none` = no delegation link; `missing` = the link names a user that
* does not exist (the caller must fail CLOSED — see {@link resolveDelegatorContext});
* `resolved` = the delegator's reconstructed evaluation context.
*/
export type DelegatorResolution =
| { kind: 'none' }
| { kind: 'missing'; userId: string }
| { kind: 'resolved'; context: any };
/**
* [ADR-0090 D10 — agent intersection] Resolve the evaluation context of the USER
* behind an agent/service principal that acts `onBehalfOf` them. The effective
* permission of the delegated principal is the INTERSECTION of its own grants
* and this delegator's grants (confused-deputy prevention) — never the union —
* so every enforcement layer combines the two set-lists with AND.
*
* Semantics this helper pins down (single-sourced for the middleware AND the
* explain engine so enforcement and its explanation can never drift):
*
* - **Fail-closed on a dangling link (edge b).** A `missing` delegator must be
* reported as such by the caller and denied — NOT resolved to empty sets:
* `resolvePermissionSetsForContext` synthesises the additive `member_default`
* baseline for ANY `userId`, so a deleted delegator would otherwise still
* intersect against baseline-level access. The `sys_user` existence check is
* the only correct fail-closed point.
* - **Tenant-scoped bags are inherited from the live principal, and the
* inheritance still WINS.** The agent and its delegator are, by construction,
* in the same org, so `tenantId` / `org_user_ids` carry over — delegator-side
* RLS that substitutes them then compiles faithfully instead of collapsing to
* the deny sentinel. Since #6352, `buildContextForUser` returns the resolver's
* own `org_user_ids`; the live principal's org peer set is the answer the
* delegated request runs under, so the assignment below overwrites it
* exactly as before. The delegator's GRANTS are the one tenant-scoped thing
* resolved rather than copied: since #20515 they are resolved IN the live
* principal's organization, so a delegator grant scoped to that organization
* applies and one scoped to any other does not.
* `accessible_org_ids` (ADR-0105 D2) is the exception: it is resolved from
* the DELEGATOR's own memberships by `buildContextForUser`, never inherited,
* because inheriting it would widen a delegated read past the organizations
* the delegator actually belongs to.
* - **Person-specific membership bags (`rlsMembership`) are left unresolved.**
* Absent → the RLS compiler's fail-closed substitution NARROWS the
* delegator's row set, never widens it — safe by construction. #6352 routed
* the delegator through the shared resolver (`resolveUserAuthzGrants`), which
* closed the positions / permission-set / posture half of this gap; the
* team/territory bags are not part of that envelope on either side, so they
* remain a follow-up for the `RlsMembershipResolver` contract rather than for
* this function.
* - **One hop only (edge a).** The `onBehalfOf` shape carries a single delegator
* id with no nested link, so a transitive agent→service→user chain is not
* representable in one context. Intersecting against the immediate delegator
* is a safe lower bound on the true multi-hop intersection (each hop only
* narrows), so this never escalates; true chain-walk is a producer-side
* follow-up that collapses the chain to the ultimate human delegator.
* - **Trigger is the LINK, not the label (edge d).** A `service` acting for a
* user is the identical confused-deputy risk as an `agent`; both intersect.
* `principalKind` stays advisory. `human`/`system`/`guest` never carry
* `onBehalfOf` in practice, so they are unaffected.
*/
export async function resolveDelegatorContext(
ql: any,
context: any,
nowMs: number = Date.now(),
): Promise<DelegatorResolution> {
const oboId = context?.onBehalfOf?.userId;
if (!oboId) return { kind: 'none' };
let user: any = null;
try {
user = await ql.findOne('sys_user', { where: { id: oboId }, context: SYSTEM_CTX });
} catch {
user = null;
}
if (!user) return { kind: 'missing', userId: String(oboId) };
// [#20515] The delegator's GRANTS are resolved in the live principal's
// organization — the organization the delegated request runs in, which this
// function already hands the delegator's context below. Resolved with no
// tenant, the delegator leg would hold only global grants (and before the
// resolver's no-tenant rule, every organization's), never the grants the
// delegator actually holds where the request runs.
const liveTenantId = typeof context?.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
const dctx = await buildContextForUser(ql, oboId, nowMs, liveTenantId);
// Inherit tenant-scoped substitution bags from the live principal (same org).
if (context?.tenantId != null) dctx.tenantId = context.tenantId;
if (context?.org_user_ids != null) dctx.org_user_ids = context.org_user_ids;
// [ADR-0105 D2] The delegator's own org access set is NOT inherited — it is
// the delegator's membership that bounds a delegated read, and
// `buildContextForUser` resolves it for them. Inheriting the live principal's
// set would widen the delegator past their own memberships.
if (user.email != null && user.email !== '') dctx.email = user.email;
return { kind: 'resolved', context: dctx };
}
const SCOPE_ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const;
/**
* [ADR-0090 D10] The NARROWER of two access-depth scopes (min rank). Unknown
* values clamp to the narrowest (`own`, fail-closed). Used to intersect the
* agent's and the delegator's effective read/write depth so the stash that
* flows to plugin-sharing carries the tighter of the two.
*/
export function narrowerScope(a: string, b: string): string {
const rank = (s: string): number => {
const i = SCOPE_ORDER.indexOf(s as (typeof SCOPE_ORDER)[number]);
return i < 0 ? 0 : i;
};
return rank(a) <= rank(b) ? a : b;
}
/**
* [ADR-0090 D10 — maintainer ruling 2026-09-08, option 1] The depth the
* DELEGATED principal's own leg contributes, and whether that leg is what
* narrowed the read.
*
* **The rule: a ceiling that says nothing subtracts nothing.** `ceilingScope`
* is `PermissionEvaluator.getDeclaredScope` — `undefined` when every set the
* delegated principal holds is silent about depth. Silence is "no opinion", so
* this leg contributes NO owner narrowing (`'org'`) and every row bound comes
* from the delegator's leg, which plugin-sharing AND-s in beside it. A ceiling
* that DOES declare a depth contributes exactly that depth, unchanged.
*
* ⚠️ This is deliberately NOT `narrowerScope(ceiling, delegator)`. The
* OWD/sharing owner-match is IDENTITY-scoped: `__readScope` bounds the AGENT
* identity's owner-match and `__delegatorReadScope` bounds the DELEGATOR's, and
* the true intersection is the AND of the two filters (`sharing-plugin.ts`).
* Folding the minimum into this leg would scope the agent identity to the
* delegator's depth — `owner_id = agentId` at the delegator's depth — hiding
* the very rows the delegator legitimately owns.
*
* Why the rule exists, in one measurement: `mcp_agent_data_read` /
* `mcp_agent_data_write` are pure CAPABILITY ceilings — `{'*': {allowRead:
* true, …}}`, no `readScope`, no `viewAllRecords`, and their own doc comment
* says "NO row-level security … all row/owner/tenant narrowing comes from the
* delegating user". `getEffectiveScope` nonetheless answered `'own'` for them
* (its owner-only default for a granting-but-silent set), so this leg imposed
* an owner-match nobody declared and a `viewAllRecords` sales manager who reads
* 9 accounts / 23 opportunities / 45 tasks through the Console or a per-user
* API key read 5 / 0 / 0 through an OAuth MCP client — silently, `total: 0`,
* with the agent then reporting "there are no opportunities this quarter" as
* fact.
*
* ⛔ This widens VISIBILITY DEPTH only, and only on the SILENT-ceiling input.
* Every other subtraction is decided elsewhere and is unchanged — see the
* subtraction table on `MCP_AGENT_PERMISSION_SET_READ` in
* `objects/default-permission-sets.ts`.
*
* `narrowedByCeiling` is the diagnostic half of the same ruling: true iff the
* ceiling's OWN declared depth is strictly narrower than the delegator's, i.e.
* the delegated read is answering from a row set THIS leg shrank — and the MCP
* `query_records` result must then SAY so rather than serve a bare count.
*/
export function intersectDelegatedScope(
ceilingScope: string | undefined,
delegatorScope: string,
): { agentLegScope: string; narrowedByCeiling: boolean } {
if (ceilingScope === undefined) {
return { agentLegScope: 'org', narrowedByCeiling: false };
}
return {
agentLegScope: ceilingScope,
narrowedByCeiling: narrowerScope(ceilingScope, delegatorScope) !== delegatorScope,
};
}
/**
* [ADR-0090 D10 — ruling 2026-09-08, consequence 2] The sentence a delegated
* read carries when the ceiling narrowed it.
*
* ONE spelling, so the explain path (`layer: 'object_crud'` / `depth`) and the
* MCP tool result cannot describe the same intersection differently. It states
* the DIRECTION of the error — the row set is a subset, the count is not a fact
* about the data — because the consumer being corrected is an AI that would
* otherwise report `total: 0` as an answer.
*/
export function d10NarrowingStatement(input: {
object: string;
delegatorId: string;
effectiveScope: string;
delegatorScope: string;
}): string {
return (
`This result was narrowed by the ADR-0090 D10 intersection: the agent principal acting on behalf of `
+ `${input.delegatorId} is capped at '${input.effectiveScope}' record depth on ${input.object}, while `
+ `${input.delegatorId} alone reaches '${input.delegatorScope}'. Rows outside that depth are ABSENT from `
+ `this result and are NOT absent from the object — do not report this count as a fact about the data. `
+ `Ask ${input.delegatorId} to re-run the question under their own credentials to see the full set.`
);
}
/**
* [ADR-0090 D10] Intersect two FLS masks. A field is readable/editable in the
* result only if it is readable/editable on BOTH sides. A field ABSENT from a
* side is unconstrained on that side (the FieldMasker leaves unlisted fields
* fully visible/editable), so an absent side contributes `true` — the AND then
* lets the OTHER side's constraint win. Net effect: the intersection hides or
* write-locks every field that EITHER principal hides or write-locks.
*/
export function intersectFieldMasks(
a: Record<string, { readable?: boolean; editable?: boolean }>,
b: Record<string, { readable?: boolean; editable?: boolean }>,
): Record<string, { readable: boolean; editable: boolean }> {
const out: Record<string, { readable: boolean; editable: boolean }> = {};
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
for (const k of keys) {
const ar = k in a ? a[k]?.readable !== false : true;
const ae = k in a ? a[k]?.editable !== false : true;
const br = k in b ? b[k]?.readable !== false : true;
const be = k in b ? b[k]?.editable !== false : true;
out[k] = { readable: ar && br, editable: ae && be };
}
return out;
}
/**
* [#9127] Render a `maskingRule` for the report. PRESENTATION ONLY — it never
* decides whether the rule applies (that is enforcement's call, arriving via
* `deps.getPartialMaskRules`); it only names the rule the caller's masked
* value was produced by, so an admin reading "why is this `138****5678`" gets
* the preset or the explicit span instead of a bare field name.
*/
function describeMaskingRule(rule: FieldMaskingRule): string {
return typeof rule === 'string' ? rule : `keepHead ${rule.keepHead}, keepTail ${rule.keepTail}`;
}
/** D1-equivalent OWD reading (mirrors plugin-sharing's effectiveSharingModel). */
function describeOwd(schema: any): { model: string; declared: boolean; effect: 'private' | 'read' | 'public' } {
const m = schema?.sharingModel ?? schema?.security?.sharingModel;
if (m === 'private') return { model: 'private', declared: true, effect: 'private' };
if (m === 'public_read') return { model: 'public_read', declared: true, effect: 'read' };
if (m === 'public_read_write' || m === 'controlled_by_parent') {
return { model: String(m), declared: true, effect: 'public' };
}
if (m == null) {
const isSystem = schema?.isSystem === true || String(schema?.name ?? '').startsWith('sys_');
return isSystem
? { model: '(unset, system default: public)', declared: false, effect: 'public' }
: { model: "(unset → 'private', ADR-0090 D1 fail-closed default)", declared: false, effect: 'private' };
}
return { model: `${String(m)} (unknown → private, fail-closed)`, declared: true, effect: 'private' };
}
/** [ADR-0055] What each refusing leg of the master-detail write check means, for the report's prose. */
const MASTER_DENIAL_LEG_DETAIL: Record<ControlledByParentWriteDenialLeg, string> = {
object_permission: 'the principal holds no object-level update grant on a master',
row_level_security: "a master row is outside the principal's write row-level security on that master",
record_sharing:
'record sharing does not let the principal edit a master row, and no app-authored update policy on that ' +
'master admits it',
master_chain: 'the controlled_by_parent chain cannot be walked to a master that governs its own rows',
};
/**
* [ADR-0055] Why the master-detail write check reached no verdict, and what the
* write answers instead. The answer is the same for every by-id write the check
* runs on: each condition throws one error class whatever the verb.
*/
const MASTER_UNRESOLVED_DETAIL: Record<ControlledByParentWriteUnresolvedReason, { condition: string; answer: string }> = {
master_detail_relation_missing: {
condition:
'the object declares controlled_by_parent without exactly one required master_detail field to derive access from',
answer: '422 INVALID_METADATA',
},
record_not_found: { condition: 'the record does not exist', answer: '404 RECORD_NOT_FOUND' },
master_reference_missing: { condition: "the record's master reference is empty", answer: '422 MISSING_REQUIRED_FIELD' },
};
const MASTER_DERIVED = 'Write access to this record derives from its master (controlled_by_parent, ADR-0055)';
const MASTER_READ_DERIVED = 'Read access to this record derives from its master (controlled_by_parent, ADR-0055)';
/**
* [ADR-0055] The record-grained writes whose verdict on a `controlled_by_parent`
* record the master-detail write check decides: every write step 2.8 of the
* write path runs that check on (the guard of step 2.8 in `security-plugin.ts`)
* that addresses one existing record by id.
*
* `insert` is the one verb step 2.8 runs on that is not here. Its master is read
* off the request BODY, which an explanation does not carry, and it addresses
* no existing record, so a record-grained `create` has no master check to ask.