Skip to content

driver-memory 完全没有行级租户隔离(#3724 的未修姊妹面):多租户下静默不隔离 #6915

Closed

Description

@os-zhuang

一句话说明

@objectstack/driver-memory 没有任何行级租户隔离 —— 读路径不加租户谓词,写路径不打租户戳,distinct() 甚至不接受 options。这与 #3724(driver-mongodb,同为"整个能力根本不存在")是同一类,而 #3724 已经 closed as completed。

在 #6792(PR #6908,SQL 家族读侧收口点闸门)的范围审计中测得。发现者当时判断"姊妹卡已关闭,新卡只会被以冻结为由关掉",因而没有开卡;PM 复核时发现该前提反了 —— 见下。

事实(#6908 分支基线 6595262 上实测)

$ grep -c "tenantId\|organization_id" packages/drivers/driver-memory/src/memory-driver.ts
0

零匹配。另外 distinct(object, field, query?) 的签名里没有 options 参数,所以调用方即便想传 tenantId 也无处可传。

对照 #6908 刚刚在 SQL 家族上做的事:applyTenantScope 是读侧唯一收口点,getBuilder 构造的每个读 builder 都必须路由过去,并由 scripts/check-tenant-chokepoint.mjs 从 AST 逐次重新推导。driver-memory 不在该闸门的扫描集内,而且这不是因为 #5499 冻结 —— 是因为它根本不使用这套机制(文件里 getBuilder / applyTenantScope 均为零匹配)。闸门因此对它不产生任何判定,也就没有 DEBT 行可记:是一个真实的缺席,而不是被静默豁免。

为什么"姊妹卡已关闭"不是不开卡的理由

#3724 的关闭状态是 state_reason: completed,它自己给的处置是二选一:

  • A. 实现行级租户隔离 —— 对齐 SQL driver 的租户列解析 / 读谓词 / 写打戳;
  • B. 显式声明不支持多租户 —— 检测到多租户模式就启动时硬失败,而不是静默地不隔离(原卡倾向 B)。

packages/drivers/driver-mongodb 现有的 mongodb-tenancy-guard.ts 正是处置 B。也就是说 #3724 是被"修好"关掉的,不是被"不修"关掉的,先例指向与"不必开卡"相反的方向。

与 #5499 冻结的关系

冻结的理由独立成立(且 #5704 已把它从测试后端迁走),但 #3724-B 恰好是能穿过冻结的那种形状:启动期拒绝不是对该 driver 能力的投资,而是消除一个静默失败模式。#3724 原文的判断在这里同样适用:

当前状态——能在多租户下启动、且不隔离——都不该保留。

是否值得动、以及走 A 还是 B,是分诊/维护者的定级问题,不是本卡替谁作的决定。本卡只负责把这个面记录下来,并纠正那条会让它被静默丢弃的前提。

关联

未认领,仅作记录。

Activity

  1. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    Triage (maintainer-authorized one-off pass, 2026-08-09, registered on #6015): promoted finding → pm:queue (domain:drivers kept), direction per the closed sister's precedent: disposition B — a boot-time hard failure when multi-tenant mode meets driver-memory, mirroring mongodb-tenancy-guard.ts (which is exactly how #3724 closed completed). A guard is compatible with the #5499 freeze — it invests nothing in the frozen driver's capabilities, it stops a silent non-isolation from ever serving traffic. Implementing real row-level isolation (A) stays behind the appetite door. The filer's correction stands: "sister card closed" meant fixed via B, not rejected — the same shape deserves the same guard.


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling (2026-08-10, chat): #6915 and #7117 take pm:on-hold under #5499. The standing 2026-08-05 instruction in seat post #6020 governs — it was not relaxed for this shape.

    Applied: pm:queue → pm:on-hold, domain:drivers kept. Unassigned; not dispatched.

    This resolves the conflict raised at 09:0xZ (seat comment 5230719256) and repeated on #7117 (comment 5233324830): triage had queued this card, the seat deferred rather than override a triage label on its own reading, and the maintainer has now settled it in favour of the freeze.

    Nothing about the finding is retracted. The measurement stands as filed — driver-memory has zero row-level tenant isolation, grep -c "tenantId\|organization_id" packages/drivers/driver-memory/src/memory-driver.ts → 0, and distinct(object, field, query?) does not even accept options. What changes is only its scheduling: it is held under the investment freeze rather than queued.

    The #3724-shaped question in the body (A implement isolation vs B refuse loudly at startup) is likewise not withdrawn — it stays recorded here for whenever the freeze is revisited. The argument that route B is the shape that survives a freeze, because a startup refusal removes a silent failure rather than investing in the driver's capabilities, is on the record and unaffected by this ruling.


    Generated by Claude Code

  3. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Returned to pm:queue — the hold's restart condition is met: the maintainer fully lifted the #5499 freeze on 2026-08-11 (verbatim on #5499, comment 5252526378).

    Note for selection: this is a tenant-isolation ABSENCE card carrying the #3724 A/B shape (implement row-level isolation vs refuse loudly at startup) — the disposition question its thread already raised is still open, so it likely routes through the decision path rather than straight to a dev. Re-verify anchors on dispatch; they were last checked at hold time.


    Generated by Claude Code

  4. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    pm:queue → needs-user-decision. The drivers seat is not dispatching this: the A/B choice the card names is a product-shape ruling, and the thing that used to decide it for us has just been removed.

    Anchors re-verified on origin/main @ 9bf4dd0 (all still hold)

    What changed since the card was written

    The card reasoned under the #5499 freeze, where route B was the only shape that could pass — a startup refusal is not an investment in the driver, it is the removal of a silent failure mode. The maintainer fully lifted #5499 on 2026-08-11 (5252526378), so route A is now permissible too. That is exactly why this can no longer be settled by precedent alone.

    The two routes, costed

    A — implement row-level isolation. Tenant column resolution + read predicates + write stamping, distinct() gains an options parameter, and driver-memory joins check-tenant-chokepoint.mjs's scan set so the AST gate re-derives it. Real work, M+, and it commits driver-memory to being a multi-tenant-capable deployment target.

    B — refuse at startup (mirrors mongodb-tenancy-guard.ts). Detect multi-tenant mode at boot and hard-fail instead of running unisolated. One guard + test, S/M, precedented shape already in this repo, dispatchable immediately. Anyone running driver-memory multi-tenant today breaks loudly — which is the entire point, and is a behavior change on existing deployments.

    Recommendation (B), and the question behind it

    B, unless driver-memory is meant to be a real multi-tenant deployment target. Two drivers in the same family answering the same tenancy question differently is worse than either answer, and #3724's own words still apply verbatim: 「当前状态——能在多租户下启动、且不隔离——都不该保留。」 The freeze lift removed the constraint that forced B; it did not supply an argument for A.

    What only the maintainer can settle: after #5704 moved driver-memory out of being the test backend, what is it for? A dev/demo/in-process driver takes B. A driver anyone may ship multi-tenant takes A.

    One line settles it and this seat dispatches accordingly — B is ready to run as-is.


    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling — 2026-08-12

    裁定:处置 B —— 检测到多租户姿态时启动硬失败,照抄 #3724 先例。

    要点:

    裁定人:维护者 huangyiirene(2026-08-12,接受 PM 综合分析后批准);由 PM 会话 session_01GZKbx4xyF7U5WXj6ch49BM 代笔落卡。转 pm:queue。


    Generated by Claude Code

  6. self-assigned this
    on Aug 12, 2026
  7. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Claimed by the drivers seat — PM session session_013ZU38vNzLXCscgdFZjKd53, dev branch claude/issue-6915-memory-tenancy-guard. Dispatching route B per the maintainer ruling in 5261729371.

    Anchors re-verified on origin/main @ b313fde (the card measured on 6595262; memory-driver.ts has been rewritten twice since by #7723 and #7852, so this is a fresh reading):

    • grep -c 'tenantId\|organization_id' packages/drivers/driver-memory/src/memory-driver.ts → 0. The absence is intact.
    • scripts/check-tenant-chokepoint.mjs:119-121 still scans driver-sql / driver-sqlite-wasm / driver-turso only. Under route B driver-memory stays outside that scan set — the gate re-derives a chokepoint that a refusing driver does not have.

    The precedent, read rather than assumed (mongodb-tenancy-guard.ts, 142 lines) — it is two layers, and the ruling's "照抄先例" covers both:

    1. assertSingleTenantPosture() reads the shared resolveTenancyPosture() resolver (ADR-0105 D1 — the canonical knob, which subsumes the legacy OS_MULTI_ORG_ENABLED boolean, "so the driver, auth, the registry and the CLI can never disagree about the mode"). Both walled postures (group, isolated) are refused; only single passes. Called at mongodb-driver.ts:141 and :163.
    2. assertObjectsNotTenantScoped() refuses syncing object schemas that declare tenancy.enabled: true, reporting every offender in one message "so an operator fixes the whole set in one pass instead of rediscovering them one boot at a time". Called at :586 and :598.

    Both sit behind a typed MongoDBMultiTenantUnsupportedError with an exported code constant, re-exported from the package index.

    One tension recorded, not resolved here. The ruling states 「#5499 投资冻结继续有效」, while #5499's own comment 5252526378 reads 「driver-memory 也解冻,继续清剩下两行 DEBT」. These reconcile if that lift was scoped to clearing the DEBT rows rather than reopening capability investment — which is the reading the ruling states and the one this seat is acting on. It does not block this card (route B is precisely the shape that passes either way, and it is what this seat recommended). It does mean this seat's own seat-post claim that #5499 「不再门控任何东西」 was overbroad; #6020 is being corrected accordingly. Flagging rather than absorbing, since a freeze's scope governs what the next drivers card may do.


    Generated by Claude Code

  8. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Done. Branch claude/issue-6915-memory-tenancy-guard @ eb1ad94, draft PR #7924. Route B per the maintainer ruling 5261729371; no row-level isolation, no tenant column resolver, driver-memory still outside scripts/check-tenant-chokepoint.mjs.

    What I built, per layer

    New packages/drivers/driver-memory/src/memory-tenancy-guard.ts — same shape, layering and error ergonomics as mongodb-tenancy-guard.ts, re-exported from the package index the way the sibling's is.

    Layer 1 — deployment posture. assertSingleTenantPosture() reads resolveTenancyPosture() (ADR-0105 D1); both walled postures refused, only single passes. Called from the constructor and re-checked in connect(), exactly as the precedent does — and for a reason the sibling's docstring only half-states, which I measured (below).

    Layer 2 — object metadata. assertObjectsNotTenantScoped() refuses schemas declaring tenancy.enabled: true, naming every offender in one message. Called from syncSchema() before the table is allocated.

    Both throw MemoryMultiTenantUnsupportedError / MEMORY_MULTI_TENANT_UNSUPPORTED. No override env var.

    Assumptions A–D under measurement

    A — two layers, both reachable. SURVIVES, with one shape correction. Both call sites exist here. Layer 1: memory-driver.ts has both a constructor and a real async connect() (it initialises the persistence adapter), and the engine connects registered drivers at engine.ts:5295. Layer 2: syncSchema() exists — but syncSchemasBatch() does not, and this driver's supports = {} so it never opts into batchSchemaSync (engine.ts:10035 / plugin.ts:1319 gate on that bit). So the batch call site the sibling has genuinely has no counterpart here, and I did not invent one — adding a batch path would be capability investment in a frozen driver. assertObjectsNotTenantScoped() keeps the array-taking signature anyway: it is the precedent's shape, and it is what makes the all-offenders-in-one-message property directly testable. A test pins both the plural message and the singular one syncSchema actually produces.

    B — resolveTenancyPosture() importable without a new dependency. FALSIFIED. driver-mongodb declares @objectstack/types; driver-memory did not — its deps were @objectstack/core, @objectstack/spec, mingo. tsc said so directly: memory-tenancy-guard.ts(54,39): error TS2307: Cannot find module '@objectstack/types'. Added "@objectstack/types": "workspace:*" plus a vitest alias; pnpm-lock.yaml gains the three mechanical lines. That is the one file in the diff outside packages/drivers/driver-memory + the changeset — a lockfile consequence of the dependency, not a cross-package change, but flagging it rather than absorbing it. The rest of the assumption holds: the resolver is the only thing read, no env var is touched directly. (Amusing corroboration that the seat's premise was half-right: driver-memory/vitest.config.ts:19 already carried the comment [ADR-0105 D1] Reached transitively via @objectstack/types — the transitive type reach existed, the declared dependency did not.)

    C — no pre-existing tenancy refusal. SURVIVES. grep -c 'tenantId\|organization_id' packages/drivers/driver-memory/src/memory-driver.ts → 0 on b9f930b. Repo-wide over the package, the only pre-existing hit for tenancy at all was that vitest comment. Nothing partial to build a second guard on top of.

    D — must not fire for ordinary single-tenant use. SURVIVES, and this is the measurement I'd most want re-checked. I intersected "names InMemoryDriver / @objectstack/driver-memory" (66 files) with "sets OS_TENANCY_POSTURE / OS_MULTI_ORG_ENABLED" across packages/apps/examples/scripts. Six files:

    • plugin-dev/src/dev-plugin-tenancy-{failfast,mount-refusal,posture}.test.ts — all three vi.mock('@objectstack/driver-memory') into an ERR_MODULE_NOT_FOUND throw, so the real driver is never constructed;
    • cli/src/commands/serve.ts and metadata-protocol/src/protocol.ts — prose mentions in comments only;
    • plugin-dev/src/dev-plugin.ts — the one real construction, and it is genuinely reachable under a walled posture. That is the intended refusal, not a regression.

    @objectstack/verify's harness sets a posture but never touches this driver. The dogfood suites moved off it at #5704. So nothing in the repo runs the real driver under a walled posture, and the default (nothing set → single) leaves every consumer untouched. Three tests pin that positively rather than by absence: construct→connect→sync→round-trip with no posture set, the same under explicit single, and objects that omit or disable the tenancy block.

    One correction to the recommended route, with evidence

    The dispatch suggested mirroring both posture call sites; I want to record why both are load-bearing here, because the sibling's docstring gives a reason that no longer applies (framework#3741 fixed the thing that motivated hoisting into the constructor) and the real reason is different:

    ObjectQLEngine.init() at engine.ts:5303-5306 downgrades a driver connect rejection to a warning when OS_ALLOW_DRIVER_CONNECT_FAILURE=1, and boots with a DEGRADED BOOT banner. A connect()-only guard is therefore bypassable by an env var — restoring precisely the silent non-isolation this card removes. The constructor is behind no such hatch. connect() is still kept: it is the seam that aborts kernel bootstrap with this message, and it catches a host that flips the posture between construction and connect. Both, for reasons that are now written down in the guard's docstring.

    Gates

    Gate Number
    pnpm lint clean
    turbo run typecheck (whole repo) 126/126 tasks
    @objectstack/driver-memory 24 files / 732 tests (20 new, all passing)
    @objectstack/verify 5 files / 23 tests
    @objectstack/dogfood 92 passed + 1 skipped / 588 passed + 3 skipped
    objectql 3315 · runtime 2096 · spec 10017 · cli 1190 · metadata-protocol 1066 · plugin-auth 1039 · plugin-dev 45 · service-datasource 326 · service-analytics 1558 all green
    check:driver-conformance 40 covered / 0 DEBT / 0 exempt — unchanged
    check:query-options-erasure 67 non-test sites / 17 files, no files added — baseline untouched, not raised
    check:tenant-chokepoint 19 bindings / 3 files — unchanged, driver-memory correctly absent
    check-driver-memory-census OK — 2 ruled consumers
    check:error-code-casing · check:empty-changeset · check-changeset-no-major clean

    Changeset: minor, not patch. A deployment that boots today can stop booting — that is the point, but it is still a behavior change and the release notes must be able to say so. Same grade #3724 took for the sibling guard (17.0.0-rc.0, feat(driver-mongodb)!:).

    Adjacent card filed — #7926, not folded in

    Measuring assumption D turned up a real defect in another package, so it is a separate issue per the dispatch's rule 4. dev-plugin.ts:311-327 wraps driver assembly in a bare catch {}, so a construction-time refusal is swallowed and reported as ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver. Measured by running the real DevPlugin.init() under OS_TENANCY_POSTURE=isolated: the constructor throws MEMORY_MULTI_TENANT_UNSUPPORTED and every word of the guard's message — the posture, the two env knobs, the driver-sql remedy — is discarded in favour of a claim that is false. In that particular run the stack still refused overall, but via the later ADR-0093 D5 organizations fail-fast; with the enterprise package present and healthy, DevPlugin would boot driverless on a lie. driver-mongodb has the same exposure and has simply never tripped it, since DevPlugin never constructs a Mongo driver. No fix attempted here — it is out of this card's diff scope and its right shape is a triage question.

    One thing I deliberately did not do

    packages/spec/src/api/error-code-ledger.zod.ts:399 registers MONGODB_MULTI_TENANT_UNSUPPORTED. I did not register MEMORY_MULTI_TENANT_UNSUPPORTED alongside it: that ledger governs codes a route may put in error.code on the wire (ADR-0112 D3), a boot refusal never reaches an envelope, and no gate requires it — check:error-code-casing checks casing only and passes. It would also have been a packages/spec edit, which the dispatch says to stop and report on rather than take unilaterally. Worth a PM look either way, since by the same reading the existing mongodb row is arguably registered-but-unemittable — the exact shape that file's own "Retiring a code" section warns about. Recording, not acting.


    Generated by Claude Code

  9. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    PM review: ACCEPT — PR #7924. Route B delivered as ruled; patch round green.

    The ruling was honoured, including the part that is easy to drift on. No tenant column resolver, no read predicate, no write stamping, distinct()'s signature untouched, and driver-memory correctly still absent from check-tenant-chokepoint.mjs (check:tenant-chokepoint 19 bindings / 3 files, unchanged). Nothing here is route A wearing route B's clothes.

    The asymmetry with the precedent is justified, not skipped. The sibling's second call site (syncSchemasBatch) has no counterpart: this driver has no such method and its supports = {} means it never opts into batchSchemaSync, so the engine syncs one object per call. Rather than invent a batch path, the array-taking signature was kept — precedent shape, directly testable all-offenders-in-one-message property — with the reason recorded in the guard's own docblock: adding a batch path here would be capability investment in a driver whose capabilities are frozen. That is the #5499 scope ruling from 5261729371 applied correctly to an implementation decision, by the dev, unprompted.

    Assumption B falsified, and the falsification was flagged rather than absorbed. @objectstack/types was not a declared dependency of driver-memory — tsc said so directly (TS2307). The fix (dependency + vitest alias + three mechanical lockfile lines) is the one thing in the diff outside driver-memory + changeset, and it was called out as such instead of being folded in quietly. Validate Package Dependencies is green, so the graph accepts it. The corroborating detail is a nice catch: vitest.config.ts already carried a comment claiming the resolver was reached transitively — the transitive type reach existed, the declared dependency did not.

    The precedent's stated reason was checked and found stale — and the real one is better. The sibling's docstring justifies hoisting into the constructor on grounds that framework#3741 has since fixed. The measured reason is different and stronger: ObjectQLEngine.init() downgrades a driver connect rejection to a warning under OS_ALLOW_DRIVER_CONNECT_FAILURE=1, so a connect()-only guard is bypassable by an env var — restoring exactly the silent non-isolation this card removes. The constructor is behind no such hatch; connect() is kept because it is the seam that aborts kernel bootstrap and because it catches a posture flipped between construction and connect. Both seams now have their reasons written down.

    Assumption D — the risk this card carried — was measured, not asserted. The intersection of "names InMemoryDriver / @objectstack/driver-memory" (66 files) with "sets OS_TENANCY_POSTURE / OS_MULTI_ORG_ENABLED" came to six, each accounted for: three plugin-dev tests mock the driver into an ERR_MODULE_NOT_FOUND throw so the real one is never constructed, two are prose in comments, and the one real construction (dev-plugin.ts) is genuinely reachable under a walled posture — the intended refusal. Backed by real consumer runs (@objectstack/verify 23, dogfood 588 + 3 skipped, plugin-dev 45) and three positive tests pinning that the ordinary single-tenant path still constructs, connects, syncs and round-trips. Nothing in the repo runs the real driver under a walled posture; the default (nothing set → single) leaves every consumer untouched.

    Patch round: the ADR-0087 disposition answers the question rather than satisfying the gate. not-required (no-migration-prescription), and the why is better reasoning than the seat's own lean supplied. Three things it establishes that a token would not have: tenancy.enabled: true stays valid, honoured, authorable metadata everywhere it already was, so a migration would be actively wrong — stripping the block on upgrade would silently disarm real isolation on the drivers that enforce it; what is refused is a deployment pairing, whose correct repair depends on which half is the mistake (move to driver-sql, or set the posture to single) — an operator decision the ledger has no way to express as a FROM/TO rule; and the precedent was checked rather than assumed — #3724 registered nothing (it predates this gate) and neither registry holds an entry for a driver-level tenancy refusal, so there is no convention to match or break.

    Error text is operator-grade, which for this card is a deliverable and not decoration: it names the detected posture, both env knobs that can produce it, the driver-sql alternative including connection: { filename: ':memory:' } as the in-process drop-in, the offending objects individually, and the tracking issue. Deliberately no override env var, with the reason stated — an escape hatch would restore the silent non-isolation the guard exists to remove.

    Gates: 26/26 conclusive, all success on the new head — ESLint, TypeScript Type Check, Check Changeset, Validate Package Dependencies, every Test Core shard, Dogfood, Temporal. check:driver-conformance 40 covered / 0 DEBT / 0 exempt unchanged.

    Two hand-backs, both correctly judged:

    • DevPlugin 的 bare catch 把任何 driver 构造失败都报成「not installed」(#6915 实测) #7926 filed rather than folded in — dev-plugin.ts's bare catch {} swallows a construction-time refusal and reports "not installed", a claim that is false. Right call to file it: different package, and it is a defect this guard exposes rather than one it causes.
    • Not registering MEMORY_MULTI_TENANT_UNSUPPORTED in packages/spec's error-code ledger — the seat agrees, and the reasoning is right: ADR-0112 D3 governs codes that may appear in error.code on the wire, a boot refusal never reaches an envelope, and it would have been a packages/spec edit this dispatch said to stop and report on. The observation that rides along — that the existing MONGODB_MULTI_TENANT_UNSUPPORTED row may therefore be registered-but-unemittable — is the spec seat's to weigh; the seat will pass it to them on the thread already open there rather than filing over their lane.

    Flipping ready and queueing.


    Generated by Claude Code

  10. added a commit that references this issue on Aug 29, 2026
  11. added a commit that references this issue on Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions