From 5e6f1c9740a5cddb1e1ebf74d144bbef35fbeaf7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:44:37 +0800 Subject: [PATCH 1/5] wip(qa): #15784 membership-removal census probe (temporary, removed before review) Co-Authored-By: Claude Opus 5 --- .../membership-removal-census.probe.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/qa/dogfood/test/membership-removal-census.probe.test.ts diff --git a/packages/qa/dogfood/test/membership-removal-census.probe.test.ts b/packages/qa/dogfood/test/membership-removal-census.probe.test.ts new file mode 100644 index 0000000000..d2b58ed5ba --- /dev/null +++ b/packages/qa/dogfood/test/membership-removal-census.probe.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// TEMPORARY census probe for #15784 scope item 1 — NOT a shipped test. +// Registers a probe on the candidate seam and drives every enumerated +// membership-removal path, recording which reach it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYSTEM_CTX = { isSystem: true }; +const PROBE_PKG = 'census.15784.probe'; + +type Firing = { event: string; id: unknown; user_id?: unknown; organization_id?: unknown }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 60; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('#15784 census probe: which membership-removal writers reach an engine hook on sys_member', () => { + let stack: VerifyStack; + let ql: any; + let driver: any; + let orgId: string; + let partnerOrgId: string; + let adminToken: string; + let adminUserId: string; + const fired: Firing[] = []; + const report: string[] = []; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + // The engine's own driver for sys_member — `getDriver` is TS-private, not + // runtime-private, and this probe deliberately reaches BELOW the engine. + driver = (ql as any).getDriver('sys_member'); + + for (const event of ['beforeDelete', 'afterDelete', 'beforeUpdate', 'afterUpdate'] as const) { + ql.registerHook(event, async (ctx: any) => { + fired.push({ + event, + id: ctx?.input?.id, + user_id: ctx?.previous?.user_id, + organization_id: ctx?.previous?.organization_id, + }); + }, { object: 'sys_member', packageId: PROBE_PKG, priority: 500 }); + } + + const org = await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX }); + orgId = String(org.id); + const partner = await ql.insert('sys_organization', { name: 'Partner Organization', slug: 'partner' }, { context: SYSTEM_CTX }); + partnerOrgId = String(partner.id); + + const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + adminUserId = String(adminUser.id); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); + if (adminMembers.length > 0) { + await ql.update('sys_member', { id: adminMembers[0].id, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } else { + await ql.insert('sys_member', { user_id: adminUserId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } + await ql.insert('sys_member', { user_id: adminUserId, organization_id: partnerOrgId, role: 'owner' }, { context: SYSTEM_CTX }); + }, 240_000); + + afterAll(async () => { + console.log('\n===== #15784 CENSUS PROBE REPORT ====='); + for (const l of report) console.log(l); + console.log('===== END =====\n'); + await stack?.stop?.(); + }); + + async function newMember(tag: string): Promise<{ userId: string; memberId: string }> { + const email = `census.${tag}.15784@example.com`; + await stack.signUp(email, 'Member!Pass123', `Census ${tag}`); + const [u] = await findRows(ql, 'sys_user', { email }, 1); + const m = await waitForMembership(ql, String(u.id)); + return { userId: String(u.id), memberId: String(m.id) }; + } + + function since(mark: number): Firing[] { return fired.slice(mark); } + + // ── PATH 0 — THE FIRING CONTROL ─────────────────────────────────────────── + it('CONTROL: a direct engine delete fires the probe (if this fails, nothing below is a reading)', async () => { + const { memberId } = await newMember('control'); + const mark = fired.length; + await ql.delete('sys_member', { where: { id: memberId }, context: SYSTEM_CTX }); + const seen = since(mark); + report.push(`PATH 0 engine.delete (direct) -> ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); + expect(seen.some((f) => f.event === 'afterDelete')).toBe(true); + }, 120_000); + + // ── PATH 1 — better-auth's own remove-member endpoint ───────────────────── + it('better-auth POST /organization/remove-member', async () => { + const { userId, memberId } = await newMember('removemember'); + await ql.update('sys_member', { id: memberId, organization_id: orgId }, { context: SYSTEM_CTX }); + const mark = fired.length; + const res = await stack.apiAs(adminToken, 'POST', '/auth/organization/remove-member', { + memberIdOrEmail: memberId, + organizationId: orgId, + }); + const body = await res.clone().text(); + const seen = since(mark); + const gone = (await findRows(ql, 'sys_member', { id: memberId }, 1)).length === 0; + report.push(`PATH 1 better-auth /organization/remove-member -> HTTP ${res.status}; row gone=${gone}; ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); + if (res.status !== 200) report.push(` (body: ${body.slice(0, 200)})`); + expect(userId).toBeTruthy(); + }, 120_000); + + // ── PATH 2 — a bulk / multi engine delete ──────────────────────────────── + it('engine multi delete (bulk operation)', async () => { + const { memberId } = await newMember('bulk'); + await ql.update('sys_member', { id: memberId, organization_id: partnerOrgId }, { context: SYSTEM_CTX }); + const mark = fired.length; + await ql.delete('sys_member', { where: { id: memberId }, multi: true, context: SYSTEM_CTX }); + const seen = since(mark); + report.push(`PATH 2 engine.delete multi:true -> ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); + }, 120_000); + + // ── PATH 3 — cascade from a sys_user delete ────────────────────────────── + it('cascade: deleting the sys_user row takes its memberships', async () => { + const { userId, memberId } = await newMember('cascade'); + // `sys_session.user_id` / `sys_account.user_id` are REQUIRED lookups that + // default to `restrict`, so a signed-up user cannot be deleted while those + // rows stand. Clear them first: this path measures the sys_member cascade. + for (const child of ['sys_session', 'sys_account']) { + for (const r of await findRows(ql, child, { user_id: userId }, 20)) { + await ql.delete(child, { where: { id: r.id }, context: SYSTEM_CTX }); + } + } + const mark = fired.length; + let err: string | undefined; + try { + await ql.delete('sys_user', { where: { id: userId }, context: SYSTEM_CTX }); + } catch (e: any) { err = e?.message ?? String(e); } + const seen = since(mark); + const gone = (await findRows(ql, 'sys_member', { id: memberId }, 1)).length === 0; + report.push(`PATH 3 cascade via sys_user delete -> row gone=${gone}; ${seen.map((f) => f.event).join(',') || 'NOTHING'}${err ? `; refused: ${err.slice(0, 120)}` : ''}`); + }, 120_000); + + // ── PATH 4 — a RAW DRIVER delete (the cloud package-uninstall shape) ───── + it('raw driver delete bypasses the engine entirely', async () => { + const { memberId } = await newMember('rawdriver'); + const mark = fired.length; + let err: string | undefined; + try { + await driver.delete('sys_member', memberId); + } catch (e: any) { err = e?.message ?? String(e); } + const seen = since(mark); + const gone = (await findRows(ql, 'sys_member', { id: memberId }, 1)).length === 0; + report.push(`PATH 4 driver.delete (raw, hooks bypassed) -> row gone=${gone}; ${seen.map((f) => f.event).join(',') || 'NOTHING'}${err ? `; threw: ${err.slice(0, 120)}` : ''}`); + }, 120_000); + + // ── PATH 5 — INVALIDATE: re-point organization_id ──────────────────────── + it('invalidate: the membership row is re-pointed at another organization', async () => { + const { memberId } = await newMember('repoint'); + await ql.update('sys_member', { id: memberId, organization_id: orgId }, { context: SYSTEM_CTX }); + const mark = fired.length; + await ql.update('sys_member', { id: memberId, organization_id: partnerOrgId }, { context: SYSTEM_CTX }); + const seen = since(mark); + report.push(`PATH 5 engine.update re-point organization_id -> ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); + }, 120_000); +}); From ac4428dba171122ed16c5a860e99f82bbbddd6af Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:55:38 +0800 Subject: [PATCH 2/5] feat(plugin-auth): revoke or re-point a session whose membership ended (#15784) Co-Authored-By: Claude Opus 5 --- .../src/identity/sys-session.object.ts | 16 +- .../plugins/plugin-auth/src/auth-plugin.ts | 15 + packages/plugins/plugin-auth/src/index.ts | 1 + .../src/membership-ended-session.ts | 414 ++++++++++++++++++ ...rship-ended-session-revoke.dogfood.test.ts | 199 +++++++++ 5 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/membership-ended-session.ts create mode 100644 packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index b52866f9f1..d154cb1214 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -174,7 +174,7 @@ export const SysSession = ObjectSchema.create({ required: false, readonly: true, group: 'Session', - description: 'When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed.', + description: 'When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed.', }), revoke_reason: Field.text({ label: 'Revoke Reason', @@ -182,7 +182,19 @@ export const SysSession = ObjectSchema.create({ maxLength: 64, readonly: true, group: 'Session', - description: 'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …).', + // [#15784] The accept set widens here, and this description IS the + // published vocabulary — there is no Zod enum behind this column (it is + // free `text`), so this line is the only contract a consumer can read. + // Every value before `organization_membership_ended` is either a TIMER + // (idle_timeout, absolute_max, concurrent_cap) or an interactive revoke + // (user_revoked, admin — `plugin-auth/session-tombstone.ts`); that one is + // the first AUTHORIZATION-EVENT cause, written when the membership + // backing a session's active organization ends and the user holds no + // other. ⛔ It is a COURTESY, never the enforcement: the wall is the + // per-request membership check in `resolve-authz-context.ts`. + description: + 'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, ' + + 'admin, organization_membership_ended, …).', }), // ── Active context (multi-org/team) ────────────────────────── diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index e22b9f8631..2a247ce41b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -58,6 +58,7 @@ import { type SecondaryStorageLike, } from './identity-write-guard.js'; import { registerLastAdminGuard } from './last-admin-guard.js'; +import { registerMembershipEndedSessionTrigger } from './membership-ended-session.js'; import { registerMemberRoleCanonicalization } from './member-role-canonical.js'; import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js'; import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js'; @@ -1397,6 +1398,20 @@ export class AuthPlugin implements Plugin { packageId: 'com.objectstack.plugin-auth.last-admin-guard', logger: ctx.logger, }); + // [#15784] The COURTESY half of #15409's ruling: when a membership + // ends, the session's claim on THAT organization ends with it — + // re-pointed if the user still belongs somewhere, revoked if not. + // Registered on `sys_member` for the same reason the guard above is: + // the census (posted on #15784) measured that better-auth's + // remove-member endpoint, a direct delete, a bulk delete, the cascade + // from a sys_user delete and an organization re-point ALL reach this + // seam, while an endpoint hook would have reached one of them. + // ⛔ This is never the enforcement — see the module header, and + // `resolve-authz-context.ts`, which stays untouched. + registerMembershipEndedSessionTrigger(engine, { + packageId: 'com.objectstack.plugin-auth.membership-ended-session', + logger: ctx.logger, + }); } catch { // Engine not available (mock mode) — permission-set defaults remain // the only gate, exactly the pre-guard status quo. diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 5b4f096b5f..45076f9676 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -31,6 +31,7 @@ export * from './identity-write-guard.js'; // has to be able to register the invariant itself rather than ship an // environment that can ban or delete its last administrator. export * from './last-admin-guard.js'; +export * from './membership-ended-session.js'; // [#8317] `sys_member.role` canonicalisation — the write-path hooks and the // one-off convergent pass. Exported for the same reason the two guards above // are, plus one of its own: a host that upgrades outside this plugin's boot diff --git a/packages/plugins/plugin-auth/src/membership-ended-session.ts b/packages/plugins/plugin-auth/src/membership-ended-session.ts new file mode 100644 index 0000000000..ebb516d382 --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-ended-session.ts @@ -0,0 +1,414 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15784] When a membership ends, the session's claim on that organization + * ends with it — the COURTESY half of #15409's ruling. + * + * ## ⛔ This is not the enforcement, and must never be treated as one + * + * #15409 closed the security hole PER REQUEST: `resolve-authz-context.ts` asks, + * on every single request, whether the session's `activeOrganizationId` is + * still backed by a `sys_member` row, and drops the claim when it is not. That + * check covers every removal path **by construction**, including the ones this + * module cannot see. + * + * This module is an EVENT trigger, and an event trigger covers exactly the + * paths someone remembered to wire. Its own census (posted on #15784) measured + * one production path it does NOT cover, and there will be others: + * + * - cloud's package-uninstall sample-data purge calls the RAW DRIVER with an + * object name taken from the package manifest (`envDriver.delete(objectName, + * id)`), so it lands below every engine hook — measured firing NOTHING here + * while the row really disappeared. Filed as cloud#2003. + * + * ⇒ ⛔ **Never weaken, bypass or "optimise away" the per-request check on the + * strength of this trigger existing** (scope item 3, verbatim). A trigger can + * be missed; an evaluation cannot. A missed path here costs a stale login + * session — never access. + * + * ## Ruled shape: act on the ORGANIZATION'S CLAIM, never on the user + * + * Maintainer ruling, decision batch #49 item 4 (2026-09-05), option **B** — the + * same principle #15409 landed: + * + * - a session whose `active_organization_id` points at the organization the + * membership just ended in loses THAT claim: re-pointed to a membership the + * user still holds, or cleared; + * - a user with **no remaining membership** has nothing left to be signed + * into, so the session is revoked through the existing + * `sys_session.revoked_at` / `revoke_reason` mechanism with + * {@link MEMBERSHIP_ENDED_REVOKE_REASON}; + * - ⛔ revoking EVERY session of the user was rejected — it signs people out + * of organizations they legitimately belong to; + * - ⛔ doing nothing was rejected — the admin was told the person was removed. + * + * ## Why the seam is an ENGINE HOOK and not the endpoint + * + * Measured, not assumed (#15784's census, a probe registered on `sys_member` + * driven against a real booted stack): + * + * ``` + * PATH 0 engine.delete (direct) -> beforeDelete,afterDelete (firing control) + * PATH 1 better-auth /organization/remove-member -> HTTP 200; row gone; beforeDelete,afterDelete + * PATH 2 engine.delete multi:true -> beforeDelete,afterDelete + * PATH 3 cascade via sys_user delete -> row gone; beforeDelete,afterDelete + * PATH 4 driver.delete (raw) -> row gone; NOTHING + * PATH 5 engine.update re-point organization_id -> beforeUpdate,afterUpdate + * ``` + * + * A hook on `/organization/remove-member` would have covered exactly one row of + * that table. This is also the precedent already in this package: + * `last-admin-guard.ts` enforces its invariant with `beforeUpdate` / + * `beforeDelete` on `sys_member` for the same stated reason — an HTTP guard + * protects only the endpoint it is attached to. + * + * ## Why REVOKING ends the session, with no client change + * + * `session-tombstone.ts` already made a revoked row invisible to better-auth's + * own session reads, and this write uses the same shape the automatic controls + * use (`enforceSessionControls` / `enforceConcurrentCap`): `expires_at` a + * second into the past, plus both audit columns. `findSession` then answers + * `null`, the next request is unauthenticated, and the Console's existing + * 401 → login redirect handles it. + * + * ## Best-effort, and LOUD about it + * + * A failed revocation must never turn a successful member removal into a 500 — + * so every write is caught. But a control that silently stops running is a + * control the operator still believes is on (#12981), so each catch reports + * what did not happen and what it costs. + */ + +import { SystemObjectName } from '@objectstack/spec/system'; + +/** + * The `revoke_reason` a session ended by a membership removal records. + * + * ## Why this exact string + * + * Every reason on this column before it is a TIMER — `idle_timeout`, + * `absolute_max`, `concurrent_cap` — or an interactive revoke — + * `user_revoked`, `admin` (`session-tombstone.ts`). This is the first + * AUTHORIZATION-EVENT reason, so it names the event and not a clock. + * + * It is deliberately the SAME string the API-key arm of this ruling family + * already mints for the same event: `resolve-authz-context.ts` refuses an API + * key whose backing membership ended with + * `authRefusal.reason: 'organization_membership_ended'` (#15256, decision 1A). + * One grep therefore finds every place the platform acts on a membership + * ending, across both credential kinds — worth more than four saved + * characters, and comfortably inside the column's `maxLength: 64`. + */ +export const MEMBERSHIP_ENDED_REVOKE_REASON = 'organization_membership_ended'; + +/** + * How many of one user's sessions this sweep will consider. + * + * Mirrors `enforceConcurrentCap`'s ceiling. Overflow is REPORTED and the rest + * are still processed — unlike `last-admin-guard`, which refuses on overflow + * because it is an invariant. This is a courtesy: doing it for 200 sessions and + * saying so beats doing it for none. + */ +const DEFAULT_MAX_SESSION_SCAN = 200; + +/** System context for this module's reads and writes. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +type LoggerLike = { + info?(msg: string, meta?: unknown): void; + warn(msg: string, meta?: unknown): void; +}; + +/** The slice of the engine this module uses. */ +export interface MembershipEndedSessionEngine { + find(object: string, query: unknown, options?: unknown): Promise; + update(object: string, data: unknown, options?: unknown): Promise; + registerHook(event: string, handler: (ctx: any) => unknown, options?: unknown): void; + unregisterHooksByPackage?(packageId: string): void; +} + +export interface MembershipEndedSessionOptions { + /** Owning package id — used for `unregisterHooksByPackage` on re-bind. */ + packageId: string; + logger?: LoggerLike; + /** Ceiling on sessions considered per ended membership. */ + maxSessionScan?: number; +} + +/** What this module did to one session. */ +export type MembershipEndOutcome = + /** The claim was re-pointed at an organization the user still belongs to. */ + | { action: 'repointed'; sessionId: string; from: string | null; to: string | null } + /** The user held no remaining membership: the session was revoked. */ + | { action: 'revoked'; sessionId: string; from: string | null } + /** A write was refused; the session is UNCHANGED. */ + | { action: 'failed'; sessionId: string; intended: 'repointed' | 'revoked' }; + +/** The end of one `(user, organization)` binding. */ +export interface EndedMembership { + userId: string; + /** `null` in single-org deployments, where memberships carry no org id. */ + organizationId: string | null; + /** The `sys_member` row that ended, so it is excluded from "what remains". */ + memberRowId?: string; +} + +function rowsOf(result: unknown): Array> { + if (Array.isArray(result)) return result as Array>; + const records = (result as { records?: unknown } | null)?.records; + return Array.isArray(records) ? (records as Array>) : []; +} + +function idOf(value: unknown): string | null { + if (value == null) return null; + const s = String(value); + return s.length > 0 ? s : null; +} + +/** + * Is this `sys_session` row still one a request could authenticate with? + * + * The same predicate `enforceConcurrentCap` uses: not already tombstoned, and + * not already past its expiry. + */ +function isLiveSession(row: Record, now: number): boolean { + if (row.revoked_at) return false; + if (row.expires_at && new Date(row.expires_at).getTime() <= now) return false; + return true; +} + +/** + * Apply the ruling to every live session of `userId` that claims the + * organization the membership just ended in. + * + * Exported so the behaviour is testable without a hook, and so a host that + * removes memberships outside the engine (see the module header's known-open + * path) can call it directly rather than re-implementing the rule. + * + * Never throws. + */ +export async function endSessionClaimsForEndedMembership( + engine: MembershipEndedSessionEngine, + ended: EndedMembership, + opts: { logger?: LoggerLike; maxSessionScan?: number } = {}, +): Promise { + const { logger } = opts; + const maxScan = opts.maxSessionScan ?? DEFAULT_MAX_SESSION_SCAN; + const userId = idOf(ended.userId); + if (!userId) return []; + const endedOrg = ended.organizationId == null ? null : idOf(ended.organizationId); + const outcomes: MembershipEndOutcome[] = []; + + try { + // ── 1. What does this user still belong to? ────────────────────────────── + // + // Read AFTER the removal, so the ended row is already gone on the delete + // path. On the re-point path the row survives with its NEW organization, so + // it legitimately counts as a remaining membership — and is excluded only + // from being its own re-point target when it still names the ended org. + const stillHeld = rowsOf( + await engine.find( + SystemObjectName.MEMBER, + { where: { user_id: userId }, fields: ['id', 'organization_id', 'created_at'], limit: maxScan + 1 }, + { context: SYSTEM_CTX }, + ), + ).filter((m) => idOf(m.organization_id) !== endedOrg); + + // Oldest first, so the re-point target is deterministic and matches the + // organization the platform's own active-org backfill would have chosen. + const targets = stillHeld + .filter((m) => idOf(m.organization_id) != null) + .sort((a, b) => new Date(a.created_at ?? 0).getTime() - new Date(b.created_at ?? 0).getTime()); + const repointTo = targets.length > 0 ? idOf(targets[0].organization_id) : null; + const holdsAnother = stillHeld.length > 0; + + // ── 2. Which of this user's live sessions claim the ended organization? ── + const sessions = rowsOf( + await engine.find( + SystemObjectName.SESSION, + { + where: { user_id: userId }, + fields: ['id', 'active_organization_id', 'expires_at', 'revoked_at'], + limit: maxScan + 1, + }, + { context: SYSTEM_CTX }, + ), + ); + if (sessions.length > maxScan) { + logger?.warn( + '[MembershipEndedSession] this user has more sessions than the sweep considers, so some ' + + 'sessions keeping a claim on the organization they were just removed from were NOT ' + + 'visited. They are not a security exposure — the per-request membership check ' + + '(#15409) drops the stale claim on their next request — but the courtesy this trigger ' + + 'provides did not reach them. Remedy: raise maxSessionScan.', + { object: SystemObjectName.SESSION, userId, maxSessionScan: maxScan }, + ); + } + + const now = Date.now(); + const affected = sessions + .slice(0, maxScan) + .filter((s) => isLiveSession(s, now)) + .filter((s) => idOf(s.active_organization_id) === endedOrg); + + // ── 3. Act on the CLAIM, never on the user ────────────────────────────── + for (const session of affected) { + const sessionId = idOf(session.id); + if (!sessionId) continue; + const from = idOf(session.active_organization_id); + + if (holdsAnother) { + // ⛔ Not a revocation: this person legitimately belongs somewhere else. + // `repointTo` is null when every remaining membership carries a null + // organization id (single-org mode) — clearing the claim is then the + // "or cleared" half of the ruling, and the resolver treats a session + // with no active organization as an existing, well-defined state. + try { + await engine.update( + SystemObjectName.SESSION, + { id: sessionId, active_organization_id: repointTo }, + { context: SYSTEM_CTX }, + ); + outcomes.push({ action: 'repointed', sessionId, from, to: repointTo }); + } catch (e) { + outcomes.push({ action: 'failed', sessionId, intended: 'repointed' }); + logger?.warn( + '[MembershipEndedSession] a session still naming the organization its owner was just ' + + 'removed from was NOT re-pointed — the removal itself succeeded, so nothing looks ' + + 'wrong. The session keeps a stale `active_organization_id`. This is NOT an access ' + + 'exposure: the per-request membership check (#15409) resolves that claim to no ' + + 'active organization on the very next request. What is lost is the courtesy — the ' + + 'user is not switched to an organization they do still belong to. Remedy: make the ' + + 'sys_session update land; check write permission on `active_organization_id`.', + { object: SystemObjectName.SESSION, sessionId, userId, error: (e as Error)?.message }, + ); + } + continue; + } + + // No membership left anywhere ⇒ nothing to be signed into. Revoke through + // the EXISTING mechanism, in the shape the automatic controls write. + try { + await engine.update( + SystemObjectName.SESSION, + { + id: sessionId, + // A second in the past, matching `enforceSessionControls` / + // `enforceConcurrentCap`, so every `expiresAt < now` liveness check + // in better-auth is strictly true even at millisecond resolution. + expires_at: new Date(now - 1000), + revoked_at: new Date(now), + revoke_reason: MEMBERSHIP_ENDED_REVOKE_REASON, + }, + { context: SYSTEM_CTX }, + ); + outcomes.push({ action: 'revoked', sessionId, from }); + } catch (e) { + outcomes.push({ action: 'failed', sessionId, intended: 'revoked' }); + logger?.warn( + '[MembershipEndedSession] the session of a user whose LAST membership just ended was ' + + 'NOT revoked — the removal itself succeeded, so nothing looks wrong, and the admin ' + + 'who clicked "Remove member" believes that person was signed out. They are still ' + + 'signed in, for up to the session\'s remaining lifetime. This is NOT an access ' + + 'exposure: with no backing membership the per-request check (#15409) already ' + + 'resolves them to no active organization. Remedy: make the sys_session update land ' + + '— check write permission on `expires_at` / `revoked_at` / `revoke_reason`.', + { object: SystemObjectName.SESSION, sessionId, userId, error: (e as Error)?.message }, + ); + } + } + } catch (e) { + // The LOOKUP half failing. Same posture: never break the removal, never be + // silent about a control that did not run. + logger?.warn( + '[MembershipEndedSession] the membership-ended session sweep did not run to completion — ' + + 'the member removal succeeded, so nothing looks wrong. Sessions holding a claim on the ' + + 'organization the membership ended in were neither re-pointed nor revoked. This is NOT ' + + 'an access exposure — #15409\'s per-request check covers every removal path by ' + + 'construction — but the sign-out an admin expects did not happen. Remedy: this is the ' + + 'READ half, so check driver connectivity and read access on sys_member / sys_session.', + { object: SystemObjectName.MEMBER, userId, error: (e as Error)?.message }, + ); + } + + return outcomes; +} + +/** + * Bind the trigger to an ObjectQL engine. + * + * TWO hooks, because the census measured two ways a membership ends: + * + * - `afterDelete` on `sys_member` — the row is gone (better-auth's + * `/organization/remove-member`, a direct delete, a bulk delete, the cascade + * from a `sys_user` delete); + * - `afterUpdate` on `sys_member` — the row survives but its + * `organization_id` moved, which ends the membership in the organization it + * left just as surely. + * + * Both are AFTER hooks on purpose: a membership the platform refused to remove + * (`last-admin-guard`'s `beforeDelete` at priority 20) must not have its + * sessions touched. + * + * Idempotent per package, like the sibling guards: a caller re-binding after a + * hot reload runs `unregisterHooksByPackage(packageId)` first. + */ +export function registerMembershipEndedSessionTrigger( + engine: MembershipEndedSessionEngine, + opts: MembershipEndedSessionOptions, +): void { + const { packageId, logger } = opts; + const maxSessionScan = opts.maxSessionScan ?? DEFAULT_MAX_SESSION_SCAN; + + const onDelete = async (ctx: any): Promise => { + if (ctx?.object !== SystemObjectName.MEMBER) return; + // A delete carries no payload, so `previous` is the only source for WHO and + // WHERE — the engine binds it for `afterDelete` precisely for this. + const previous = ctx?.previous; + const userId = idOf(previous?.user_id); + if (!userId) return; + await endSessionClaimsForEndedMembership( + engine, + { userId, organizationId: idOf(previous?.organization_id), memberRowId: idOf(ctx?.input?.id) ?? undefined }, + { logger, maxSessionScan }, + ); + }; + + const onUpdate = async (ctx: any): Promise => { + if (ctx?.object !== SystemObjectName.MEMBER) return; + const data = ctx?.input?.data as Record | undefined; + // Only a write that MOVED the organization ends a membership. A role + // change does not — the person is still in the room. + if (!data || !('organization_id' in data)) return; + const previous = ctx?.previous; + const before = idOf(previous?.organization_id); + const after = idOf(data.organization_id); + if (before === after) return; + const userId = idOf(previous?.user_id) ?? idOf((data as any).user_id); + if (!userId) return; + await endSessionClaimsForEndedMembership( + engine, + { userId, organizationId: before, memberRowId: idOf(ctx?.input?.id) ?? undefined }, + { logger, maxSessionScan }, + ); + }; + + if (packageId && typeof engine.unregisterHooksByPackage === 'function') { + try { + engine.unregisterHooksByPackage(packageId); + } catch { + /* first bind — nothing registered yet */ + } + } + + // Default priority: this is an AFTER hook that writes a different table, so + // it has no ordering relationship with the guards at 5 / 10 / 20. + engine.registerHook('afterDelete', onDelete, { object: SystemObjectName.MEMBER, packageId }); + engine.registerHook('afterUpdate', onUpdate, { object: SystemObjectName.MEMBER, packageId }); + + logger?.info?.( + '[MembershipEndedSession] membership-ended session trigger registered on sys_member ' + + '(delete + organization re-point) — #15784, the COURTESY half of #15409. ' + + 'The per-request membership check remains the enforcement.', + ); +} diff --git a/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts b/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts new file mode 100644 index 0000000000..83c6f45f27 --- /dev/null +++ b/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15784] "Remove member" actually signs the person out — the COURTESY half of + * #15409's ruling, proven at better-auth's OWN endpoint. + * + * ⛔ **This file does not test the enforcement.** #15409's per-request check in + * `resolve-authz-context.ts` is the wall and is pinned elsewhere + * (`packages/core/src/security/resolve-authz-context.test.ts`, + * `packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts`). + * What is proven here is the product behaviour an admin was promised: the + * session stops naming the organization the person was just removed from. + * + * Ruled shape (maintainer, decision batch #49 item 4, option B — act on the + * ORGANIZATION'S CLAIM, never on the user): + * + * - user holds ANOTHER membership → the claim is re-pointed; ⛔ NOT signed out + * - user holds NO membership → the session is revoked, and the next + * request is unauthenticated + * + * Why the endpoint and not the seam (#3106): a trigger that works when called + * directly proves nothing about whether the route crosses it. The census on + * #15784 measured which writers reach the seam; this file proves the one the + * card names arrives there and produces the ruled outcome. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MEMBERSHIP_ENDED_REVOKE_REASON } from '@objectstack/plugin-auth'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The sign-up reconciler's membership row lands asynchronously. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 60; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('#15784: a membership that ends takes the session\'s claim on that organization with it', () => { + let stack: VerifyStack; + let ql: any; + let adminToken: string; + let orgId: string; + let partnerOrgId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + const org = await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX }); + orgId = String(org.id); + const partner = await ql.insert('sys_organization', { name: 'Partner Organization', slug: 'partner' }, { context: SYSTEM_CTX }); + partnerOrgId = String(partner.id); + + // The dev admin predates the org rows — give them the owner membership the + // single-org bootstrap would have, in both orgs, so remove-member is + // authorized in either. + const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const adminUserId = String(adminUser.id); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); + if (adminMembers.length > 0) { + await ql.update('sys_member', { id: adminMembers[0].id, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } else { + await ql.insert('sys_member', { user_id: adminUserId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } + await ql.insert('sys_member', { user_id: adminUserId, organization_id: partnerOrgId, role: 'owner' }, { context: SYSTEM_CTX }); + }, 240_000); + + afterAll(async () => { await stack?.stop?.(); }); + + /** + * A signed-up fixture: their user row, their reconciled membership (moved to + * `org`), and their live session with `active_organization_id` naming it. + * + * The active-organization stamp is applied here rather than assumed: what + * this file is about is a session that CLAIMS an organization, and the claim + * has to be on the row for the trigger to have anything to act on. + */ + async function fixture(tag: string, org: string): Promise<{ + userId: string; memberId: string; token: string; sessionId: string; + }> { + const email = `m15784.${tag}@example.com`; + const token = await stack.signUp(email, 'Member!Pass123', `Member ${tag}`); + const [u] = await findRows(ql, 'sys_user', { email }, 1); + const userId = String(u.id); + const member = await waitForMembership(ql, userId); + await ql.update('sys_member', { id: member.id, organization_id: org }, { context: SYSTEM_CTX }); + const sessions = await findRows(ql, 'sys_session', { user_id: userId }, 5); + expect(sessions.length, `${tag} should have a live session`).toBeGreaterThan(0); + const sessionId = String(sessions[0].id); + await ql.update('sys_session', { id: sessionId, active_organization_id: org }, { context: SYSTEM_CTX }); + return { userId, memberId: String(member.id), token, sessionId }; + } + + async function session(sessionId: string): Promise { + const [row] = await findRows(ql, 'sys_session', { id: sessionId }, 1); + return row; + } + + async function removeMember(memberId: string, organizationId: string): Promise { + return stack.apiAs(adminToken, 'POST', '/auth/organization/remove-member', { + memberIdOrEmail: memberId, + organizationId, + }); + } + + // ── W1: the acceptance case — no membership left ⇒ revoked ─────────────── + + it('a removed member with NO remaining membership has their session revoked, and the next request is unauthenticated', async () => { + const sole = await fixture('sole', orgId); + // Two controls stood up alongside, so "unaffected" is measured on the same + // run rather than asserted about a different one. + const elsewhere = await fixture('elsewhere', partnerOrgId); // control 1: a different org + const intact = await fixture('intact', orgId); // control 2: still a member + + const res = await removeMember(sole.memberId, orgId); + expect(res.status, await res.clone().text()).toBe(200); + + // The membership really ended. + expect(await findRows(ql, 'sys_member', { id: sole.memberId }, 1)).toHaveLength(0); + + // The session is revoked THROUGH THE EXISTING MECHANISM, with the new + // event-driven reason — not deleted, so the audit trail survives. + const revoked = await session(sole.sessionId); + expect(revoked, 'the session row is a tombstone, not a deletion').toBeTruthy(); + expect(revoked.revoked_at).toBeTruthy(); + expect(revoked.revoke_reason).toBe(MEMBERSHIP_ENDED_REVOKE_REASON); + expect(new Date(revoked.expires_at).getTime()).toBeLessThan(Date.now()); + + // The whole point: better-auth returns nothing on the next request, which + // is what the Console's existing 401 -> login redirect keys off. No client + // change is involved in this assertion. + const after = await stack.apiAs(sole.token, 'GET', '/auth/get-session'); + const body = await after.clone().text(); + expect( + after.status === 401 || body === 'null' || body === '' || body === '{}', + `expected an unauthenticated answer, got ${after.status} ${body.slice(0, 200)}`, + ).toBe(true); + + // ── CONTROL 1 — a member of a DIFFERENT organization is unaffected ────── + const other = await session(elsewhere.sessionId); + expect(other.revoked_at ?? null).toBeNull(); + expect(String(other.active_organization_id)).toBe(partnerOrgId); + + // ── CONTROL 2 — an intact member's session is untouched ──────────────── + const kept = await session(intact.sessionId); + expect(kept.revoked_at ?? null).toBeNull(); + expect(String(kept.active_organization_id)).toBe(orgId); + }, 180_000); + + // ── W2: the ruled half the option text was silent about ────────────────── + + it('a removed member who still holds ANOTHER membership is RE-POINTED, never signed out', async () => { + const dual = await fixture('dual', orgId); + // A second, legitimate membership — the one option A would have signed + // them out of. + await ql.insert('sys_member', { user_id: dual.userId, organization_id: partnerOrgId, role: 'member' }, { context: SYSTEM_CTX }); + + const res = await removeMember(dual.memberId, orgId); + expect(res.status, await res.clone().text()).toBe(200); + + const row = await session(dual.sessionId); + // ⛔ NOT revoked: this person legitimately belongs to the partner org. + expect(row.revoked_at ?? null).toBeNull(); + expect(row.revoke_reason ?? null).toBeNull(); + // The claim moved to a membership they still hold. + expect(String(row.active_organization_id)).toBe(partnerOrgId); + + // And the session still authenticates — the courtesy did not become a + // punishment. + const after = await stack.apiAs(dual.token, 'GET', '/auth/get-session'); + expect(after.status).toBe(200); + expect((await after.clone().text()).length).toBeGreaterThan(2); + }, 180_000); + + // ── W3: the OTHER measured removal shape — an organization re-point ────── + + it('a membership re-pointed at another organization ends the claim on the one it left', async () => { + const moved = await fixture('moved', orgId); + await ql.update('sys_member', { id: moved.memberId, organization_id: partnerOrgId }, { context: SYSTEM_CTX }); + + const row = await session(moved.sessionId); + // The row survives (they still hold a membership — the moved one), and the + // claim follows it. + expect(row.revoked_at ?? null).toBeNull(); + expect(String(row.active_organization_id)).toBe(partnerOrgId); + }, 180_000); +}); From 8a7446de84dffc080d30ee42e98ea2f4c6a3aae0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:01:41 +0800 Subject: [PATCH 3/5] test(plugin-auth,dogfood): pin the membership-ended session trigger; regenerate i18n bundles (#15784) Co-Authored-By: Claude Opus 5 --- .changeset/membership-ended-session-revoke.md | 41 +++ .../apps/translations/en.objects.generated.ts | 4 +- .../translations/es-ES.objects.generated.ts | 4 +- .../translations/ja-JP.objects.generated.ts | 4 +- .../translations/zh-CN.objects.generated.ts | 4 +- .../src/membership-ended-session.test.ts | 267 ++++++++++++++++++ .../membership-removal-census.probe.test.ts | 173 ------------ 7 files changed, 316 insertions(+), 181 deletions(-) create mode 100644 .changeset/membership-ended-session-revoke.md create mode 100644 packages/plugins/plugin-auth/src/membership-ended-session.test.ts delete mode 100644 packages/qa/dogfood/test/membership-removal-census.probe.test.ts diff --git a/.changeset/membership-ended-session-revoke.md b/.changeset/membership-ended-session-revoke.md new file mode 100644 index 0000000000..c6bb0a2580 --- /dev/null +++ b/.changeset/membership-ended-session-revoke.md @@ -0,0 +1,41 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/plugin-auth': minor +--- + +`sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out + +Removing a member deleted the `sys_member` row and left the session alive, for up to seven +days. #15409 closed the security half per request (a session whose `activeOrganizationId` +is not backed by a membership resolves with no active organization). This is the courtesy +half an admin was promised, and it is **never the enforcement**: a trigger can be missed, +an evaluation cannot. + +- **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening + on a published system object, hence `minor` on `@objectstack/platform-objects`. Every + reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an + interactive revoke (`user_revoked`, `admin`); this is the first authorization-event + cause. There is no Zod enum behind the column — it is free `text` — so the field's own + description is the published vocabulary, and that is where the value is declared. The + string deliberately matches the one the API-key arm of the same ruling family already + mints for this event (`authRefusal.reason` in `resolve-authz-context.ts`), so one grep + finds every place the platform acts on a membership ending. +- **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling, + decision batch #49 item 4, option B). A user who still holds another membership is + **re-pointed** to it — never signed out of organizations they legitimately belong to. A + user with no remaining membership has their session revoked through the existing + `revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns + nothing on the next request and the Console's existing 401 → login redirect handles it, + with **no client change**. +- **The seam is an engine hook on `sys_member`**, not a hook on better-auth's + `/organization/remove-member`. A census measured that the endpoint, a direct delete, a + bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach + the hook, while an endpoint hook would have reached one of them. Same precedent as + `last-admin-guard.ts`. +- **New public surface on `@objectstack/plugin-auth`** — `MEMBERSHIP_ENDED_REVOKE_REASON`, + `endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence + `minor` rather than `patch`. + +Known open by measurement, not by omission: a raw driver delete bypasses the trigger +entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The +per-request check covers it; the courtesy does not. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 61f17e21e7..51ac0b50cc 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -328,11 +328,11 @@ export const enObjects: NonNullable = { }, revoked_at: { label: "Revoked At", - help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed." }, revoke_reason: { label: "Revoke Reason", - help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)." }, active_organization_id: { label: "Active Organization" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 83f8108d2a..185568c735 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -328,11 +328,11 @@ export const esESObjects: NonNullable = { }, revoked_at: { label: "Revoked At", - help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed." }, revoke_reason: { label: "Revoke Reason", - help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)." }, active_organization_id: { label: "Organización activa" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index fd43d37bde..dd42028ae3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -328,11 +328,11 @@ export const jaJPObjects: NonNullable = { }, revoked_at: { label: "Revoked At", - help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed." }, revoke_reason: { label: "Revoke Reason", - help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)." }, active_organization_id: { label: "アクティブ組織" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index d2d6fdd5f8..b76408754f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -328,11 +328,11 @@ export const zhCNObjects: NonNullable = { }, revoked_at: { label: "Revoked At", - help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed." + help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed." }, revoke_reason: { label: "Revoke Reason", - help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)." + help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)." }, active_organization_id: { label: "当前组织" diff --git a/packages/plugins/plugin-auth/src/membership-ended-session.test.ts b/packages/plugins/plugin-auth/src/membership-ended-session.test.ts new file mode 100644 index 0000000000..6209f400cc --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-ended-session.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#15784] The membership-ended session trigger, branch by branch. +// +// The END-TO-END proof lives in `qa/dogfood/membership-ended-session-revoke`, +// driven through better-auth's own `/organization/remove-member` — a seam that +// works when called directly proves nothing about whether the route crosses it +// (#3106). What this file covers is the half a route test cannot reach +// cheaply: the decision table, the shapes that must be NO-OPS, the bounded +// scan, and the two failure paths, which must degrade loudly and never throw. + +import { describe, it, expect, vi } from 'vitest'; +import { + MEMBERSHIP_ENDED_REVOKE_REASON, + endSessionClaimsForEndedMembership, + registerMembershipEndedSessionTrigger, + type MembershipEndedSessionEngine, +} from './membership-ended-session'; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const USER = 'user_1'; + +type Row = Record; + +/** + * A find/update double over two tables. + * + * `find` honours only `where.user_id` and `where.id`, which is every predicate + * this module issues — a double that answered more would be pretending to be an + * engine, and one that answered less would let a wrong query pass. + */ +function engineDouble(members: Row[], sessions: Row[], opts: { failUpdate?: boolean; failFind?: boolean } = {}) { + const updates: Row[] = []; + const tables: Record = { sys_member: members, sys_session: sessions }; + const engine: MembershipEndedSessionEngine & { updates: Row[] } = { + updates, + async find(object: string, query: any) { + if (opts.failFind) throw new Error('driver unreachable'); + const rows = tables[object] ?? []; + const where = query?.where ?? {}; + return rows.filter((r) => Object.entries(where).every(([k, v]) => String(r[k] ?? '') === String(v ?? ''))); + }, + async update(object: string, data: any) { + if (opts.failUpdate) throw new Error('write refused'); + updates.push({ object, ...data }); + const rows = tables[object] ?? []; + const row = rows.find((r) => String(r.id) === String(data.id)); + if (row) Object.assign(row, data); + return row; + }, + registerHook() { /* not used by the direct-call tests */ }, + }; + return engine; +} + +const liveSession = (id: string, org: string | null): Row => ({ + id, user_id: USER, active_organization_id: org, expires_at: new Date(Date.now() + 86_400_000), revoked_at: null, +}); + +describe('#15784 endSessionClaimsForEndedMembership — the ruled decision table (option B)', () => { + it('NO remaining membership: the session is REVOKED through the existing mechanism', async () => { + const engine = engineDouble([], [liveSession('s1', ORG_A)]); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }); + + expect(out).toEqual([{ action: 'revoked', sessionId: 's1', from: ORG_A }]); + const write = engine.updates.at(-1)!; + expect(write.object).toBe('sys_session'); + expect(write.revoke_reason).toBe(MEMBERSHIP_ENDED_REVOKE_REASON); + expect(write.revoked_at).toBeInstanceOf(Date); + // Expired IN PLACE and strictly in the past, so every `expiresAt < now` + // liveness check in better-auth is true even at millisecond resolution. + expect((write.expires_at as Date).getTime()).toBeLessThan(Date.now()); + // ⛔ Not a delete: the tombstone is the audit record. + expect(engine.updates.every((u) => u.object === 'sys_session')).toBe(true); + }); + + it('ANOTHER membership remains: the claim is RE-POINTED and the session is NOT revoked', async () => { + const engine = engineDouble( + [{ id: 'm2', user_id: USER, organization_id: ORG_B, created_at: '2026-01-01T00:00:00Z' }], + [liveSession('s1', ORG_A)], + ); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }); + + expect(out).toEqual([{ action: 'repointed', sessionId: 's1', from: ORG_A, to: ORG_B }]); + const write = engine.updates.at(-1)!; + expect(write.active_organization_id).toBe(ORG_B); + // ⛔ Option A's shape, explicitly refused by the ruling: nothing about the + // revocation columns is written for a person who still belongs somewhere. + expect('revoked_at' in write).toBe(false); + expect('revoke_reason' in write).toBe(false); + expect('expires_at' in write).toBe(false); + }); + + it('the re-point target is the OLDEST remaining membership, so it is deterministic', async () => { + const engine = engineDouble( + [ + { id: 'm3', user_id: USER, organization_id: 'org_new', created_at: '2026-06-01T00:00:00Z' }, + { id: 'm2', user_id: USER, organization_id: ORG_B, created_at: '2026-01-01T00:00:00Z' }, + ], + [liveSession('s1', ORG_A)], + ); + await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }); + expect(engine.updates.at(-1)!.active_organization_id).toBe(ORG_B); + }); + + it('memberships remain but none carries an organization id: the claim is CLEARED, not revoked', async () => { + // Single-org mode — `sys_member.organization_id` is null there. The ruling's + // "cleared, or re-pointed": they still hold a membership, so they keep the + // session. + const engine = engineDouble( + [{ id: 'm2', user_id: USER, organization_id: null, created_at: '2026-01-01T00:00:00Z' }], + [liveSession('s1', ORG_A)], + ); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }); + expect(out).toEqual([{ action: 'repointed', sessionId: 's1', from: ORG_A, to: null }]); + expect(engine.updates.at(-1)!.active_organization_id).toBeNull(); + }); +}); + +describe('#15784 the shapes that must be NO-OPS — acting on the claim, never on the user', () => { + it('a session claiming a DIFFERENT organization is untouched', async () => { + const engine = engineDouble([], [liveSession('s_other', ORG_B)]); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }); + expect(out).toEqual([]); + expect(engine.updates).toEqual([]); + }); + + it('an ALREADY-revoked session is not re-stamped, so it keeps the revocation it records', async () => { + const engine = engineDouble([], [{ + id: 's_dead', user_id: USER, active_organization_id: ORG_A, + expires_at: new Date(Date.now() - 1000), revoked_at: new Date('2026-01-01T00:00:00Z'), + }]); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }); + expect(out).toEqual([]); + expect(engine.updates).toEqual([]); + }); + + it('an already-EXPIRED session is not touched either', async () => { + const engine = engineDouble([], [{ + id: 's_old', user_id: USER, active_organization_id: ORG_A, + expires_at: new Date(Date.now() - 60_000), revoked_at: null, + }]); + expect(await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A })).toEqual([]); + }); + + it('a membership ending with no user id does nothing at all', async () => { + const engine = engineDouble([], [liveSession('s1', ORG_A)]); + expect(await endSessionClaimsForEndedMembership(engine, { userId: '', organizationId: ORG_A })).toEqual([]); + expect(engine.updates).toEqual([]); + }); +}); + +describe('#15784 degradation — best-effort, and never silent', () => { + it('a refused session WRITE is reported, names the #15409 backstop, and does not throw', async () => { + const warn = vi.fn(); + const engine = engineDouble([], [liveSession('s1', ORG_A)], { failUpdate: true }); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }, { logger: { warn } }); + + expect(out).toEqual([{ action: 'failed', sessionId: 's1', intended: 'revoked' }]); + expect(warn).toHaveBeenCalledTimes(1); + const [message] = warn.mock.calls[0]; + // The operator has to learn BOTH halves: what did not happen, and that it + // is not an access exposure — otherwise this reads as a security incident. + expect(message).toContain('NOT revoked'); + expect(message).toContain('#15409'); + expect(message).toContain('Remedy'); + }); + + it('a failed READ is reported once and swallowed — a member removal must not 500 on this', async () => { + const warn = vi.fn(); + const engine = engineDouble([], [liveSession('s1', ORG_A)], { failFind: true }); + const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }, { logger: { warn } }); + + expect(out).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('did not run to completion'); + }); + + it('more sessions than the scan ceiling is reported, and the ones in reach are still handled', async () => { + const warn = vi.fn(); + const sessions = Array.from({ length: 4 }, (_, i) => liveSession(`s${i}`, ORG_A)); + const engine = engineDouble([], sessions); + const out = await endSessionClaimsForEndedMembership( + engine, { userId: USER, organizationId: ORG_A }, { logger: { warn }, maxSessionScan: 2 }, + ); + expect(out).toHaveLength(2); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('more sessions than the sweep considers'); + }); +}); + +describe('#15784 registration — which writes the trigger listens for', () => { + function recordingEngine() { + const hooks: Array<{ event: string; handler: (ctx: any) => any; options: any }> = []; + const engine: any = { + hooks, + find: async () => [], + update: async () => undefined, + registerHook(event: string, handler: any, options: any) { hooks.push({ event, handler, options }); }, + unregisterHooksByPackage: vi.fn(), + }; + return engine; + } + + it('binds afterDelete and afterUpdate on sys_member — and nothing on sys_session', () => { + const engine = recordingEngine(); + registerMembershipEndedSessionTrigger(engine, { packageId: 'p' }); + expect(engine.hooks.map((h: any) => `${h.event}:${h.options.object}`)).toEqual([ + 'afterDelete:sys_member', + 'afterUpdate:sys_member', + ]); + // AFTER hooks on purpose: a removal `last-admin-guard` refuses at + // `beforeDelete` must not have its sessions touched. + expect(engine.hooks.every((h: any) => h.event.startsWith('after'))).toBe(true); + expect(engine.unregisterHooksByPackage).toHaveBeenCalledWith('p'); + }); + + it('a ROLE change is not a membership ending — the update hook ignores it', async () => { + const engine = recordingEngine(); + const seen: any[] = []; + engine.find = async (object: string) => { seen.push(object); return []; }; + registerMembershipEndedSessionTrigger(engine, { packageId: 'p' }); + const onUpdate = engine.hooks.find((h: any) => h.event === 'afterUpdate')!.handler; + + await onUpdate({ + object: 'sys_member', + input: { id: 'm1', data: { role: 'admin' } }, + previous: { user_id: USER, organization_id: ORG_A, role: 'member' }, + }); + // Not one read was issued: the person is still in the room. + expect(seen).toEqual([]); + }); + + it('an organization RE-POINT is a membership ending in the organization it left', async () => { + const engine = recordingEngine(); + const queried: any[] = []; + engine.find = async (object: string, query: any) => { queried.push({ object, query }); return []; }; + registerMembershipEndedSessionTrigger(engine, { packageId: 'p' }); + const onUpdate = engine.hooks.find((h: any) => h.event === 'afterUpdate')!.handler; + + await onUpdate({ + object: 'sys_member', + input: { id: 'm1', data: { organization_id: ORG_B } }, + previous: { user_id: USER, organization_id: ORG_A }, + }); + expect(queried.length).toBeGreaterThan(0); + expect(queried[0].query.where).toEqual({ user_id: USER }); + }); + + it('a delete reads WHO and WHERE from `previous` — a delete carries no payload', async () => { + const engine = recordingEngine(); + const queried: any[] = []; + engine.find = async (object: string, query: any) => { queried.push({ object, query }); return []; }; + registerMembershipEndedSessionTrigger(engine, { packageId: 'p' }); + const onDelete = engine.hooks.find((h: any) => h.event === 'afterDelete')!.handler; + + await onDelete({ object: 'sys_member', input: { id: 'm1' }, previous: { user_id: USER, organization_id: ORG_A } }); + expect(queried[0]).toEqual({ object: 'sys_member', query: expect.objectContaining({ where: { user_id: USER } }) }); + + // No `previous` (a driver-level write the engine never saw a pre-image for) + // is a no-op, not a crash. + queried.length = 0; + await onDelete({ object: 'sys_member', input: { id: 'm2' } }); + expect(queried).toEqual([]); + }); +}); diff --git a/packages/qa/dogfood/test/membership-removal-census.probe.test.ts b/packages/qa/dogfood/test/membership-removal-census.probe.test.ts deleted file mode 100644 index d2b58ed5ba..0000000000 --- a/packages/qa/dogfood/test/membership-removal-census.probe.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// TEMPORARY census probe for #15784 scope item 1 — NOT a shipped test. -// Registers a probe on the candidate seam and drives every enumerated -// membership-removal path, recording which reach it. - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; - -const SYSTEM_CTX = { isSystem: true }; -const PROBE_PKG = 'census.15784.probe'; - -type Firing = { event: string; id: unknown; user_id?: unknown; organization_id?: unknown }; - -async function findRows(ql: any, object: string, where: any, limit = 50): Promise { - const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); - return Array.isArray(rows) ? rows : (rows?.records ?? []); -} - -async function waitForMembership(ql: any, userId: string): Promise { - for (let i = 0; i < 60; i++) { - const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); - if (rows.length > 0) return rows[0]; - await new Promise((r) => setTimeout(r, 250)); - } - throw new Error(`no sys_member row appeared for ${userId}`); -} - -describe('#15784 census probe: which membership-removal writers reach an engine hook on sys_member', () => { - let stack: VerifyStack; - let ql: any; - let driver: any; - let orgId: string; - let partnerOrgId: string; - let adminToken: string; - let adminUserId: string; - const fired: Firing[] = []; - const report: string[] = []; - - beforeAll(async () => { - stack = await bootStack(showcaseStack); - adminToken = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - // The engine's own driver for sys_member — `getDriver` is TS-private, not - // runtime-private, and this probe deliberately reaches BELOW the engine. - driver = (ql as any).getDriver('sys_member'); - - for (const event of ['beforeDelete', 'afterDelete', 'beforeUpdate', 'afterUpdate'] as const) { - ql.registerHook(event, async (ctx: any) => { - fired.push({ - event, - id: ctx?.input?.id, - user_id: ctx?.previous?.user_id, - organization_id: ctx?.previous?.organization_id, - }); - }, { object: 'sys_member', packageId: PROBE_PKG, priority: 500 }); - } - - const org = await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX }); - orgId = String(org.id); - const partner = await ql.insert('sys_organization', { name: 'Partner Organization', slug: 'partner' }, { context: SYSTEM_CTX }); - partnerOrgId = String(partner.id); - - const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); - adminUserId = String(adminUser.id); - const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); - if (adminMembers.length > 0) { - await ql.update('sys_member', { id: adminMembers[0].id, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); - } else { - await ql.insert('sys_member', { user_id: adminUserId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); - } - await ql.insert('sys_member', { user_id: adminUserId, organization_id: partnerOrgId, role: 'owner' }, { context: SYSTEM_CTX }); - }, 240_000); - - afterAll(async () => { - console.log('\n===== #15784 CENSUS PROBE REPORT ====='); - for (const l of report) console.log(l); - console.log('===== END =====\n'); - await stack?.stop?.(); - }); - - async function newMember(tag: string): Promise<{ userId: string; memberId: string }> { - const email = `census.${tag}.15784@example.com`; - await stack.signUp(email, 'Member!Pass123', `Census ${tag}`); - const [u] = await findRows(ql, 'sys_user', { email }, 1); - const m = await waitForMembership(ql, String(u.id)); - return { userId: String(u.id), memberId: String(m.id) }; - } - - function since(mark: number): Firing[] { return fired.slice(mark); } - - // ── PATH 0 — THE FIRING CONTROL ─────────────────────────────────────────── - it('CONTROL: a direct engine delete fires the probe (if this fails, nothing below is a reading)', async () => { - const { memberId } = await newMember('control'); - const mark = fired.length; - await ql.delete('sys_member', { where: { id: memberId }, context: SYSTEM_CTX }); - const seen = since(mark); - report.push(`PATH 0 engine.delete (direct) -> ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); - expect(seen.some((f) => f.event === 'afterDelete')).toBe(true); - }, 120_000); - - // ── PATH 1 — better-auth's own remove-member endpoint ───────────────────── - it('better-auth POST /organization/remove-member', async () => { - const { userId, memberId } = await newMember('removemember'); - await ql.update('sys_member', { id: memberId, organization_id: orgId }, { context: SYSTEM_CTX }); - const mark = fired.length; - const res = await stack.apiAs(adminToken, 'POST', '/auth/organization/remove-member', { - memberIdOrEmail: memberId, - organizationId: orgId, - }); - const body = await res.clone().text(); - const seen = since(mark); - const gone = (await findRows(ql, 'sys_member', { id: memberId }, 1)).length === 0; - report.push(`PATH 1 better-auth /organization/remove-member -> HTTP ${res.status}; row gone=${gone}; ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); - if (res.status !== 200) report.push(` (body: ${body.slice(0, 200)})`); - expect(userId).toBeTruthy(); - }, 120_000); - - // ── PATH 2 — a bulk / multi engine delete ──────────────────────────────── - it('engine multi delete (bulk operation)', async () => { - const { memberId } = await newMember('bulk'); - await ql.update('sys_member', { id: memberId, organization_id: partnerOrgId }, { context: SYSTEM_CTX }); - const mark = fired.length; - await ql.delete('sys_member', { where: { id: memberId }, multi: true, context: SYSTEM_CTX }); - const seen = since(mark); - report.push(`PATH 2 engine.delete multi:true -> ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); - }, 120_000); - - // ── PATH 3 — cascade from a sys_user delete ────────────────────────────── - it('cascade: deleting the sys_user row takes its memberships', async () => { - const { userId, memberId } = await newMember('cascade'); - // `sys_session.user_id` / `sys_account.user_id` are REQUIRED lookups that - // default to `restrict`, so a signed-up user cannot be deleted while those - // rows stand. Clear them first: this path measures the sys_member cascade. - for (const child of ['sys_session', 'sys_account']) { - for (const r of await findRows(ql, child, { user_id: userId }, 20)) { - await ql.delete(child, { where: { id: r.id }, context: SYSTEM_CTX }); - } - } - const mark = fired.length; - let err: string | undefined; - try { - await ql.delete('sys_user', { where: { id: userId }, context: SYSTEM_CTX }); - } catch (e: any) { err = e?.message ?? String(e); } - const seen = since(mark); - const gone = (await findRows(ql, 'sys_member', { id: memberId }, 1)).length === 0; - report.push(`PATH 3 cascade via sys_user delete -> row gone=${gone}; ${seen.map((f) => f.event).join(',') || 'NOTHING'}${err ? `; refused: ${err.slice(0, 120)}` : ''}`); - }, 120_000); - - // ── PATH 4 — a RAW DRIVER delete (the cloud package-uninstall shape) ───── - it('raw driver delete bypasses the engine entirely', async () => { - const { memberId } = await newMember('rawdriver'); - const mark = fired.length; - let err: string | undefined; - try { - await driver.delete('sys_member', memberId); - } catch (e: any) { err = e?.message ?? String(e); } - const seen = since(mark); - const gone = (await findRows(ql, 'sys_member', { id: memberId }, 1)).length === 0; - report.push(`PATH 4 driver.delete (raw, hooks bypassed) -> row gone=${gone}; ${seen.map((f) => f.event).join(',') || 'NOTHING'}${err ? `; threw: ${err.slice(0, 120)}` : ''}`); - }, 120_000); - - // ── PATH 5 — INVALIDATE: re-point organization_id ──────────────────────── - it('invalidate: the membership row is re-pointed at another organization', async () => { - const { memberId } = await newMember('repoint'); - await ql.update('sys_member', { id: memberId, organization_id: orgId }, { context: SYSTEM_CTX }); - const mark = fired.length; - await ql.update('sys_member', { id: memberId, organization_id: partnerOrgId }, { context: SYSTEM_CTX }); - const seen = since(mark); - report.push(`PATH 5 engine.update re-point organization_id -> ${seen.map((f) => f.event).join(',') || 'NOTHING'}`); - }, 120_000); -}); From 24536a5378bfadad9b5344dc609f88cbe21419f9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:09:00 +0800 Subject: [PATCH 4/5] fix(plugin-auth,docs): strip tracker ids from runtime prose, make the test double judgeable, re-derive the two censuses (#15784) Co-Authored-By: Claude Opus 5 --- content/docs/permissions/system-context.mdx | 2 +- .../docs/permissions/tenant-audit-census.mdx | 32 +++++----- ...08-tenant-audit-write-call-sites.counts.md | 17 +++--- .../src/membership-ended-session.test.ts | 61 +++++++++++++++---- .../src/membership-ended-session.ts | 11 ++-- 5 files changed, 82 insertions(+), 41 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6d1f6564ca..12e7441e93 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1427` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index c51b36edd8..936abdd9de 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 219 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 221 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **219** | +| 175 write call sites | quoted in the merged changeset | **221** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **147 of 219** decidable, **72** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 101 decidably elevated, 0 decidably not, 101 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **147 of 221** decidable, **74** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 103 decidably elevated, 0 decidably not, 101 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -167,11 +167,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -101 of 219 (46%) as decidably elevated, with 101 more whose elevation is a +103 of 221 (47%) as decidably elevated, with 101 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 219`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 221`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -183,23 +183,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **219** | +| write call sites on the application surface | **221** | | …whose object name is statically decidable | 147 | -| …whose object name is chosen at run time | 72 | +| …whose object name is chosen at run time | 74 | | …against an object with tenancy ENABLED | 147 | | …against an object that declares tenancy off | 0 | -| threading a tenant context | 135 | +| threading a tenant context | 137 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 101 | +| threading a decidably ELEVATED (`isSystem`) context | 103 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 101 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 174 | +| receiver carried a readable engine type | 176 | | receiver erased, placed by the object NAME | 19 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 | @@ -207,7 +207,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 108 | | object name spelled through a `const` | 39 | | object name is an `object: string` parameter | 19 | -| object name is some other run-time expression | 53 | +| object name is some other run-time expression | 55 | The corpus walked is every tracked non-test source under `packages/services/` and `packages/plugins/`; calls to a same-named method on something that is not @@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-03 at `631038b03`. +Measured on 2026-09-05 at `8a7446de8`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 542 | -| engine-shaped types recognised | 57 | +| tracked non-test sources scanned | 547 | +| engine-shaped types recognised | 58 | | declared objects in the registry | 298 | -| same-named calls subtracted as non-engine | 130 | +| same-named calls subtracted as non-engine | 134 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index a831460e8d..4b34f0ff2c 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 219 | +| Write call sites | 221 | | Object name statically decidable | 147 | -| Object name chosen at run time | 72 | +| Object name chosen at run time | 74 | | Against a tenancy-enabled object | 147 | | Against an object declaring tenancy off | 0 | -| Threading a tenant context | 135 | +| Threading a tenant context | 137 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 101 | +| Threading a decidably elevated context | 103 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 101 | @@ -52,14 +52,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-03 at `631038b03`. +Measured on 2026-09-05 at `8a7446de8`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 542 | -| engine-shaped types recognised | 57 | +| tracked non-test sources scanned | 547 | +| engine-shaped types recognised | 58 | | declared objects in the registry | 298 | -| same-named calls subtracted as non-engine | 130 | +| same-named calls subtracted as non-engine | 134 | ## Every site @@ -92,6 +92,7 @@ Measured on 2026-09-03 at `631038b03`. | `packages/plugins/plugin-auth/src/backfill-account-issuer.ts` | `update` | `sys_account` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `m` | undecidable | options unreadable | 1 | diff --git a/packages/plugins/plugin-auth/src/membership-ended-session.test.ts b/packages/plugins/plugin-auth/src/membership-ended-session.test.ts index 6209f400cc..6de8e16192 100644 --- a/packages/plugins/plugin-auth/src/membership-ended-session.test.ts +++ b/packages/plugins/plugin-auth/src/membership-ended-session.test.ts @@ -23,26 +23,53 @@ const USER = 'user_1'; type Row = Record; +/** + * The WHERE matcher, at MODULE scope and pure, so both conformance gates can + * lift it and judge it — a matcher that closes over its fixtures is unjudgeable, + * which is a worse answer than a wrong one. + * + * It honours only equality on a field name, which is every predicate this module + * issues, and REFUSES everything else loudly. A double that reads a combinator + * as a field name answers a question nobody asked, silently. + */ +function matchesWhere(row: Row, where: Record): boolean { + for (const [key, value] of Object.entries(where)) { + if (key.startsWith('$') || key === 'and' || key === 'or' || key === 'not') { + throw new Error(`engineDouble: unsupported WHERE combinator '${key}' — implement it or stop issuing it`); + } + if (value !== null && typeof value === 'object') { + throw new Error(`engineDouble: unsupported operator object on '${key}' — implement it or stop issuing it`); + } + if (String(row[key] ?? '') !== String(value ?? '')) return false; + } + return true; +} + /** * A find/update double over two tables. * - * `find` honours only `where.user_id` and `where.id`, which is every predicate - * this module issues — a double that answered more would be pretending to be an - * engine, and one that answered less would let a wrong query pass. + * Deliberately no more forgiving than the real engine in the two places a + * forgiving fake would hide the very logic under test: it REFUSES a `where` + * shape it does not implement, and it APPLIES the caller's `limit` — by + * presence, so `limit: 0` returns nothing, and after the filter, so a bound + * never returns rows the predicate excluded. The scan-ceiling test below is + * measuring exactly that bound. + * + * Failure injection is done by REPLACING a method on the returned object, never + * by a flag inside one: a flag read makes the method unliftable, and an + * unjudgeable double is how a fake stops being held to anything. */ -function engineDouble(members: Row[], sessions: Row[], opts: { failUpdate?: boolean; failFind?: boolean } = {}) { +function engineDouble(members: Row[], sessions: Row[]) { const updates: Row[] = []; const tables: Record = { sys_member: members, sys_session: sessions }; const engine: MembershipEndedSessionEngine & { updates: Row[] } = { updates, async find(object: string, query: any) { - if (opts.failFind) throw new Error('driver unreachable'); const rows = tables[object] ?? []; - const where = query?.where ?? {}; - return rows.filter((r) => Object.entries(where).every(([k, v]) => String(r[k] ?? '') === String(v ?? ''))); + const matched = rows.filter((r) => matchesWhere(r, query?.where ?? {})); + return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; }, async update(object: string, data: any) { - if (opts.failUpdate) throw new Error('write refused'); updates.push({ object, ...data }); const rows = tables[object] ?? []; const row = rows.find((r) => String(r.id) === String(data.id)); @@ -54,6 +81,18 @@ function engineDouble(members: Row[], sessions: Row[], opts: { failUpdate?: bool return engine; } +/** The read half refuses — a driver this call cannot reach. */ +function withFailingFind(engine: ReturnType) { + engine.find = async () => { throw new Error('driver unreachable'); }; + return engine; +} + +/** The write half refuses — the row is readable and not writable. */ +function withFailingUpdate(engine: ReturnType) { + engine.update = async () => { throw new Error('write refused'); }; + return engine; +} + const liveSession = (id: string, org: string | null): Row => ({ id, user_id: USER, active_organization_id: org, expires_at: new Date(Date.now() + 86_400_000), revoked_at: null, }); @@ -154,7 +193,7 @@ describe('#15784 the shapes that must be NO-OPS — acting on the claim, never o describe('#15784 degradation — best-effort, and never silent', () => { it('a refused session WRITE is reported, names the #15409 backstop, and does not throw', async () => { const warn = vi.fn(); - const engine = engineDouble([], [liveSession('s1', ORG_A)], { failUpdate: true }); + const engine = withFailingUpdate(engineDouble([], [liveSession('s1', ORG_A)])); const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }, { logger: { warn } }); expect(out).toEqual([{ action: 'failed', sessionId: 's1', intended: 'revoked' }]); @@ -163,13 +202,13 @@ describe('#15784 degradation — best-effort, and never silent', () => { // The operator has to learn BOTH halves: what did not happen, and that it // is not an access exposure — otherwise this reads as a security incident. expect(message).toContain('NOT revoked'); - expect(message).toContain('#15409'); + expect(message).toContain('per-request membership check'); expect(message).toContain('Remedy'); }); it('a failed READ is reported once and swallowed — a member removal must not 500 on this', async () => { const warn = vi.fn(); - const engine = engineDouble([], [liveSession('s1', ORG_A)], { failFind: true }); + const engine = withFailingFind(engineDouble([], [liveSession('s1', ORG_A)])); const out = await endSessionClaimsForEndedMembership(engine, { userId: USER, organizationId: ORG_A }, { logger: { warn } }); expect(out).toEqual([]); diff --git a/packages/plugins/plugin-auth/src/membership-ended-session.ts b/packages/plugins/plugin-auth/src/membership-ended-session.ts index ebb516d382..696080c8e2 100644 --- a/packages/plugins/plugin-auth/src/membership-ended-session.ts +++ b/packages/plugins/plugin-auth/src/membership-ended-session.ts @@ -239,7 +239,7 @@ export async function endSessionClaimsForEndedMembership( '[MembershipEndedSession] this user has more sessions than the sweep considers, so some ' + 'sessions keeping a claim on the organization they were just removed from were NOT ' + 'visited. They are not a security exposure — the per-request membership check ' - + '(#15409) drops the stale claim on their next request — but the courtesy this trigger ' + + 'drops the stale claim on their next request — but the courtesy this trigger ' + 'provides did not reach them. Remedy: raise maxSessionScan.', { object: SystemObjectName.SESSION, userId, maxSessionScan: maxScan }, ); @@ -276,7 +276,8 @@ export async function endSessionClaimsForEndedMembership( '[MembershipEndedSession] a session still naming the organization its owner was just ' + 'removed from was NOT re-pointed — the removal itself succeeded, so nothing looks ' + 'wrong. The session keeps a stale `active_organization_id`. This is NOT an access ' - + 'exposure: the per-request membership check (#15409) resolves that claim to no ' + + 'exposure: the per-request membership check in `resolve-authz-context` resolves ' + + 'that claim to no ' + 'active organization on the very next request. What is lost is the courtesy — the ' + 'user is not switched to an organization they do still belong to. Remedy: make the ' + 'sys_session update land; check write permission on `active_organization_id`.', @@ -310,7 +311,7 @@ export async function endSessionClaimsForEndedMembership( + 'NOT revoked — the removal itself succeeded, so nothing looks wrong, and the admin ' + 'who clicked "Remove member" believes that person was signed out. They are still ' + 'signed in, for up to the session\'s remaining lifetime. This is NOT an access ' - + 'exposure: with no backing membership the per-request check (#15409) already ' + + 'exposure: with no backing membership the per-request membership check already ' + 'resolves them to no active organization. Remedy: make the sys_session update land ' + '— check write permission on `expires_at` / `revoked_at` / `revoke_reason`.', { object: SystemObjectName.SESSION, sessionId, userId, error: (e as Error)?.message }, @@ -324,7 +325,7 @@ export async function endSessionClaimsForEndedMembership( '[MembershipEndedSession] the membership-ended session sweep did not run to completion — ' + 'the member removal succeeded, so nothing looks wrong. Sessions holding a claim on the ' + 'organization the membership ended in were neither re-pointed nor revoked. This is NOT ' - + 'an access exposure — #15409\'s per-request check covers every removal path by ' + + 'an access exposure — the per-request membership check covers every removal path by ' + 'construction — but the sign-out an admin expects did not happen. Remedy: this is the ' + 'READ half, so check driver connectivity and read access on sys_member / sys_session.', { object: SystemObjectName.MEMBER, userId, error: (e as Error)?.message }, @@ -408,7 +409,7 @@ export function registerMembershipEndedSessionTrigger( logger?.info?.( '[MembershipEndedSession] membership-ended session trigger registered on sys_member ' - + '(delete + organization re-point) — #15784, the COURTESY half of #15409. ' + + '(delete + organization re-point) — the COURTESY half of the membership-claim ruling. ' + 'The per-request membership check remains the enforcement.', ); } From 61bc592f3d091ce71b6e01f30d0fd46cbe959450 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:54:36 +0800 Subject: [PATCH 5/5] fix(platform-objects): regenerate the i18n source-hash companions for the widened revoke_reason description (#15784) The three translated locales' *.source-hashes.generated.ts files were left stale by the field-description change: the hashes are keyed on the source strings, so check:i18n read all three bundle sets as DRIFTED. Regenerated with the whole pass (pnpm i18n:extract), which emits both families together. Co-Authored-By: Claude Opus 5 --- .../src/apps/translations/es-ES.source-hashes.generated.ts | 4 ++-- .../src/apps/translations/ja-JP.source-hashes.generated.ts | 4 ++-- .../src/apps/translations/zh-CN.source-hashes.generated.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index d4ed7bf8e7..abbcb39173 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -567,9 +567,9 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_secret.fields.id.label": "00b0385c9c152888", "objects.sys_session.fields.last_activity_at.help": "f7851e9373505e73", "objects.sys_session.fields.last_activity_at.label": "43bd2f1b231bc12b", - "objects.sys_session.fields.revoke_reason.help": "ff2c9a1aa9be356d", + "objects.sys_session.fields.revoke_reason.help": "fcd18baf6b341b7f", "objects.sys_session.fields.revoke_reason.label": "13b0146153a2ecf8", - "objects.sys_session.fields.revoked_at.help": "62d1b62cde5ce487", + "objects.sys_session.fields.revoked_at.help": "77a508fdceda2f6d", "objects.sys_session.fields.revoked_at.label": "054f918e632528c7", "objects.sys_setting.fields.scope.options.global": "5e377106508d2ecd", "objects.sys_setting_audit._views.recent.label": "62d27bb9d0349c99", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 083cd9700a..a2dc83a3b2 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -560,9 +560,9 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_secret.fields.id.label": "00b0385c9c152888", "objects.sys_session.fields.last_activity_at.help": "f7851e9373505e73", "objects.sys_session.fields.last_activity_at.label": "43bd2f1b231bc12b", - "objects.sys_session.fields.revoke_reason.help": "ff2c9a1aa9be356d", + "objects.sys_session.fields.revoke_reason.help": "fcd18baf6b341b7f", "objects.sys_session.fields.revoke_reason.label": "13b0146153a2ecf8", - "objects.sys_session.fields.revoked_at.help": "62d1b62cde5ce487", + "objects.sys_session.fields.revoked_at.help": "77a508fdceda2f6d", "objects.sys_session.fields.revoked_at.label": "054f918e632528c7", "objects.sys_setting_audit._views.recent.label": "62d27bb9d0349c99", "objects.sys_setting_audit.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 469f43a8c6..0c179bd2ca 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -507,9 +507,9 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_secret.fields.id.label": "00b0385c9c152888", "objects.sys_session.fields.last_activity_at.help": "f7851e9373505e73", "objects.sys_session.fields.last_activity_at.label": "43bd2f1b231bc12b", - "objects.sys_session.fields.revoke_reason.help": "ff2c9a1aa9be356d", + "objects.sys_session.fields.revoke_reason.help": "fcd18baf6b341b7f", "objects.sys_session.fields.revoke_reason.label": "13b0146153a2ecf8", - "objects.sys_session.fields.revoked_at.help": "62d1b62cde5ce487", + "objects.sys_session.fields.revoked_at.help": "77a508fdceda2f6d", "objects.sys_session.fields.revoked_at.label": "054f918e632528c7", "objects.sys_setting_audit._views.recent.label": "62d27bb9d0349c99", "objects.sys_setting_audit.fields.id.label": "00b0385c9c152888",