Repository navigation
Expand file tree
/
Copy pathdelegated-admin-gate.ts
More file actions
1546 lines (1452 loc) · 74.3 KB
/
Copy pathdelegated-admin-gate.ts
File metadata and controls
1546 lines (1452 loc) · 74.3 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 D12] Delegated-administration write gate.
*
* "Administration itself becomes a scoped capability." Today whoever can
* write the RBAC link tables can manage ALL permissions; this gate turns
* those writes into a governed operation:
*
* - TENANT-LEVEL ADMINS (a resolved set OTHER than the two organization-
* administrator sets carries the ADR-0066 superuser wildcard
* `objects['*'].modifyAllRecords` — the platform administrator's
* `admin_full_access`) keep the status quo — the gate passes and the
* ordinary CRUD/RLS checks decide.
* - ORGANIZATION-LEVEL ADMINS (the D12 amendment of 2026-10-17, ruling of
* record 6228728551: a holder of `organization_admin` /
* `organization_admin_no_bypass` for the active organization who is not
* tenant-level) create and remove assignment rows of THEIR organization
* when every permission set the row distributes is inside the organization
* boundary ({@link organizationBoundaryFailure}): no wildcard superuser bit,
* no `adminScope`, only `scope: 'org'` capabilities, and not one of the sets
* the ADR keeps outside by name. Nothing else — no edit in place, no
* organization-less or foreign-organization row, no membership row.
* - DELEGATES (non-tenant-admins holding ≥1 `adminScope`) may write ONLY
* what one of their scopes fully approves: the right kind of action
* (manageAssignments), inside their BU subtree, handing out only
* allowlisted sets, with STRICT containment whenever the granted set itself
* carries an adminScope. A position assignment hands out every set it
* grants — the definition's list and the set of the position's own name. The rows it governs are the two assignment tables
* (`sys_user_position`, `sys_user_permission_set`); the binding and set
* authoring rows retired with the catalog row objects (ADR-0131 D3), so a
* position's sets and a set's definition are written through the metadata
* door alone.
* - EVERYONE ELSE is denied — holding plain CRUD on `sys_user_position`
* no longer makes you a permission administrator (fail closed, D12).
*
* DIRECT position assignments to the `everyone` / `guest` audience anchors
* are rejected for every caller (anchors are implicit — a stored assignment
* row is a modeling error).
*
* `sys_member` (organization membership) is likewise tenant-level only: no
* `adminScope` axis expresses membership, and an admin-grade membership row is
* auto-elevated to `organization_admin` — writing one mints an administrator
* (#3697 follow-up), which is why an organization administrator may not write
* one either: administrator status comes from the membership grade alone. The delegable way to add people is an INVITATION, whose
* placement this gate authorizes and whose role the framework caps at the
* issuer's own grade.
*
* System/boot writes carry `isSystem` and short-circuit the security
* middleware before this gate (seeders, publish materializer, better-auth
* reconciliation are unaffected).
*/
import { isGrantActive, securityCatalogReaderOf, type SecurityCatalogReader } from '@objectstack/core';
import type { AdminScope, AdminScopeParsed, PermissionSet } from '@objectstack/spec/security';
import {
ADMIN_FULL_ACCESS,
ORGANIZATION_ADMIN,
ORGANIZATION_ADMIN_NO_BYPASS,
} from '@objectstack/spec';
import {
MCP_AGENT_PERMISSION_SET_READ,
MCP_AGENT_PERMISSION_SET_RESTRICTED,
MCP_AGENT_PERMISSION_SET_WRITE,
} from '@objectstack/spec/ai';
import { PermissionDeniedError } from './errors.js';
import {
resolveOwnOrganizationRow,
rowOrganizationId,
seedCtx as organizationScopedCtx,
} from './organization-scope.js';
import { grantSetNameOf } from './grant-permission-set-name.js';
import { isReservedIdentityName } from './objects/reserved-identity-names.js';
const SYSTEM_CTX = { isSystem: true } as const;
/**
* Max by-name candidates read when resolving a scope's business-unit anchor.
*
* `sys_business_unit.name` carries NO uniqueness — the object's only unique
* index is `(code, organization_id)` — so under a walled posture two
* organizations may each hold a unit called `sales`. A `limit: 1` read
* therefore answers "whichever row the driver ordered first", which is an id
* ordering and not an organization. The anchor read asks for a small page of
* candidates instead and {@link resolveOwnOrganizationRow} picks the caller's
* own row out of it; a driver that honours the tenant scope has already
* removed most of the others.
*/
const ANCHOR_CANDIDATE_LIMIT = 50;
/** Max BU-tree depth walked when expanding a scope subtree (safety bound). */
const MAX_TREE_DEPTH = 32;
/** [ADR-0091 D3] Default self-delegation ceiling: 30 days. A "temporary"
* grant that can be rolled forever is a permanent grant with extra steps. */
const DEFAULT_DELEGATION_CEILING_MS = 30 * 24 * 60 * 60 * 1000;
/**
* The organization a delegated-admin resolution runs in — the caller's own
* active organization, never a scan across all of them.
*
* The same spelling as `SecurityPlugin.callerOrganizationId`, and deliberately
* so: the `adminScope` this gate resolves arrives on a permission set resolved
* for THAT organization's caller. Resolving the scope's business-unit anchor in
* any other organization pairs an authority held in one tenant with a tree
* owned by another — which is what an unscoped by-name read did. (The set
* itself is the security catalog's, which is environment-level — ADR-0131 D3;
* the business units are not.)
*
* Under the `single` posture this is the Default Organization: since ADR-0131
* C1 it exists from boot, and every session on a stock `single` deployment —
* the administrator's and a delegate's alike — carries it as its active
* organization. Undefined only for a context carrying no organization at all,
* where a by-name anchor is read organization-less and nothing here changes.
*
* Under the `group` posture (ADR-0105 D2) this is the ACTIVE organization, not
* the caller's whole membership set. That is the narrower of the two, which is
* the direction a security boundary takes — and it is already the one the
* permission-set load took, so the scope and its anchor stay in one tenant.
*/
function callerOrganizationId(context: any): string | undefined {
const id = context?.organizationId ?? context?.tenantId;
return typeof id === 'string' && id !== '' ? id : undefined;
}
/**
* Does a `sys_user_position` row take effect in `organizationId`?
*
* The runtime resolver's own answer (`resolveAuthzContext` step 4, in
* `@objectstack/core`): a holding stamped with a DIFFERENT organization grants
* nothing there; an organization-less holding grants in every organization;
* `organizationId` undefined (a context carrying no organization) drops nothing.
*
* Every holding the gate reasons about goes through this, because
* `sys_user_position.position` is a position NAME, the same string in every
* organization that holds it (ADR-0131 D3: one environment catalog) — so under
* a walled posture another organization's holding of the position is the same
* string. The
* gate must count exactly the holdings the runtime grants: counting another
* organization's lets a user self-delegate a position they do not hold there;
* dropping organization-less ones refuses holdings the runtime honours.
*/
function holdingTakesEffectIn(row: any, organizationId?: string): boolean {
if (!organizationId) return true;
const org = rowOrganizationId(row);
return !org || org === organizationId;
}
/** Coerce a stored timestamp to epoch ms; undefined = absent, NaN = unparseable. */
function toEpochMs(value: unknown): number | undefined {
if (value == null || value === '') return undefined;
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
if (value instanceof Date) return value.getTime();
if (typeof value === 'string') return Date.parse(value);
return Number.NaN;
}
const GOVERNED_OBJECTS = new Set([
'sys_user_position',
'sys_user_permission_set',
// [#3697 follow-up] Organization MEMBERSHIP is an authority surface too: a
// row whose `role` is admin-grade is auto-elevated to `organization_admin`
// by `auto-org-admin-grant.ts`, whose wildcard `modifyAllRecords` IS
// `isTenantAdmin()`. So writing one mints a tenant admin — the same
// escalation the invitation role cap closes on the issuance path.
//
// Unreachable today (every writer is a better-auth path running under
// `isSystem`, short-circuited before this gate; the ADR-0092 D2 identity
// write guard refuses user-context writes to better-auth tables upstream).
// It is here so the chain does not silently reopen the day someone adds a
// direct-write surface — a `case` label is not enforcement, and the call
// site is what matters (AGENTS.md Prime Directive #10).
'sys_member',
]);
const GOVERNED_OPERATIONS = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']);
const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
/**
* The governed objects whose rows record their writer in `granted_by` — a
* `readonly` provenance column on both (ADR-0090 D12's dual audit: `granted_by`
* = writer, `delegated_from` = authority source, `reason` = why).
*
* The writer is the value, so a caller does not get to name one: every
* non-system insert that this gate admits is stamped with its own `userId`,
* whatever the payload carried — tenant-level admins, delegates and
* self-delegations alike. The stamp is assigned in place on the payload rows
* BEFORE the engine's insert pipeline snapshots them; the engine's create-side
* `readonly` strip does not judge `sys_` objects, so the stamp is what lands.
* A non-system UPDATE gets no stamp: the column is `readonly`, so the engine's
* update strip drops a caller's value and the row keeps its writer.
*
* System writers never reach this gate (`isSystem` short-circuits the security
* middleware first) and keep the id or null they wrote: invitation placement
* writes its issuer, the organization-admin reconcile the attributed human or
* null, the platform-admin promotion null (ADR-0118 D1 — never a sentinel).
*/
const GRANTED_BY_OBJECTS = new Set(['sys_user_position', 'sys_user_permission_set']);
/**
* Stamp the writer into `granted_by` on every row of an admitted non-system
* insert into a {@link GRANTED_BY_OBJECTS} table. Unconditional on purpose:
* keeping a supplied value would let a caller name any user as the granter.
*/
function stampWriter(opCtx: any, userId: unknown): void {
if (opCtx?.operation !== 'insert' || !GRANTED_BY_OBJECTS.has(opCtx?.object)) return;
if (userId == null || userId === '') return;
for (const row of rowsOf(opCtx)) {
if (row && typeof row === 'object') row.granted_by = userId;
}
}
export interface DelegatedAdminGateDeps {
/** ObjectQL engine handle (system-context reads for pre-images/lookups). */
ql: any;
/** Shared permission-set resolution (same path as the CRUD middleware). */
resolveSets: (context: any) => Promise<PermissionSet[]>;
logger?: { warn?: (msg: string, meta?: any) => void };
/** [ADR-0091 D3] Clock for delegation validity/ceiling checks (tests inject). */
now?: () => number;
/** [ADR-0091 D3] Max self-delegation duration (ms). Default 30 days. */
delegationCeilingMs?: number;
}
/**
* [ADR-0090 D12 / ADR-0105 D8] What the caller may delegate — the read half
* of the gate, shaped for a picker (see
* {@link DelegatedAdminGate.describeDelegableScope}).
*/
export interface DelegableScopeReport {
/**
* ADR-0066 superuser wildcard: unconstrained. The lists below then enumerate
* everything the write path admits — never an audience anchor or a built-in
* identity name, which no writer may assign.
*/
isTenantAdmin: boolean;
/** Every `adminScope` the caller holds, with its subtree resolved to ids. */
scopes: Array<{
setName: string;
businessUnit: string;
includeSubtree: boolean;
manageAssignments: boolean;
assignablePermissionSets: string[];
businessUnitIds: string[];
}>;
/** Union of the subtrees where the caller may PLACE people (`manageAssignments`). */
placeableBusinessUnitIds: string[];
/** Positions the caller may assign — every set they distribute is allowlisted. */
assignablePositions: string[];
}
interface HeldScope {
/** The set that carries the scope (for error messages). */
setName: string;
/**
* The scope as `resolveHeldScopes` NORMALISES it, not as it was authored:
* every flag there is forced to a boolean (`!== false` / `=== true`) and the
* allowlist to a string[]. So this names the PARSED shape (ADR-0122) — the
* authored one, which arrives as raw JSON and may state none of them, is
* `AdminScope` and appears at the two `parseMaybeJson` sites below.
*/
scope: AdminScopeParsed & { assignablePermissionSets: string[] };
/** Resolved BU ids covered (root + descendants when includeSubtree). Empty = misconfigured → approves nothing. */
subtree: Set<string>;
}
/** A permission set a position distributes, as the containment check reads it. */
interface BoundSet {
name: string;
adminScope?: unknown;
}
/**
* What a refusal adds when the set it names is the one of the position's own
* name: the position's definition need not list it, so the reader is told why
* it was judged at all.
*/
function ownNameNote(positionName: string, setName: string): string {
return setName === positionName
? " (the permission set of the position's own name, which an assignment of the position also grants)"
: '';
}
function rowsOf(opCtx: any): any[] {
const d = opCtx?.data;
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') return [d];
return [];
}
function parseMaybeJson(v: unknown): any {
if (typeof v !== 'string') return v;
try { return JSON.parse(v); } catch { return undefined; }
}
/** ADR-0066 tenant-level admin: a resolved set whose '*' entry carries modifyAllRecords. */
export function isTenantAdmin(sets: PermissionSet[]): boolean {
for (const ps of sets) {
const objects: any = parseMaybeJson((ps as any).objects) ?? {};
const wildcard = objects?.['*'];
if (wildcard && wildcard.modifyAllRecords === true) return true;
}
return false;
}
/**
* [ADR-0090 D12, amended 2026-10-17] The two sets that make their holder an
* organization-level administrator — granted by `auto-org-admin-grant.ts` from
* an owner or admin membership, for the organization of that membership. The
* caller's sets are resolved for its active organization, so holding one here
* means administering THAT organization.
*/
const ORGANIZATION_ADMIN_SETS: ReadonlySet<string> = new Set([ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS]);
/**
* [ADR-0090 D12, amended 2026-10-17] The sets outside the organization boundary
* BY NAME, whatever the three conditions say of them: `admin_full_access` and
* both organization-administrator sets (administrator status is conferred by
* membership grade only, never copied by an assignment), and the ADR's
* denylist — the three D10 agent ceilings, whose bound is the delegating
* principal's grants and not the organization wall.
*/
const ORGANIZATION_BOUNDARY_DENYLIST: ReadonlySet<string> = new Set([
ADMIN_FULL_ACCESS,
ORGANIZATION_ADMIN,
ORGANIZATION_ADMIN_NO_BYPASS,
MCP_AGENT_PERMISSION_SET_READ,
MCP_AGENT_PERMISSION_SET_WRITE,
MCP_AGENT_PERMISSION_SET_RESTRICTED,
]);
/** Does the caller hold an organization-administrator set for its active organization? */
function holdsOrganizationAdmin(sets: PermissionSet[]): boolean {
return sets.some((ps) => ORGANIZATION_ADMIN_SETS.has(String((ps as any)?.name ?? '')));
}
/**
* D12's tenant-level administrator: a superuser wildcard from any set but the
* two organization-administrator sets. `organization_admin` carries the
* wildcard too, bounded by the organization wall — under the 2026-10-17
* amendment its holder is organization-level, judged by the boundary below.
*/
function isTenantLevelAdmin(sets: PermissionSet[]): boolean {
return isTenantAdmin(sets.filter((ps) => !ORGANIZATION_ADMIN_SETS.has(String((ps as any)?.name ?? ''))));
}
/** A refusal reason from the organization-level branch, and the subject it names. */
interface OrganizationFailure {
reason: string;
meta: Record<string, unknown>;
}
/** Single scalar id from an update/delete opCtx (mirrors the engine's single-id rule). */
function extractSingleId(opCtx: any): string | number | null {
const isScalar = (v: unknown): v is string | number =>
v !== null && (typeof v === 'string' || typeof v === 'number');
const data = opCtx?.data;
if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) return data.id;
const whereId = opCtx?.options?.where?.id ?? opCtx?.where?.id ?? opCtx?.id;
return isScalar(whereId) ? whereId : null;
}
export class DelegatedAdminGate {
constructor(private readonly deps: DelegatedAdminGateDeps) {}
/** Per-call caches (BU subtrees, position bindings) live on the instance
* only for the duration of one assert — recreated each call for freshness. */
async assert(opCtx: any): Promise<void> {
if (!GOVERNED_OBJECTS.has(opCtx?.object)) return;
if (!GOVERNED_OPERATIONS.has(opCtx?.operation)) return;
// Authority first: a refused write throws here and is never stamped.
await this.authorize(opCtx);
// Then provenance, on every admitted path — see {@link GRANTED_BY_OBJECTS}.
stampWriter(opCtx, opCtx.context?.userId);
}
/** The authority decision for a governed write: returns when admitted, throws when refused. */
private async authorize(opCtx: any): Promise<void> {
const ctx = opCtx.context ?? {};
// Every business-unit anchor this call resolves is resolved in the
// caller's own organization — see {@link callerOrganizationId}.
const organizationId = callerOrganizationId(ctx);
// ── Unconditional invariant: no stored assignments to audience anchors —
// they are implicit for whole principal classes (ADR-0090 D5/D9), so a
// row is at best inert and at worst a privilege-mask. Applies to every
// caller, tenant admins included (boot/system short-circuited earlier).
if (opCtx.object === 'sys_user_position' && ['insert', 'update'].includes(opCtx.operation)) {
for (const row of rowsOf(opCtx)) {
const pos = String(row?.position ?? '');
if (ANCHOR_POSITIONS.has(pos)) {
throw new PermissionDeniedError(
`[Security] Access denied: the '${pos}' audience anchor is implicit — it cannot be ` +
`assigned via sys_user_position (ADR-0090 D9). Authenticated principals hold ` +
`'everyone' and anonymous principals hold 'guest' automatically.`,
{ operation: opCtx.operation, object: opCtx.object, position: pos },
);
}
}
}
// ── Principal-less non-system writes to RBAC tables: fail CLOSED. ──
if (!ctx.userId) {
throw new PermissionDeniedError(
`[Security] Access denied: '${opCtx.operation}' on '${opCtx.object}' requires an ` +
`authenticated administrator (ADR-0090 D12 — administration is a scoped capability).`,
{ operation: opCtx.operation, object: opCtx.object },
);
}
let sets: PermissionSet[] = [];
try {
sets = await this.deps.resolveSets(ctx);
} catch {
sets = []; // resolution failure → treated as no authority (fail closed)
}
if (isTenantLevelAdmin(sets)) return; // status quo — downstream CRUD/RLS decide
// ── [#3697 follow-up] Organization membership is NOT delegable ────────
// Every other governed object has an adminScope axis that can approve part
// of it (a subtree, an action flag, an allowlist). `sys_member` has none:
// no axis of `AdminScope` expresses "org membership", and the row's own
// `role` column is an authority dial — admin-grade membership is
// auto-elevated to `organization_admin`, i.e. to `isTenantAdmin()`. A
// delegate who could write one would mint authority strictly greater than
// their own, which is exactly what ADR-0090 D12 exists to prevent.
//
// So it is tenant-admin-only, full stop — reached only by a hypothetical
// future direct-write surface (better-auth's own writes are `isSystem` and
// never arrive here). Adding people to an organization already has a
// delegable path: the INVITATION, whose placement is authorized against
// the issuer's adminScope and whose role is held to the issuer's own grade.
if (opCtx.object === 'sys_member') {
throw new PermissionDeniedError(
`[Security] Access denied: '${opCtx.operation}' on 'sys_member' is tenant-level only — ` +
`organization membership is not a delegable capability (ADR-0090 D12), and an ` +
`admin-grade membership row resolves to tenant administration. Add people through an ` +
`invitation instead: its placement is authorized against your adminScope.`,
{ operation: opCtx.operation, object: opCtx.object, userId: ctx.userId },
);
}
// ── [ADR-0091 D3] Self-service delegation of duty (职务代理) ──────────
// A write that STAMPS `delegated_from` is a delegation: the holder passes
// their OWN hat, judged by delegation rules — not by an adminScope. This
// is the ONE authority path open to a non-admin, so it is tried before the
// held-scope resolution that would otherwise fail closed. (Tenant admins
// short-circuited above; a scope-holding delegate who stamps
// `delegated_from` is still held to the delegation invariants — a
// delegation is a delegation regardless of who writes it.)
if (this.isDelegationWrite(opCtx)) {
return this.assertSelfDelegation(opCtx, ctx, organizationId);
}
// ── [ADR-0090 D12, amended 2026-10-17] Organization-level administration ──
// An organization administrator creates and removes its own organization's
// assignment rows inside the organization boundary. A caller who is also a
// delegate and fails the boundary is still judged by its adminScope below;
// one who is not is refused with the boundary's own reason.
let organizationFailure: OrganizationFailure | null = null;
if (holdsOrganizationAdmin(sets)) {
organizationFailure = await this.organizationAdminFailure(opCtx, organizationId);
if (organizationFailure === null) return;
}
const held = await this.resolveHeldScopes(sets, organizationId);
if (held.length === 0 && organizationFailure) {
throw new PermissionDeniedError(
`[Security] Access denied: organization administrator '${opCtx.operation}' on '${opCtx.object}' ` +
`rejected — ${organizationFailure.reason} (ADR-0090 D12, the organization boundary).`,
{ operation: opCtx.operation, object: opCtx.object, ...organizationFailure.meta },
);
}
if (held.length === 0) {
throw new PermissionDeniedError(
`[Security] Access denied: '${opCtx.operation}' on '${opCtx.object}' requires tenant-level ` +
`administration or a delegated adminScope (ADR-0090 D12) — plain CRUD grants on RBAC ` +
`tables do not make a permission administrator.`,
{ operation: opCtx.operation, object: opCtx.object, userId: ctx.userId },
);
}
// Delegates may not run filter-writes — a mutation the gate cannot
// attribute to ONE pre-imaged row cannot be boundary-checked (a broad
// `where` with a patch payload would slip the subtree check otherwise).
// Supported delegate shapes: insert with payload rows, or single-row
// update/delete by scalar id.
const isMutationWithoutId = ['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)
&& extractSingleId(opCtx) == null;
if (isMutationWithoutId) {
throw new PermissionDeniedError(
`[Security] Access denied: delegated administrators must target single rows by id on ` +
`'${opCtx.object}' — filter writes cannot be checked against a delegation boundary.`,
{ operation: opCtx.operation, object: opCtx.object },
);
}
switch (opCtx.object) {
case 'sys_user_position':
return this.assertAssignmentWrite(opCtx, held);
case 'sys_user_permission_set':
return this.assertDirectGrantWrite(opCtx, held);
}
}
/**
* [ADR-0090 D6 × D12] Does the actor (via their resolved sets) hold a
* delegated `adminScope` whose BU subtree covers `targetUserId`? Used by
* the explain API: an administrator who can already manage a user's
* assignments inside their delegation boundary may also read WHY that
* user's access resolves the way it does — without tenant-level
* `manage_users`. Any held scope qualifies, whether or not it grants
* `manageAssignments`: it administers capability inside the subtree, and
* the report is read-only. Fail-closed: unresolvable scopes/memberships
* cover nothing.
*
* `callerContext` supplies the organization the scopes resolve in. Omitting
* it resolves on the organization-less surface — correct for a `single`
* posture, and never wider than what this method answered before it existed.
*/
async scopesCoverUser(
sets: PermissionSet[],
targetUserId: string,
callerContext?: any,
): Promise<boolean> {
const held = await this.resolveHeldScopes(sets, callerOrganizationId(callerContext));
if (held.length === 0) return false;
const userBUs = await this.businessUnitsOfUser(targetUserId);
if (userBUs.size === 0) return false;
for (const s of held) {
for (const bu of userBUs) if (s.subtree.has(bu)) return true;
}
return false;
}
/**
* [ADR-0090 D12 / ADR-0105 D8] Describe what the caller may DELEGATE —
* the read half of this gate.
*
* A UI that offers "place this person in a unit, with these positions"
* (the D8 scoped-invitation form) must narrow its options to what the
* issuer could actually assign; otherwise every picker lists the whole
* tree and the user discovers the boundary only by being refused. The
* lists here are computed by the SAME `resolveHeldScopes` /
* `setsBoundToPosition` / containment helpers the write path enforces
* with, so an option this report offers is one `assert()` accepts — the
* picker narrows, it does not decide.
*
* Self-scoped by construction: the caller's own resolved sets are the
* only input, so reading it discloses nothing beyond the authority they
* already hold. Fail-closed shape: unresolvable scopes contribute
* nothing, and a caller with no delegated authority gets empty lists.
*
* `callerContext` supplies the organization every unit id in the report is
* resolved in — without it a business unit was named by NAME across
* organizations, so a picker could offer (and this report could disclose)
* another organization's unit ids. Omitting it resolves on the
* organization-less surface, correct for a `single` posture.
*
* A tenant-level admin (ADR-0066 superuser wildcard) is unconstrained;
* the report says so AND enumerates everything the write path admits, so a
* consumer can render one uniform picker instead of special-casing. Every
* branch reads one position listing, which never offers a name no writer
* may assign (an audience anchor or a built-in identity). An organization
* administrator (D12 as amended 2026-10-17) is not: `isTenantAdmin` is
* false, every unit of its organization is placeable and the positions
* inside the organization boundary are assignable — on the same report
* shape, beside any delegated scopes it also holds.
*/
async describeDelegableScope(
sets: PermissionSet[],
callerContext?: any,
): Promise<DelegableScopeReport> {
const organizationId = callerOrganizationId(callerContext);
// [ADR-0131 D3/D4] Every position the security catalog holds — the
// environment registry, one namespace per deployment — never the
// per-organization `sys_position` rows: a position declared only in the
// registry is assignable, and a row with no definition grants nothing
// (the resolver reads the catalog), so it is not offered.
//
// Two kinds of catalog name are never offered, by ANY branch below,
// because no writer may assign them: the ADR-0090 D9 audience anchors
// (refused by `authorize`'s unconditional invariant) and the ADR-0068 D2
// built-in identity names (`sys_user_position.position` refuses them from
// every writer, tenant-level administrators included — the shared
// `isReservedIdentityName` predicate that validation is generated from).
// They carry no sets, so no branch's own test would settle them: the
// listing every branch reads leaves them out, once.
const allPositions = async (): Promise<string[]> => {
const catalog = this.catalog();
if (!catalog) return [];
try {
return (await catalog.list('position'))
.map((entry) => entry.name)
.filter((n) => n && !ANCHOR_POSITIONS.has(n) && !isReservedIdentityName(n));
} catch {
return []; // a picker over an unreadable catalog offers nothing (fail closed)
}
};
if (isTenantLevelAdmin(sets)) {
return {
isTenantAdmin: true,
scopes: [],
// A tenant admin is unconstrained INSIDE their organization, never
// across organizations: the picker this feeds must not name a unit
// the caller could not place anyone in.
placeableBusinessUnitIds: await this.organizationBusinessUnitIds(organizationId),
assignablePositions: await allPositions(),
};
}
const held = await this.resolveHeldScopes(sets, organizationId);
const scopes = held.map((h) => ({
setName: h.setName,
businessUnit: h.scope.businessUnit,
includeSubtree: h.scope.includeSubtree,
manageAssignments: h.scope.manageAssignments,
assignablePermissionSets: [...h.scope.assignablePermissionSets],
businessUnitIds: [...h.subtree],
}));
// Placement needs `manageAssignments` — a scope without it is an
// allowlist that places nobody.
const placing = held.filter((h) => h.scope.manageAssignments);
const placeableBusinessUnitIds = [...new Set(placing.flatMap((h) => [...h.subtree]))];
// A position is assignable when at least ONE placing scope allowlists
// every set it distributes — the set of its own name included — exactly
// `assertAssignmentWrite`'s test, containment check included.
const assignablePositions: string[] = [];
if (placing.length > 0) {
for (const positionName of await allPositions()) {
let boundSets: BoundSet[];
try {
boundSets = await this.setsBoundToPosition(positionName);
} catch {
continue; // a position whose binding cannot be read is not offered
}
const ok = placing.some((s) =>
boundSets.every(
(bound) =>
s.scope.assignablePermissionSets.includes(bound.name) &&
this.assertScopeGrantContainment(bound, held, /*dryRun*/ true) === null,
),
);
if (ok) assignablePositions.push(positionName);
}
}
// [ADR-0090 D12, amended 2026-10-17] An organization administrator places
// into every unit of its organization and assigns every position inside
// the organization boundary — `organizationAdminFailure`'s own test, so an
// option offered here is one the write path admits. The ADR-0068 D2
// identity names carry no sets, so the boundary does not settle them; the
// shared listing has already left them out (see `allPositions`).
if (holdsOrganizationAdmin(sets) && organizationId) {
for (const id of await this.organizationBusinessUnitIds(organizationId)) {
if (!placeableBusinessUnitIds.includes(id)) placeableBusinessUnitIds.push(id);
}
for (const positionName of await allPositions()) {
if (assignablePositions.includes(positionName)) continue;
let failure: unknown;
try {
failure = await this.positionBoundaryFailure(positionName);
} catch {
continue; // a position whose sets cannot be read is not offered
}
if (failure === null) assignablePositions.push(positionName);
}
}
return {
isTenantAdmin: false,
scopes,
placeableBusinessUnitIds,
assignablePositions,
};
}
/**
* [ADR-0090 D6 × D12, amended 2026-10-17] Is the caller an organization
* administrator of its active organization, and `targetUserId` a member of
* it? Used by the explain API: an administrator who may assign a member's
* positions and sets may also read why that member's access resolves as it
* does. Fail-closed: no active organization, or an unreadable membership,
* covers nobody.
*/
async organizationAdminCoversUser(
sets: PermissionSet[],
targetUserId: string,
callerContext?: any,
): Promise<boolean> {
if (!holdsOrganizationAdmin(sets)) return false;
const organizationId = callerOrganizationId(callerContext);
const ql = this.deps.ql;
if (!organizationId || !targetUserId || !ql?.find) return false;
try {
const rows = await ql.find('sys_member', {
where: { user_id: targetUserId, organization_id: organizationId },
limit: 1,
context: SYSTEM_CTX,
});
return (Array.isArray(rows) ? rows : []).some((r: any) => rowOrganizationId(r) === organizationId);
} catch {
return false;
}
}
// ── [ADR-0090 D12, amended 2026-10-17] Organization-level administration ──
/**
* Why the organization administrator of `organizationId` may NOT perform this
* write — or `null` when the organization boundary admits it.
*
* The right is to create and remove `sys_user_position` /
* `sys_user_permission_set` rows of the caller's own organization: an insert,
* or a delete of one row by id. A row inserted with no organization is
* stamped with the caller's here, once admitted, so the right never mints an
* organization-less grant (one that applies in every organization); a row
* naming another organization, or a removal of an organization-less or
* foreign row, is refused. That the row's user is a member of the
* organization is judged for every writer by the grant-holder membership
* hooks (`grant-holder-membership-refusal.ts`), not again here.
*
* Every permission set the row distributes must be inside the boundary
* ({@link organizationBoundaryFailure}); for a position, every set it carries
* ({@link setsCarriedByPosition}). One set outside puts the row outside.
*/
private async organizationAdminFailure(
opCtx: any,
organizationId?: string,
): Promise<OrganizationFailure | null> {
const op = String(opCtx.operation);
if (!organizationId) {
return { reason: 'the request carries no active organization to administer', meta: {} };
}
if (op !== 'insert' && op !== 'delete') {
return {
reason: `'${op}' is not part of the right — an assignment row is created and removed, never edited in place`,
meta: {},
};
}
if (op === 'delete' && extractSingleId(opCtx) == null) {
return {
reason: 'a removal must target one assignment row by id — a filter delete cannot be checked against the organization boundary',
meta: {},
};
}
for (const t of await this.materializeTargets(opCtx, opCtx.object)) {
const row = t.next ?? t.prev;
if (!row) return { reason: 'the assignment row to remove could not be read', meta: {} };
const rowOrg = rowOrganizationId(row);
if (t.next ? rowOrg !== null && rowOrg !== organizationId : rowOrg !== organizationId) {
return {
reason: rowOrg
? `the row belongs to organization '${rowOrg}', not the one you administer`
: 'the existing row carries no organization — it applies in every organization, so only a platform administrator may remove it',
meta: { organizationId: rowOrg },
};
}
if (opCtx.object === 'sys_user_position') {
const bu = t.next?.business_unit_id;
if (bu != null && bu !== '' && !(await this.businessUnitInOrganization(String(bu), organizationId))) {
return {
reason: `business unit '${bu}' is not a unit of the organization you administer`,
meta: { businessUnitId: bu },
};
}
const positionName = String(row.position ?? '');
const failure = await this.positionBoundaryFailure(positionName);
if (failure) return { reason: failure.reason, meta: { position: positionName, permissionSet: failure.permissionSet } };
} else {
const setName = grantSetNameOf(row) ?? '';
const reason = setName
? await this.organizationBoundaryFailure(setName)
: 'the grant names no permission set';
if (reason) return { reason, meta: { permissionSet: setName } };
}
}
// Admitted: an insert that named no organization lands in the caller's.
if (op === 'insert') {
for (const row of rowsOf(opCtx)) {
if (row && typeof row === 'object' && rowOrganizationId(row) === null) row.organization_id = organizationId;
}
}
return null;
}
/**
* Is position `positionName` inside the organization boundary? `null` when
* every set it carries is; otherwise the first failure, naming the set.
* A position the catalog does not hold cannot be shown inside, and is not.
*/
private async positionBoundaryFailure(
positionName: string,
): Promise<{ reason: string; permissionSet?: string } | null> {
if (!positionName) return { reason: 'the assignment names no position' };
const carried = await this.setsCarriedByPosition(positionName);
if (carried === null) {
return { reason: `position '${positionName}' is not in the security catalog, so it cannot be shown to be inside the organization boundary` };
}
for (const setName of carried) {
const reason = await this.organizationBoundaryFailure(setName);
if (reason) return { reason: `position '${positionName}' carries ${reason}`, permissionSet: setName };
}
return null;
}
/**
* [ADR-0090 D12, amended 2026-10-17] Every permission set an assignment of
* `positionName` grants: the names its definition's `permissionSets` lists,
* and — while the resolver folds held position names into the requested set
* names (`security-plugin.ts`, `[...positions, ...explicitPermissionSets]`) —
* the set of the position's own name, when the catalog holds one. `null`
* when the catalog does not hold the position. An unreadable catalog throws
* (`AuthzStoreUnavailableError`) and the write is refused with it.
*
* The one answer to "which sets does this position carry" for both branches:
* the delegated walk ({@link setsBoundToPosition}) reads it too.
*/
private async setsCarriedByPosition(positionName: string): Promise<string[] | null> {
const catalog = this.catalog();
if (!catalog) return null;
const position = await catalog.resolve('position', positionName);
if (!position) return null;
const listed = Array.isArray(position.definition.permissionSets)
? (position.definition.permissionSets as unknown[]).filter((n): n is string => typeof n === 'string' && n !== '')
: [];
const ownName = (await catalog.resolve('permission', positionName)) ? [positionName] : [];
return [...new Set([...listed, ...ownName])];
}
/**
* [ADR-0090 D12, amended 2026-10-17] Is permission set `setName` inside the
* organization boundary? `null` when it is; otherwise the reason, naming the
* set and the condition it fails:
*
* - by name — {@link ORGANIZATION_BOUNDARY_DENYLIST};
* - (a) its `objects['*']` entry carries `viewAllRecords` or `modifyAllRecords`;
* - (b) it carries an `adminScope`;
* - (c) a name in its `systemPermissions` is not a capability declared
* `scope: 'org'` in the security catalog (a name no declaration carries is
* not an organization capability).
*
* A set the catalog does not hold cannot be shown inside, and is not.
*/
private async organizationBoundaryFailure(setName: string): Promise<string | null> {
if (ORGANIZATION_BOUNDARY_DENYLIST.has(setName)) {
return `permission set '${setName}', which is outside the organization boundary by name (platform and organization administration and the agent ceilings are never assigned by an organization administrator)`;
}
const catalog = this.catalog();
const entry = catalog ? await catalog.resolve('permission', setName) : undefined;
if (!catalog || !entry) {
return `permission set '${setName}', which is not in the security catalog, so it cannot be shown to be inside the organization boundary`;
}
const definition: any = entry.definition;
const wildcard = (parseMaybeJson(definition.objects) ?? {})?.['*'];
for (const bit of ['viewAllRecords', 'modifyAllRecords'] as const) {
if (wildcard?.[bit] === true) {
return `permission set '${setName}', which fails condition (a) of the organization boundary: its objects['*'] entry carries the superuser bit '${bit}'`;
}
}
if (parseMaybeJson(definition.adminScope ?? definition.admin_scope)) {
return `permission set '${setName}', which fails condition (b) of the organization boundary: it carries an adminScope`;
}
const capabilities = parseMaybeJson(definition.systemPermissions);
for (const capability of Array.isArray(capabilities) ? capabilities : []) {
const declared = await catalog.resolve('capability', String(capability));
if (declared?.definition.scope !== 'org') {
return `permission set '${setName}', which fails condition (c) of the organization boundary: its capability '${String(capability)}' is ${declared ? "not declared scope 'org'" : "declared nowhere, so it is not an organization capability"}`;
}
}
return null;
}
/** Is business unit `id` a unit of `organizationId`? Fail-closed on an unreadable unit. */
private async businessUnitInOrganization(id: string, organizationId: string): Promise<boolean> {
return (await this.organizationBusinessUnitIds(organizationId, id)).includes(id);
}
/**
* The ids of `organizationId`'s business units (narrowed to `onlyId` when
* given) — never another organization's, whatever a driver returns. An
* organization-less caller (`single` posture without an organization) is not
* filtered, as before. Fail-closed: an unreadable table yields none.
*/
private async organizationBusinessUnitIds(organizationId: string | undefined, onlyId?: string): Promise<string[]> {
const ql = this.deps.ql;
if (!ql?.find) return [];
try {
const rows = await ql.find('sys_business_unit', {
...(onlyId !== undefined ? { where: { id: onlyId } } : {}),
limit: 5000,
context: organizationScopedCtx(organizationId),
});
return (Array.isArray(rows) ? rows : [])
.filter((r: any) => !organizationId || rowOrganizationId(r) === organizationId)
.map((r: any) => String(r?.id ?? ''))
.filter(Boolean);
} catch {
return [];
}
}
// ── [ADR-0091 D3] Self-service delegation of duty ────────────────────
/** A delegation write = a `sys_user_position` INSERT whose rows stamp
* `delegated_from`. Insert-only by design: a delegation is not
* self-renewable (no update path to push `valid_until` forward — continuing
* past expiry requires a fresh delegation, leaving a new audit record). */
private isDelegationWrite(opCtx: any): boolean {
if (opCtx?.object !== 'sys_user_position' || opCtx?.operation !== 'insert') return false;
return rowsOf(opCtx).some((r) => r?.delegated_from != null && r.delegated_from !== '');
}
private now(): number {
return this.deps.now ? this.deps.now() : Date.now();
}
/**
* A holder of a `delegatable` position may assign it to a delegate WITHOUT
* being an administrator, iff every row is a well-formed delegation
* (ADR-0091 D3):
* 1. `delegated_from` = the writer (you delegate your OWN authority);
* 2. mandatory `valid_until`, in the future, within the config ceiling;
* 3. mandatory `reason` (dual-audit substrate);
* 4. the delegator CURRENTLY holds the position (validity-filtered) and
* holds it DIRECTLY — a grant that itself arrived via delegation is not
* re-delegatable (chains are cut);
* 5. the position is `delegatable: true`;
* 6. the position distributes NO `adminScope`-carrying set (administration
* is never self-delegated — that would bypass the D12 containment gate);
* 7. [cloud#830 follow-up] if the delegation carries a `business_unit_id`
* anchor, that anchor falls inside the delegator's OWN effective anchor
* for the position — a delegation may NARROW visibility, never widen it.
* The writer is stamped into `granted_by` by {@link DelegatedAdminGate.assert}
* once this returns (dual audit: `granted_by` = writer, `delegated_from` =
* authority source).
*/
private async assertSelfDelegation(opCtx: any, ctx: any, organizationId?: string): Promise<void> {
const now = this.now();
const ceiling = this.deps.delegationCeilingMs ?? DEFAULT_DELEGATION_CEILING_MS;
const ceilingDays = Math.round(ceiling / (24 * 60 * 60 * 1000));
for (const row of rowsOf(opCtx)) {
const deny = (reason: string, meta: Record<string, unknown> = {}): never => {
throw new PermissionDeniedError(
`[Security] Access denied: delegation of duty rejected — ${reason} (ADR-0091 D3).`,
{ operation: opCtx.operation, object: opCtx.object, ...meta },
);
};
// Homogeneity: a delegation insert must be ALL delegations — a mixed
// batch can't be audited as one authority act.
const df = row?.delegated_from;
if (df == null || df === '') {
deny('a delegation insert may not mix delegation and non-delegation rows');
}
// 1. You may only delegate your OWN authority.
if (String(df) !== String(ctx.userId)) {
deny(`delegated_from '${df}' is not you — you may only delegate authority you hold yourself`);
}
const positionName = String(row?.position ?? '');
if (!positionName) deny('the delegation names no position');
const targetUser = row?.user_id != null ? String(row.user_id) : '';
if (!targetUser) deny('the delegation names no delegate (user_id)');
if (targetUser === String(ctx.userId)) {
deny('you cannot delegate a position to yourself');
}
// 2. valid_until: mandatory, future, within ceiling.
const until = toEpochMs(row?.valid_until);
if (until === undefined) {
deny(`'${positionName}' delegation requires a valid_until — an open-ended delegation is a permanent grant`, { position: positionName });
}
if (Number.isNaN(until as number)) deny('valid_until is not a parseable timestamp', { position: positionName });
if (!((until as number) > now)) deny('valid_until is not in the future', { position: positionName });
if ((until as number) > now + ceiling) {
deny(`valid_until exceeds the ${ceilingDays}-day delegation ceiling`, { position: positionName });
}
// 3. reason: mandatory.
if (typeof row?.reason !== 'string' || row.reason.trim().length === 0) {
deny(`'${positionName}' delegation requires a reason (dual audit)`, { position: positionName });
}
// 4. Delegator currently holds it, directly (no re-delegation).
const holdings = await this.activeHoldings(String(ctx.userId), positionName, now, organizationId);
const directHolding = holdings.some((h) => h.direct);
if (!directHolding) {
if (holdings.length > 0) {
deny(`you hold '${positionName}' only via delegation — a delegated grant is not re-delegatable`, { position: positionName });
}
deny(`you do not currently hold '${positionName}' — only a current holder may delegate it`, { position: positionName });
}