Skip to content

Commit b3034ee

Browse files
refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the admin_full_access anchor rule and the organization-reach rule, exported from core security (#25352)
Fixes #25315 Part of #15212. This card carries the #15212 ledger items 6348725929 and 6353870843. Claim: 6354363886, amended in 6354903470 to add `bootstrap-platform-admin.ts`. ## Measurement first The measurement is in comment 6354428826 on #25315. The seat's ruling on it was: fold; keep R-1 and R-2; keep A-1. **Anchor rule (ADR-0131 D5): one rule, spelled three times.** - Readers: - core `grantAnchorsPlatformStanding`; - plugin-auth `anchorOwnerTest`; - plugin-security `isAnchorHumanHolder`. This third copy was found during the measurement. - All three read the REQUESTED posture. - Kept by design: - **A-1:** the core and guard normalizers differ, for a non-string, non-number `organization_id`. - **A-2:** the promotion compares `organization_id` raw (`===`), and reads no `organizationId`. - The Default Organization is never `''`, so no differing input reaches an answer. The rule takes the organization already normalized, and each reader keeps its own normalizer. **Reach rule (ADR-0131 D8/D9): one rule over different facts.** - Readers: core's localization fallback and `SettingsService`. - Kept by design; both make the fallback the stricter reader: - **R-1:** the fallback reads the REQUESTED posture; the service reads the posture IN FORCE. - **R-2:** the fallback degrades a failed derivation to `unowned`; the service propagates it. - The rule takes the wall and the D9 decision as parameters. Each call site keeps its own source and failure policy, and its doc comment names the difference. ## What changes **New in core security (`packages/core/src/security/`):** - `platform-standing-anchor.ts`: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`. - `organization-reach.ts`: - `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`, which asks each fact lazily and in order; - `organizationReachAdmits`, `organizationReachRowOwner` and `organizationIdOf`; - `UNOWNED_ORGANIZATION_REACH` and the `OrganizationReach` type. - Both modules are exported from the security index. **Readers now calling them:** - `resolve-authz-context.ts`: §6b's anchor, and `localizationFallbackReach` plus its row filter. `settingRowOrganizationOf` is removed. - `last-admin-guard.ts`: `anchorOwnerTest`, over the simulated Default Organization. - `bootstrap-platform-admin.ts`: `findPlatformAdminGrantHolder` takes the anchor facts. `isAnchorHumanHolder` calls the rule and keeps its system-user exclusion. - `settings-service.ts`: `organizationReachOf`, `withinReach`, `loadScopedRows` and the write owner. The local `OrganizationReach` type and `callerOrganizationIdOf` are removed. **Grep:** no restatement of either rule is left. The ownership checks that remain in plugin-security's `suggested-audience-bindings.ts` and `organization-ownership-plan.ts` decide record ownership and attribution, not standing or reach. The memoised `sys_organization` probe was skipped. The fallback is bounded already, and a memo would add a cache keyed on an outage path. Clause-②: yes (widening) Metadata-protocol: none The diff adds core exports and narrows no accept set. No behaviour change: every existing pin is unchanged and green. ## Tests and ablations **New tests:** - `platform-standing-anchor.test.ts`: 12 rows over posture, Default Organization and grant owner. - `organization-reach.test.ts`: 26 cases. They cover caller × wall × D9 decision with the facts each row asks, row-owner and admits tables, and throw propagation. - Two reader pins for the row comparison, which no existing pin exercised: - `resolve-localization-fallback-row-reach.pin.test.ts`: a filter-dropping engine with the NULL row stored first; - `settings-organization-reach-memory.pin.test.ts`: the in-memory store, with no query to filter. **Ablations.** Each mutation ran through `scripts/ablation-replace.mjs` with an anchor that must hit. Each was restored to the HEAD blob with `git diff HEAD` empty. For the anchor ablation, core was rebuilt and `ablation-dist-preflight` proved the marker left `dist/` and came back. | ablation of the exported predicate | core | plugin-auth guard | plugin-security promotion | service-settings | |:--|:--|:--|:--|:--| | anchor: any organization's grant anchors | 7 red | 6 red | 5 red | — | | reach: D9 `derived` read as `unowned` | 3 red (1 is the fallback pin) | — | — | 5 red | | reach: own reach admits NULL rows | 5 red (2 are the new fallback pin) | — | — | 2 red (the new pin) | ## Proof (head `264c7ef59f`, which is `40ac96aa` merged with `origin/main` `54df640b38`) - **vitest:** - core `src/security/`: 1080 pass; plus the new row-reach pin, 3/3. - service-settings: 984 / 984. - plugin-auth: 3525 pass, 10 skipped. - plugin-security: 4403 pass, 45 skipped. - **typecheck:** exit 0 for core, plugin-auth, service-settings and plugin-security. - **eslint** over the changed files: exit 0. - **Full `pnpm build`:** 76 / 76. - **`node scripts/pm/dispatch-gates.mjs --commands`:** 74 derived, each exit code captured before any pipe. All 74 exit 0 at `40ac96aa`, after `git fetch --unshallow` and a full build. The re-run on the merged head is reported in the os-dev-report on #25315. - **`check-governed-merges --branch`:** NOT governed, 0 of 12 paths. 814 changed lines (+694 / −120), not Tier H/S. - **Upgrade harness** (`upgrade-18x-harness.dogfood.test.ts`): 16 / 16. That covers the `fixture-18.0` ceremony leg and the `fixture-18.0.0` GA leg. - **R1, fresh boot:** a production `os serve` of the showcase on an empty store under `single`: - ready in 10 s, health 200; - sign-up 200, sign-in 200; - `me/localization` 200 (`UTC` / `en-US`); - `me/permissions`: the first user holds `platform_admin` / `admin_full_access`, so the promotion and the anchor work end to end. - **R4, a walled production `os serve`** (`NODE_ENV=production`, `OS_PLATFORM_OWNER_EMAIL`, invite-only, a 32-byte `OS_SECRET_KEY`), on an empty store: - `isolated` and `group`: each ready in 8 s, health 200, sign-up 200, sign-in 200, `me/localization` 200. The configured owner holds `platform_admin`. - `isolated`: `PUT /api/settings/localization` with `Asia/Tokyo` answered 200, and the next `me/localization` read answered `Asia/Tokyo`. - ⚠ For R4 only, `@objectstack/organizations` was added to the showcase locally. `package.json` and the lockfile were restored afterwards, and none of it is in the diff. ## Acceptance notes - `origin/main` (`54df640b38`, which brings #25311) was merged in at the seat's request as `264c7ef59f`, a clean merge with no overlap. After it, I rebuilt plugin-security, plugin-sharing, runtime and cli. plugin-security re-ran 4442 pass / 45 skipped, and its typecheck exited 0. R1, R4 and the harness were re-measured on this head; the figures above are this head's. - On the walled R4 boots, `[security] audience-binding suggestion sync failed (non-fatal)` appears once at boot. It comes from `suggested-audience-bindings.ts`, which this diff does not touch. Noted, not filed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01W6m1VLhu6og8R1ACfCKJ6D --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c9ba22 commit b3034ee

12 files changed

Lines changed: 694 additions & 120 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-auth': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/service-settings': patch
6+
---
7+
8+
refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before.
14+
15+
- **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing:
16+
- behind a wall, no grant does;
17+
- under `single`, the grant owned by the Default Organization does;
18+
- an organization-less grant never does, and neither does any grant when no Default Organization can be named.
19+
20+
Three readers call it:
21+
- the authorization resolver passes the memoised Default Organization;
22+
- the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it;
23+
- the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion.
24+
25+
Each reader keeps its own normalizer for a grant row's organization.
26+
- **The organization-reach rule (ADR-0131 D8/D9).** New exports:
27+
- `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`;
28+
- `organizationReachAdmits(reach, rowOrganizationId)`;
29+
- `organizationReachRowOwner(reach)`;
30+
- `organizationIdOf(value)`;
31+
- `UNOWNED_ORGANIZATION_REACH`;
32+
- the types `OrganizationReach` and `OrganizationReachFacts`.
33+
34+
The rule decides whose `sys_setting` rows a caller may draw on:
35+
- the caller's own organization;
36+
- for a caller naming none where no wall stands, the organization D9 derives;
37+
- nothing behind a wall;
38+
- rows with no organization, only in a composition that registers no organization object.
39+
40+
`SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts:
41+
- the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller;
42+
- the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults.
43+
44+
These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader.
45+
46+
No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action.

‎packages/core/src/security/index.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,28 @@ export {
232232
type DefaultOrganizationAnswer,
233233
} from './default-organization.js';
234234

235+
// [ADR-0131 D5] The ONE anchor rule for platform standing: which
236+
// `admin_full_access` grant anchors it, with the posture and the Default
237+
// Organization as parameters. The resolver's §6b and plugin-auth's
238+
// last-administrator guard (over the simulated Default Organization) ask it.
239+
export {
240+
organizationAnchorsPlatformStanding,
241+
type PlatformStandingAnchorFacts,
242+
} from './platform-standing-anchor.js';
243+
244+
// [ADR-0131 D8/D9] The ONE organization-reach rule: whose `sys_setting` rows a
245+
// caller may draw on. `SettingsService` and the resolver's localization
246+
// fallback ask it, each with its own posture source and failure policy.
247+
export {
248+
resolveOrganizationReach,
249+
organizationReachAdmits,
250+
organizationReachRowOwner,
251+
organizationIdOf,
252+
UNOWNED_ORGANIZATION_REACH,
253+
type OrganizationReach,
254+
type OrganizationReachFacts,
255+
} from './organization-reach.js';
256+
235257
// [ADR-0131 D9, ADR-0138 D4] The ONE "which organization does a caller with no
236258
// organization of its own act in?" decision. Two askers: the engine's
237259
// system-context insert (`@objectstack/objectql`, which re-exports these same
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D8/D9] The one organization-reach rule, pinned on its own as a
5+
* table over every caller shape, wall and D9 decision. Both readers —
6+
* `SettingsService` and the resolver's direct localization fallback — hand
7+
* their facts to this rule, so these rows are what both of them answer. Their
8+
* own pins (`settings-organization-reach.pin.test.ts`,
9+
* `resolve-localization-fallback-organization.pin.test.ts`) hold each reader's
10+
* facts — the posture source and the failure policy; this table holds the rule.
11+
*/
12+
13+
import { describe, expect, it } from 'vitest';
14+
15+
import {
16+
organizationIdOf,
17+
organizationReachAdmits,
18+
organizationReachRowOwner,
19+
resolveOrganizationReach,
20+
UNOWNED_ORGANIZATION_REACH,
21+
type OrganizationReach,
22+
} from './organization-reach.js';
23+
import type { SystemWriteOrganizationDecision } from './system-write-organization.js';
24+
import * as securityIndex from './index.js';
25+
26+
const DERIVED: SystemWriteOrganizationDecision = { kind: 'derived', organizationId: 'org_only' };
27+
const LEAN: SystemWriteOrganizationDecision = { kind: 'no-organization-object' };
28+
const REFUSE_NONE: SystemWriteOrganizationDecision = { kind: 'refuse', reason: 'no-organization', organizationCount: 0 };
29+
const REFUSE_SEVERAL: SystemWriteOrganizationDecision = {
30+
kind: 'refuse',
31+
reason: 'ambiguous-organization',
32+
organizationCount: 2,
33+
};
34+
const REFUSE_WALLED: SystemWriteOrganizationDecision = { kind: 'refuse', reason: 'walled-posture' };
35+
36+
/** The facts for one row, recording which of them the rule asked. */
37+
function factsOf(callerOrganizationId: unknown, wall: boolean, decision: SystemWriteOrganizationDecision) {
38+
const asked: string[] = [];
39+
return {
40+
asked,
41+
facts: {
42+
callerOrganizationId,
43+
wallStands: () => {
44+
asked.push('wall');
45+
return wall;
46+
},
47+
deriveOrganization: async () => {
48+
asked.push('derive');
49+
return decision;
50+
},
51+
},
52+
};
53+
}
54+
55+
describe('[ADR-0131 D8/D9] resolveOrganizationReach — the one reach rule', () => {
56+
// [caller organization, wall stands?, D9 decision, reach, facts asked]
57+
const table: Array<[unknown, boolean, SystemWriteOrganizationDecision, OrganizationReach, string[]]> = [
58+
// A caller that names an organization is that organization's, under every
59+
// posture, and asks nothing else.
60+
['org_a', false, DERIVED, { kind: 'own', organizationId: 'org_a' }, []],
61+
['org_a', true, REFUSE_WALLED, { kind: 'own', organizationId: 'org_a' }, []],
62+
['org_a', false, LEAN, { kind: 'own', organizationId: 'org_a' }, []],
63+
// A caller that names none, behind a wall: unowned, D9 never asked.
64+
[undefined, true, DERIVED, UNOWNED_ORGANIZATION_REACH, ['wall']],
65+
['', true, LEAN, UNOWNED_ORGANIZATION_REACH, ['wall']],
66+
// A caller that names none, no wall: whatever D9 decides.
67+
[undefined, false, DERIVED, { kind: 'own', organizationId: 'org_only' }, ['wall', 'derive']],
68+
[null, false, LEAN, { kind: 'no-organization-object' }, ['wall', 'derive']],
69+
['', false, REFUSE_NONE, UNOWNED_ORGANIZATION_REACH, ['wall', 'derive']],
70+
[undefined, false, REFUSE_SEVERAL, UNOWNED_ORGANIZATION_REACH, ['wall', 'derive']],
71+
[undefined, false, REFUSE_WALLED, UNOWNED_ORGANIZATION_REACH, ['wall', 'derive']],
72+
// A non-string caller id names no organization.
73+
[42, false, DERIVED, { kind: 'own', organizationId: 'org_only' }, ['wall', 'derive']],
74+
];
75+
76+
it.each(table)('caller=%j · wall=%s · decision=%j → %j', async (caller, wall, decision, reach, askedFacts) => {
77+
const { asked, facts } = factsOf(caller, wall, decision);
78+
expect(await resolveOrganizationReach(facts)).toEqual(reach);
79+
expect(asked).toEqual(askedFacts);
80+
});
81+
82+
it('a fact that throws propagates — the reader owns its failure policy', async () => {
83+
await expect(resolveOrganizationReach({
84+
callerOrganizationId: undefined,
85+
wallStands: () => { throw new Error('posture unreadable'); },
86+
deriveOrganization: async () => DERIVED,
87+
})).rejects.toThrow('posture unreadable');
88+
await expect(resolveOrganizationReach({
89+
callerOrganizationId: undefined,
90+
wallStands: async () => false,
91+
deriveOrganization: async () => { throw new Error('organization read failed'); },
92+
})).rejects.toThrow('organization read failed');
93+
});
94+
});
95+
96+
describe('[ADR-0131 D8] organizationReachRowOwner / organizationReachAdmits — whose rows a reach reads', () => {
97+
const own: OrganizationReach = { kind: 'own', organizationId: 'org_a' };
98+
const lean: OrganizationReach = { kind: 'no-organization-object' };
99+
100+
it('the row owner: the organization, NULL for the lean arm, nothing for unowned', () => {
101+
expect(organizationReachRowOwner(own)).toBe('org_a');
102+
expect(organizationReachRowOwner(lean)).toBeNull();
103+
expect(organizationReachRowOwner(UNOWNED_ORGANIZATION_REACH)).toBeUndefined();
104+
});
105+
106+
// [reach, row's organization, admitted?]
107+
const rows: Array<[OrganizationReach, unknown, boolean]> = [
108+
[own, 'org_a', true],
109+
[own, 'org_b', false],
110+
// A row with no organization is never an organization's fallback.
111+
[own, null, false],
112+
[own, undefined, false],
113+
[own, '', false],
114+
// The lean arm reads the NULL rows only.
115+
[lean, null, true],
116+
[lean, undefined, true],
117+
[lean, '', true],
118+
[lean, 'org_a', false],
119+
// Unowned reads nothing — a NULL row included.
120+
[UNOWNED_ORGANIZATION_REACH, null, false],
121+
[UNOWNED_ORGANIZATION_REACH, 'org_a', false],
122+
];
123+
124+
it.each(rows)('reach=%j · row organization=%j → %s', (reach, rowOrganizationId, admitted) => {
125+
expect(organizationReachAdmits(reach, rowOrganizationId)).toBe(admitted);
126+
});
127+
128+
it('"no organization" is spelled one way: a non-empty string, else null', () => {
129+
expect(organizationIdOf('org_a')).toBe('org_a');
130+
expect(organizationIdOf('')).toBeNull();
131+
expect(organizationIdOf(null)).toBeNull();
132+
expect(organizationIdOf(undefined)).toBeNull();
133+
expect(organizationIdOf(7)).toBeNull();
134+
});
135+
136+
it('is exported from core security — the bindings every reader imports', () => {
137+
expect(securityIndex.resolveOrganizationReach).toBe(resolveOrganizationReach);
138+
expect(securityIndex.organizationReachAdmits).toBe(organizationReachAdmits);
139+
expect(securityIndex.organizationReachRowOwner).toBe(organizationReachRowOwner);
140+
expect(securityIndex.organizationIdOf).toBe(organizationIdOf);
141+
expect(securityIndex.UNOWNED_ORGANIZATION_REACH).toBe(UNOWNED_ORGANIZATION_REACH);
142+
});
143+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D8/D9] The ONE organization-reach rule: which organization's
5+
* `sys_setting` rows a caller may draw on. Two readers ask it, and must never
6+
* disagree about whose rows those are:
7+
*
8+
* - `SettingsService` (`@objectstack/service-settings`) — the tenant and user
9+
* rungs of every settings read, and the owner of every settings write;
10+
* - the direct localization fallback (`resolveLocalizationContext`,
11+
* `resolve-authz-context.ts`), which stands in for that service when it is
12+
* absent or has just failed, so it can never answer what the service would
13+
* refuse.
14+
*
15+
* What differs between the readers is WHERE they learn the facts, never what
16+
* the facts decide: the service asks the posture IN FORCE (the `tenancy`
17+
* service) and lets a failed read reach its caller; the fallback asks the
18+
* REQUESTED posture (`resolveTenancyPosture()`), the stricter source, and
19+
* degrades a failed read to the built-in defaults. So the rule takes those
20+
* facts as parameters. ⛔ Never restate the rule beside a read: hand the facts
21+
* to {@link resolveOrganizationReach}, and judge rows with
22+
* {@link organizationReachAdmits}.
23+
*/
24+
25+
import type { SystemWriteOrganizationDecision } from './system-write-organization.js';
26+
27+
/**
28+
* Which rows a caller's organization reach may draw on.
29+
*
30+
* [ADR-0131 D1/D8] A row with no organization is no organization's: no reach
31+
* reads it as a fallback for an organization, and none reads another
32+
* organization's row for a caller that names none.
33+
*
34+
* - `own` — that organization's rows, and only those. The organization is the
35+
* caller's own, or — for a caller that names none where no wall stands —
36+
* the one organization D9's predicate derives.
37+
* - `no-organization-object` — the composition registers no organization
38+
* object, so no organization can exist (the lean embedding, kept by the
39+
* ruling ADR-0131's D9 note records): the rows written with no
40+
* organization, the only rows such a composition can hold.
41+
* - `unowned` — no organization to name: a caller that names none behind a
42+
* wall, or where D9 derives none (no organization yet, or several). No row
43+
* at all.
44+
*/
45+
export type OrganizationReach =
46+
| { kind: 'own'; organizationId: string }
47+
| { kind: 'no-organization-object' }
48+
| { kind: 'unowned' };
49+
50+
/** The reach that reads nothing. */
51+
export const UNOWNED_ORGANIZATION_REACH: OrganizationReach = Object.freeze({ kind: 'unowned' as const });
52+
53+
/** An organization id with "no organization" spelled one way: a non-empty string, else `null`. */
54+
export function organizationIdOf(value: unknown): string | null {
55+
return typeof value === 'string' && value !== '' ? value : null;
56+
}
57+
58+
/** The facts the reach rule decides on. */
59+
export interface OrganizationReachFacts {
60+
/** The caller's own organization as it carries it (`tenantId`); normalized by {@link organizationIdOf}. */
61+
callerOrganizationId: unknown;
62+
/**
63+
* Does a wall stand between organizations? Asked only for a caller that
64+
* names none. A throw propagates: the reader decides what a posture it could
65+
* not read means.
66+
*/
67+
wallStands: () => boolean | Promise<boolean>;
68+
/**
69+
* ADR-0131 D9's decision for a caller that names no organization
70+
* (`resolveGuestOrganization`). Asked only for such a caller, where no wall
71+
* stands. A throw propagates, as above.
72+
*/
73+
deriveOrganization: () => Promise<SystemWriteOrganizationDecision>;
74+
}
75+
76+
/**
77+
* [ADR-0131 D8/D9] The caller's organization reach ({@link OrganizationReach}).
78+
*
79+
* A caller that names an organization is that organization's, under every
80+
* posture. One that names none is `unowned` behind a wall; elsewhere it is
81+
* whatever D9 derives: the one organization (`own`), a composition with no
82+
* organization object, or none (refused there, so `unowned` here).
83+
*
84+
* Reads nothing itself: every fact is a parameter, asked lazily and in that
85+
* order, so a caller that names an organization asks neither.
86+
*/
87+
export async function resolveOrganizationReach(facts: OrganizationReachFacts): Promise<OrganizationReach> {
88+
const organizationId = organizationIdOf(facts.callerOrganizationId);
89+
if (organizationId !== null) return { kind: 'own', organizationId };
90+
if (await facts.wallStands()) return UNOWNED_ORGANIZATION_REACH;
91+
const decision = await facts.deriveOrganization();
92+
if (decision.kind === 'derived') return { kind: 'own', organizationId: decision.organizationId };
93+
if (decision.kind === 'no-organization-object') return { kind: 'no-organization-object' };
94+
return UNOWNED_ORGANIZATION_REACH;
95+
}
96+
97+
/**
98+
* The `organization_id` a reach's rows carry — the value a read filters on and
99+
* a write stores: the organization for `own`, `null` (no organization) for
100+
* `no-organization-object`, and `undefined` for `unowned`, which reads no row.
101+
*/
102+
export function organizationReachRowOwner(reach: OrganizationReach): string | null | undefined {
103+
if (reach.kind === 'own') return reach.organizationId;
104+
if (reach.kind === 'no-organization-object') return null;
105+
return undefined;
106+
}
107+
108+
/**
109+
* Is a row owned by `rowOrganizationId` one the reach may draw on? The one
110+
* predicate a query's filter, an in-memory filter and a rung's pick share — the
111+
* organization is compared HERE, never trusted to the filter.
112+
*/
113+
export function organizationReachAdmits(reach: OrganizationReach, rowOrganizationId: unknown): boolean {
114+
const owner = organizationReachRowOwner(reach);
115+
return owner !== undefined && organizationIdOf(rowOrganizationId) === owner;
116+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D5] The one anchor rule, pinned on its own as a table over every
5+
* posture and grant-owner shape. Both readers — the resolver's §6b
6+
* (`resolve-authz-context.ts`) and plugin-auth's last-administrator guard —
7+
* hand their facts to this rule, so these rows are what both of them answer.
8+
* Their own pins (`resolve-authz-context*.test.ts`, `last-admin-guard*.test.ts`)
9+
* hold each reader's facts; this table holds the rule.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
14+
import { organizationAnchorsPlatformStanding } from './platform-standing-anchor.js';
15+
import * as securityIndex from './index.js';
16+
17+
const DEFAULT_ORG = 'org_default';
18+
19+
describe('[ADR-0131 D5] organizationAnchorsPlatformStanding — the one anchor rule', () => {
20+
// [posture walled?, Default Organization, grant's organization, anchors?]
21+
const table: Array<[boolean, string | undefined, string | null | undefined, boolean]> = [
22+
// single: the Default Organization's grant anchors, nothing else does.
23+
[false, DEFAULT_ORG, DEFAULT_ORG, true],
24+
[false, DEFAULT_ORG, 'org_acme', false],
25+
[false, DEFAULT_ORG, null, false],
26+
[false, DEFAULT_ORG, undefined, false],
27+
// single, no Default Organization nameable (none, several none `default`,
28+
// or no organization object): nothing anchors — an organization-less
29+
// grant included, which must not match an absent id.
30+
[false, undefined, DEFAULT_ORG, false],
31+
[false, undefined, null, false],
32+
[false, undefined, undefined, false],
33+
// walled (`group` / `isolated`): no grant anchors, the Default
34+
// Organization's own included.
35+
[true, DEFAULT_ORG, DEFAULT_ORG, false],
36+
[true, DEFAULT_ORG, 'org_acme', false],
37+
[true, DEFAULT_ORG, null, false],
38+
[true, undefined, undefined, false],
39+
];
40+
41+
it.each(table)('walled=%s · default=%s · grant owner=%s → %s', (walled, defaultOrganizationId, owner, anchors) => {
42+
expect(organizationAnchorsPlatformStanding(owner, { walled, defaultOrganizationId })).toBe(anchors);
43+
});
44+
45+
it('is exported from core security — the binding every reader imports', () => {
46+
expect(securityIndex.organizationAnchorsPlatformStanding).toBe(organizationAnchorsPlatformStanding);
47+
});
48+
});

0 commit comments

Comments
 (0)