Skip to content

Commit 88e32a8

Browse files
os-warrenclaude
andauthored
fix(plugin-security): bind the report sink above start()'s two bail-outs (#10706) (#11055)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 072d072 commit 88e32a8

3 files changed

Lines changed: 214 additions & 1 deletion

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
`SecurityPlugin.start()` binds its report sink **above** the two bail-outs, so a
6+
degraded boot no longer leaves the plugin permanently unable to report (#10706).
7+
8+
`private logger … = {}` is an empty object from construction, and
9+
`this.logger = ctx.logger` was its only assignment — sitting in the "capture
10+
handles" block, **below** the two `return`s that fire when `objectql`/`metadata`
11+
cannot be resolved, or when the engine carries no `registerMiddleware`. On
12+
either path the field stayed `{}` for the **lifetime of the instance**. Every
13+
report site is written `this.logger.warn?.(…)`, so an unbound sink is not a
14+
state any caller can notice: the reports simply do not happen. The assignment
15+
now runs immediately after the `Starting Security Plugin...` line, before either
16+
bail-out can be taken.
17+
18+
Boot behaviour is otherwise unchanged, and that is pinned rather than asserted:
19+
both bail-outs still `return`, the middleware and the `security` service are
20+
still **not** registered on those paths, and both bail-outs still report through
21+
`ctx.logger` — which was always a real sink, so the bail-out itself was already
22+
loud. What was silent was the plugin's own field afterwards.
23+
24+
Scope note: this is independent of the open design call on #10556 about what the
25+
default sink should be. Only the **placement** of the binding changes; the `= {}`
26+
default itself is untouched, and the fix is correct under every option there.
27+
28+
Reachability, measured rather than assumed: every in-repo caller of the two
29+
public methods that report through the field (`checkAuthoredRowWrite`,
30+
`getReadFilter`) reaches them through the registered `security` service, and
31+
that service is registered *below* the bail-outs too — so on a bailed-out boot
32+
there is no live consumer. The defect was latent, not live. It is still a defect
33+
on its own terms: a sink that can never be bound after an early return is
34+
unrepresentable as a state the code can notice.
35+
36+
New pin: `start-logger-binding.test.ts`.

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,16 @@ export class SecurityPlugin implements Plugin {
866866
async start(ctx: PluginContext): Promise<void> {
867867
ctx.logger.info('Starting Security Plugin...');
868868

869+
// [#10706] Bind the report sink FIRST — above the two bail-outs below.
870+
// Both of them `return` before the "capture handles" block, so binding the
871+
// sink there left `this.logger` at its `= {}` construction default for the
872+
// whole LIFETIME of the instance on a degraded boot: every report site is
873+
// written `this.logger.warn?.(...)`, so an unbound sink is not a state any
874+
// caller can notice — it silently reports nothing. Assigning here is
875+
// correct under every option on #10556's open default-sink question, so it
876+
// does not wait on that ruling; the `= {}` default itself is #10556's.
877+
this.logger = ctx.logger;
878+
869879
// Get required services
870880
let ql: IObjectQLEngine | undefined;
871881
let metadata: IMetadataService | undefined;
@@ -887,7 +897,6 @@ export class SecurityPlugin implements Plugin {
887897
// engine middleware AND the public getReadFilter service method.
888898
this.metadata = metadata;
889899
this.ql = ql;
890-
this.logger = ctx.logger;
891900
this.rlsCompiler.setLogger?.(ctx.logger);
892901
// [C2 / ADR-0095] Late-bound resolver for the optional `sharing` service.
893902
this.resolveKernelService = (name: string) => {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#10706] `start()` binds `this.logger` ABOVE its two bail-outs.
4+
//
5+
// The mechanic this pins: `private logger … = {}` is an empty object from
6+
// construction, and `this.logger = ctx.logger` is its ONLY assignment. It used
7+
// to sit in the "capture handles" block, BELOW the two `return`s that fire when
8+
// `objectql`/`metadata` are missing or when the engine carries no
9+
// `registerMiddleware`. On either bail-out the field therefore stayed `{}` for
10+
// the LIFETIME of the instance — and because every report site is written
11+
// `this.logger.warn?.(…)`, an unbound sink is indistinguishable from a
12+
// configured one at every call site: it silently reports nothing.
13+
//
14+
// ⛔ This suite is deliberately INDEPENDENT of #10556's open design call on what
15+
// the `= {}` default should be. It asserts only WHERE the real sink is bound,
16+
// which is correct under every option on that question. Nothing here reads or
17+
// asserts the default value itself.
18+
//
19+
// Four directions are pinned, and the middle two are the load-bearing ones:
20+
//
21+
// 1. after either bail-out, `this.logger` IS `ctx.logger` — and RECEIVES a
22+
// report. Identity alone is not enough, and "non-empty" would be no
23+
// assertion at all: `{}` and a real logger both satisfy
24+
// `typeof x === 'object'`.
25+
// 2. STILL BAILS. Both early returns still return, and the middleware is
26+
// still NOT registered. An implementation that "fixed" this by deleting a
27+
// bail-out would pass a logger-only suite while changing boot behaviour.
28+
// 3. the bail-out itself stays LOUD through `ctx.logger` — it already was,
29+
// and that must not regress.
30+
// 4. the normal boot path is unchanged — same sink, registration still
31+
// happens.
32+
33+
import { describe, it, expect, vi } from 'vitest';
34+
// Relative specifier: resolves to THIS package's `src/security-plugin.ts`, never
35+
// to `dist/`. The only aliases in `vitest.config.ts` are anchored regexes for
36+
// `@objectstack/driver-sql` and `@objectstack/objectql`; neither can match a
37+
// relative path, so the subject here is the source in the checkout.
38+
import { SecurityPlugin } from './security-plugin.js';
39+
40+
type Ctx = {
41+
logger: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
42+
registerService: ReturnType<typeof vi.fn>;
43+
getService: ReturnType<typeof vi.fn>;
44+
registerMiddleware: ReturnType<typeof vi.fn>;
45+
};
46+
47+
/**
48+
* A boot context in one of three postures:
49+
*
50+
* - `'throws'` — `getService('objectql')` throws → the `:878` bail-out.
51+
* - `'no-mw'` — objectql resolves but carries no `registerMiddleware`
52+
* → the `:883` bail-out.
53+
* - `'healthy'` — a normal boot that reaches registration.
54+
*/
55+
function makeCtx(posture: 'throws' | 'no-mw' | 'healthy'): Ctx {
56+
const registerMiddleware = vi.fn();
57+
const manifestService = { register: vi.fn() };
58+
const ctx: any = {
59+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
60+
registerService: vi.fn(),
61+
registerMiddleware,
62+
getService: vi.fn().mockImplementation((name: string) => {
63+
if (name === 'manifest') return manifestService;
64+
if (posture === 'throws') throw new Error('service not available');
65+
if (name === 'objectql') return posture === 'no-mw' ? {} : { registerMiddleware };
66+
if (name === 'metadata') return { list: vi.fn().mockResolvedValue([]) };
67+
return undefined;
68+
}),
69+
};
70+
return ctx as Ctx;
71+
}
72+
73+
/** The private field, read exactly as the mechanic describes it. */
74+
const sinkOf = (plugin: SecurityPlugin) => (plugin as any).logger;
75+
76+
/**
77+
* Drive a REAL report through `this.logger` and return whether it landed.
78+
*
79+
* `getReadFilter` is a public instance method whose on-behalf-of refusal
80+
* (`this.logger.error?.(…)`, the ADR-0095/#2852 fail-closed branch) depends on
81+
* NONE of the handles captured below the bail-outs — no `this.ql`, no
82+
* `this.metadata`. That makes it the one report site that is genuinely
83+
* reachable on a bailed-out instance, which is what makes it the right probe
84+
* for "the sink RECEIVES something".
85+
*
86+
* Pre-fix this call is silent: `{}.error` is `undefined` and `?.()` no-ops.
87+
*/
88+
async function probeSinkReceives(plugin: SecurityPlugin, ctx: Ctx): Promise<boolean> {
89+
ctx.logger.error.mockClear();
90+
const verdict = await plugin.getReadFilter('crm_opportunity', {
91+
userId: 'u_agent',
92+
onBehalfOf: { userId: 'u_delegator' },
93+
});
94+
// The refusal itself must still be fail-closed, independent of the sink.
95+
expect(verdict).toBeTruthy();
96+
return ctx.logger.error.mock.calls.length > 0;
97+
}
98+
99+
describe('[#10706] start() binds the report sink above both bail-outs', () => {
100+
describe.each([
101+
['getService throws — the ObjectQL/metadata bail-out', 'throws' as const],
102+
['engine without registerMiddleware — the no-middleware bail-out', 'no-mw' as const],
103+
])('%s', (_label, posture) => {
104+
it('binds `this.logger` to the real sink, and that sink RECEIVES a report', async () => {
105+
const plugin = new SecurityPlugin();
106+
const ctx = makeCtx(posture);
107+
108+
// Before `start()` the field is still the construction default — this is
109+
// the state the fix makes unreachable AFTER a start, and asserting it
110+
// here is what makes the post-start assertion a change and not a tautology.
111+
expect(sinkOf(plugin)).not.toBe(ctx.logger);
112+
113+
await plugin.init(ctx as any);
114+
await plugin.start(ctx as any);
115+
116+
// Direction 1a — identity: the field IS the context's logger.
117+
expect(sinkOf(plugin)).toBe(ctx.logger);
118+
119+
// Direction 1b — the sink RECEIVES. `typeof sink === 'object'` would be
120+
// satisfied by `{}` too, so the only honest assertion is a delivered call.
121+
await expect(probeSinkReceives(plugin, ctx)).resolves.toBe(true);
122+
});
123+
124+
it('STILL BAILS — no middleware and no `security` service are registered', async () => {
125+
const plugin = new SecurityPlugin();
126+
const ctx = makeCtx(posture);
127+
128+
await plugin.init(ctx as any);
129+
await plugin.start(ctx as any);
130+
131+
// The bail-out is the POINT of these paths. A "fix" that moved the sink
132+
// by deleting a return would satisfy the logger assertions above and
133+
// silently change boot behaviour; these two assertions are what refuse it.
134+
expect(ctx.registerMiddleware).not.toHaveBeenCalled();
135+
const registeredNames = ctx.registerService.mock.calls.map((c) => c[0]);
136+
expect(registeredNames).not.toContain('security');
137+
});
138+
139+
it('the bail-out stays LOUD — it still reports through `ctx.logger`', async () => {
140+
const plugin = new SecurityPlugin();
141+
const ctx = makeCtx(posture);
142+
143+
await plugin.init(ctx as any);
144+
await plugin.start(ctx as any);
145+
146+
expect(ctx.logger.warn).toHaveBeenCalledWith(
147+
expect.stringContaining('security middleware not registered'),
148+
);
149+
});
150+
});
151+
152+
it('normal boot is unchanged — same sink, and registration still happens', async () => {
153+
const plugin = new SecurityPlugin();
154+
const ctx = makeCtx('healthy');
155+
156+
await plugin.init(ctx as any);
157+
await plugin.start(ctx as any);
158+
159+
expect(sinkOf(plugin)).toBe(ctx.logger);
160+
expect(ctx.registerMiddleware).toHaveBeenCalledWith(expect.any(Function));
161+
const registeredNames = ctx.registerService.mock.calls.map((c) => c[0]);
162+
expect(registeredNames).toContain('security');
163+
// The healthy path must NOT take either bail-out.
164+
expect(ctx.logger.warn).not.toHaveBeenCalledWith(
165+
expect.stringContaining('security middleware not registered'),
166+
);
167+
});
168+
});

0 commit comments

Comments
 (0)