diff --git a/.changeset/member-default-wildcard-prose-and-vacuous-d7-denials.md b/.changeset/member-default-wildcard-prose-and-vacuous-d7-denials.md new file mode 100644 index 0000000000..c6c4363c16 --- /dev/null +++ b/.changeset/member-default-wildcard-prose-and-vacuous-d7-denials.md @@ -0,0 +1,46 @@ +--- +'@objectstack/platform-objects': patch +--- + +**`member_default`'s removed wildcard was still named as live fact, and two D7 dogfood denials had gone vacuous (#6964).** + +#5491 (PR #6684) removed `member_default`'s plain `'*'` object grant and ADR-0095 +D1 retired its wildcard `tenant_isolation` RLS policy. Six live sites outside the +two surfaces PR #6958 already fixed still asserted one of those two facts as +current, and — the reason this is not only prose — two dogfood tests rested their +whole evidential claim on the first one. + +**Prose (`platform-objects`, `qa/dogfood`, published docs).** The +`requiredPermissions` gates on `sys_scim_provider` and `sys_sso_provider` +justified themselves by an exposure that no longer exists, which invites the next +reader to conclude the gates are redundant. They are not: `requiredPermissions` +is a capability AND-gate evaluated *before* the CRUD grant, so it denies +regardless of how permissive any grant is — including one an app-declared profile +or a customer-authored set names. `sys_sso_provider`'s `tenancy.enabled:false` +and `rls-multitenant`'s investigation narrative are re-premised on the ADR-0095 +D1 Layer 0 tenant wall, which is what actually decides them now. And +`content/docs/permissions/index.mdx` stated the retired wildcard +`tenant_isolation` policy as shipped behaviour, contradicting +`releases/implementation-status.mdx` in the same repo; the doc now matches the +status page. + +**The defect.** `showcase-default-profile` and `showcase-d7-default-profile` +proved ADR-0056 D7 with `expect(status).not.toBe(200)` on an app object, +justified by "`member_default` has a wildcard grant → would be 200". With the +wildcard gone that baseline grants nothing on app objects, so the denial became +the trivially expected outcome and the assertion passed *because nothing is +produced* — it could no longer tell "the declared default is in force" from "no +default is in force at all", which is the one thing those files exist to tell. +Measured on a live showcase boot, one fresh sign-up per wiring: under the +built-in baseline `showcase_private_note` and `showcase_contact` are **403**, +exactly as under the declared default. + +Both denial cases are replaced wholesale rather than re-worded, with an object +only the built-in baseline grants (`sys_user_preference`): 200 if and only if +`member_default` governs. The same run settles the risk that would have killed +that idea — a named `fallbackPermissionSet` **replaces** `member_default` rather +than merging additively on top of it. Reverse-verified: stripping the declared- +default wiring turns the new case red (200) and the positive case red (403), +while the deleted cases stay green — the vacuity, demonstrated directly. + +No runtime behaviour changes. diff --git a/content/docs/permissions/index.mdx b/content/docs/permissions/index.mdx index 8db4556533..3d3f3db02b 100644 --- a/content/docs/permissions/index.mdx +++ b/content/docs/permissions/index.mdx @@ -44,7 +44,7 @@ export const HighValueOpportunitySharingRule = defineSharingRule({ Because AI agents act through the same permission-aware surface, these rules bound agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as-tools)). -> **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. +> **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. ## What's in this module diff --git a/packages/platform-objects/src/identity/sys-scim-provider.object.ts b/packages/platform-objects/src/identity/sys-scim-provider.object.ts index 936e8c41e4..beb700a4a4 100644 --- a/packages/platform-objects/src/identity/sys-scim-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-provider.object.ts @@ -36,10 +36,20 @@ export const SysScimProvider = ObjectSchema.create({ // [ADR-0066 D3/④] Admin-only identity config carrying a live credential // (`scim_token` — the bearer external IdPs authenticate provisioning calls // with). Object-level capability gate, mirroring the sibling - // `sys_sso_provider`: ordinary members are denied entirely (without it, the - // `member_default` wildcard `'*': allowRead` would expose SCIM connections - // to every authenticated user). better-auth's own endpoints read via a - // system context, so SCIM provisioning is unaffected. + // `sys_sso_provider`: ordinary members are denied entirely. + // + // [#6964] The exposure this used to name — `member_default`'s + // `'*': allowRead` — no longer exists: #5491 removed that wildcard and the + // platform baseline is explicit-allow. The gate is not thereby redundant, and + // its live reason is the stronger one: `requiredPermissions` is a capability + // AND-gate evaluated BEFORE the CRUD grant (`security-plugin.ts` step 1.5), so + // a caller missing the capability is denied "regardless of how permissive + // their grants are" — including a grant an app-declared default profile or a + // customer-authored permission set names on this object. Without it the + // table's only protection would be that no set happens to grant it today. + // + // better-auth's own endpoints read via a system context, so SCIM provisioning + // is unaffected. requiredPermissions: ['manage_platform_settings'], // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema. protection: { diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 27f923c7bb..0649cd337c 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -31,17 +31,28 @@ export const SysSsoProvider = ObjectSchema.create({ icon: 'shield-check', isSystem: true, managedBy: 'better-auth', - // ADR-0024 — env-global, ADMIN-ONLY identity config. Two orthogonal controls: + // ADR-0024 — env-global, ADMIN-ONLY identity config. Two orthogonal controls, + // both re-premised in #6964 because the mechanisms they used to name expired: // • `tenancy.enabled: false` — the env IS the tenant; providers are env-wide, - // not org-partitioned. Opting out of multi-tenancy lets a platform admin's - // `viewAllRecords` superuser bypass see every provider (without it, the - // `member_default` wildcard `tenant_isolation` RLS denies every row, since - // better-auth writes via its adapter with no tenantId → `organization_id` - // is never stamped). + // not org-partitioned. What this marks out of the way is now the ADR-0095 + // D1 **Layer 0 tenant wall** (`plugin-security/tenant-layer.ts`), not the + // wildcard `tenant_isolation` RLS `member_default` used to ship — D1 + // retired that policy. The consequence is unchanged: this table HAS an + // `organization_id` column, and better-auth writes via its adapter with no + // tenantId → the column is never stamped, so a wall AND-composing + // `organization_id == ` would deny every row. Opting out sets + // `tenancyDisabled`, which makes `computeTenantLayer0Filter` return `null` + // (platform-global object ⇒ Layer 0 contributes nothing), so admins see + // every env provider. This is now the ONLY thing that opens the table up: + // under ADR-0095 W2 the `viewAllRecords` superuser bit alone no longer + // crosses the wall — that takes a true `PLATFORM_ADMIN`. // • `requiredPermissions: ['manage_platform_settings']` — object-level - // capability gate (ADR-0066 D3) so ordinary members are denied entirely - // (without it, tenancy-disabled + `member_default`'s `'*': allowRead` would - // leak providers to every authenticated user). + // capability AND-gate (ADR-0066 D3/⑤) evaluated BEFORE the CRUD grant + // (`security-plugin.ts` step 1.5), so ordinary members are denied + // "regardless of how permissive their grants are". It no longer stands + // against `member_default`'s `'*': allowRead` — #5491 removed that + // wildcard — but against any grant on this object that an app-declared + // default profile or a customer-authored permission set might name. // Together: admins see all env providers; non-admins get 403. better-auth's // own endpoints already read via a system context. (Env-only object — no // control-plane cross-tenant risk.) diff --git a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts index 0a451b1ffe..5cd708d816 100644 --- a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts +++ b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts @@ -3,16 +3,28 @@ // Org-scoped (#1994) cross-tenant gate — the faithful counterpart to the // single-tenant smoke in `auto-verify-rls.dogfood.test.ts`. // -// THE INVESTIGATION (why the single-tenant run showed all `member-visible`): -// `member_default` scopes rows with a wildcard `tenant_isolation` policy -// (`organization_id = current_user.organization_id`). When the org-scoping -// plugin is absent, SecurityPlugin.collectRLSPolicies STRIPS every policy whose -// predicate references `current_user.organization_id` (security-plugin.ts) — and -// `member_default` carries NO owner-scoped READ policy — so a fresh member can -// read every row. That is the `member-visible` verdict: not a broad-read default -// of the app, but the harness booting single-tenant. Apps like hotcrm (9 sharing -// files, `requires: ['sharing']`) rely on exactly this org boundary, so a -// single-tenant boot under-reports their authorization model. +// THE INVESTIGATION (why the single-tenant run showed all `member-visible`), +// stated in the mechanism of its time: `member_default` THEN scoped rows with a +// wildcard `tenant_isolation` policy (`organization_id = +// current_user.organization_id`), and when the org-scoping plugin was absent +// SecurityPlugin.collectRLSPolicies STRIPPED every policy whose predicate +// referenced `current_user.organization_id` (security-plugin.ts) — and +// `member_default` carries NO owner-scoped READ policy — so a fresh member read +// every row. That is the `member-visible` verdict: not a broad-read default of +// the app, but the harness booting single-tenant. +// +// [#6964] Both halves have since moved; the conclusion has not. ADR-0095 D1 +// RETIRED that wildcard policy — the tenant scope is now the Layer 0 wall +// (`plugin-security/tenant-layer.ts`), which is inert under the `single` posture +// by construction (`computeTenantLayer0Filter` returns `null` when +// `postureEnforcesWall` is false) rather than by policy stripping. And #5491 +// removed `member_default`'s `'*'` grant, so on an app object a fresh member is +// now refused at the CRUD gate before any row scope is consulted. What survives +// is the fact this file is built on: a single-tenant boot applies NO org row +// scope to reads, and `member_default` still carries no owner-scoped READ policy +// (its `owner_only_*` policies are `update`/`delete` only). Apps like hotcrm +// (9 sharing files, `requires: ['sharing']`) rely on exactly this org boundary, +// so a single-tenant boot under-reports their authorization model. // // THE FIX: boot with `{ multiTenant: true }` so OrgScopingPlugin registers // before SecurityPlugin and the wildcard `organization_id` policies APPLY. The diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 1685446d6e..608ffb9edd 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -5,9 +5,29 @@ // `isDefault: true`; `appDefaultPermissionSetName(stack.permissions)` (the helper the // CLI calls) extracts its name, and passing it as the SecurityPlugin // `fallbackPermissionSet` makes a fresh sign-up governed by THAT profile instead -// of the built-in `member_default` wildcard. Read-mostly default ⇒ the member -// can read announcements but is DENIED the private-note object (which the -// wildcard would have allowed) — proving the app's declared default is in force. +// of the built-in `member_default`. Read-mostly default ⇒ the member can read +// announcements, and does NOT hold what the built-in baseline grants — proving +// the app's declared default is in force and the built-in one is not. +// +// [#6964] The denial half of that proof was REPLACED WHOLESALE, not re-worded. +// It used to read `showcase_contact` and justify `not.toBe(200)` with +// "`member_default` has a wildcard grant → would be 200". #5491 (PR #6684) +// removed that wildcard, so the counterfactual expired and the case passed +// because NOTHING IS PRODUCED. Measured on this exact stack, one fresh sign-up +// per wiring: +// +// fallback=member_default announcement=403 contact=403 sys_user_preference=200 +// fallback=showcase_demo_default announcement=200 contact=403 sys_user_preference=403 +// fallback=showcase_member_default announcement=200 contact=403 sys_user_preference=403 +// +// Row 1 is the world the old case claimed to exclude, and `showcase_contact` is +// 403 there too — it held identically either way, so it could not tell "the +// declared default is in force" from "no default is in force at all", which is +// the one thing this file exists to tell. The surviving discriminator runs the +// other way round: name an object ONLY the built-in baseline grants. The same +// run settles the risk that would have killed that idea — a NAMED fallback set +// REPLACES `member_default` rather than merging additively on top of it, so +// `sys_user_preference` is 200 if and only if the built-in baseline governs. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -52,12 +72,14 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = expect(r.status, 'declared default grants announcement read').toBe(200); }); - it('and NOT by the built-in member_default wildcard (contact is denied)', async () => { - const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_contact'); - // member_default has a wildcard grant → would be 200. The app default grants - // no contact access → denied, proving the declared default is in force. - // (private_note is no longer a valid canary: the ADR-0090 zoo deliberately - // grants it in the baseline as the personal-data-on-private-OWD demo.) - expect(r.status, 'declared default does NOT grant showcase_contact').not.toBe(200); + it('and NOT by the built-in member_default baseline (its own explicit grant is absent)', async () => { + // `sys_user_preference` is granted by `member_default` and by nothing else + // here (`default-permission-sets.ts`: allowRead/allowCreate/allowEdit, with a + // `sys_user_preference_self` RLS carve-out), and `showcase_member_default` + // names no `sys_*` object at all. So it is 200 exactly when the built-in + // baseline governs — which is the discrimination `showcase_contact` lost when + // #5491 removed the wildcard that used to make a denial informative. + const r = await stack.apiAs(memberToken, 'GET', '/data/sys_user_preference'); + expect(r.status, 'the built-in baseline is REPLACED by the declared default, not merged with it').not.toBe(200); }); }); diff --git a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts index 402e0b7a7d..dbdeadb940 100644 --- a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -4,10 +4,31 @@ // `isDefault: true` becomes the fallback for authenticated users with no explicit // grants — the app declares its default access posture instead of inheriting the // built-in `member_default`. Proven on the real showcase: a fresh sign-up governed -// by a custom default profile that grants ONLY `showcase_announcement` can read it -// but is DENIED `showcase_private_note` (which the wildcard `member_default` would -// have allowed) — so the declared default is provably in effect. Foundation for -// SSO/JIT provisioning. +// by a custom default profile that grants ONLY `showcase_announcement` can read +// it, and does NOT hold what the built-in baseline grants — so the declared +// default is provably IN FORCE and the built-in one is provably OUT. Foundation +// for SSO/JIT provisioning. +// +// [#6964] The denial half of that proof was REPLACED WHOLESALE, not re-worded. +// It used to read `showcase_private_note` and justify `not.toBe(200)` with +// "`member_default` has a wildcard grant → would be 200". #5491 (PR #6684) +// removed that wildcard, so the counterfactual expired and the case passed +// because NOTHING IS PRODUCED. Measured on this exact stack, one fresh sign-up +// per wiring: +// +// fallback=member_default announcement=403 private_note=403 sys_user_preference=200 +// fallback=showcase_demo_default announcement=200 private_note=403 sys_user_preference=403 +// fallback=showcase_member_default announcement=200 private_note=200 sys_user_preference=403 +// +// Row 1 is the world the old case claimed to exclude, and `private_note` is 403 +// there too — it held identically either way. The surviving discriminator runs +// the other way round: name an object ONLY the built-in baseline grants. The +// same run settles the risk that would have killed that idea — a NAMED fallback +// set REPLACES `member_default` rather than merging additively on top of it, so +// `sys_user_preference` is 200 if and only if the built-in baseline governs. +// +// The pair below is therefore red in both directions: the first case goes red if +// the declared default is not in force, the second if the built-in one still is. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -47,10 +68,13 @@ describe('showcase: app-declared default profile (ADR-0056 D7)', () => { expect(r.status, 'default profile grants announcement read').toBe(200); }); - it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => { - const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note'); - // member_default has a wildcard grant → would be 200. The declared default - // grants only announcement → this object is denied, proving D7 is in effect. - expect(r.status, 'default profile does NOT grant private_note').not.toBe(200); + it('and NOT by the built-in member_default baseline (its own explicit grant is absent)', async () => { + // `sys_user_preference` is granted by `member_default` and by nothing else + // here (`default-permission-sets.ts`: allowRead/allowCreate/allowEdit, with a + // `sys_user_preference_self` RLS carve-out). So it is 200 exactly when the + // built-in baseline governs — the counterfactual `showcase_private_note` used + // to carry before #5491 removed the wildcard that made it discriminating. + const r = await stack.apiAs(memberToken, 'GET', '/data/sys_user_preference'); + expect(r.status, 'the built-in baseline is REPLACED by the declared default, not merged with it').not.toBe(200); }); });