diff --git a/.changeset/25233-external-floor-docblock.md b/.changeset/25233-external-floor-docblock.md new file mode 100644 index 00000000000..5947fb5e4fc --- /dev/null +++ b/.changeset/25233-external-floor-docblock.md @@ -0,0 +1,10 @@ +--- +'@objectstack/core': patch +--- + +docs: the external org-wide-default floor's docblock no longer lists `controlled_by_parent` among the models it closes + +Clause-②: no +Metadata-protocol: none + +`owdForAudience`'s module docblock said an external is floored on a `controlled_by_parent` detail. It is not: plugin-sharing exempts the detail, whose rows follow its master, and the master meets the floor (#25541). Comment only; no behaviour changes. diff --git a/docs/adr/0090-permission-model-v2-concept-convergence.md b/docs/adr/0090-permission-model-v2-concept-convergence.md index 4cd0c84789e..d795edb7581 100644 --- a/docs/adr/0090-permission-model-v2-concept-convergence.md +++ b/docs/adr/0090-permission-model-v2-concept-convergence.md @@ -4,6 +4,7 @@ · **Amended** (2026-10-13, ADR-0138 D3/D4) — D9's `guest` anchor bindings resolve for anonymous requests, in the organization ADR-0138 D4 resolves; an empty set denies all. · **Amended** (2026-10-14, [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3/D13) — two of the row objects D3 renames into, `sys_position` and `sys_position_permission_set`, and the `sys_capability` D5 names, are retired by #15204 stage 8 (`66949b7729`, 2026-10-13): a position is a metadata-registry definition that names its sets in `PositionSchema.permissionSets`; `sys_user_position` stays. See the notes under D3, D5 and D8. · **Amended** (2026-10-14, ruling of record 6159300530 on #23268, letter A) — D11's external org-wide default is retired: external visibility is the [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) `EXTERNAL` rung, explicitly shared rows only. See the note under D11. +· **Re-amended** (2026-10-23, ruling of record 6355206139 on #25233, letter B) — D11: externals are a population inside the member pool, by role, and reach rows through explicit grants, sharing rules and held grants' RLS included. See the second note under D11. · **Amended** (2026-10-15, ruling of record 6181988435 on #23488, letter A) — D12's position↔set binding right and its environment-set authoring right are retired (`AdminScope.manageBindings` and `AdminScope.authorEnvironmentSets`, ADR-0049 enforce-or-remove): a position's sets and a permission set's body are environment definitions, and the definition write is `manage_metadata`'s per [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D3, never a delegate scope's. D12's *what* is user↔position assignments (and direct set grants) inside the subtree; read its bindings and authoring clauses as superseded by this line. · **Amended** (2026-10-14, [ADR-0138](./0138-guest-model-anonymous-principal-doors-grants-and-organization.md) D3 item 5, ruling C of record 6159279205 on #23267) — D9's last bullet holds for `everyone` alone: a package suggests `everyone` bindings and the admin confirms each, while no door binds a set to `guest` (no package suggestion, no confirmation, no operator bind) until ADR-0138 D3 item 5's bind door is built, an act of the environment's administrator, never of a package. Read that bullet's `guest` half as superseded by this line. · **Amended** (2026-10-17, ruling of record 6228728551 on #23960, letter B) — D12 names three kinds of administrator, tenant-level, organization-level and delegated: an organization administrator (`organization_admin` / `organization_admin_no_bypass` for the organization, not a platform administrator) creates and removes `sys_user_position` / `sys_user_permission_set` rows for that organization's members when every set the row distributes has no `*` wildcard superuser bit, no `adminScope` and only `scope: 'org'` capabilities; `admin_full_access`, platform sets, `organization_admin` itself and every delegated set stay outside, and organization-administrator status is conferred by membership grade only. Strict containment and the `everyone` / `guest` anchors are unchanged. See the note under D12. @@ -493,6 +494,17 @@ alias tax on every API. > separate from members (the pool below is kept). The bullets that follow record the decision as > taken; read their `externalSharingModel` and external-OWD clauses as superseded by this note. +> **Re-amended 2026-10-23 (ruling of record 6355206139 on #25233, letter B).** In the note above, +> "drawn from an identity pool separate from members (the pool below is kept)" is replaced: +> external principals are the population inside the member pool that holds the `external` +> organization role, and they reach rows through explicit shares, share links, sharing rules, and +> the position and permission-set grants they hold with their RLS evaluated (a sharing set's +> "records related to the principal's account" is such a predicate). The bullets' "from a separate +> portal / partner identity pool" reads the same way. The last bullet's "the D6 access matrix +> gains an external-audience column" is retired: it rode `externalSharingModel`, and audience is a +> property of the principal, so a metadata-only matrix has no column to fill. The EXTERNAL rung's +> cells live in the authorization matrix gate (#25541). + Portal and partner scenarios need the Salesforce insight: internal and external record baselines are **two different dials, and external must never be wider**. Decision: diff --git a/docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md b/docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md index f04fbc78e38..9136be69160 100644 --- a/docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md +++ b/docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md @@ -2,6 +2,7 @@ **Status**: Accepted (2026-07-14) — implemented: D1 tenant Layer 0 (`plugin-security/src/tenant-layer.ts`), D2/D3 posture ladder (`core/src/security/posture-ladder.ts`), locked by `authz-matrix-gate.test.ts` + `posture-ladder.test.ts` (#2920 B1–B4) · **Amended** (2026-10-18, ruling of record 6250748677 on #24164, letter E′) — D1: Layer 0 is inert on 22 of the 23 platform objects that are to declare a default row scope, and walls the 23rd, `sys_user_preference`, to its organisation; on all 23, Layer 1 silence is to mean the object's declared default row scope, a protocol key (#24338), not no filter. See the note under D1. +· **Amended** (2026-10-23, ruling of record 6355206139 on #25233, letter B) — D2: the `EXTERNAL` rung admits explicit grants only (shares and share links, sharing rules, and held position and permission-set grants with their RLS evaluated), never the OWD baseline; the resolver returns it for a holder of the `external` organization role (#25541). See the note under D2. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0002](./0002-environment-database-isolation.md) (environment-per-database), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (scope depth), [ADR-0066](./0066-unified-authorization-model.md) (unified authz model, superuser bypass), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (permission model v2), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy service) **Scopes**: the "cross-tenant RLS does not exist here" claim in [ADR-0073](./0073-automation-execution-identity.md) (see Context) @@ -28,9 +29,9 @@ drafting this ADR). enum `PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL`, resolved once in `resolveAuthzContext`. Each rung maps to **exactly one** row-visibility injection rule; visibility is strictly nested down the ladder. The - `EXTERNAL` rung's semantics are defined and test-locked now (explicit shares - only, never OWD); its enforcement path ships when an external principal type - exists. + `EXTERNAL` rung's semantics are defined and test-locked now (explicit grants + only, never OWD; channel list amended 2026-10-23, see D2); its enforcement + path shipped with the `external` organization role (#25233). 3. **D3 — Posture derives from capabilities, never from roles.** The platform-admin tier derives from held capabilities (`viewAllRecords`/`modifyAllRecords`), and better-auth `role='admin'` is @@ -207,7 +208,7 @@ enum AuthzPosture { PLATFORM_ADMIN = 3, // crosses the tenant wall where object posture permits (D1) TENANT_ADMIN = 2, // all rows within the organization MEMBER = 1, // business RLS (ownership / unit depth / sharing) within the organization - EXTERNAL = 0, // explicitly shared rows only — never OWD-derived visibility + EXTERNAL = 0, // explicit grants only — shares, sharing rules, grant RLS; never the OWD baseline } ``` @@ -219,15 +220,36 @@ enum AuthzPosture { set at rung *n* is a superset of rung *n−1*. The `resolve-authz-context.test.ts` suite upgrades from per-case assertions to a `role × object × expected-rows` matrix snapshot asserting the nesting. -- **EXTERNAL is defined and locked now, enforced later.** Semantics fixed by - this ADR: an EXTERNAL principal sees only rows explicitly shared to it — - sharing rules and OWD-derived baselines never apply; a misconfiguration can - only *shrink* its visibility, never widen it. No external principal type - exists today (the sharing chain has no such concept), so the resolver cannot - yet return `EXTERNAL`; the enum value, its injection rule, and its - semantics tests land with D2 so the rung cannot be reinvented differently - when portal/external membership arrives (aligned with ADR-0093's membership - model when it does). +- **EXTERNAL — the channel list (amended 2026-10-23, ruling of record + 6355206139 on #25233, letter B).** An EXTERNAL principal is a member of the + organization's member pool who holds the `external` organization role. It + reaches rows through explicit grants only: + - shares, share links included; + - sharing rules (the grants they materialise); + - the position and permission-set grants it holds, with their row-level + security evaluated, so a predicate such as + `supplier in current_user.` reaches it as it reaches a member. + + It never reaches the internal OWD baseline: its org-wide default is + owner-only on every object unless a held grant's RLS (`using`) speaks for + that object and operation class, and its writes carry the member's + owner-only floor (`external_default`). A `controlled_by_parent` detail is + not floored on its own; its rows follow the master, which is. Nor does it + reach the everyone-anchored default sets or the member directory. A + misconfiguration can only *shrink* its visibility, never widen it. The + resolver stamps `audience: 'external'` on a holder and `derivePosture` + returns `EXTERNAL` (#25515, #25541). + + One limit, named: an object with no `owner_id` whose effective model is + public is under no OWD for any principal, so an external holding an + RLS-less grant on it reads every row. Reaching such an object takes an + administrator's explicit grant. + + _Originally (2026-07-14): "an EXTERNAL principal sees only rows explicitly + shared to it — sharing rules and OWD-derived baselines never apply … No + external principal type exists today, so the resolver cannot yet return + `EXTERNAL`." Sharing rules now apply; the deny-by-default direction + stands._ ### D3 — Posture derives from capabilities, never from roles @@ -243,6 +265,13 @@ enum AuthzPosture { source that grants that position** (the existing `mapMembershipRole` normalization becomes a grant-provisioning concern, not an enforcement-time input). +- `EXTERNAL` (2026-10-23, #25233) derives from the reserved `org_external` + position, which the resolver projects from `sys_member.role = external` in + the active organization. As with `role='admin'` above, the role is the one + source of a position; enforcement reads the posture, never the role. It is + the one rung whose evidence is an identity position rather than a + capability bit, and it outranks `TENANT_ADMIN` (only `PLATFORM_ADMIN` + precedes it). ADR-0099 I3 records the exception. - **No enforcement-time code path may consult the better-auth role directly.** This removes dual-track adjudication: #2836-class conflicts (explicit deny from one track out-composing a wildcard allow from the other) become diff --git a/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md b/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md index 61c936c0795..147a1fcd00e 100644 --- a/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md +++ b/docs/adr/0099-posture-adjudicated-tiering-and-external-rung.md @@ -1,6 +1,6 @@ # ADR-0099: Posture-Adjudicated Tiering — one axis for tier decisions, the EXTERNAL rung's enforcement path, no explicit deny -**Status**: Accepted (2026-07-18; proposed 2026-07-17) — P0 equivalence gate landed with the acceptance (see the Acceptance addendum); P1 shipped (#3226). Amended 2026-07-18: **two-axis correction** — D2's Layer 1 tier input corrected to the per-object scope bits, original P2 superseded by P2′ (see the Amendment) +**Status**: Accepted (2026-07-18; proposed 2026-07-17) — P0 equivalence gate landed with the acceptance (see the Acceptance addendum); P1 shipped (#3226). Amended 2026-07-18: **two-axis correction** — D2's Layer 1 tier input corrected to the per-object scope bits, original P2 superseded by P2′ (see the Amendment) · **Amended** (2026-10-23, ruling of record 6355206139 on #25233, letter B) — D2's EXTERNAL column, D3 and I7: the rung admits explicit grants (shares, share links, sharing rules, held grants with their RLS) and is enforced (#25541). See the notes under D2 and D3. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (the posture ladder + Layer 0 — Accepted, implemented; this ADR is its adjudication follow-through), [ADR-0066](./0066-unified-authorization-model.md) (superuser bypass ①; precedence), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D10 principal taxonomy / `audience`, D11 external OWD), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (membership lifecycle — where an external principal type would come from), [ADR-0094](./0094-sys-permission-set-pure-projection.md) (one-authority precedent) **Composes with**: [ADR-0096](./0096-execution-surface-identity-admission.md) — 0096 governs **admission** (may this call reach the engine, as whom); this ADR governs **tiering** (given an admitted principal, which tier of rows each layer grants). Orthogonal axes, deliberately separate ADRs. @@ -147,9 +147,9 @@ it would have caught). | Layer | `PLATFORM_ADMIN` | `TENANT_ADMIN` | `MEMBER` | `EXTERNAL` | |:---|:---|:---|:---|:---| | **Layer 0** (tenant wall) | exempt, where object posture permits (ADR-0095 D1) | **walled** — never exempt | walled | walled | -| **Layer 1** (business RLS) | short-circuit (side-gated by super-bits) | short-circuit within org (side-gated) | business RLS applies (ownership / depth / sharing) | **not evaluated** — the rung's rule replaces it | -| **OWD / sharing** | n/a (already all-rows in tier) | n/a within org | OWD baseline + shares + hierarchy widening | **explicit shares only — OWD and sharing *rules* never apply** | -| **Write path** | tier does not bypass write guards: readonly strip (#2957), identity write guard (ADR-0092), Layer 0 write wall (#2946) all still apply | same | same | shares with edit access only | +| **Layer 1** (business RLS) | short-circuit (side-gated by super-bits) | short-circuit within org (side-gated) | business RLS applies (ownership / depth / sharing) | evaluated for the grants it holds (position and permission-set RLS); the member baseline's sets are never among them | +| **OWD / sharing** | n/a (already all-rows in tier) | n/a within org | OWD baseline + shares + hierarchy widening | owner-only floor on every object; shares (share links included) and sharing rules widen it; a held grant's RLS lifts the floor for its object | +| **Write path** | tier does not bypass write guards: readonly strip (#2957), identity write guard (ADR-0092), Layer 0 write wall (#2946) all still apply | same | same | same guards; the owner-only write floor (`external_default`), widened by edit shares and grant RLS as for a member | | **Explain** | reports the carried rung — identical input as enforcement, by construction | same | same | same | > **Amendment note (2026-07-18):** the **Layer 1** row above is corrected by @@ -158,6 +158,12 @@ it would have caught). > per-object `viewAllRecords` short-circuits business RLS on that object, > tenant-walled as ever). The Layer 0 and EXTERNAL rows stand as written. +> **Amendment note (2026-10-23, ruling of record 6355206139 on #25233, letter B):** +> the `EXTERNAL` column above is the amended one. It supersedes the 2026-07-18 +> note's "the EXTERNAL rows stand as written" for that column; the original +> read "not evaluated — the rung's rule replaces it", "explicit shares only — +> OWD and sharing *rules* never apply" and "shares with edit access only". + Invariants (each carries a matrix cell): - **I1** — `TENANT_ADMIN` never crosses Layer 0. No capability composition may @@ -168,6 +174,11 @@ Invariants (each carries a matrix cell): - **I3** — Posture is derived only by the resolver, only from capability grants (ADR-0095 D3); no enforcement site re-derives tier at the point of decision (fallback probe excepted, per D1, and it may only narrow). + _Amended 2026-10-23 (ruling of record 6355206139 on #25233, letter B): + `EXTERNAL` is the one exception to "only from capability grants". It + derives from the reserved `org_external` position, which the resolver + projects from `sys_member.role = external` (ADR-0095 D3); the resolver + stays the only deriver, and no enforcement site reads the role._ - **I4** — Enforcement and explain read the same carried value; a divergence is a defect, not a display issue. - **I5** — Posture selects tier, never side: no rung grants a read or write @@ -178,6 +189,19 @@ Invariants (each carries a matrix cell): ### D3 — The EXTERNAL rung's enforcement path (demand-gated) +> **Amended 2026-10-23 (ruling of record 6355206139 on #25233, letter B).** The +> rung admits the channel list of [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) +> D2: shares and share links, sharing rules, and held position and +> permission-set grants with their RLS evaluated. Items 2 and 3 below are read +> through it: the injection rule is the owner-only OWD floor plus those +> channels, not a share-only filter, and sharing rules do apply. Item 1 shipped +> in #25541: the population is the holders of the `external` organization role +> (stage 1, #25515). An object with no `owner_id` and a public effective model +> is under no OWD for any principal, so an external holding an RLS-less grant +> on it reads every row. Explain's `guest` debugger mapping to `EXTERNAL` now +> shares the rung's name with a live population; that is harmless, because the +> guest deny-all precedes every sharing gate. + Semantics are already locked (ADR-0095 D2): an EXTERNAL principal sees only rows explicitly shared to it; OWD baselines and sharing *rules* never widen its visibility; misconfiguration can only narrow. What this ADR adds is the @@ -388,8 +412,10 @@ amended rather than enforced: - **I7 (new — the I5 dual):** the scope axis never crosses a boundary the posture axis has not opened. A super-bit holder below `PLATFORM_ADMIN` stays tenant-walled (enforced by P1; pinned in the matrix), and an EXTERNAL - principal's explicit-share rule replaces Layer 1 regardless of any held - bits (pinned when P3 lands). + principal's rung replaces the member baseline regardless of any held bits: + its OWD is the owner-only floor and only the grants it holds, with their + RLS, widen it (amended 2026-10-23, ruling 6355206139 letter B; pinned in + the matrix's EXTERNAL column, #25541). - **I2 clarification:** nesting is a per-rung *floor* invariant. Additive per-object grants widen visibility within a rung (two MEMBERs may differ) without violating the ladder; no rung's floor exceeds the rung above. diff --git a/docs/adr/0135-identity-and-access-architecture.md b/docs/adr/0135-identity-and-access-architecture.md index 24883cc2be8..4b25fcc069c 100644 --- a/docs/adr/0135-identity-and-access-architecture.md +++ b/docs/adr/0135-identity-and-access-architecture.md @@ -145,6 +145,12 @@ Two markings do that work, at two levels: loudly when the stamp does not land — a row that silently keeps the wrong `source` is how a managed user is offered the local-password action D5.2 exists to hide. +> **Note (2026-10-23, objectstack, ruling of record 6355206139 on #25233).** A third population +> runs across D2's two: an organization member of either source who holds the `external` role +> (the `EXTERNAL` rung, [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) D2). +> It is marked by role on `sys_member`, not by `sys_user.source`, and D4's markings are unchanged. +> Cloud ADR-0024's own text is not amended here. + ### D5 — Identity comes from the IdP; authorization is decided in the environment Authentication may be delegated. **Authorization never is.** What a signed-in principal may do is diff --git a/packages/core/src/security/external-owd-floor.ts b/packages/core/src/security/external-owd-floor.ts index fbc822ea196..550edd2114e 100644 --- a/packages/core/src/security/external-owd-floor.ts +++ b/packages/core/src/security/external-owd-floor.ts @@ -10,9 +10,10 @@ * rules, and position and permission-set grants with their RLS evaluated. It * never admits the internal OWD baseline. So, for an external principal, an * object's org-wide default is floored at owner_only: where a member reads every - * row of a `public_read` / `public_read_write` / `controlled_by_parent` object - * (or of a system object that declares no model), an external reads the rows it - * owns and the rows shared to it, as on a `private` one. + * row of a `public_read` / `public_read_write` object (or of a system object + * that declares no model), an external reads the rows it owns and the rows + * shared to it, as on a `private` one. A `controlled_by_parent` detail is not + * floored here: its rows follow the master, which is (plugin-sharing exempts it). * * One thing lifts the floor, and it is a grant: when a position or * permission-set grant the external holds carries an RLS predicate (`using`)