Repository navigation
Expand file tree
/
Copy pathorganization-scope.ts
More file actions
145 lines (140 loc) · 7.65 KB
/
Copy pathorganization-scope.ts
File metadata and controls
145 lines (140 loc) · 7.65 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
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Organization scoping for the RBAC catalog's per-organization passes: the
* system context one pass runs under and the one read that tells an
* organization's own row apart from organization-less residue (the sweep and
* the reap guard retired with their one caller, the seeder; #15204 stage 6b-2).
*
* ## Why the catalog is materialized per organization
*
* `sys_position`, `sys_permission_set` and `sys_sharing_rule` all declare
* `organization_id` with no tenancy opt-out, and both spell their name index
* `{ fields: ['name'], unique: 'organization' }` — unique PER ORGANIZATION, not
* globally. The seeders nevertheless upserted by bare `name` under a bare
* `{ isSystem: true }` context, which stores `organization_id` NULL, so one row
* stood for every tenant.
*
* Under a walled posture that row is invalid state. It was measured to be
* unreadable by anyone: plugin-security's Layer 0 composes a STRICT
* `organization_id = :tenant` and the middleware ANDed it over the driver's
* v17 predicate `(organization_id = :tenant OR organization_id IS NULL)`, and
* the conjunction of the two is the strict equality alone — so on a walled deployment every
* principal, at every rung, listed ZERO positions, permission sets and sharing
* rules while the tables held rows.
*
* The repair ruled for that measurement does not touch the wall at either
* layer. It gives each organization its own row: upsert by
* `(name, organization_id)`, one pass per organization, so the answer to "which
* organization owns this row" is never NULL and never shared.
*
* ## The doctrine this file implements
*
* An organization-less row is INVALID STATE under a walled posture — refuse or
* warn loudly, never treat it as a platform-wide default. The older reading, in
* which a NULL organization marked a platform row visible to every tenant, is
* retired at every layer: ADR-0131 D8 removed the driver's
* `OR organization_id IS NULL` arm from `applyTenantScope`, and
* `check-tenant-chokepoint` refuses it. A per-organization pass therefore no
* longer SEES an organization-less row at all — the tenant predicate is
* `organization_id = :tenant` and nothing else — and the 18.x upgrade ceremony
* (ADR-0131 D10) attributes or retires such rows before a walled deployment
* boots.
*
* ## What replaces a reap
*
* #8617 reaped its pre-fix organization-less rows. This catalog does not, and
* the difference is deliberate rather than an omission:
*
* - a fresh walled deployment never mints an organization-less catalog row
* FROM THESE FOUR SEEDERS once they run per organization, so there is nothing
* of theirs to migrate. It does NOT follow that a fresh walled deployment
* holds none, and this file used to claim it did (#11532).
* `bootstrapPlatformAdmin` is a FIFTH seeder, outside the four converted
* here, and it writes one organization-less `sys_permission_set` row per
* `defaultPermissionSets` entry on EVERY boot — before any organization
* exists (measured on a fresh walled rig: 8 rows, 1.3 s ahead of the first
* `sys_organization`). That is the RULED outcome rather than a leak: the
* 2026-08-20 maintainer ruling on #10103 keeps that platform bucket
* "unreaped and loudly warned about under walled posture", and PLATFORM_ADMIN
* is derived from an unscoped grant pointing at its `admin_full_access` row
* BY ROW ID, so a reap would silently demote every platform admin. The guard
* below therefore has to tell the two classes apart;
* - a `single`-posture deployment is where organization-less rows are the
* CORRECT shape, and the carve-out below leaves it byte-for-byte unchanged;
* - the rows a reap would delete are grant TARGETS — `sys_user_position`,
* `sys_position_permission_set` and `sys_user_permission_set` all point at
* them by row id — so deleting them revokes standing access with no signal at
* the moment of loss. #8617's reap could promise "NO grant changes" precisely
* because it never touched a junction table; here the junctions ARE the
* grants.
*
* So the pass said so instead. Its guard (retired with the seeder) named the
* rows and named the remedy, and — this is the load-bearing half — the pass
* still CREATES the organization's own copy. The failure shape it exists to
* prevent is the silent no-op: a tenant-threaded pass that saw the pre-fix
* organization-less row through the driver's v17 NULL arm (retired by ADR-0131
* D8) read the name as already represented, took the update branch and created
* nothing, leaving the deployment exactly as broken as before while reporting
* success.
* {@link resolveOwnOrganizationRow} is the one read that distinguishes "this
* organization has its row" from "somebody's organization-less row is visible
* here", and every seeder in this catalog routes through it.
*/
/**
* How many organizations one boot-time seeding sweep enumerates.
*
* Bounded for the same reason #8617 bounds its own sweep: this runs on
* `kernel:ready` and each organization costs a bounded number of reads. An
* organization past the bound is not left unseeded — the organization-creation
* hook covers every organization minted after this fix, and a redeploy re-runs
* the sweep — but the bound IS reported rather than silently truncating.
*/
export const SEED_ORGANIZATION_SCAN_LIMIT = 500;
/**
* The system context ONE seeding pass runs under.
*
* `organizationId` present ⇒ a tenant-scoped pass: reads route through
* `SqlDriver.applyTenantScope` and writes are stamped with that organization.
* `organizationId` absent is meaningful and correct in exactly one place — a
* `single`-posture deployment, which has no organization for a row to belong
* to — and is never a fallback for "we could not work out the organization".
*/
export function seedCtx(organizationId?: string): { isSystem: true; tenantId?: string } {
return organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
}
/** The organization a stored row belongs to, `null` for an organization-less one. */
export function rowOrganizationId(row: any): string | null {
return (row?.organization_id ?? row?.organizationId) ?? null;
}
/**
* Resolve THIS organization's own row for a name, out of what a tenant-scoped
* read returned.
*
* A scoped read passes through `applyTenantScope`, which since ADR-0131 D8
* returns the caller's rows only — the driver's NULL arm is retired. A row
* set can still mix the two classes when it comes from a driver with no
* native tenant scoping or from an unscoped read, and they are not
* interchangeable; the seeders must never treat them as such:
*
* - a row stamped with `organizationId` is this organization's — update it;
* - an organization-less row is a PRE-FIX residue that merely happens to be
* in the row set. Reading it as "already seeded" is the silent no-op this
* catalog exists to prevent, so it is reported separately and never returned
* as the organization's own row.
*
* Under a `single`-posture pass (`organizationId` undefined) the
* organization-less row IS the row, which is the carve-out, so it is returned
* as `own` and nothing is flagged.
*/
export function resolveOwnOrganizationRow(
rows: any[],
organizationId?: string,
): { own: any | null; organizationLessResidue: any | null } {
const list = Array.isArray(rows) ? rows : [];
if (!organizationId) {
return { own: list[0] ?? null, organizationLessResidue: null };
}
const own = list.find((r) => rowOrganizationId(r) === organizationId) ?? null;
const residue = list.find((r) => rowOrganizationId(r) === null) ?? null;
return { own, organizationLessResidue: residue };
}