From 2c334bec686dacd033bac0f7388c03a937a3c607 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:34:06 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(plugin-sharing):=20hierarchy=20resolver?= =?UTF-8?q?=20=E6=8C=89=E6=9D=83=E5=A8=81=E5=AD=97=E6=AE=B5=E6=8B=BF?= =?UTF-8?q?=E5=88=B0=E8=B0=83=E7=94=A8=E6=96=B9=E6=B4=BB=E5=8A=A8=E7=BB=84?= =?UTF-8?q?=E7=BB=87=20(#5859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveOwnerScopeIds` 构造 `HierarchyScopeContext` 时读 `(context as any).organizationId` —— 仓内没有任何传输层写过这个键(REST 与 runtime dispatcher 都从 `resolveAuthzContext` 组装,活动组织落在 `tenantId`),所以该字段结构性恒 null,企业版 resolver 只读它, 整条 DEPTH 租户隔离从未生效(#5852:group 姿态下普通成员对兄弟组织记录的 share 得 201)。 - producer 按权威字段填充:`organizationId` = 执行上下文的活动组织;`tenantId` 作为 @deprecated 别名原样携带(非消费端 `?? tenantId` 兜底)。 - 无组织时如实传 `null`,空白串归一为 `null`。 - resolver 抛错的静默回退改为留声(logger.warn)。 - 测试改用真实 seam 产生的 context(`resolveAuthzContext`,exec-context-seam.testkit.ts), 不再手工构造 `{ userId, organizationId }` —— 那正是本缺陷躲过全部单测的原因。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv --- .changeset/sharing-hierarchy-org-authority.md | 38 +++ .../src/exec-context-seam.testkit.ts | 107 +++++++++ .../src/sharing-service.test.ts | 219 +++++++++++++++++- .../plugin-sharing/src/sharing-service.ts | 77 +++++- 4 files changed, 435 insertions(+), 6 deletions(-) create mode 100644 .changeset/sharing-hierarchy-org-authority.md create mode 100644 packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts diff --git a/.changeset/sharing-hierarchy-org-authority.md b/.changeset/sharing-hierarchy-org-authority.md new file mode 100644 index 0000000000..162a13e52d --- /dev/null +++ b/.changeset/sharing-hierarchy-org-authority.md @@ -0,0 +1,38 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +fix(plugin-sharing): hierarchy resolver 拿到调用方真实的活动组织(权威 `organizationId`)(#5859) + +`resolveOwnerScopeIds` 构造 `HierarchyScopeContext` 时读的是 +`(context as any).organizationId` —— **仓内没有任何传输层写过这个键**。REST +(`rest-server.ts`)和 runtime dispatcher(`resolve-execution-context.ts`)都从同一个 +授权解析器 `resolveAuthzContext` 组装执行上下文,活动组织落在 `tenantId` +(session 路径 = `session.activeOrganizationId`,API-key 路径 = `sys_api_key.organization_id`), +`ExecutionContext` 的字段注释写的也正是这句。所以这个读取**结构性恒为 `null`**: +自 ADR-0057 以来,每一次 DEPTH(`unit` / `unit_and_below` / `own_and_reports`)解析 +都是在**没有组织约束**的前提下跑的,而企业版 resolver 只按 `organizationId` 收窄 +自己的 owner 集合 —— 于是整条 DEPTH 租户隔离从未生效。 + +爆炸半径不止「共享管理」一路:同一个 owner 集合喂给 `matchesOwnerScope` → +`canEdit` / `canDelete`,以及批量写路径 `buildWriteFilter`。#5852 的实测里, +`group` 姿态下的普通成员对**兄弟组织**记录 `POST /data/:obj/:idB/shares` 得到 +**201**;探针那个 app 的写路径另被 `member_default` 的 `owner_only_writes` +(keyed on `created_by`)挡下,所以只观测到共享管理一路 —— **不带这条 owner-only +RLS 的部署,跨组织 edit/delete 同样放行**。 + +本次修复(producer 半边,契约半边见 #5858 / PR #5973): + +- 权威字段 `organizationId` 由执行上下文的活动组织填充;`tenantId` 作为 + `@deprecated` 兼容别名原样继续携带(不是消费端 `?? tenantId` 兜底 —— 那正是 + #5858 为 resolver 明令排除的宽容消费者形状)。同样的映射在 + `@objectstack/plugin-security` 的 Layer-0 租户墙里早已在用 + (`computeTenantLayer0Filter({ organizationId: context?.tenantId })`),两层 + enforcement 现在按同一个字段的同一个值收窄。 +- 无活动组织时如实传 `null`(契约类型即 `string | null`),空白字符串归一为 + `null`,绝不让一个假的组织 id 混进 resolver 的查询与日志。 +- resolver 抛错的静默回退改为**留声**(`logger.warn`):此前「resolver 炸了」和 + 「层级里确实没有别人」在外部完全同形,这也是本缺陷长期不可见的原因之一。 + +安全收紧:按组织收窄 owner 集合的 resolver(企业版即是)从此真正拿到组织, +跨组织的 share 管理 / edit / delete / 批量写全部按组织边界闭合。 diff --git a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts new file mode 100644 index 0000000000..8e24c36f98 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5859] A REAL execution context for sharing tests — produced by the seam, + * never hand-written. + * + * Why this exists: #5852's cross-organization escalation survived every unit + * test in this package because those tests hand-built the context they fed in + * (`{ userId, organizationId }`) — a shape the runtime NEVER produces. The + * sharing service read `organizationId`, the transports write the caller's + * active org onto `tenantId`, and no test could see the gap because each test + * wrote both sides itself. + * + * So a test that wants to say something about tenancy must not name the + * context's tenancy fields at all. This helper takes what a real deployment + * actually holds — a better-auth session (`activeOrganizationId`) and + * `sys_member` rows — and runs it through `resolveAuthzContext`, the SINGLE + * shared authorization resolver (`@objectstack/core/security`) that BOTH HTTP + * entry points delegate to (`packages/rest/src/rest-server.ts` and + * `packages/runtime/src/security/resolve-execution-context.ts`). Whichever + * field that resolver decides carries the active organization is the field the + * test hands to the sharing service — so a rename, a drop, or a re-spelling of + * the tenancy authority breaks these tests instead of silently disabling them. + * + * `.testkit.ts`, not `.test.ts`: it holds no assertions and must not be + * collected as a suite. It is imported only by tests, so tsup (entry + * `src/index.ts`) never bundles it into `dist`. + */ + +import { resolveAuthzContext } from '@objectstack/core'; +import type { SharingExecutionContext } from '@objectstack/spec/contracts'; + +/** A `sys_member` row as the identity tables really store it. */ +export interface SeamMembership { + organization_id: string; + role?: string; +} + +export interface SeamPrincipal { + /** `sys_user.id` of the signed-in caller. */ + userId: string; + email?: string; + /** + * better-auth `session.activeOrganizationId` — the ONE wire field a real + * login carries the caller's active organization on (ADR-0081 D1 stamps it + * from the user's `sys_member` row on session create). `null` reproduces a + * membership-less / platform-scoped session. + */ + activeOrganizationId?: string | null; + /** `sys_member` rows for this user (defaults to one row per active org). */ + memberships?: SeamMembership[]; +} + +/** Minimal in-memory ObjectQL: `find(object, { where })` with `===` + `$in`. */ +function makeSeamQl(tables: Record) { + return { + async find(object: string, opts: any) { + const rows = tables[object] ?? []; + const where = opts?.where ?? {}; + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + return r[k] === v; + }), + ); + }, + }; +} + +/** + * Resolve an execution context the way an inbound HTTP request does. + * + * The returned object is the authorization envelope `resolveAuthzContext` + * produced, spread exactly as both transports spread it (plus + * `isSystem: false`) — this helper never names a tenancy field, so neither does + * the test that calls it. + */ +export async function bootRequestContext(principal: SeamPrincipal): Promise { + const activeOrg = principal.activeOrganizationId ?? null; + const memberships: SeamMembership[] = + principal.memberships ?? (activeOrg ? [{ organization_id: activeOrg, role: 'member' }] : []); + + const ql = makeSeamQl({ + sys_user: [{ id: principal.userId, email: principal.email }], + sys_member: memberships.map((m, i) => ({ + id: `mem_${principal.userId}_${i}`, + user_id: principal.userId, + organization_id: m.organization_id, + role: m.role ?? 'member', + })), + sys_user_position: [], + sys_user_permission_set: [], + sys_permission_set: [], + }); + + const authz = await resolveAuthzContext({ + ql, + headers: new Headers(), + // The better-auth session shape, as `AuthManager` hands it to the resolver. + getSession: async () => ({ + user: { id: principal.userId, email: principal.email }, + session: { userId: principal.userId, activeOrganizationId: activeOrg }, + }), + }); + + return { ...authz, isSystem: false } as unknown as SharingExecutionContext; +} diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index 7e4be30c0d..82bf2eac3f 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -1,9 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { assertEngineDeleteDispatch } from '@objectstack/objectql'; import { SharingService } from './sharing-service.js'; import { buildSharingMiddleware } from './sharing-plugin.js'; +import { bootRequestContext } from './exec-context-seam.testkit.js'; // ───────────────────────────────────────────────────────────────────── // In-memory fake engine @@ -841,6 +842,12 @@ describe('[ADR-0111 D1] SharingService.canManageShares', () => { // ── [ADR-0111 D1 DEPTH] hierarchy-manager authority ────────────────── // a1 is owned by alice. bob is alice's manager (own_and_reports covers alice); // carol is an unrelated peer. The enterprise resolver is stubbed. + // + // [#5859] These two contexts come from the SEAM (`bootRequestContext`), not + // from an object literal. The stubs below ignore the organization, so the + // verdicts do not change — what changes is that the DEPTH branch is now + // exercised with the context shape the runtime actually produces (an active + // org included), instead of one no transport ever emits. it('a hierarchy manager whose write DEPTH covers the owner may manage the record', async () => { const svc = new SharingService({ engine, @@ -853,7 +860,8 @@ describe('[ADR-0111 D1] SharingService.canManageShares', () => { resolveOwnerIds: async (ctx: any) => ctx.userId === 'bob' ? ['bob', 'alice'] : [ctx.userId], }), }); - expect(await svc.canManageShares('account', 'a1', { userId: 'bob' })).toBe(true); + const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); + expect(await svc.canManageShares('account', 'a1', bob)).toBe(true); }); it('a peer with the same DEPTH scope but NOT covering the owner is denied', async () => { @@ -867,7 +875,8 @@ describe('[ADR-0111 D1] SharingService.canManageShares', () => { resolveOwnerIds: async (ctx: any) => [ctx.userId], // carol's unit owner-set excludes alice }), }); - expect(await svc.canManageShares('account', 'a1', { userId: 'carol' })).toBe(false); + const carol = await bootRequestContext({ userId: 'carol', activeOrganizationId: 'org_a' }); + expect(await svc.canManageShares('account', 'a1', carol)).toBe(false); }); it("a probe reporting 'org' does NOT widen management (fail-open guard — only Modify All via hasWriteBypass grants org)", async () => { @@ -905,7 +914,11 @@ describe('[ADR-0111 D1] SharingService.canManageShares', () => { // No hierarchyResolver → resolveOwnerScopeIds fails closed to [me], which // excludes alice, so bob cannot manage alice's record. }); - expect(await svc.canManageShares('account', 'a1', { userId: 'bob' })).toBe(false); + // [#5859] A seam context WITH an active organization, so the missing + // resolver is unambiguously the only thing standing between bob and + // alice's record. + const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); + expect(await svc.canManageShares('account', 'a1', bob)).toBe(false); }); }); @@ -1146,3 +1159,201 @@ describe('[ADR-0111 D5] sys_record_share read self-scope (middleware)', () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────── +// [#5859 / #5852] The hierarchy resolver's tenancy authority. +// +// `HierarchyScopeContext.organizationId` is the AUTHORITATIVE tenancy field +// (#5858 / PR #5973) and the only one an enterprise resolver scopes its owner +// query by. This producer used to fill it from `(context as any).organizationId` +// — a key NO transport ever sets — so every resolver ran unscoped and the whole +// DEPTH tenant isolation was inert: in #5852 an ordinary member of org_a +// `POST`ed a share onto a SIBLING organization's record and got 201. +// +// Every context below is produced by the seam (`bootRequestContext` → +// `resolveAuthzContext`, the one resolver both HTTP entry points delegate to). +// No test in this block names a tenancy field on a context — the org enters +// the way a real login carries it (`session.activeOrganizationId`) and the +// resolver decides which field it lands on. That is the whole point: the old +// unit tests hand-wrote `{ userId, organizationId }`, a shape the runtime never +// produces, which is exactly how this defect stayed green. +// ───────────────────────────────────────────────────────────────────── + +describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () => { + const MANAGER_PROBE = { + hasWriteBypass: async () => false, + resolveWriteScope: async () => 'unit_and_below' as const, + }; + + /** + * A reference resolver shaped like the enterprise one: it scopes its owner + * set by `context.organizationId` and — this is the measured #5852 shape — + * runs the query UNSCOPED when it gets no organization, returning every user + * it can see. Fixing the producer is what stops it ever being asked that way; + * the resolver's own fail-closed duty (contract doc) is cloud#1148's half. + */ + const ORG_MEMBERS: Record = { + org_a: ['alice', 'bob'], + org_b: ['dana'], + }; + function orgScopedResolver(seen: any[]) { + return () => ({ + async resolveOwnerIds(ctx: any): Promise { + seen.push(ctx); + if (ctx.organizationId == null) return Object.values(ORG_MEMBERS).flat(); + return ORG_MEMBERS[String(ctx.organizationId)] ?? [String(ctx.userId)]; + }, + }); + } + + let engine: ReturnType; + beforeEach(() => { + engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, sys_record_share: {} }); + engine._tables.account = [ + { id: 'a1', name: 'Acme (org_a)', owner_id: 'alice' }, + { id: 'b1', name: 'Beta (org_b)', owner_id: 'dana' }, + ]; + }); + + it('the seam carries the active org on ONE field, and it is not the authoritative one', async () => { + const ctx = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); + // Measured, not assumed: `resolveAuthzContext` resolves + // `session.activeOrganizationId` onto `tenantId` (and `ExecutionContext` + // documents it as exactly that). Nothing sets `organizationId` — so a + // producer reading that key reads `undefined` on every real request, which + // is the defect this issue closes. + expect((ctx as any).tenantId).toBe('org_a'); + expect(Object.keys(ctx as any)).not.toContain('organizationId'); + expect((ctx as any).organizationId).toBeUndefined(); + }); + + it('hands the resolver a NON-EMPTY organizationId equal to the caller active org', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + }); + const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); + + expect(await svc.canManageShares('account', 'a1', bob)).toBe(true); + expect(seen).toHaveLength(1); + expect(seen[0].organizationId).not.toBeNull(); + expect(seen[0].organizationId).toBe('org_a'); + expect(seen[0].userId).toBe('bob'); + // The @deprecated alias keeps carrying what it always carried — it is not + // the authority, and the producer never substitutes one for the other. + expect(seen[0].tenantId).toBe('org_a'); + }); + + it('#5852 flip: a member cannot manage shares on a SIBLING organization record', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + }); + const bob = await bootRequestContext({ + userId: 'bob', + // A `group`-posture member of BOTH orgs, active in org_a — the #5852 + // posture. Membership is read reach; it is NOT hierarchy-scope reach. + activeOrganizationId: 'org_a', + memberships: [{ organization_id: 'org_a' }, { organization_id: 'org_b' }], + }); + + // Same org → the DEPTH branch still grants (no over-closing). + expect(await svc.canManageShares('account', 'a1', bob)).toBe(true); + // Sibling org → denied. Before the fix the resolver got no organization, + // answered with every user it could see, dana was in the owner set, and the + // real HTTP probe returned 201 on `POST /data/account/b1/shares`. + expect(await svc.canManageShares('account', 'b1', bob)).toBe(false); + expect(seen.every((c) => c.organizationId === 'org_a')).toBe(true); + }); + + it('write radius: cross-org edit / delete / bulk-write filter all stay closed', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + hierarchyResolver: orgScopedResolver(seen), + // NO securityService: no `modifyAllRecords` bypass, and no owner-only RLS + // anywhere — the sharing service is the ONLY gate in this fixture, which + // is the deployment shape #5852 named as unprotected (the probe app was + // masked by a `member_default` owner_only_writes rule keyed on + // `created_by`). + }); + const base = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); + // `__writeScope` is stamped by plugin-security's middleware onto a spread of + // the request context (security-plugin.ts) — mirrored here exactly. + const bob: any = { ...base, __writeScope: 'unit_and_below' }; + + expect(await svc.canEdit('account', 'a1', bob)).toBe(true); // alice, org_a + expect(await svc.canEdit('account', 'b1', bob)).toBe(false); // dana, org_b + expect(await svc.canDelete('account', 'a1', bob)).toBe(true); + expect(await svc.canDelete('account', 'b1', bob)).toBe(false); + + // The bulk path reads the same owner set — dana must not be in it. + const filter: any = await svc.buildWriteFilter('account', bob, 'update'); + expect(filter).toEqual({ owner_id: { $in: ['alice', 'bob'] } }); + }); + + it('no active organization is reported as an HONEST null — never the deprecated alias, never a stand-in', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + }); + // A session with no active organization — the supported pure-single-tenant + // shape (the verify harness boots it deliberately: `autoDefaultOrganization: + // false`), not an anomaly this layer invents a verdict for. + const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null }); + expect((orgless as any).tenantId).toBeUndefined(); + + await svc.canManageShares('account', 'a1', orgless); + expect(seen).toHaveLength(1); + // `string | null` per the contract: the producer states the absence rather + // than omitting the key (which is what let #5852's resolver read + // `undefined` and query unscoped without anyone noticing). + expect(seen[0]).toHaveProperty('organizationId'); + expect(seen[0].organizationId).toBeNull(); + // What the resolver must DO with that null is its own contract obligation + // ("Fail CLOSED on a missing organization … 'no org' is not 'every org'", + // IHierarchyScopeResolver.resolveOwnerIds) — cloud#1148's half. Whether the + // OPEN edition should additionally refuse to consult it is the open + // tenancy-posture question on #5859; deliberately not decided here. + }); + + it('fail closed: a THROWING resolver falls back to owner-only and SAYS so', async () => { + const warn = vi.fn(); + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: () => ({ + async resolveOwnerIds(): Promise { throw new Error('resolver exploded'); }, + }), + logger: { warn }, + }); + const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); + // Unchanged verdict (owner-only), newly AUDIBLE: a swallowed resolver + // failure and "the hierarchy legitimately covers nobody else" used to look + // identical from outside, which is a large part of why #5852 went unseen. + expect(await svc.canManageShares('account', 'a1', bob)).toBe(false); + expect(warn).toHaveBeenCalled(); + expect(warn.mock.calls[0][1]).toMatchObject({ organizationId: 'org_a', error: 'resolver exploded' }); + }); + + it('a blank organization normalizes to null rather than travelling as a junk id', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + }); + const blank = await bootRequestContext({ userId: 'bob', activeOrganizationId: ' ' }); + await svc.canManageShares('account', 'a1', blank); + expect(seen).toHaveLength(1); + // A whitespace org is not an organization: it must not reach a resolver as + // a literal that silently matches no rows and reads as "scoped" in a log. + expect(seen[0].organizationId).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 678ffaba9f..9041205e33 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -92,6 +92,39 @@ export function effectiveSharingModel(schema: any): 'private' | 'read' | 'public return 'private'; } +/** + * [#5859 / #5852] The caller's ACTIVE ORGANIZATION as carried by an execution + * context — the value `HierarchyScopeContext.organizationId` (the authoritative + * tenancy field since #5858 / PR #5973) must be filled with. + * + * Every transport puts it on `tenantId`: both HTTP entry points build their + * context from the ONE shared authorization resolver + * (`resolveAuthzContext`, `@objectstack/core`), which resolves + * `tenantId = session.activeOrganizationId` on the session path and + * `sys_api_key.organization_id` on the API-key path, and `ExecutionContext` + * documents the field as exactly that ("Current organization/tenant ID + * (resolved from `session.activeOrganizationId`)"). NOTHING sets + * `organizationId` on an execution context — which is why reading that key + * here produced a structural `null` on every real request, and every + * hierarchy resolver ran with no organization to scope by (#5852: an ordinary + * member `POST`ing a share on a SIBLING organization's record got 201). + * + * This is the PRODUCER speaking the contract's authoritative name — not a + * consumer-side `?? tenantId` tolerance, which #5858 explicitly rules out for + * resolvers. The identical mapping already backs the Layer-0 tenant wall in + * `@objectstack/plugin-security` (`computeTenantLayer0Filter({ organizationId: + * context?.tenantId })`), so the two enforcement layers now scope by the same + * value from the same field. + * + * Returns `null` — never `undefined`, and never a blank string — for "no active + * organization": the contract types the field as `string | null`, and `null` is + * the value a resolver's fail-closed obligation is written against. + */ +function activeOrganizationId(context: SharingExecutionContext): string | null { + const org = (context as any)?.tenantId; + return typeof org === 'string' && org.trim() !== '' ? org : null; +} + function hasOwnerField(schema: any): boolean { return Boolean(schema?.fields && OWNER_FIELD in schema.fields); } @@ -862,6 +895,31 @@ export class SharingService implements ISharingService { * only by `@objectstack/security-enterprise`). The open edition has none, so * this fails CLOSED to owner-only — a hierarchy scope NEVER widens without the * enterprise resolver (the spec gate also refuses to compile such a grant). + * + * [#5859 / #5852] The PRODUCER side of {@link HierarchyScopeContext}: the + * authoritative `organizationId` is filled from the execution context's + * active organization ({@link activeOrganizationId}). The read this replaced + * (`(context as any).organizationId`) named a key NO transport ever sets, so + * the field was structurally `null` on every real request and every resolver + * ran with no organization to scope by. + * + * `null` is passed through HONESTLY when the caller has no active + * organization — never papered over with the deprecated `tenantId` alias, and + * never turned into a blank-string org that would match nothing. What a + * resolver must then do is ITS contract: + * `IHierarchyScopeResolver.resolveOwnerIds` — "**Fail CLOSED on a missing + * organization** … 'no org' is not 'every org'. Return owner-only (or throw, + * which the sharing layer treats the same way); never widen." + * + * An additional REFUSAL here (not consulting the resolver at all on a null + * org) is deliberately NOT implemented yet: "no active organization" is the + * normal state of the supported pure-single-tenant deployment — the verify + * harness boots exactly that shape on purpose (`autoDefaultOrganization: + * false`) and the ADR-0057 D1 dogfood proofs pin hierarchy DEPTH working in + * it. Whether the open edition should refuse there is a tenancy-posture + * question (ADR-0105 D1: `single` → no wall; `isolated`/`group` → a missing + * org denies, cf. `computeTenantLayer0Filter`), and the sharing service holds + * no posture today — see #5859 for the open decision. */ private async resolveOwnerScopeIds( context: SharingExecutionContext, @@ -871,17 +929,32 @@ export class SharingService implements ISharingService { if (!scope || scope === 'own' || scope === 'org') return [me]; const resolver = this.hierarchyResolver?.(); if (!resolver) return [me]; + + const organizationId = activeOrganizationId(context); try { const ids = await resolver.resolveOwnerIds( { userId: me, - organizationId: (context as any).organizationId ?? null, + // AUTHORITATIVE (#5858 / PR #5973). Never `(context as any).organizationId`: + // no execution context in this repo carries that key. + organizationId, + // The @deprecated compatibility alias, carried through unchanged for + // resolvers that still read it. It is NOT the authority — a resolver + // reading it alone is the shape #5858 ruled out. tenantId: (context as any).tenantId ?? null, }, scope, ); return Array.isArray(ids) && ids.length > 0 ? ids : [me]; - } catch { + } catch (err: any) { + // A throwing resolver is treated exactly like an empty answer (the + // contract says so) — but say it out loud: a silently-swallowed resolver + // failure is indistinguishable from "the hierarchy legitimately covers + // nobody else", which is how #5852 stayed invisible for so long. + this.logger?.warn?.( + '[sharing] hierarchy scope resolver failed — falling back to owner-only (fail closed)', + { userId: me, scope, organizationId, error: err?.message }, + ); return [me]; } } From ad0e5d3cd6e7d8c5e95f89758908078374d99df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:16:12 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(plugin-sharing):=20=E5=A7=BF=E6=80=81?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E7=9A=84=E7=BB=84=E7=BB=87=E9=97=A8=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=9C=89=E5=A2=99=E5=A7=BF=E6=80=81?= =?UTF-8?q?=E4=B8=8B=E7=BC=BA=E6=9D=83=E5=A8=81=E7=BB=84=E7=BB=87=E5=8D=B3?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E5=B1=95=E5=BC=80=20DEPTH=20(#5859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 #5859 裁决(C)追加:`SharingService` 新增 late-bound `tenancy` 姿态探针(读法与 SecurityPlugin 为 Layer-0 墙读 `tenancy` 服务一致,由 SharingServicePlugin 接线), 按 ADR-0105 D1 的既有分叉决定「没有活动组织」意味着什么: - `single`(纯单租户,无组织):行为不变,DEPTH 照常 —— 那是唯一隐含租户,不是「所有组织」; - `group` / `isolated`:权威组织缺失/空白 → 不咨询 resolver,回落 owner-only, warn 点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 的 fail-closed 契约义务; - 姿态解析不出(未接线/抛错/词表外)→ 按有墙处理,未知姿态不是 single 的证据。 测试两个方向都钉:single+无组织仍 widened(先绿保持绿)、walled+无组织拒绝(先红后绿)、 姿态不可解析拒绝、legacy `isolationActive:false` 视为无墙、空白组织在两侧各自的表现。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv --- .changeset/sharing-hierarchy-org-authority.md | 20 ++- .../plugin-sharing/src/sharing-plugin.ts | 10 ++ .../src/sharing-service.test.ts | 123 ++++++++++++++++-- .../plugin-sharing/src/sharing-service.ts | 97 ++++++++++++-- 4 files changed, 225 insertions(+), 25 deletions(-) diff --git a/.changeset/sharing-hierarchy-org-authority.md b/.changeset/sharing-hierarchy-org-authority.md index 162a13e52d..004eb04e26 100644 --- a/.changeset/sharing-hierarchy-org-authority.md +++ b/.changeset/sharing-hierarchy-org-authority.md @@ -34,5 +34,21 @@ RLS 的部署,跨组织 edit/delete 同样放行**。 - resolver 抛错的静默回退改为**留声**(`logger.warn`):此前「resolver 炸了」和 「层级里确实没有别人」在外部完全同形,这也是本缺陷长期不可见的原因之一。 -安全收紧:按组织收窄 owner 集合的 resolver(企业版即是)从此真正拿到组织, -跨组织的 share 管理 / edit / delete / 批量写全部按组织边界闭合。 +## 姿态感知的组织门(user-visible 行为变化) + +`SharingService` 新增一个 late-bound 的 `tenancy` 姿态探针(读法与 `SecurityPlugin` +为 Layer-0 墙读 `tenancy` 服务的完全一致,由 `SharingServicePlugin` 自动接线), +按 **ADR-0105 D1** 的既有分叉决定「没有活动组织」意味着什么 —— 与 +`computeTenantLayer0Filter` 对同一问题给出的答案逐条同形: + +- **`single`**(纯单租户,无组织):**行为不变**,DEPTH 照常widened。此处「没有组织」 + 是那一个隐含租户,不是「所有组织」。 +- **`group` / `isolated`**(有墙):权威组织缺失/空白 → **拒绝**,根本不咨询 resolver, + 回落 owner-only 并打一条点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 契约义务的 `warn`。 + 即:有墙部署里,缺组织的 owner-scope 解析从「按无租户约束展开」变为「拒绝展开」。 +- **姿态解析不出**(未接线 / 探针抛错 / 词表外的值)→ 按**有墙**处理。未知姿态不是 + `single` 的证据,否则恰恰在配置已经可疑的部署上恢复了展开。 + +对已有部署的影响:`single` 部署零变化;`group` / `isolated` 部署中,一个**没有活动 +组织**的调用方将不再通过 DEPTH 拿到跨组织的 owner 集合(共享管理 / edit / delete / +批量写四条路径同时闭合)。 diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index b2f126f106..339188898b 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -449,6 +449,16 @@ export class SharingServicePlugin implements Plugin { try { return ctx.getService('security'); } catch { return null; } }, + // [ADR-0105 D1 / #5859] Late-bound tenancy posture — read exactly the + // way SecurityPlugin reads it for the Layer 0 wall, so the two layers + // can never disagree about whether an organization wall is in force. + // Absent (no plugin-auth) → the org gate assumes WALLED and refuses to + // widen a hierarchy scope that carries no organization; an unresolvable + // posture is not evidence of `single`. + tenancy: () => { + try { return ctx.getService('tenancy'); } + catch { return null; } + }, }); ctx.registerService('sharing', this.service); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index 82bf2eac3f..02afb8dd49 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { assertEngineDeleteDispatch } from '@objectstack/objectql'; -import { SharingService } from './sharing-service.js'; +import { SharingService, type SharingServiceOptions } from './sharing-service.js'; import { buildSharingMiddleware } from './sharing-plugin.js'; import { bootRequestContext } from './exec-context-seam.testkit.js'; @@ -1206,6 +1206,15 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () }); } + /** + * [ADR-0105 D1] The deployment posture, stated the way the `tenancy` service + * states it. `single` = no organization wall (the pure single-tenant end of + * the spectrum the ADR-0057 D1 proofs boot); `group` / `isolated` = a wall is + * in force. Every fixture below says which deployment it is talking about, + * because after #5859 the answer to "no active organization" depends on it. + */ + const posture = (p: string) => () => ({ posture: p }); + let engine: ReturnType; beforeEach(() => { engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, sys_record_share: {} }); @@ -1233,6 +1242,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () engine, securityService: () => MANAGER_PROBE, hierarchyResolver: orgScopedResolver(seen), + tenancy: posture('isolated'), }); const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); @@ -1252,6 +1262,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () engine, securityService: () => MANAGER_PROBE, hierarchyResolver: orgScopedResolver(seen), + tenancy: posture('group'), }); const bob = await bootRequestContext({ userId: 'bob', @@ -1275,6 +1286,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () const svc = new SharingService({ engine, hierarchyResolver: orgScopedResolver(seen), + tenancy: posture('group'), // NO securityService: no `modifyAllRecords` bypass, and no owner-only RLS // anywhere — the sharing service is the ONLY gate in this fixture, which // is the deployment shape #5852 named as unprotected (the probe app was @@ -1296,31 +1308,99 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () expect(filter).toEqual({ owner_id: { $in: ['alice', 'bob'] } }); }); - it('no active organization is reported as an HONEST null — never the deprecated alias, never a stand-in', async () => { + // ── [ADR-0105 D1] The posture fork on "no active organization" ──────── + // Same caller, same absent org, two deployments, two answers — the same + // fork Layer 0 already makes (`computeTenantLayer0Filter`: `single` inert, + // walled postures deny). Both directions are pinned; neither is a default. + + it('single posture: no organization at all → DEPTH still widens, and the null is HONEST', async () => { const seen: any[] = []; const svc = new SharingService({ engine, securityService: () => MANAGER_PROBE, hierarchyResolver: orgScopedResolver(seen), + // The pure single-tenant end of the spectrum — the shape the verify + // harness boots deliberately (`autoDefaultOrganization: false`) and the + // ADR-0057 D1 dogfood proofs run in. "No org" here is the one implicit + // tenant, not "every org", so refusing would retire DEPTH for every + // org-less deployment. + tenancy: posture('single'), }); - // A session with no active organization — the supported pure-single-tenant - // shape (the verify harness boots it deliberately: `autoDefaultOrganization: - // false`), not an anomaly this layer invents a verdict for. const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null }); expect((orgless as any).tenantId).toBeUndefined(); - await svc.canManageShares('account', 'a1', orgless); + expect(await svc.canManageShares('account', 'a1', orgless)).toBe(true); expect(seen).toHaveLength(1); - // `string | null` per the contract: the producer states the absence rather - // than omitting the key (which is what let #5852's resolver read - // `undefined` and query unscoped without anyone noticing). + // `string | null` per the contract: the producer STATES the absence rather + // than omitting the key (omission is what let a resolver read `undefined` + // and query unscoped without anyone noticing). What a resolver must then do + // with that null is its own obligation — cloud#1148's half. expect(seen[0]).toHaveProperty('organizationId'); expect(seen[0].organizationId).toBeNull(); - // What the resolver must DO with that null is its own contract obligation - // ("Fail CLOSED on a missing organization … 'no org' is not 'every org'", - // IHierarchyScopeResolver.resolveOwnerIds) — cloud#1148's half. Whether the - // OPEN edition should additionally refuse to consult it is the open - // tenancy-posture question on #5859; deliberately not decided here. + }); + + it.each(['group', 'isolated'])( + '%s posture: no active organization → the resolver is NOT consulted, loudly', + async (p) => { + const seen: any[] = []; + const warn = vi.fn(); + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + tenancy: posture(p), + logger: { warn }, + }); + const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null }); + + // A wall is in force and the caller carries no organization to scope by: + // owner-only, never widened — and the resolver is not even asked, so an + // out-of-tree implementation cannot answer for every org on its own. + expect(await svc.canManageShares('account', 'a1', orgless)).toBe(false); + expect(await svc.canEdit('account', 'b1', { ...(orgless as any), __writeScope: 'unit' })).toBe(false); + expect(seen).toHaveLength(0); + expect(warn).toHaveBeenCalled(); + const [message, meta] = warn.mock.calls[0]; + expect(String(message)).toContain('organization wall is in force'); + expect(String(message)).toContain('ADR-0095 D1 / ADR-0105 D1'); + expect(meta).toMatchObject({ userId: 'bob' }); + }, + ); + + it('an UNRESOLVABLE posture is not evidence of `single` — it refuses too', async () => { + const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null }); + const probes: Array = [ + undefined, // no `tenancy` wired at all + () => null, // service not registered + () => { throw new Error('tenancy unavailable'); }, + () => ({ posture: 'not-a-posture' }), // outside the vocabulary + ]; + for (const tenancy of probes) { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + tenancy, + }); + expect(await svc.canManageShares('account', 'a1', orgless)).toBe(false); + expect(seen).toHaveLength(0); + } + }); + + it('the legacy `isolationActive: false` shape still states "no wall" (single)', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + // Pre-ADR-0105 `tenancy` shape — a POSITIVE statement that no wall is + // enforced, unlike a missing/unknown posture. + tenancy: () => ({ isolationActive: false }), + }); + const orgless = await bootRequestContext({ userId: 'bob', activeOrganizationId: null }); + expect(await svc.canManageShares('account', 'a1', orgless)).toBe(true); + expect(seen).toHaveLength(1); }); it('fail closed: a THROWING resolver falls back to owner-only and SAYS so', async () => { @@ -1331,6 +1411,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () hierarchyResolver: () => ({ async resolveOwnerIds(): Promise { throw new Error('resolver exploded'); }, }), + tenancy: posture('isolated'), logger: { warn }, }); const bob = await bootRequestContext({ userId: 'bob', activeOrganizationId: 'org_a' }); @@ -1348,6 +1429,7 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () engine, securityService: () => MANAGER_PROBE, hierarchyResolver: orgScopedResolver(seen), + tenancy: posture('single'), }); const blank = await bootRequestContext({ userId: 'bob', activeOrganizationId: ' ' }); await svc.canManageShares('account', 'a1', blank); @@ -1356,4 +1438,17 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', () // a literal that silently matches no rows and reads as "scoped" in a log. expect(seen[0].organizationId).toBeNull(); }); + + it('a blank organization is ALSO an absent one under a wall (same normalization, refusing side)', async () => { + const seen: any[] = []; + const svc = new SharingService({ + engine, + securityService: () => MANAGER_PROBE, + hierarchyResolver: orgScopedResolver(seen), + tenancy: posture('isolated'), + }); + const blank = await bootRequestContext({ userId: 'bob', activeOrganizationId: ' ' }); + expect(await svc.canManageShares('account', 'a1', blank)).toBe(false); + expect(seen).toHaveLength(0); + }); }); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 9041205e33..4d64583768 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -8,6 +8,11 @@ import type { SharingExecutionContext, ShareAccessLevel, } from '@objectstack/spec/contracts'; +import { + normalizeTenancyPosture, + postureEnforcesWall, + type TenancyPosture, +} from '@objectstack/spec/security'; import { WRITE_ACCESS_LEVELS, normalizeAccessLevel } from './access-level.js'; import { deleteRowsForDeletedRecords, @@ -156,6 +161,20 @@ const RECORD_SHARE_SWEEP_SUBJECT = { issue: '#5103', } as const; +/** + * [ADR-0105 D1 / #5859] The narrow slice of the `tenancy` service the + * organization gate needs — the deployment's posture, i.e. whether an + * organization wall is enforced at all. Kept structural (and identical in shape + * to what `SecurityPlugin` reads) so a stack without `@objectstack/plugin-auth` + * needs no adapter, and so a unit test can state a posture without a kernel. + */ +export interface SharingTenancyProbe { + /** `single` | `group` | `isolated` (the legacy `multi` spelling normalizes). */ + readonly posture?: TenancyPosture | string; + /** Pre-ADR-0105 shape: "is the hard organization wall on?" */ + readonly isolationActive?: boolean; +} + export interface SharingServiceOptions { engine: SharingEngine; /** Object names that bypass sharing — typically platform internals. */ @@ -172,6 +191,16 @@ export interface SharingServiceOptions { * null → management authority fails CLOSED to owner-only. */ securityService?: () => SharingSecurityProbe | null | undefined; + /** + * [ADR-0105 D1 / #5859] Late-bound lookup for the `tenancy` service — the + * single source of truth for which posture is IN FORCE. Read ONLY to decide + * whether a missing authoritative organization must refuse a hierarchy scope + * (see {@link SharingService.organizationScopeRequired}). + * + * Absent / throwing / posture-less → the gate assumes a WALLED deployment and + * refuses: an unresolvable posture must not be read as "no wall, carry on". + */ + tenancy?: () => SharingTenancyProbe | null | undefined; /** [#5103] Optional logger for the record-delete cascade / orphan sweep. */ logger?: { info?: Function; warn?: Function; error?: Function; debug?: Function }; } @@ -189,12 +218,14 @@ export class SharingService implements ISharingService { private readonly bypassObjects: Set; private readonly hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined; private readonly securityService?: () => SharingSecurityProbe | null | undefined; + private readonly tenancy?: () => SharingTenancyProbe | null | undefined; private readonly logger?: SharingServiceOptions['logger']; constructor(options: SharingServiceOptions) { this.engine = options.engine; this.hierarchyResolver = options.hierarchyResolver; this.securityService = options.securityService; + this.tenancy = options.tenancy; this.logger = options.logger; this.bypassObjects = new Set([ 'sys_record_share', @@ -911,15 +942,11 @@ export class SharingService implements ISharingService { * organization** … 'no org' is not 'every org'. Return owner-only (or throw, * which the sharing layer treats the same way); never widen." * - * An additional REFUSAL here (not consulting the resolver at all on a null - * org) is deliberately NOT implemented yet: "no active organization" is the - * normal state of the supported pure-single-tenant deployment — the verify - * harness boots exactly that shape on purpose (`autoDefaultOrganization: - * false`) and the ADR-0057 D1 dogfood proofs pin hierarchy DEPTH working in - * it. Whether the open edition should refuse there is a tenancy-posture - * question (ADR-0105 D1: `single` → no wall; `isolated`/`group` → a missing - * org denies, cf. `computeTenantLayer0Filter`), and the sharing service holds - * no posture today — see #5859 for the open decision. + * On top of that, a WALLED deployment refuses outright: when an organization + * wall is in force and the authoritative org is missing, the resolver is not + * consulted at all and the caller falls back to owner-only, loudly — see + * {@link SharingService.organizationScopeRequired} for why the refusal is + * posture-scoped rather than unconditional. */ private async resolveOwnerScopeIds( context: SharingExecutionContext, @@ -931,6 +958,17 @@ export class SharingService implements ISharingService { if (!resolver) return [me]; const organizationId = activeOrganizationId(context); + if (organizationId === null && this.organizationScopeRequired()) { + this.logger?.warn?.( + '[sharing] hierarchy scope NOT widened: an organization wall is in force but the caller ' + + 'context carries no active organization — failing closed to owner-only. ' + + '"No org" is not "every org" (IHierarchyScopeResolver.resolveOwnerIds, #5973); ' + + 'the same rule walls Layer 0 (ADR-0095 D1 / ADR-0105 D1).', + { userId: me, scope }, + ); + return [me]; + } + try { const ids = await resolver.resolveOwnerIds( { @@ -959,6 +997,47 @@ export class SharingService implements ISharingService { } } + /** + * [ADR-0105 D1 / #5859] Must a hierarchy scope be refused when the caller + * carries no authoritative organization? + * + * The answer is the deployment's TENANCY POSTURE, not a constant — which is + * the same answer Layer 0 already gives to the same question + * (`computeTenantLayer0Filter`, ADR-0095 D1 / ADR-0105 D1): + * + * - `single` → **no**. There is no organization dimension at all; "no org" + * there means "the one implicit tenant", not "every org", and hierarchy + * DEPTH is pinned working in exactly that shape (the ADR-0057 D1 proofs + * boot a pure single-tenant stack on purpose — `@objectstack/verify`'s + * harness sets `autoDefaultOrganization: false` to model it). Refusing + * here would retire DEPTH for every org-less deployment. + * - `group` / `isolated` → **yes**. A wall is in force, so a caller with no + * active organization has no tenancy constraint to scope an owner set by, + * and widening one would hand out exactly the cross-organization reach + * #5852 measured. Layer 0 denies in the same situation; this is that rule, + * applied one layer up where the sharing gates read. + * + * Fails CLOSED on an unresolvable posture (no `tenancy` probe wired, a + * throwing probe, or a value outside the vocabulary): an unknown posture is + * NOT evidence of `single`, and reading it as such would restore the widening + * on precisely the deployments whose configuration is already suspect. + */ + private organizationScopeRequired(): boolean { + let probe: SharingTenancyProbe | null | undefined; + try { + probe = this.tenancy?.(); + } catch { + return true; // unresolvable → assume walled + } + if (!probe) return true; + const posture = normalizeTenancyPosture(probe.posture); + if (posture) return postureEnforcesWall(posture); + // Pre-ADR-0105 shape: only `isolationActive === false` is a positive + // statement that no wall is enforced. `undefined` stays unresolved. + if (probe.isolationActive === false) return false; + return true; + } + private shouldBypass(object: string, context: SharingExecutionContext): boolean { if (context?.isSystem) return true; if (this.bypassObjects.has(object)) return true;