Skip to content

The audience-binding-suggestion reconciler writes with a tenant-less system context, so one organization-less row serves every tenant — the second half of the #8577 install-path dead end #8617

Description

@os-zhuang

Found while implementing #8577 (scoping sys_audience_binding_suggestion's declared unique index per organization), by measuring the install path end to end through the real reconciler rather than stopping at the driver-level 409/201 oracle. Filed separately because #8577's scope is exactly the two index respellings; nothing here is fixed there.

What #8577 fixes, and what it does not

#8577 makes the storage able to hold one suggestion row per organization: (package_id, permission_set_name, anchor) stops being an installation-wide key. Measured with a tenant-threaded execution context on a real ObjectQL + SqlDriver engine, driving the real syncAudienceBindingSuggestions:

PRE-fix org_jia sync -> created 1 | org_yi sync -> created 0, org_yi sees 0 rows
POST-fix org_jia sync -> created 1 | org_yi sync -> created 1, each sees its own

But the shipped call path never threads a tenant.packages/plugins/plugin-security/src/suggested-audience-bindings.ts reads and writes through a module-level constant:

constSYSTEM_CTX={isSystem: true};

and security-plugin.ts invokes the reconciler with the bare engine at boot and after a package-door permission publish. listAudienceBindingSuggestions gates on the caller's identity but then reconciles with SYSTEM_CTX too.

Measured on a real engine (better-sqlite3, OS_MULTI_ORG_ENABLED=true, OS_TENANCY_POSTURE=isolated)

measurement
insert under { isSystem: true }stores organization_idNULL
insert under { isSystem: true, tenantId: X }stores organization_id = X
find under { isSystem: true }sees every organization's rows
find under { isSystem: true, tenantId: X }sees X's rows and the NULL-organization rows

The last row is the platform's declared behaviour, not a surprise: the driver expands a tenant predicate to organization_id = :tenant OR organization_id IS NULL (ADR-0120 D3's platform bucket).

Running the reconciler exactly as shipped, twice, on the post-#8577 schema:

first sync -> { created: 1 }
second sync -> { created: 0 }
stored rows -> [ { org: NULL, key: com.acme.crm/sales_readonly/everyone, status: pending } ]

Identical before and after #8577. So on a shared-runtime multi-organization installation the surface holds one organization-less row that every tenant reads, and its consequences are:

  1. The first tenant admin to confirm or dismiss flips that single row. Every other tenant's console then shows a resolved suggestion and never prompts them — while confirmAudienceBindingSuggestion created the actual sys_position_permission_set binding with the caller's context, i.e. in the confirming tenant's organization only. So for every other tenant the package's default permission set is still not bound, and the surface says it is.
  2. findAnchorPositions resolves sys_position by name under the same tenant-less context with limit 1, so the everyone anchor a suggestion is checked against is whichever organization's row the driver returns first.
  3. bindingExists therefore answers a question about some other tenant's binding.

This is the same functional dead end #8577 describes — the second organization's admin is never prompted — reached by a second, independent road. Fixing the index is necessary (without it even a correctly tenant-scoped write is refused) but it is not sufficient on this deployment shape.

Suggested shape (not a decision — the tenancy question is the maintainer's)

Two readings, and they lead to different architectures, so this needs the ruling before code:

  • A. The suggestion surface is per-organization (what the object's own header says: rows are "resolved when a tenant admin confirms", and organization_id is injected with no tenancy opt-out). Then the reconciler must run per organization with that organization's context, and every read in it must be tenant-scoped. This is the reading Two MORE clean inheritors of the #8323 class the #8554 sweep missed: sys_notification_subscription and sys_audience_binding_suggestion — both measured live #8577's respelling assumes.
  • B. The suggestion surface is installation-wide (one row per package x set x anchor, deliberately organization-less). Then the object should declare a tenancy opt-out and its index should be an explicit unique: 'global' — and the per-tenant prompt has to live somewhere else, because one row cannot carry N tenants' decisions.

B contradicts the object's declared shape and ADR-0090 D5/D9's per-tenant confirm gate, so A looks right, but the choice is a public-contract question about the object.

Pinned, not just described

packages/plugins/plugin-security/src/suggested-audience-bindings-install-path.test.ts (added by #8577) records today's behaviour in its section 3, the shipped SYSTEM_CTX call path is tenant-blind (recorded, not endorsed). Those two assertions are a record, not an endorsement: the fix for this issue must DELETE them, and if they still pass afterwards the fix did not work.

Related

Filed unassigned, unlabeled for triage. Found by session session_012WMpuAfA2KSdDjGF6tm1bH (dev seat for #8577).

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions