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
46 changes: 46 additions & 0 deletions .changeset/member-default-wildcard-prose-and-vacuous-d7-denials.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 14 additions & 4 deletions packages/platform-objects/src/identity/sys-scim-provider.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
29 changes: 20 additions & 9 deletions packages/platform-objects/src/identity/sys-sso-provider.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 == <caller org>` 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.)
Expand Down
32 changes: 22 additions & 10 deletions packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
Loading
Loading