Skip to content

Observation: package permission sets are materialized with a tenant-less system context, so sys_permission_set rows land organization-less #8672

Description

@qq9340100

Found while implementing #8617 (per-organization audience-binding-suggestion reconciler). Filed as an observation, not a defect claim — the harm is unmeasured and the contract question underneath it is a maintainer call, not mine. Recording it so the next author does not have to re-derive it.

What is there

packages/plugins/plugin-security/src/permission-set-projection.ts exports the shared engine helpers used by the permission-set seeder and the package-door publish materializer:

exportasyncfunctiontryFind(ql: any,object: string,where: any,limit=100): Promise<any[]>{try{constrows=awaitql.find(object,{ where, limit },{context: SYSTEM_CTX});

SYSTEM_CTX there is { isSystem: true } with no tenantId, and upsertPackagePermissionSet in bootstrap-declared-permissions.ts writes sys_permission_set through those helpers — at boot, on a package-door permission publish, and from confirmAudienceBindingSuggestion when a suggestion's set is not yet materialized.

sys_permission_set declares no tenancy opt-out, so its organization_id is injected like any other tenant column. Measured on a real ObjectQL + SqlDriver engine under OS_TENANCY_POSTURE=isolated (the same rig as #8617):

callresult
insert under { isSystem: true }stores organization_id NULL
find under { isSystem: true, tenantId: X }sees X's rows and the NULL ones

So a package-declared permission set lands in the platform bucket (ADR-0120 D3) and is read by every tenant.

Why this is an observation and not a fix

Unlike sys_audience_binding_suggestion — whose header says rows are "resolved when a tenant admin confirms", which is what made #8617 a straightforward per-organization repair — a permission SET plausibly is installation-wide by design: the package ships one definition, and the per-tenant decision is the BINDING (sys_position_permission_set), not the set. On that reading an organization-less row is exactly right and nothing needs changing.

What I did not measure, and what would decide it:

  1. whether any deployment shape wants two organizations to hold different sets under one name (if yes, the org-less write blocks it, and the declared unique-index class of tenant-scoped objects get GLOBAL unique indexes: 409-vs-201 enumerates other tenants' values, and a user's preferences silently stop persisting in their second org #8323 / fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) #8461 / sys_position.name is the third instance of the #8323 class: an admin-authored name on a tenant-scoped RBAC object carries an installation-wide unique index #8468 / Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 applies here too);
  2. whether a tenant can create its own sys_permission_set row through the admin door, and how that row's organization_id interacts with the org-less package row of the same name — upsertPackagePermissionSet's provenance branches (managed_byplatform/user/absent ⇒ "env-authored, never clobbered") resolve ownership by provenance, never by organization.

Related

Filed unassigned. Found by session session_01MX1qcBzfwZb5wkRrJTNbhH (dev seat for #8617).


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions