Skip to content

Commit 59c544d

Browse files
os-zhuangclaude
andauthored
fix(qa,platform-objects,docs): re-premise member_default's removed wildcard and replace two vacuous D7 denial cases (#6964) (#7149)
* fix(qa,platform-objects,docs): re-premise member_default's removed wildcard and replace two vacuous D7 denial cases (#6964) * chore: changeset for #6964 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c8f6eb commit 59c544d

7 files changed

Lines changed: 168 additions & 43 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
**`member_default`'s removed wildcard was still named as live fact, and two D7 dogfood denials had gone vacuous (#6964).**
6+
7+
#5491 (PR #6684) removed `member_default`'s plain `'*'` object grant and ADR-0095
8+
D1 retired its wildcard `tenant_isolation` RLS policy. Six live sites outside the
9+
two surfaces PR #6958 already fixed still asserted one of those two facts as
10+
current, and — the reason this is not only prose — two dogfood tests rested their
11+
whole evidential claim on the first one.
12+
13+
**Prose (`platform-objects`, `qa/dogfood`, published docs).** The
14+
`requiredPermissions` gates on `sys_scim_provider` and `sys_sso_provider`
15+
justified themselves by an exposure that no longer exists, which invites the next
16+
reader to conclude the gates are redundant. They are not: `requiredPermissions`
17+
is a capability AND-gate evaluated *before* the CRUD grant, so it denies
18+
regardless of how permissive any grant is — including one an app-declared profile
19+
or a customer-authored set names. `sys_sso_provider`'s `tenancy.enabled:false`
20+
and `rls-multitenant`'s investigation narrative are re-premised on the ADR-0095
21+
D1 Layer 0 tenant wall, which is what actually decides them now. And
22+
`content/docs/permissions/index.mdx` stated the retired wildcard
23+
`tenant_isolation` policy as shipped behaviour, contradicting
24+
`releases/implementation-status.mdx` in the same repo; the doc now matches the
25+
status page.
26+
27+
**The defect.** `showcase-default-profile` and `showcase-d7-default-profile`
28+
proved ADR-0056 D7 with `expect(status).not.toBe(200)` on an app object,
29+
justified by "`member_default` has a wildcard grant → would be 200". With the
30+
wildcard gone that baseline grants nothing on app objects, so the denial became
31+
the trivially expected outcome and the assertion passed *because nothing is
32+
produced* — it could no longer tell "the declared default is in force" from "no
33+
default is in force at all", which is the one thing those files exist to tell.
34+
Measured on a live showcase boot, one fresh sign-up per wiring: under the
35+
built-in baseline `showcase_private_note` and `showcase_contact` are **403**,
36+
exactly as under the declared default.
37+
38+
Both denial cases are replaced wholesale rather than re-worded, with an object
39+
only the built-in baseline grants (`sys_user_preference`): 200 if and only if
40+
`member_default` governs. The same run settles the risk that would have killed
41+
that idea — a named `fallbackPermissionSet` **replaces** `member_default` rather
42+
than merging additively on top of it. Reverse-verified: stripping the declared-
43+
default wiring turns the new case red (200) and the positive case red (403),
44+
while the deleted cases stay green — the vacuity, demonstrated directly.
45+
46+
No runtime behaviour changes.

content/docs/permissions/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const HighValueOpportunitySharingRule = defineSharingRule({
4444
Because AI agents act through the same permission-aware surface, these rules bound
4545
agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as-tools)).
4646

47-
> **Implementation status — Permission Model v2 (ADR-0090) is live.** REST → ObjectQL propagates a populated `ExecutionContext` (userId, tenantId, positions, permissions, principalKind) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks fire on every authenticated request; authenticated principals implicitly hold the `everyone` position and anonymous principals hold `guest` (D9). The `member_default` baseline is **additive** (D5 — no fallback cliff) and its owner-write policies are domained to `org_member` holders. The default `member_default` set ships the wildcard tenant-isolation RLS (`organization_id == current_user.organization_id`) with the same canonical-name contract as before; SecurityPlugin is the sole authority for tenant isolation, and analytics auto-bridges to `security.getReadFilter`. **Anonymous traffic is denied by default** (ADR-0056 D2), and public forms self-authorize via a declaration-derived `publicFormGrant`. **An unset OWD fails closed to `private`** (D1) and the D7 publish linter makes it a build error. Criteria sharing rules (with `position` / `unit_and_subordinates` recipients) are live and dogfood-proven; v17 reconciled the authoring surface with the enforced runtime (#1878) — `group` was renamed to the enforced `team`, `business_unit` joined the enum, and owner-type rules and `guest` recipients were pruned rather than left declared-but-skipped ([Sharing Rules](/docs/permissions/sharing-rules#recipient-types)). RBAC-table writes are governed by the delegated-admin gate (D12), and the `security` service answers `explain(request)` per evaluation layer (D6). The Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See [Implementation Status](/docs/releases/implementation-status) for the latest matrix.
47+
> **Implementation status — Permission Model v2 (ADR-0090) is live.** REST → ObjectQL propagates a populated `ExecutionContext` (userId, tenantId, positions, permissions, principalKind) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks fire on every authenticated request; authenticated principals implicitly hold the `everyone` position and anonymous principals hold `guest` (D9). The `member_default` baseline is **additive** (D5 — no fallback cliff) and its owner-write policies are domained to `org_member` holders. Tenant isolation is a **Layer 0 tenant wall** (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired, because as an OR-merged business policy it could be widened. `member_default` still ships the per-object `sys_organization_self` / `sys_user_self` overrides for the global tables that carry no `organization_id` column. SecurityPlugin remains the sole authority for tenant isolation, and analytics auto-bridges to `security.getReadFilter`. **Anonymous traffic is denied by default** (ADR-0056 D2), and public forms self-authorize via a declaration-derived `publicFormGrant`. **An unset OWD fails closed to `private`** (D1) and the D7 publish linter makes it a build error. Criteria sharing rules (with `position` / `unit_and_subordinates` recipients) are live and dogfood-proven; v17 reconciled the authoring surface with the enforced runtime (#1878) — `group` was renamed to the enforced `team`, `business_unit` joined the enum, and owner-type rules and `guest` recipients were pruned rather than left declared-but-skipped ([Sharing Rules](/docs/permissions/sharing-rules#recipient-types)). RBAC-table writes are governed by the delegated-admin gate (D12), and the `security` service answers `explain(request)` per evaluation layer (D6). The Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See [Implementation Status](/docs/releases/implementation-status) for the latest matrix.
4848

4949
## What's in this module
5050

packages/platform-objects/src/identity/sys-scim-provider.object.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,20 @@ export const SysScimProvider = ObjectSchema.create({
3636
// [ADR-0066 D3/④] Admin-only identity config carrying a live credential
3737
// (`scim_token` — the bearer external IdPs authenticate provisioning calls
3838
// with). Object-level capability gate, mirroring the sibling
39-
// `sys_sso_provider`: ordinary members are denied entirely (without it, the
40-
// `member_default` wildcard `'*': allowRead` would expose SCIM connections
41-
// to every authenticated user). better-auth's own endpoints read via a
42-
// system context, so SCIM provisioning is unaffected.
39+
// `sys_sso_provider`: ordinary members are denied entirely.
40+
//
41+
// [#6964] The exposure this used to name — `member_default`'s
42+
// `'*': allowRead` — no longer exists: #5491 removed that wildcard and the
43+
// platform baseline is explicit-allow. The gate is not thereby redundant, and
44+
// its live reason is the stronger one: `requiredPermissions` is a capability
45+
// AND-gate evaluated BEFORE the CRUD grant (`security-plugin.ts` step 1.5), so
46+
// a caller missing the capability is denied "regardless of how permissive
47+
// their grants are" — including a grant an app-declared default profile or a
48+
// customer-authored permission set names on this object. Without it the
49+
// table's only protection would be that no set happens to grant it today.
50+
//
51+
// better-auth's own endpoints read via a system context, so SCIM provisioning
52+
// is unaffected.
4353
requiredPermissions: ['manage_platform_settings'],
4454
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
4555
protection: {

packages/platform-objects/src/identity/sys-sso-provider.object.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,28 @@ export const SysSsoProvider = ObjectSchema.create({
3131
icon: 'shield-check',
3232
isSystem: true,
3333
managedBy: 'better-auth',
34-
// ADR-0024 — env-global, ADMIN-ONLY identity config. Two orthogonal controls:
34+
// ADR-0024 — env-global, ADMIN-ONLY identity config. Two orthogonal controls,
35+
// both re-premised in #6964 because the mechanisms they used to name expired:
3536
// • `tenancy.enabled: false` — the env IS the tenant; providers are env-wide,
36-
// not org-partitioned. Opting out of multi-tenancy lets a platform admin's
37-
// `viewAllRecords` superuser bypass see every provider (without it, the
38-
// `member_default` wildcard `tenant_isolation` RLS denies every row, since
39-
// better-auth writes via its adapter with no tenantId → `organization_id`
40-
// is never stamped).
37+
// not org-partitioned. What this marks out of the way is now the ADR-0095
38+
// D1 **Layer 0 tenant wall** (`plugin-security/tenant-layer.ts`), not the
39+
// wildcard `tenant_isolation` RLS `member_default` used to ship — D1
40+
// retired that policy. The consequence is unchanged: this table HAS an
41+
// `organization_id` column, and better-auth writes via its adapter with no
42+
// tenantId → the column is never stamped, so a wall AND-composing
43+
// `organization_id == <caller org>` would deny every row. Opting out sets
44+
// `tenancyDisabled`, which makes `computeTenantLayer0Filter` return `null`
45+
// (platform-global object ⇒ Layer 0 contributes nothing), so admins see
46+
// every env provider. This is now the ONLY thing that opens the table up:
47+
// under ADR-0095 W2 the `viewAllRecords` superuser bit alone no longer
48+
// crosses the wall — that takes a true `PLATFORM_ADMIN`.
4149
// • `requiredPermissions: ['manage_platform_settings']` — object-level
42-
// capability gate (ADR-0066 D3) so ordinary members are denied entirely
43-
// (without it, tenancy-disabled + `member_default`'s `'*': allowRead` would
44-
// leak providers to every authenticated user).
50+
// capability AND-gate (ADR-0066 D3/⑤) evaluated BEFORE the CRUD grant
51+
// (`security-plugin.ts` step 1.5), so ordinary members are denied
52+
// "regardless of how permissive their grants are". It no longer stands
53+
// against `member_default`'s `'*': allowRead` — #5491 removed that
54+
// wildcard — but against any grant on this object that an app-declared
55+
// default profile or a customer-authored permission set might name.
4556
// Together: admins see all env providers; non-admins get 403. better-auth's
4657
// own endpoints already read via a system context. (Env-only object — no
4758
// control-plane cross-tenant risk.)

packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,28 @@
33
// Org-scoped (#1994) cross-tenant gate — the faithful counterpart to the
44
// single-tenant smoke in `auto-verify-rls.dogfood.test.ts`.
55
//
6-
// THE INVESTIGATION (why the single-tenant run showed all `member-visible`):
7-
// `member_default` scopes rows with a wildcard `tenant_isolation` policy
8-
// (`organization_id = current_user.organization_id`). When the org-scoping
9-
// plugin is absent, SecurityPlugin.collectRLSPolicies STRIPS every policy whose
10-
// predicate references `current_user.organization_id` (security-plugin.ts) — and
11-
// `member_default` carries NO owner-scoped READ policy — so a fresh member can
12-
// read every row. That is the `member-visible` verdict: not a broad-read default
13-
// of the app, but the harness booting single-tenant. Apps like hotcrm (9 sharing
14-
// files, `requires: ['sharing']`) rely on exactly this org boundary, so a
15-
// single-tenant boot under-reports their authorization model.
6+
// THE INVESTIGATION (why the single-tenant run showed all `member-visible`),
7+
// stated in the mechanism of its time: `member_default` THEN scoped rows with a
8+
// wildcard `tenant_isolation` policy (`organization_id =
9+
// current_user.organization_id`), and when the org-scoping plugin was absent
10+
// SecurityPlugin.collectRLSPolicies STRIPPED every policy whose predicate
11+
// referenced `current_user.organization_id` (security-plugin.ts) — and
12+
// `member_default` carries NO owner-scoped READ policy — so a fresh member read
13+
// every row. That is the `member-visible` verdict: not a broad-read default of
14+
// the app, but the harness booting single-tenant.
15+
//
16+
// [#6964] Both halves have since moved; the conclusion has not. ADR-0095 D1
17+
// RETIRED that wildcard policy — the tenant scope is now the Layer 0 wall
18+
// (`plugin-security/tenant-layer.ts`), which is inert under the `single` posture
19+
// by construction (`computeTenantLayer0Filter` returns `null` when
20+
// `postureEnforcesWall` is false) rather than by policy stripping. And #5491
21+
// removed `member_default`'s `'*'` grant, so on an app object a fresh member is
22+
// now refused at the CRUD gate before any row scope is consulted. What survives
23+
// is the fact this file is built on: a single-tenant boot applies NO org row
24+
// scope to reads, and `member_default` still carries no owner-scoped READ policy
25+
// (its `owner_only_*` policies are `update`/`delete` only). Apps like hotcrm
26+
// (9 sharing files, `requires: ['sharing']`) rely on exactly this org boundary,
27+
// so a single-tenant boot under-reports their authorization model.
1628
//
1729
// THE FIX: boot with `{ multiTenant: true }` so OrgScopingPlugin registers
1830
// before SecurityPlugin and the wildcard `organization_id` policies APPLY. The

packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,29 @@
55
// `isDefault: true`; `appDefaultPermissionSetName(stack.permissions)` (the helper the
66
// CLI calls) extracts its name, and passing it as the SecurityPlugin
77
// `fallbackPermissionSet` makes a fresh sign-up governed by THAT profile instead
8-
// of the built-in `member_default` wildcard. Read-mostly default ⇒ the member
9-
// can read announcements but is DENIED the private-note object (which the
10-
// wildcard would have allowed) — proving the app's declared default is in force.
8+
// of the built-in `member_default`. Read-mostly default ⇒ the member can read
9+
// announcements, and does NOT hold what the built-in baseline grants — proving
10+
// the app's declared default is in force and the built-in one is not.
11+
//
12+
// [#6964] The denial half of that proof was REPLACED WHOLESALE, not re-worded.
13+
// It used to read `showcase_contact` and justify `not.toBe(200)` with
14+
// "`member_default` has a wildcard grant → would be 200". #5491 (PR #6684)
15+
// removed that wildcard, so the counterfactual expired and the case passed
16+
// because NOTHING IS PRODUCED. Measured on this exact stack, one fresh sign-up
17+
// per wiring:
18+
//
19+
// fallback=member_default announcement=403 contact=403 sys_user_preference=200
20+
// fallback=showcase_demo_default announcement=200 contact=403 sys_user_preference=403
21+
// fallback=showcase_member_default announcement=200 contact=403 sys_user_preference=403
22+
//
23+
// Row 1 is the world the old case claimed to exclude, and `showcase_contact` is
24+
// 403 there too — it held identically either way, so it could not tell "the
25+
// declared default is in force" from "no default is in force at all", which is
26+
// the one thing this file exists to tell. The surviving discriminator runs the
27+
// other way round: name an object ONLY the built-in baseline grants. The same
28+
// run settles the risk that would have killed that idea — a NAMED fallback set
29+
// REPLACES `member_default` rather than merging additively on top of it, so
30+
// `sys_user_preference` is 200 if and only if the built-in baseline governs.
1131

1232
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1333
import showcaseStack from '@objectstack/example-showcase';
@@ -52,12 +72,14 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () =
5272
expect(r.status, 'declared default grants announcement read').toBe(200);
5373
});
5474

55-
it('and NOT by the built-in member_default wildcard (contact is denied)', async () => {
56-
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_contact');
57-
// member_default has a wildcard grant → would be 200. The app default grants
58-
// no contact access → denied, proving the declared default is in force.
59-
// (private_note is no longer a valid canary: the ADR-0090 zoo deliberately
60-
// grants it in the baseline as the personal-data-on-private-OWD demo.)
61-
expect(r.status, 'declared default does NOT grant showcase_contact').not.toBe(200);
75+
it('and NOT by the built-in member_default baseline (its own explicit grant is absent)', async () => {
76+
// `sys_user_preference` is granted by `member_default` and by nothing else
77+
// here (`default-permission-sets.ts`: allowRead/allowCreate/allowEdit, with a
78+
// `sys_user_preference_self` RLS carve-out), and `showcase_member_default`
79+
// names no `sys_*` object at all. So it is 200 exactly when the built-in
80+
// baseline governs — which is the discrimination `showcase_contact` lost when
81+
// #5491 removed the wildcard that used to make a denial informative.
82+
const r = await stack.apiAs(memberToken, 'GET', '/data/sys_user_preference');
83+
expect(r.status, 'the built-in baseline is REPLACED by the declared default, not merged with it').not.toBe(200);
6284
});
6385
});

0 commit comments

Comments
 (0)