Skip to content

Virtual objects on a plugin driver get no organization scoping: no Layer-0 wall and no tenant in DriverOptions, so a multi-org deployment following the #27130 recipe leaks rows across organizations #29761

Closed as not planned

Description

@baozhoutao

Edition: ObjectOS Enterprise (licensed, self-managed)
Version: 18.2.0 in production. The virtual-object route is evaluated against 18.3.0 (origin/main 0b2b309c).

Background

#27130 (closed) was answered with the virtual-object recipe: a plugin driver behind an external object (content/docs/data-modeling/virtual-objects.mdx, reference test packages/runtime/src/plugin-driver-external-object.spike.test.ts). We are migrating our production-scheduling gantt to that recipe. Our deployment is multi-organization: several companies share one instance, and each must see only its own projects, plans and work orders.

The current endpoint scopes every read to the caller's organization (we shipped a P0 fix for a cross-company leak there). The recipe gives us no way to keep that scoping.

What happens

Read on origin/main 0b2b309c:

  1. No Layer-0 wall on a virtual object. An object with an external block whose organization_id is the platform-injected definition is treated as having no tenant column: hasPhantomTenantAnchor (packages/plugins/plugin-security/src/federated-phantom-anchors.ts:131) makes objectHasOrgIdField false (packages/plugins/plugin-security/src/tenant-layer.ts:112), so the verdict is none. No organization_id condition reaches the driver's where, under any posture.
  2. The driver is not told who is asking. find(object, query, options?) gets the object name, the query and DriverOptions. For a federated object tenantId / tenantIds are withheld on purpose (packages/objectql/src/engine-driver-options.ts:131, tenantObject = … && !isFederated). There is no user field in DriverOptions either.
  3. So a driver written as the guide shows reads its source with a system identity, and every organization sees every row the driver returns. Nothing refuses it and nothing warns.

The guide and the reference test do not cover this: the spike runs under the default single posture, and the guide does not mention organization scoping.

What we found as a workaround (unverified)

If the virtual object declares its own organization_id field (any definition that differs from TENANT_SCOPE_FIELD_DEF), hasPhantomTenantAnchor returns false, the Layer-0 wall applies, and { organization_id: <org> } (or the deny filter) arrives in the driver's where. The driver then has to return organization_id on every row and push that condition down.

This depends on the exact-equality check in equalsShippedDef and is not documented, so we would rather not build on it without a platform answer.

Request

One of the following, or both:

  • (a) Document and pin the workaround. State in the virtual-objects guide that a multi-organization deployment must declare its own organization_id on the virtual object and return it from the driver. Add an isolated / group posture case to the reference test that proves the wall reaches the driver's where, and that a caller of org A never reads org B's rows.
  • (b) Give a plugin driver the caller's scope. For a plugin-published driver (not a built-in SQL driver federating a remote database), pass tenantId / tenantIds in DriverOptions, so the driver can scope its own source. The reason external-datasource-federated-read: the platform injects its org-scoping predicate onto a federated remote table that has no organization_id column #7738 withholds them from federated SQL objects (a remote table has no tenant column) does not apply to code that computes its rows.

Either way, a validate warning for "external object on a plugin driver, walled posture, no declared organization_id" would catch the leak before it ships. #27332 added the same kind of warning for the sharing model.

Related

Activity

  1. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

    Thank you for the report. A security item is with the maintainer, and the work continues there privately; this card is closed on the maintainer's decision.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions