Skip to content

Commit e758131

Browse files
claude[bot]claude
andauthored
fix(plugin-auth): report at error when a single-posture deployment holds more than one organization (#17010) (#17460)
* fix(plugin-auth): report at error when a `single`-posture deployment holds more than one organization ADR-0131 §1.2(3) states that "many organizations under `single`" is today a refused boot. It is not: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently — and the harm surfaces far away as a platform admin reading zero rows and system writes refused `ambiguous-organization`. Take a `count(sys_organization)` census on the seam that was silent and that already reads the object, and report at `error` when a non-walled deployment holds more than one, naming the posture, the count and both remedies. Boot PROCEEDS — this only reports; the refuse-vs-report fork stays with the maintainer. The per-write refusal (#8844) is untouched. Reached at boot: AuthPlugin runs `backfillMemberships` from `kernel:ready` with `resolveTargetOrg: () => tenancy.defaultOrgId()` under a membership policy that defaults to `auto`. Cost is one `count()` per process, downstream of the walled early return and of the memoized resolution. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com> * refactor(plugin-auth): keep the organization census module-private — one in-file caller, no new published symbol Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com> * docs(changeset): single-posture organization census Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 65ad77d commit e758131

3 files changed

Lines changed: 505 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010)
6+
7+
ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard.
8+
9+
The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units.
10+
11+
**The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched.
12+
13+
Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction.

packages/plugins/plugin-auth/src/tenancy-service.test.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, vi } from 'vitest';
44
import { createTenancyService, resolveDefaultOrgId } from './tenancy-service.js';
5+
import { backfillMemberships } from './reconcile-membership.js';
56

67
function makeEngine(orgs: Array<{ id: string; slug?: string }>) {
78
return {
@@ -294,3 +295,252 @@ describe('posture entitlement declared by the org-scoping runtime', () => {
294295
expect(entitle).not.toHaveBeenCalled();
295296
});
296297
});
298+
299+
// ---------------------------------------------------------------------------
300+
// [#17010] The organization census — a `single`-posture deployment that HOLDS
301+
// more than one organization stops booting silently.
302+
//
303+
// ADR-0131 §1.2(3) calls that precondition 「a refused boot」 and it is not.
304+
// ⛔ Boot still PROCEEDS here (ruled 2026-09-10): this suite pins the REPORT,
305+
// its level, the subjects it must name — and, just as load-bearing, the
306+
// NEGATIVE CONTROL, because a check that fires on a healthy install is worse
307+
// than no check at all.
308+
// ---------------------------------------------------------------------------
309+
describe('single-posture organization census (#17010)', () => {
310+
/** An engine that answers the census — `count` is what makes the reading exact. */
311+
function makeCensusEngine(orgs: Array<{ id: string; slug?: string }>) {
312+
return {
313+
find: vi.fn(async (object: string, query: any) => {
314+
if (object !== 'sys_organization') return [];
315+
const where = query?.where ?? {};
316+
let rows = orgs;
317+
if (where.slug !== undefined) rows = rows.filter((o) => o.slug === where.slug);
318+
return rows.slice(0, query?.limit ?? rows.length);
319+
}),
320+
count: vi.fn(async (object: string) => (object === 'sys_organization' ? orgs.length : 0)),
321+
insert: vi.fn(async () => ({ id: 'ignored' })),
322+
};
323+
}
324+
325+
const makeSink = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() });
326+
327+
// Spelled here rather than imported: the census is module-private (it has one
328+
// in-file caller), so this literal is the pin — rename the token and this
329+
// suite says so, which is the whole point of a grep token an operator keys on.
330+
const SINGLE_POSTURE_MANY_ORGANIZATIONS = 'single_posture_holds_many_organizations';
331+
332+
const orgs = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `org_${i + 1}` }));
333+
334+
it('reports at ERROR, naming the posture, the COUNT and both remedies', async () => {
335+
const engine = makeCensusEngine(orgs(3));
336+
const logger = makeSink();
337+
const t = createTenancyService({
338+
requested: 'single',
339+
probeIsolation: () => false,
340+
getEngine: () => engine,
341+
logger,
342+
});
343+
344+
expect(await t.defaultOrgId()).toBeNull();
345+
346+
expect(logger.error).toHaveBeenCalledTimes(1);
347+
expect(logger.warn).not.toHaveBeenCalled();
348+
const [message, meta] = logger.error.mock.calls[0]!;
349+
// The grep token an operator keys on.
350+
expect(message).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS);
351+
// ① the posture it DECLARED, ② the count it HOLDS — the two facts the card asks for.
352+
expect(message).toContain("'single'");
353+
expect(message).toContain('3');
354+
expect(meta).toEqual({ posture: 'single', organizationCount: 3 });
355+
// ③ both remedies, named: declare a walled posture, or hold one organization.
356+
expect(message).toContain('OS_TENANCY_POSTURE=group');
357+
expect(message).toContain('OS_TENANCY_POSTURE=isolated');
358+
expect(message).toContain('HOLD ONE ORGANIZATION');
359+
// ④ the consequence, including that the deployment keeps looking healthy
360+
// (AGENTS.md → "Degradation log levels": what an `error` owes its reader).
361+
expect(message).toContain('KEEP LOOKING HEALTHY');
362+
});
363+
364+
it('NEGATIVE CONTROL: exactly one organization under `single` stays SILENT', async () => {
365+
const engine = makeCensusEngine([{ id: 'org_1', slug: 'default' }]);
366+
const logger = makeSink();
367+
const t = createTenancyService({
368+
requested: 'single',
369+
probeIsolation: () => false,
370+
getEngine: () => engine,
371+
logger,
372+
});
373+
374+
expect(await t.defaultOrgId()).toBe('org_1');
375+
expect(logger.error).not.toHaveBeenCalled();
376+
expect(logger.warn).not.toHaveBeenCalled();
377+
});
378+
379+
it('NEGATIVE CONTROL: a store with NO organization yet stays SILENT', async () => {
380+
const engine = makeCensusEngine([]);
381+
const logger = makeSink();
382+
const t = createTenancyService({
383+
requested: 'single',
384+
probeIsolation: () => false,
385+
getEngine: () => engine,
386+
logger,
387+
});
388+
389+
expect(await t.defaultOrgId()).toBeNull();
390+
expect(logger.error).not.toHaveBeenCalled();
391+
expect(logger.warn).not.toHaveBeenCalled();
392+
});
393+
394+
it('a WALLED posture pays nothing and says nothing — the organizations are declared', async () => {
395+
for (const requested of ['group', 'isolated'] as const) {
396+
const engine = makeCensusEngine(orgs(3));
397+
const logger = makeSink();
398+
const t = createTenancyService({
399+
requested,
400+
probeIsolation: () => true,
401+
getEngine: () => engine,
402+
logger,
403+
});
404+
405+
expect(await t.defaultOrgId(), requested).toBeNull();
406+
expect(engine.count, requested).not.toHaveBeenCalled();
407+
expect(logger.error, requested).not.toHaveBeenCalled();
408+
expect(logger.warn, requested).not.toHaveBeenCalled();
409+
}
410+
});
411+
412+
it('COST: the census costs ONE count() per process, however often it is asked', async () => {
413+
const engine = makeCensusEngine(orgs(4));
414+
const logger = makeSink();
415+
const t = createTenancyService({
416+
requested: 'single',
417+
probeIsolation: () => false,
418+
getEngine: () => engine,
419+
logger,
420+
});
421+
422+
await t.defaultOrgId();
423+
await t.defaultOrgId();
424+
await t.defaultOrgId();
425+
426+
expect(engine.count).toHaveBeenCalledTimes(1);
427+
expect(engine.count).toHaveBeenCalledWith('sys_organization', {}, { context: { isSystem: true } });
428+
// Said ONCE, at the first degradation — not once per failed resolution.
429+
expect(logger.error).toHaveBeenCalledTimes(1);
430+
});
431+
432+
it('falls back to warn on a sink that declares no error, and never emits both', async () => {
433+
const engine = makeCensusEngine(orgs(2));
434+
const logger = { info: vi.fn(), warn: vi.fn() };
435+
const t = createTenancyService({
436+
requested: 'single',
437+
probeIsolation: () => false,
438+
getEngine: () => engine,
439+
logger,
440+
});
441+
442+
await t.defaultOrgId();
443+
expect(logger.warn).toHaveBeenCalledTimes(1);
444+
expect(logger.warn.mock.calls[0]![0]).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS);
445+
});
446+
447+
it('an engine that cannot answer stays silent AND does not latch the census', async () => {
448+
// No `count`: every reduced mock embedding. An absence of measurement is
449+
// not evidence of a defect — and it must not disable the census either.
450+
const countless: any = makeCensusEngine(orgs(3));
451+
delete countless.count;
452+
const logger = makeSink();
453+
let engine: any = countless;
454+
const t = createTenancyService({
455+
requested: 'single',
456+
probeIsolation: () => false,
457+
getEngine: () => engine,
458+
logger,
459+
});
460+
461+
await t.defaultOrgId();
462+
expect(logger.error).not.toHaveBeenCalled();
463+
expect(logger.warn).not.toHaveBeenCalled();
464+
465+
// The engine becomes answerable later (the store came up after this seam
466+
// was first reached): the census must still be takeable.
467+
engine = makeCensusEngine(orgs(3));
468+
await t.defaultOrgId();
469+
expect(logger.error).toHaveBeenCalledTimes(1);
470+
});
471+
472+
it('a throwing count, and a throwing logger, never break the resolution', async () => {
473+
const engine: any = makeCensusEngine([{ id: 'org_1', slug: 'default' }]);
474+
engine.count = vi.fn(async () => {
475+
throw new Error('store unreachable');
476+
});
477+
expect(await createTenancyService({
478+
requested: 'single',
479+
probeIsolation: () => false,
480+
getEngine: () => engine,
481+
logger: makeSink(),
482+
}).defaultOrgId()).toBe('org_1');
483+
484+
const loud = makeCensusEngine(orgs(3));
485+
const thrower = {
486+
warn: vi.fn(),
487+
error: vi.fn(() => {
488+
throw new Error('sink exploded');
489+
}),
490+
};
491+
expect(await createTenancyService({
492+
requested: 'single',
493+
probeIsolation: () => false,
494+
getEngine: () => loud,
495+
logger: thrower,
496+
}).defaultOrgId()).toBeNull();
497+
expect(thrower.error).toHaveBeenCalledTimes(1);
498+
});
499+
500+
it('a sink with no warn channel at all drops the report instead of throwing', async () => {
501+
// The declared sink types both members as optional, so a host CAN inject
502+
// `{ info }` alone. The narrowing proves `warn` before it claims the sink,
503+
// so such a host gets nothing — quietly, from inside a diagnostic.
504+
const engine = makeCensusEngine(orgs(3));
505+
const infoOnly = { info: vi.fn() };
506+
const t = createTenancyService({
507+
requested: 'single',
508+
probeIsolation: () => false,
509+
getEngine: () => engine,
510+
logger: infoOnly,
511+
});
512+
513+
expect(await t.defaultOrgId()).toBeNull();
514+
expect(infoOnly.info).not.toHaveBeenCalled();
515+
});
516+
517+
// -------------------------------------------------------------------------
518+
// The claim that makes this a BOOT-time reading rather than a lazy one:
519+
// `AuthPlugin` runs `backfillMemberships` from its `kernel:ready` hook with
520+
// `resolveTargetOrg: () => tenancy.defaultOrgId()`, under a membership policy
521+
// that defaults to `auto`. Pinned against the real pass, so the day that
522+
// wiring stops reaching this seam, this test says so.
523+
// -------------------------------------------------------------------------
524+
it('is taken AT BOOT: the kernel:ready membership backfill reaches this seam', async () => {
525+
const engine = makeCensusEngine(orgs(3));
526+
const logger = makeSink();
527+
const tenancy = createTenancyService({
528+
requested: 'single',
529+
probeIsolation: () => false,
530+
getEngine: () => engine,
531+
logger,
532+
});
533+
534+
const res = await backfillMemberships(engine, {
535+
policy: 'auto',
536+
resolveTargetOrg: () => tenancy.defaultOrgId(),
537+
logger,
538+
});
539+
540+
// The backfill itself correctly declines to guess (ADR-0093 D6) …
541+
expect(res.reason).toBe('no-target-org');
542+
// … and THAT is the boot moment the census is taken in.
543+
expect(logger.error).toHaveBeenCalledTimes(1);
544+
expect(logger.error.mock.calls[0]![0]).toContain(SINGLE_POSTURE_MANY_ORGANIZATIONS);
545+
});
546+
});

0 commit comments

Comments
 (0)