Skip to content

Commit 76b9cf5

Browse files
hotlongclaude
andauthored
feat(verify): bootStack({ orgContext: true }) — a harness admin whose execution context carries an organization (#7762) (#7977)
* feat(verify): bootStack orgContext — a harness admin whose context carries an organization (#7762) `bootStack` could not mint an admin whose resolved execution context carried an `organizationId`, so every `organization_id`-filtered read was structurally untestable at the HTTP layer in the open core. That is the direct reason #7676 escaped both suites: the HTTP regression test written for its fix was green against the UNFIXED code — the filter never engaged — and was correctly deleted rather than shipped as phantom coverage. `orgContext: true` flips AuthPlugin's ADR-0081 D1 default-organization bootstrap back on (the same one `objectstack dev`/`serve` run), binds the admin as owner, and the session hook stamps `activeOrganizationId` — the one wire field `resolveAuthzContext` reads into the execution context. The boot asserts the bind and refuses a stack without it. It stands up NO organization wall, and does not move the tenancy posture: `probeIsolation` is `() => !!ctx.getService('org-scoping')`, service registration only. Both facts are pinned, not asserted in prose. It refuses to compose with either spelling of `multiTenant` rather than silently no-op. Restores the deleted #7676 regression test at the dogfood layer, now booted org-bound so it can actually fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAE5bmz7qUK7FDZWj9Wxog * fix(verify): NodeNext-explicit `./harness.js` import in the org-context test (#7762) The extensionless relative import was a TS2835 the `check:type-check-debt` ratchet counts, taking @objectstack/verify's TEST_DEBT from 8 to 9. The sibling test files predate the gate and carry theirs in the ledger; a new test file does not get to raise it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CAE5bmz7qUK7FDZWj9Wxog --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 20d92e3 commit 76b9cf5

4 files changed

Lines changed: 575 additions & 1 deletion

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/verify": minor
3+
---
4+
5+
feat(verify): `bootStack({ orgContext: true })` — a harness admin whose execution context carries an organization (#7762)
6+
7+
`bootStack` could not mint an admin whose resolved execution context carried an
8+
`organizationId`. Every `organization_id`-filtered read in the platform was
9+
therefore structurally **untestable at the HTTP layer** in the open core: the
10+
filter never engaged, so a fixture asserting on the difference between a
11+
filtered and an unfiltered read saw no difference and passed for the wrong
12+
reason.
13+
14+
That is not theoretical. #7676 — package-seeded (`organization_id = null`)
15+
sharing rules invisible to an org-scoped admin — escaped **both** suites that
16+
should have caught it and needed a manual QA run to find. The HTTP-layer
17+
regression test written for its fix measured green against the *unfixed* code
18+
and was correctly deleted rather than shipped as phantom coverage. Two earlier
19+
defects of the same class (`sys_business_unit` approver expansion,
20+
`sys_metadata` pending-draft listing) were found the same way.
21+
22+
`orgContext: true` flips `AuthPlugin`'s ADR-0081 D1 default-organization
23+
bootstrap back on — the same one `objectstack dev` / `serve` give a real
24+
single-tenant deployment. The admin is bound to a real `sys_organization` as
25+
owner, their session carries `activeOrganizationId`, and org-scoped reads
26+
resolve it off the execution context. The boot **asserts** the bind and refuses
27+
to return a stack without it, because a best-effort bootstrap that quietly
28+
no-ops is the vacuum this option exists to close.
29+
30+
⛔ **It performs no tenant isolation.** It stamps the caller's organization; it
31+
stands up no organization wall. With no `org-scoping` service present,
32+
`SecurityPlugin` strips the wildcard `organization_id` RLS policies, so a
33+
fixture asserting "tenant B cannot read tenant A's rows" and booting this way
34+
would assert nothing and pass. Cross-tenant isolation still has exactly one
35+
honest proof: `multiTenant: true` with the real `@objectstack/organizations`.
36+
37+
The tenancy **posture** is untouched, and that is a property of the seam rather
38+
than of this implementation: `TenancyService.probeIsolation` is
39+
`() => !!ctx.getService('org-scoping')`, so the effective posture derives from
40+
service registration alone and reads nothing about what any context carries.
41+
The posture and the `degraded` flag are pinned identical with the flag off vs
42+
on.
43+
44+
`orgContext` does **not** compose with either spelling of `multiTenant` and the
45+
boot refuses the combination rather than booting something weaker than it
46+
reads: under `multiTenant: true` the enterprise package already owns the org
47+
bootstrap, and under `multiTenant: 'posture-only'` the open bootstrap
48+
deliberately abstains (walled posture), which would leave the admin org-less
49+
inside a fixture that reads as org-bound.
50+
51+
Default boots are unchanged — the option is opt-in and the existing dogfood
52+
suite is unaffected.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#7676 / #7762] The HTTP-layer regression test for #7676 — restored.
4+
//
5+
// ## Why this file has a history before it has a first commit
6+
//
7+
// #7676: `sys_sharing_rule` rows seeded by the package/app seeder carry
8+
// `organization_id = null` (`bootstrapDeclaredSharingRules` defines under
9+
// `SYSTEM_CTX`, before any org id exists). The admin read path scoped with a
10+
// strict `organization_id = <request org>` equality, so on a stock boot
11+
// `GET /api/v1/sharing/rules` answered `{data: []}` over four active seeded
12+
// rules, by-name GET and evaluate 404'd, and only the org-unfiltered by-id
13+
// branch still worked. Rules that grant access but cannot be listed, inspected
14+
// or deactivated.
15+
//
16+
// PR #7760 fixed it and its dev wrote exactly this test — then measured it
17+
// GREEN against the UNFIXED code and correctly DELETED it rather than ship
18+
// phantom coverage. The reason is #7762: `bootStack` could not mint an admin
19+
// whose resolved execution context carried an organization, so `orgId` was
20+
// `null` on every request, `adminOrgScope` returned the `where` untouched, and
21+
// the strict-equality bug was unreachable from the harness. The assertion
22+
// passed because the filter never engaged — the #4700 constant-false shape.
23+
//
24+
// `bootStack({ orgContext: true })` (#7762) is what makes the test mean
25+
// something: the admin is bound to a real organization, their session carries
26+
// `activeOrganizationId`, and the org-scoped branch of `listRules` / `getRule`
27+
// is the one that actually runs. THAT is why this file boots with the flag and
28+
// asserts the flag took effect FIRST — an org-less admin here would silently
29+
// restore the vacuum this file exists to escape.
30+
//
31+
// ⛔ Nothing here is a tenant-isolation proof. `orgContext` stands up no
32+
// organization wall (SecurityPlugin strips the wildcard `organization_id` RLS
33+
// policies with no `org-scoping` service present) — see the option's doc block
34+
// in `packages/verify/src/harness.ts`. This file proves that an org-scoped READ
35+
// FILTER engages and admits the platform-global rows it must; it proves nothing
36+
// about tenant B's rows, and must never be extended to claim that.
37+
//
38+
// @proof: org-scoped-sharing-rule-listing
39+
40+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
41+
import showcaseStack from '@objectstack/example-showcase';
42+
import { bootStack, type VerifyStack } from '@objectstack/verify';
43+
44+
const RULES = '/sharing/rules';
45+
const SYS = { isSystem: true } as const;
46+
47+
/** The four rules `examples/app-showcase` declares, all seeded org-less. */
48+
const SEEDED_RULE_NAMES = [
49+
'share_red_projects_with_execs',
50+
'share_high_value_red_projects_with_managers',
51+
'share_new_inquiries_with_field_ops',
52+
'share_open_tasks_with_manager',
53+
];
54+
55+
interface RuleRow {
56+
id: string;
57+
name: string;
58+
organization_id: string | null;
59+
}
60+
61+
describe('#7676 — package-seeded (org-less) sharing rules stay visible to an ORG-BOUND admin', () => {
62+
let stack: VerifyStack;
63+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
64+
let ql: any;
65+
let admin: string;
66+
67+
beforeAll(async () => {
68+
stack = await bootStack(showcaseStack, { orgContext: true });
69+
admin = await stack.signIn();
70+
ql = await stack.kernel.getServiceAsync('objectql');
71+
}, 120_000);
72+
73+
afterAll(async () => {
74+
await stack?.stop();
75+
});
76+
77+
it('PRECONDITION: the admin actually carries an organization (else everything below is vacuous)', async () => {
78+
// The measurement from #7762, inverted. The card recorded `POST
79+
// /api/v1/sharing/rules` answering 201 with `organization_id: null`, and
80+
// named that a direct proof the caller's context had no organization —
81+
// `defineRule` stamps the row from the resolved context. So a NON-NULL
82+
// stamp here is the same measurement reporting the opposite, taken through
83+
// the same route.
84+
const res = await stack.apiAs(admin, 'POST', RULES, {
85+
name: 'org_bound_probe_7762',
86+
label: 'Org-bound probe',
87+
object: 'showcase_project',
88+
recipientType: 'position',
89+
recipientId: 'exec',
90+
criteria: { health: 'red' },
91+
accessLevel: 'read',
92+
});
93+
expect(res.status).toBe(201);
94+
const row = (await res.json()) as RuleRow;
95+
expect(
96+
row.organization_id,
97+
'a row created by the org-bound admin is stamped with their organization',
98+
).toBeTruthy();
99+
100+
// And the org id is a real `sys_organization`, not a stray string.
101+
const org = await ql.findOne('sys_organization', {
102+
where: { id: row.organization_id },
103+
context: SYS,
104+
});
105+
expect(org?.id).toBe(row.organization_id);
106+
});
107+
108+
it('THE REPORTED CASE: GET /sharing/rules lists the seeded org-less rules', async () => {
109+
// Pre-#7676 this answered `{data: []}`. It is also the assertion that was
110+
// green on the unfixed code before `orgContext` existed — see the header.
111+
const res = await stack.apiAs(admin, 'GET', RULES);
112+
expect(res.status).toBe(200);
113+
const body = (await res.json()) as { data: RuleRow[] };
114+
const names = body.data.map((r) => r.name);
115+
116+
for (const name of SEEDED_RULE_NAMES) {
117+
expect(names, `seeded rule ${name} must be listed`).toContain(name);
118+
}
119+
// They really are the platform-global rows, not org-stamped copies — which
120+
// is what makes them invisible to a strict `organization_id = orgId`.
121+
for (const name of SEEDED_RULE_NAMES) {
122+
const row = body.data.find((r) => r.name === name);
123+
expect(row?.organization_id, `${name} is seeded org-less`).toBeNull();
124+
}
125+
});
126+
127+
it('by-NAME GET resolves a seeded org-less rule (404 RULE_NOT_FOUND before the fix)', async () => {
128+
const res = await stack.apiAs(admin, 'GET', `${RULES}/${SEEDED_RULE_NAMES[0]}`);
129+
expect(res.status).toBe(200);
130+
const row = (await res.json()) as RuleRow;
131+
expect(row.name).toBe(SEEDED_RULE_NAMES[0]);
132+
expect(row.organization_id).toBeNull();
133+
});
134+
135+
it('a seeded org-less rule can be EVALUATED — the half that granted access all along', async () => {
136+
// Enforcement always read these rows (under SYSTEM_CTX); only the admin
137+
// surface could not. "Rules that grant access but cannot be inspected or
138+
// deactivated are the worst half of both properties."
139+
const rule = await ql.findOne('sys_sharing_rule', {
140+
where: { name: SEEDED_RULE_NAMES[0] },
141+
context: SYS,
142+
});
143+
expect(rule?.id, 'the seeded rule exists in storage').toBeTruthy();
144+
const res = await stack.apiAs(admin, 'POST', `${RULES}/${rule.id}/evaluate`, {});
145+
expect(res.status).toBeLessThan(300);
146+
});
147+
148+
it('the scope still SCOPES — another organization\'s rule is not listed', async () => {
149+
// The counterweight, and the reason this is a scope rather than a hole:
150+
// widening the read to `this org ∪ platform-global` must not also admit a
151+
// THIRD org's row. Written at the storage seam because the harness mints
152+
// exactly one organization — this is a read-filter assertion, NOT a
153+
// tenant-isolation proof (see the header).
154+
const foreignId = 'srule_7762_foreign';
155+
await ql.insert(
156+
'sys_sharing_rule',
157+
{
158+
id: foreignId,
159+
organization_id: 'org_someone_else_7762',
160+
name: 'foreign_org_rule_7762',
161+
label: 'Foreign org rule',
162+
object_name: 'showcase_project',
163+
criteria_json: JSON.stringify({ health: 'red' }),
164+
recipient_type: 'position',
165+
recipient_id: 'exec',
166+
access_level: 'read',
167+
active: true,
168+
},
169+
{ context: SYS },
170+
);
171+
172+
const res = await stack.apiAs(admin, 'GET', RULES);
173+
const body = (await res.json()) as { data: RuleRow[] };
174+
expect(body.data.map((r) => r.name)).not.toContain('foreign_org_rule_7762');
175+
176+
// [#7761] The by-id branch carries the same scope — an opaque id is not a
177+
// tenant boundary.
178+
const byId = await stack.apiAs(admin, 'GET', `${RULES}/${foreignId}`);
179+
expect(byId.status).toBe(404);
180+
});
181+
});

0 commit comments

Comments
 (0)