Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/member-default-wildcard-published-prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/plugin-security": patch
---

docs(plugin-security,skills): re-premise `member_default`'s removed wildcard in the published customer skill and in the plugin's own README (#7151)

Two shipped documents still described a permission-set shape the platform has not
had for two releases. Both premises were re-measured against the real imported
`defaultPermissionSets` at this branch point, and both had expired:

- `member_default.objects['*']` is `undefined` — the plain `'*'` object grant was
removed when the platform baseline narrowed to explicit-allow.
- Neither `member_default` nor `viewer_readonly` carries a `tenant_isolation`
entry in `rowLevelSecurity`, and neither carries any wildcard tenant policy at
all. Tenant isolation is **Layer 0** (`tenant-layer.ts`) since ADR-0095 D1.

**`packages/plugins/plugin-security/README.md`** described the pre-ADR-0095
probe-and-strip mechanism as the plugin's own current behaviour ("Service present
→ keeps the wildcard `tenant_isolation` RLS policy … shipped with the default
`member_default` / `viewer_readonly` permission sets"). Rewritten to the real
mechanism: the plugin resolves a tenancy **posture** at start time; the tenant
wall is Layer 0, AND-composed ahead of business RLS and inert under `single`; and
the strip that survives targets the platform's own tenant-scoped policies **by
provenance** (`organization_admin`'s `sys_member_org` / `sys_invitation_org` /
`sys_team_org`, the `sys_organization_self` carve-out), never an app-authored
policy — which reaches the compiler and fails closed there (ADR-0105 D3).

**`skills/objectstack-data/SKILL.md`** (published customer guidance) did not
merely mention the wildcard — its ⚠️ callout built a recommendation on a leak
that cannot happen. The recommended recipe
(`tenancy: { enabled: false }` + `requiredPermissions`) is unchanged and still
correct, but every stated reason for it was rewritten to the measured one:

- the empty-list symptom is the Layer 0 tenant wall denying rows whose
`organization_id` is null or absent, not a `member_default` RLS policy;
- `viewAllRecords` short-circuits business RLS only and never crosses the wall —
that takes a true platform admin (the superuser bit **and** a
platform-exclusive capability) on a posture that permits it;
- the ⚠️ now names the surviving hazard truthfully. `tenancy: { enabled: false }`
alone switches the wall off for every caller, and the risk is any permission
set with a wildcard read grant — the shipped `viewer_readonly` still has one —
not `member_default`, which grants only the objects it names.

No runtime behaviour changes; documentation only.
6 changes: 3 additions & 3 deletions packages/plugins/plugin-security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ await kernel.use(new OrgScopingPlugin()); // MUST be BEFORE SecurityPlugin
await kernel.use(new SecurityPlugin());
```

SecurityPlugin probes `getService('org-scoping')` at start time:
SecurityPlugin resolves the tenancy **posture** (`single` | `group` | `isolated`) once at start time — preferring the `tenancy` service, and falling back to probing `getService('org-scoping')` (present ⇒ the historical `isolated` posture). Two consequences:

- **Service present** → keeps the wildcard `tenant_isolation` RLS policy (`organization_id = current_user.organization_id`) shipped with the default `member_default` / `viewer_readonly` permission sets.
- **Service absent** → strips those wildcard policies so single-tenant deployments aren't filtered to zero rows.
- **Tenant isolation is not an RLS policy.** Since ADR-0095 D1 the organization wall is **Layer 0** (`tenant-layer.ts`): an independent filter AND-composed ahead of business RLS, so a business-RLS change can never weaken it (W1) and the `viewAllRecords` / `modifyAllRecords` superuser bypass can never cross it (W2 — crossing takes a true `PLATFORM_ADMIN`). Under the `single` posture Layer 0 is inert. Accordingly the default `member_default` / `viewer_readonly` sets ship **no** wildcard `tenant_isolation` policy: `member_default` carries the owner-scoped `owner_only_writes` / `owner_only_deletes` plus per-object `_self` carve-outs on the better-auth identity tables, and `viewer_readonly` carries the `_self` carve-outs only.
- **The platform's own tenant-scoped RLS policies are still stripped when no wall is enforced** (`single`), so single-tenant deployments aren't filtered to zero rows and don't pay the field-existence safety net on every find — e.g. `organization_admin`'s `sys_member_org` / `sys_invitation_org` / `sys_team_org`, and the `sys_organization_self` carve-out. The strip is by **provenance**, not by pattern-matching the predicate: an app-authored tenant policy is never stripped — it reaches the compiler and fails closed there, with a one-time operator warning (ADR-0105 D3).

`organization_id` auto-injection on insert is provided by OrgScopingPlugin; `owner_id` auto-injection always runs in SecurityPlugin regardless.

Expand Down
45 changes: 29 additions & 16 deletions skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,28 +761,41 @@ should be visible to a **platform admin env-wide** but hidden from members —
e.g. identity tables a plugin writes via its own adapter (`sys_sso_provider`,
OAuth clients). These hit a non-obvious interaction:

- The default `member_default` ships a **wildcard `tenant_isolation` RLS**
(`organization_id == current_user.organization_id`). Any row whose
`organization_id` is **null or absent** (common for adapter-written rows that
never get the tenant stamp) is **denied** — the list renders empty.
- A platform admin's `viewAllRecords` superuser bypass is **posture-gated**: it
fires **only** for objects marked `access.default: 'private'` **or**
`tenancy: { enabled: false }`. On ordinary tenant objects it deliberately does
**not** grant cross-tenant visibility — so the admin sees 0 rows too.
- Reads of a tenant object pass the **Layer 0 tenant wall** (ADR-0095 D1): an
`organization_id == <the caller's organization>` filter AND-composed ahead of
every business RLS policy. Any row whose `organization_id` is **null or
absent** (common for adapter-written rows that never get the tenant stamp) is
**denied** — the list renders empty. Single-tenant deployments never hit this;
the wall is inert there.
- The `viewAllRecords` superuser bit is **posture-gated and wall-blind**: it
short-circuits **business RLS only**, and only on objects whose posture allows
it (`access.default: 'private'`, `tenancy: { enabled: false }`, or a
better-auth-managed identity table). It never crosses the Layer 0 wall —
crossing takes a *true platform admin* (the superuser bit **and** a
platform-exclusive capability: `manage_metadata`, `manage_platform_settings`,
`studio.access`, `manage_users`) on one of those same postures. So an org
admin holding the superuser bit stays org-scoped, and on an ordinary tenant
object nobody crosses — the admin sees 0 rows too.

**Recipe — env-global, admin-only object that admins can fully see:**

```typescript
tenancy: { enabled: false }, // env IS the tenant; admin viewAllRecords bypass applies
requiredPermissions: ['manage_platform_settings'], // object-level gate → members get 403
tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403
```

> ⚠️ **Don't use either flag alone.** `tenancy.enabled:false` *by itself* drops
> the wildcard RLS, and `member_default`'s `'*': allowRead` then **leaks every
> row to all authenticated users**. `access.default:'private'` *by itself* opts
> the admin's `'*'` grant out too, so the **admin sees nothing**. The
> `tenancy.enabled:false` + `requiredPermissions` pair is the correct combo
> (admin sees all, non-admins 403). Posture model: ADR-0066.
> ⚠️ **Both keys are load-bearing — neither works alone.**
> `tenancy: { enabled: false }` *by itself* switches the wall off for **every**
> caller, and any permission set carrying a wildcard (`'*'`) read grant then
> reads every row env-wide — the shipped `viewer_readonly` still carries one, as
> may an app-declared default profile or a customer-authored set. (The
> `member_default` baseline is **not** one of them: it is explicit-allow and
> grants only the objects it names.) `requiredPermissions` *by itself* leaves the
> object a tenant object, so the wall keeps denying the untagged rows and even a
> platform admin sees nothing. The pair is the correct combo (admin sees all,
> non-admins 403), and `requiredPermissions` is the half that holds however
> permissive the caller's grants are — it is an AND-gate checked **before** the
> CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

### Cross-skill notes

Expand Down
Loading