Skip to content

Commit 7cf1531

Browse files
os-zhuangclaude
andauthored
fix(auth): 两个 membership 入口对词表外的 policy 给出同向的拒绝 (#5205) (#5303)
`reconcileMembership` 与 `backfillMemberships` 读同一个 policy 字段,却用 相反的谓词判断它:注册路径测 `=== 'invite-only'`,于是任何其他值(包括 拼错的 `'inviteOnly'`)落到 `auto` 分支照常自动绑定 —— fail-open;回填路径 测 `!== 'auto'`,不绑 —— fail-safe。同一个输入,两个相反的姿态,而危险的 那半正好在逐个 sign-up 的路径上:调用方以为自己关掉了自动绑定,实际没有, 日志里也看不出来。 两处入口现在都在任何策略语义之前调 `isMembershipPolicy()` 判非法,返回独立的 `'invalid-policy'` outcome/reason,并在 `error` 级别(以及返回值上,好让没传 logger 的调用方也拿得到)说明是哪个值非法。不复用 `policy-skip` 是刻意的: 那个名字意思是「一个合法的策略说了不」,拿它报告「你这个值不是策略」,会把 排障的人指向一个其实没问题的部署设置。这与 #5152 在 settings 边界上的姿态 同源 —— 非法值响亮拒绝,绝不静默强转成 `auto`。 契约变更:导出的 `ReconcileOutcome` 与 `BackfillMembershipsResult['reason']` 各增一个成员,`BackfillMembershipsResult` 增可选 `error?`, `ReconcileMembershipDeps['logger']` 增可选 `error?` 方法(缺省回落到 `warn`)。 `auto` / `invite-only` 两条路径的行为逐条不变,并已由表驱动测试钉住。 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ddba89 commit 7cf1531

3 files changed

Lines changed: 356 additions & 6 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
fix(auth): an unrecognised membership policy is refused by both reconcilers, not auto-bound by one of them (#5205)
6+
7+
**The sign-up path used to bind anyway.** `reconcileMembership` and
8+
`backfillMemberships` — both public exports of `@objectstack/plugin-auth` — read
9+
the same `policy` field and judged it with opposite predicates. Sign-up tested
10+
`policy === 'invite-only'`, so any *other* value fell through to the `auto`
11+
branch and auto-bound the new user; the backfill tested `policy !== 'auto'` and
12+
refused. One input, two opposite postures, and the fail-open half was the one
13+
that runs per sign-up. A caller who wrote `'inviteOnly'` — or any host passing
14+
the policy from JavaScript, past the `MembershipPolicy` type — got auto-binding
15+
while believing they had switched it off, with nothing in the logs to say so.
16+
17+
Both entry points now check `isMembershipPolicy()` before any policy semantics
18+
and refuse: nothing is bound, and the refusal names the offending value at
19+
`error` level (and on the returned result, so it survives a caller that passed
20+
no logger). This is the posture #5152 took one layer up at the settings
21+
boundary — an unrecognised value is rejected loudly, never coerced to `auto`.
22+
23+
**Contract change — `ReconcileOutcome` gains `'invalid-policy'`, and
24+
`BackfillMembershipsResult.reason` gains the same member.** Both are exported
25+
types, so a consumer that switches exhaustively over them (a `never`-checked
26+
`default`, or a `Record< ReconcileOutcome, … >`) must handle the new member.
27+
The new verdict is deliberately *not* a reuse of the existing `policy-skip` /
28+
`'policy'`: those mean "a valid policy said no", and reporting them for "this
29+
is not a policy" sends whoever is debugging a missing bind to inspect a
30+
deployment setting that is fine. `BackfillMembershipsResult` also gains an
31+
optional `error?: string`, and the `logger` shape on `ReconcileMembershipDeps`
32+
gains an optional `error?` method (it falls back to `warn`).
33+
34+
**No behaviour change for the two real policies.** `auto` binds and
35+
`invite-only` skips exactly as before, on both paths — the framework's own
36+
callers resolve the policy through `AuthManager.getMembershipPolicy()`, whose
37+
return type is `MembershipPolicy`, so nothing on a supported path can reach the
38+
new branch. This closes the dormant divergence on the export surface.

packages/plugins/plugin-auth/src/reconcile-membership.test.ts

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi } from 'vitest';
4-
import { reconcileMembership, backfillMemberships } from './reconcile-membership.js';
4+
import {
5+
reconcileMembership,
6+
backfillMemberships,
7+
MEMBERSHIP_POLICIES,
8+
type MembershipPolicy,
9+
} from './reconcile-membership.js';
510
import { runAttributedToUser } from './auth-actor-attribution.js';
611

12+
/**
13+
* Off-vocabulary policy values, as a JS caller or a host stack could pass them
14+
* past the `MembershipPolicy` type. `reconcileMembership` and
15+
* `backfillMemberships` are public exports of `@objectstack/plugin-auth`, so
16+
* the cast is the test reproducing a real call, not defeating the type system
17+
* for convenience.
18+
*/
19+
const asPolicy = (value: unknown) => value as MembershipPolicy;
20+
721
/**
822
* In-memory engine over sys_member (+ optional sys_user) with the find/insert
923
* surface the reconciler uses. `find` honors `user_id` / `organization_id`
@@ -160,6 +174,206 @@ describe('backfillMemberships', () => {
160174
});
161175
});
162176

177+
/**
178+
* [#5205] The two entry points read the SAME policy field and used to judge it
179+
* with opposite predicates: `reconcileMembership` tested `=== 'invite-only'`
180+
* (so anything else, including a typo, fell through to the `auto` branch and
181+
* bound — fail-open), `backfillMemberships` tested `!== 'auto'` (refused —
182+
* fail-safe). The dangerous half was the sign-up path: a caller who believed
183+
* they had turned auto-binding off got it anyway, silently.
184+
*
185+
* These pin the fixed contract: one input, one direction (nothing bound), and
186+
* a verdict that says *why* — `'invalid-policy'`, not a `policy-skip` /
187+
* `'policy'` that would send a reader to inspect a setting that is fine.
188+
*/
189+
describe('reconcileMembership / backfillMemberships — off-vocabulary policy (#5205)', () => {
190+
const INVALID: Array<[label: string, value: unknown]> = [
191+
['camelCase typo', 'inviteOnly'],
192+
['snake_case typo', 'invite_only'],
193+
['wrong case', 'Auto'],
194+
['empty string', ''],
195+
['undefined', undefined],
196+
['null', null],
197+
['boolean', false],
198+
['object', { policy: 'invite-only' }],
199+
];
200+
201+
describe.each(INVALID)('policy = %s', (_label, value) => {
202+
it('reconcileMembership refuses — invalid-policy, nothing bound', async () => {
203+
const engine = makeEngine();
204+
const resolveTargetOrg = vi.fn(async () => 'org_default');
205+
const res = await reconcileMembership(engine, 'user-1', {
206+
policy: asPolicy(value),
207+
resolveTargetOrg,
208+
});
209+
expect(res.outcome).toBe('invalid-policy');
210+
// The whole bug: this used to be `bound`.
211+
expect(engine.insert).not.toHaveBeenCalled();
212+
expect(engine._members).toHaveLength(0);
213+
// Refused at the entry — no org was even resolved.
214+
expect(resolveTargetOrg).not.toHaveBeenCalled();
215+
});
216+
217+
it('backfillMemberships refuses — invalid-policy, nothing bound', async () => {
218+
const engine = makeEngine({ users: [{ id: 'u1' }, { id: 'u2' }] });
219+
const resolveTargetOrg = vi.fn(async () => 'org_default');
220+
const res = await backfillMemberships(engine, {
221+
policy: asPolicy(value),
222+
resolveTargetOrg,
223+
});
224+
expect(res.reason).toBe('invalid-policy');
225+
expect(res.bound).toBe(0);
226+
expect(engine.insert).not.toHaveBeenCalled();
227+
expect(resolveTargetOrg).not.toHaveBeenCalled();
228+
});
229+
230+
it('both paths agree: same input, same direction, neither binds', async () => {
231+
const signupEngine = makeEngine();
232+
const backfillEngine = makeEngine({ users: [{ id: 'u1' }] });
233+
const deps = { policy: asPolicy(value), resolveTargetOrg: async () => 'org_default' };
234+
235+
const signup = await reconcileMembership(signupEngine, 'user-1', deps);
236+
const backfill = await backfillMemberships(backfillEngine, deps);
237+
238+
expect(signupEngine._members).toHaveLength(0);
239+
expect(backfillEngine._members).toHaveLength(0);
240+
// …and both name the same cause, so a reader of either one lands on the
241+
// caller's policy value rather than on the deployment's setting.
242+
expect(signup.outcome).toBe('invalid-policy');
243+
expect(backfill.reason).toBe('invalid-policy');
244+
expect(signup.outcome).not.toBe('policy-skip');
245+
expect(backfill.reason).not.toBe('policy');
246+
});
247+
});
248+
249+
it('names the offending value so it is not swallowed — logged and returned', async () => {
250+
const error = vi.fn();
251+
const res = await reconcileMembership(makeEngine(), 'user-1', {
252+
policy: asPolicy('inviteOnly'),
253+
resolveTargetOrg: async () => 'org_default',
254+
logger: { error },
255+
});
256+
// Returned, so the diagnosis survives a caller that passed no logger.
257+
expect(res.error).toContain(`'inviteOnly'`);
258+
expect(res.error).toContain('invite-only'); // the expected vocabulary
259+
expect(error).toHaveBeenCalledTimes(1);
260+
const [msg, meta] = error.mock.calls[0];
261+
expect(msg).toContain(`'inviteOnly'`);
262+
expect(meta).toMatchObject({ userId: 'user-1', expected: MEMBERSHIP_POLICIES });
263+
});
264+
265+
it('logs at error, not warn — nothing else looks broken afterwards', async () => {
266+
const error = vi.fn();
267+
const warn = vi.fn();
268+
await backfillMemberships(makeEngine({ users: [{ id: 'u1' }] }), {
269+
policy: asPolicy('inviteOnly'),
270+
resolveTargetOrg: async () => 'org_default',
271+
logger: { error, warn },
272+
});
273+
expect(error).toHaveBeenCalledTimes(1);
274+
expect(warn).not.toHaveBeenCalled();
275+
});
276+
277+
it('still reaches a logger that only has warn', async () => {
278+
const warn = vi.fn();
279+
await reconcileMembership(makeEngine(), 'user-1', {
280+
policy: asPolicy('inviteOnly'),
281+
resolveTargetOrg: async () => 'org_default',
282+
logger: { warn },
283+
});
284+
expect(warn).toHaveBeenCalledTimes(1);
285+
expect(warn.mock.calls[0][0]).toContain(`'inviteOnly'`);
286+
});
287+
288+
it('refuses without a logger at all (the refusal is not a logging side effect)', async () => {
289+
const engine = makeEngine();
290+
const res = await reconcileMembership(engine, 'user-1', {
291+
policy: asPolicy('inviteOnly'),
292+
resolveTargetOrg: async () => 'org_default',
293+
});
294+
expect(res.outcome).toBe('invalid-policy');
295+
expect(engine.insert).not.toHaveBeenCalled();
296+
});
297+
298+
it('does not echo a non-string value into the log — typeof only', async () => {
299+
const error = vi.fn();
300+
const res = await reconcileMembership(makeEngine(), 'user-1', {
301+
// A caller that passed the whole config object by mistake; it may carry
302+
// fields that have no business in a log line.
303+
policy: asPolicy({ membershipPolicy: 'invite-only', adminEmail: 'ops@example.com' }),
304+
resolveTargetOrg: async () => 'org_default',
305+
logger: { error },
306+
});
307+
expect(res.outcome).toBe('invalid-policy');
308+
expect(res.error).toContain('[object]');
309+
expect(error.mock.calls[0][0]).not.toContain('ops@example.com');
310+
expect(JSON.stringify(error.mock.calls[0][1])).not.toContain('ops@example.com');
311+
});
312+
313+
it('bounds a long string value', async () => {
314+
const error = vi.fn();
315+
const res = await reconcileMembership(makeEngine(), 'user-1', {
316+
policy: asPolicy('x'.repeat(500)),
317+
resolveTargetOrg: async () => 'org_default',
318+
logger: { error },
319+
});
320+
expect(res.error).toContain('(truncated)');
321+
expect(res.error!.length).toBeLessThan(200);
322+
});
323+
324+
it('preconditions still win — a missing engine reports skipped, not invalid-policy', async () => {
325+
const res = await reconcileMembership(undefined, 'user-1', {
326+
policy: asPolicy('inviteOnly'),
327+
resolveTargetOrg: async () => 'org_default',
328+
});
329+
expect(res.outcome).toBe('skipped');
330+
const bf = await backfillMemberships(undefined, {
331+
policy: asPolicy('inviteOnly'),
332+
resolveTargetOrg: async () => 'org_default',
333+
});
334+
expect(bf.reason).toBe('engine-unavailable');
335+
});
336+
});
337+
338+
/**
339+
* [#5205] The guard must not move the ground under the two values that ARE
340+
* policies. Asserted as a table over the declared vocabulary, so the behaviour
341+
* of every legal value is stated here rather than inferred from a green suite.
342+
*/
343+
describe('legal policies are unaffected by the invalid-policy guard (#5205)', () => {
344+
const EXPECTED: Record<MembershipPolicy, { signup: string; backfillReason?: string; binds: boolean }> = {
345+
auto: { signup: 'bound', backfillReason: undefined, binds: true },
346+
'invite-only': { signup: 'policy-skip', backfillReason: 'policy', binds: false },
347+
};
348+
349+
it('covers the whole declared vocabulary', () => {
350+
expect(Object.keys(EXPECTED).sort()).toEqual([...MEMBERSHIP_POLICIES].sort());
351+
});
352+
353+
it.each([...MEMBERSHIP_POLICIES])('reconcileMembership(%s) is unchanged', async (policy) => {
354+
const engine = makeEngine();
355+
const res = await reconcileMembership(engine, 'user-1', {
356+
policy,
357+
resolveTargetOrg: async () => 'org_default',
358+
});
359+
expect(res.outcome).toBe(EXPECTED[policy].signup);
360+
expect(res.error).toBeUndefined();
361+
expect(engine._members).toHaveLength(EXPECTED[policy].binds ? 1 : 0);
362+
});
363+
364+
it.each([...MEMBERSHIP_POLICIES])('backfillMemberships(%s) is unchanged', async (policy) => {
365+
const engine = makeEngine({ users: [{ id: 'u1' }] });
366+
const res = await backfillMemberships(engine, {
367+
policy,
368+
resolveTargetOrg: async () => 'org_default',
369+
});
370+
expect(res.reason).toBe(EXPECTED[policy].backfillReason);
371+
expect(res.error).toBeUndefined();
372+
expect(res.bound).toBe(EXPECTED[policy].binds ? 1 : 0);
373+
expect(engine._members).toHaveLength(EXPECTED[policy].binds ? 1 : 0);
374+
});
375+
});
376+
163377
/**
164378
* [#4586] The reconciler bind is the third `sys_member` writer (after the
165379
* better-auth adapter and the invite-accept path), and it runs INSIDE

0 commit comments

Comments
 (0)