Skip to content

Commit f3e26b7

Browse files
os-zhuangclaude
andauthored
docs(plugin-security,skills): re-premise member_default's removed wildcard in published prose (#7151) (#7171)
Two shipped documents still asserted a `member_default` shape the platform no longer has. Re-measured against the real imported `defaultPermissionSets` at this branch point: `member_default.objects['*']` is `undefined`, and neither `member_default` nor `viewer_readonly` carries a `tenant_isolation` policy — 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. Rewritten to the posture resolution, the Layer 0 wall, and the provenance-based strip that actually survives (ADR-0105 D3). - `skills/objectstack-data/SKILL.md` built a customer-facing warning on a leak that cannot happen. The recipe is unchanged and still correct; every stated reason is now the measured one, and the callout names the surviving hazard (any set with a wildcard read grant — `viewer_readonly` still ships one). No runtime behaviour changes; documentation only. Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52c9cab commit f3e26b7

3 files changed

Lines changed: 76 additions & 19 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
docs(plugin-security,skills): re-premise `member_default`'s removed wildcard in the published customer skill and in the plugin's own README (#7151)
6+
7+
Two shipped documents still described a permission-set shape the platform has not
8+
had for two releases. Both premises were re-measured against the real imported
9+
`defaultPermissionSets` at this branch point, and both had expired:
10+
11+
- `member_default.objects['*']` is `undefined` — the plain `'*'` object grant was
12+
removed when the platform baseline narrowed to explicit-allow.
13+
- Neither `member_default` nor `viewer_readonly` carries a `tenant_isolation`
14+
entry in `rowLevelSecurity`, and neither carries any wildcard tenant policy at
15+
all. Tenant isolation is **Layer 0** (`tenant-layer.ts`) since ADR-0095 D1.
16+
17+
**`packages/plugins/plugin-security/README.md`** described the pre-ADR-0095
18+
probe-and-strip mechanism as the plugin's own current behaviour ("Service present
19+
→ keeps the wildcard `tenant_isolation` RLS policy … shipped with the default
20+
`member_default` / `viewer_readonly` permission sets"). Rewritten to the real
21+
mechanism: the plugin resolves a tenancy **posture** at start time; the tenant
22+
wall is Layer 0, AND-composed ahead of business RLS and inert under `single`; and
23+
the strip that survives targets the platform's own tenant-scoped policies **by
24+
provenance** (`organization_admin`'s `sys_member_org` / `sys_invitation_org` /
25+
`sys_team_org`, the `sys_organization_self` carve-out), never an app-authored
26+
policy — which reaches the compiler and fails closed there (ADR-0105 D3).
27+
28+
**`skills/objectstack-data/SKILL.md`** (published customer guidance) did not
29+
merely mention the wildcard — its ⚠️ callout built a recommendation on a leak
30+
that cannot happen. The recommended recipe
31+
(`tenancy: { enabled: false }` + `requiredPermissions`) is unchanged and still
32+
correct, but every stated reason for it was rewritten to the measured one:
33+
34+
- the empty-list symptom is the Layer 0 tenant wall denying rows whose
35+
`organization_id` is null or absent, not a `member_default` RLS policy;
36+
- `viewAllRecords` short-circuits business RLS only and never crosses the wall —
37+
that takes a true platform admin (the superuser bit **and** a
38+
platform-exclusive capability) on a posture that permits it;
39+
- the ⚠️ now names the surviving hazard truthfully. `tenancy: { enabled: false }`
40+
alone switches the wall off for every caller, and the risk is any permission
41+
set with a wildcard read grant — the shipped `viewer_readonly` still has one —
42+
not `member_default`, which grants only the objects it names.
43+
44+
No runtime behaviour changes; documentation only.

packages/plugins/plugin-security/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ await kernel.use(new OrgScopingPlugin()); // MUST be BEFORE SecurityPlugin
4646
await kernel.use(new SecurityPlugin());
4747
```
4848

49-
SecurityPlugin probes `getService('org-scoping')` at start time:
49+
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:
5050

51-
- **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.
52-
- **Service absent** → strips those wildcard policies so single-tenant deployments aren't filtered to zero rows.
51+
- **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.
52+
- **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).
5353

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

skills/objectstack-data/SKILL.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -761,28 +761,41 @@ should be visible to a **platform admin env-wide** but hidden from members —
761761
e.g. identity tables a plugin writes via its own adapter (`sys_sso_provider`,
762762
OAuth clients). These hit a non-obvious interaction:
763763

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

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

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

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

0 commit comments

Comments
 (0)