diff --git a/.changeset/25233-external-posture-owd-floor.md b/.changeset/25233-external-posture-owd-floor.md new file mode 100644 index 00000000000..4995ecb91c7 --- /dev/null +++ b/.changeset/25233-external-posture-owd-floor.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +'@objectstack/plugin-auth': minor +--- + +feat: a holder of the `external` organization role is the EXTERNAL principal — the org-wide default never widens it, and it reaches data only through shares, sharing rules and grant RLS + +Clause-②: yes (widening: `@objectstack/core` gains `owdForAudience`, `isExternalAudienceContext`, `externalGrantSpeaks` and the two grant-stamp keys; every narrowing below applies only to the `external` role, which no release has shipped yet) +Metadata-protocol: semantic + +Stage 2 of the `external` organization role (ruling of record 6355206139). Stage 1 kept externals off the member directory and the everyone-anchored default sets. This stage makes the role a posture of its own: + +- **Audience and posture.** The authorization resolver stamps `audience: 'external'` on a holder's execution context, and `derivePosture` returns `EXTERNAL` for it. Platform admin still outranks it; tenant admin does not. The assembled context carries the audience to agents acting for an external, as it carries the posture. +- **The org-wide default.** For an external, every object's org-wide default is owner-only: it reads and writes the rows it owns and the rows shared to it, by a share or a sharing rule. A `public_read` or `public_read_write` object is not open to it. A `controlled_by_parent` detail is not floored on its own: its rows follow the master, which meets the floor, so an external reads the lines of a master shared to it. +- **Grant RLS lifts that floor.** When a permission set or position the external holds carries a row policy for the object (a `using` predicate, such as `supplier in current_user.`), the object's own default stands and that policy bounds the rows. The object's declared default row scope and the platform's ownership floor do not count as a grant. +- **Writes.** `external_default` carries the platform's owner-only write floor (`owner_only_writes`, `owner_only_deletes`), bound to `org_external`, so an external never writes wider than a member. It shares the member floor's identity, so every relief that lifts a member's floor lifts an external's too. +- **Explain and describe** name the baseline an external resolves, `external_default`, as "external baseline (org_external)", and explain's OWD layer reports the owner-only floor when no held grant speaks. +- **Directory doors.** `list-teams` and `get-full-organization`'s `teams` answer an external only the teams it sits on. `list-invitations`, which better-auth gates on membership alone, answers an external only invitations to its own email. + +Nothing changes for a principal that does not hold the `external` role. diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index d2fb9947400..bbe260644fa 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -125,14 +125,21 @@ only narrows, so it cannot make a `private` row visible. On `public_read` and ## External principals — no external org-wide default There is one org-wide default, `sharingModel`. An external (portal or -partner) principal reaches **explicitly shared rows only** — the authorization -kernel's `EXTERNAL` rung (ADR-0095), never derived from an org-wide default. -Grant it a row with a manual share or a sharing rule, or publish a record -through a share link (`publicSharing`). +partner) principal — an organization member whose membership is `external` — +reaches rows through **explicit grants only**: the authorization kernel's +`EXTERNAL` rung (ADR-0095), never derived from an org-wide default. For an +external, every object's org-wide default is owner-only; it reads and writes +the rows it owns and the rows granted to it: + +- a manual share or a sharing rule; +- a share link (`publicSharing`); +- a permission set or position it holds whose `rowLevelSecurity` speaks for + the object (for example `supplier in current_user.`). Such a policy + lifts the owner-only floor for that object, and the policy bounds the rows. `externalSharingModel`, the external dial ADR-0090 D11 declared, was removed -in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): no request ever -resolved an external principal, so the value changed no answer. The schema +in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): while it existed no +request resolved an external principal, so the value changed no answer. The schema refuses it with the prescription "delete the key"; `os migrate meta --from 17` lists the mechanical edits for existing sources. diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index a097f663f1c..e0912c795ba 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -54,7 +54,7 @@ const result = BaseEngineOptionsSchema.parse(data); | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -108,7 +108,7 @@ Options for DataEngine.aggregate operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -187,7 +187,7 @@ Options for DataEngine.count operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -263,7 +263,7 @@ Options for DataEngine.delete operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -444,7 +444,7 @@ Options for DataEngine.insert operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -516,7 +516,7 @@ Query options for IDataEngine.find() operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -815,7 +815,7 @@ Options for DataEngine.update operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -931,7 +931,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -1016,7 +1016,7 @@ QueryAST-aligned options for DataEngine.count operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -1082,7 +1082,7 @@ QueryAST-aligned options for DataEngine.delete operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -1144,7 +1144,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | @@ -1234,7 +1234,7 @@ QueryAST-aligned options for DataEngine.update operations | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 739c5745527..177c85c02e8 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -60,7 +60,7 @@ const result = AuthGateCodeSchema.parse(data); | **positions** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **audience** | `Enum<'internal' \| 'external'>` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | | **authGate** | `{ code: Enum<'PASSWORD_EXPIRED' \| 'PASSWORD_RESET_REQUIRED' \| 'MFA_REQUIRED'>; message: string }` | optional | ADR-0069 authentication-policy gate: present only while the principal is blocked from protected resources until they remediate (expired password, enforced MFA), absent for every healthy session. `code` is the stable machine code the client branches on, from the closed vocabulary PASSWORD_EXPIRED / PASSWORD_RESET_REQUIRED (an administrator forced a password change) / MFA_REQUIRED, and `message` is what the blocked user reads; both are required because the transport seam renders them as the 403 body. Each code declares a scope: MFA_REQUIRED holds the account, so an API key whose owner holds it is refused with the same 403; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED hold password sign-in alone, and API keys of that owner keep working. AUTHENTICATION, not authorization — it suspends access entirely rather than narrowing it, and nothing in the permission/RLS path reads it, while the allow-listed remediation endpoints stay reachable. Server-constructed only, never client-supplied; a guest/anonymous principal never carries one. | | **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | | | **performedBy** | `{ clientId: string }` | optional | ADR-0090 D10 rule 4 dual attribution: the agent that PERFORMED this operation, when the performer is not the principal the write is authorized as. Set only at the /mcp OAuth door, on the same branch that decides principalKind: agent and onBehalfOf; absent everywhere else, and the absence is the record that the principal acted for itself. ATTRIBUTION ONLY — no security middleware reads it, it never becomes the authorization subject, and userId stays the human so owner-stamping and current_user.* RLS still resolve to them. Server-constructed only, never client-supplied. Surfaced to hooks as HookContext.provenance.performedByClientId and recorded by the audit writer as sys_audit_log.metadata.performed_by beside on_behalf_of; it does not move `actor`, which ADR-0118 D1/D5 keeps two-valued. | diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 79d0dcecb78..9f684a4e05c 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -84,7 +84,7 @@ const result = AccessMatrixSchema.parse(data); ## AuthzPosture -ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. +ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. ### Allowed Values @@ -120,7 +120,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | **permissionSets** | `string[]` | optional (default: `[]`) | | | **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | | | **onBehalfOf** | `{ userId: string }` | optional | | -| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. | +| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline. | ### Nested Shape: `ExplainDecision.layers[number]` diff --git a/packages/core/src/security/assemble-execution-context.test.ts b/packages/core/src/security/assemble-execution-context.test.ts index f5a44b54747..5ab053111d6 100644 --- a/packages/core/src/security/assemble-execution-context.test.ts +++ b/packages/core/src/security/assemble-execution-context.test.ts @@ -541,3 +541,51 @@ describe('#6216 — the measured residual: keys that were present-with-undefined expect(observable(now)).toEqual(observable(before)); }); }); + +// [Ruling of record 6355206139, letter B] The population stamp is an ENTRY +// decision made once, by the resolver: an `external` member's envelope carries +// `audience: 'external'` on every face, its agent's envelope too (the OWD floor +// re-runs the on-behalf-of leg under that envelope), and an internal principal +// or a guest never carries it. +describe('ruling 6355206139 — the resolver’s audience stamp rides the envelope', () => { + const EXTERNAL_MEMBER = baseAuthz({ + userId: 'u_ext', + tenantId: 'org1', + positions: ['org_external'], + permissions: ['external_default'], + posture: 'EXTERNAL', + audience: 'external', + accessible_org_ids: ['org1'], + }); + const assemble = (authz: ResolvedAuthzContext, oauth?: OAuthTokenProvenance) => + assembleExecutionContextOrGuest({ + authz, + oauth, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }); + + it('an external member’s envelope carries audience external beside its EXTERNAL rung, and parses', () => { + const ctx = assemble(EXTERNAL_MEMBER); + expect(ctx.audience).toBe('external'); + expect(ctx.posture).toBe('EXTERNAL'); + expect(ExecutionContextSchema.safeParse(ctx).success).toBe(true); + }); + + it('an agent acting for an external member carries the human’s audience', () => { + const ctx = assemble(EXTERNAL_MEMBER, { + userId: 'u_ext', scopes: ['data:read'], clientId: 'cli1', + scopePermissions: ['agent_data_read'], delegatesActions: false, + } as OAuthTokenProvenance); + expect(ctx.principalKind).toBe('agent'); + expect(ctx.audience).toBe('external'); + }); + + it('CONTROL: an internal principal and a guest carry no audience', () => { + expect(Object.keys(assemble(HUMAN_FULL))).not.toContain('audience'); + expect(Object.keys(assemble(ANONYMOUS))).not.toContain('audience'); + expect(Object.keys(assemble({ ...ANONYMOUS, audience: 'external' }))).not.toContain('audience'); + }); +}); diff --git a/packages/core/src/security/assemble-execution-context.ts b/packages/core/src/security/assemble-execution-context.ts index 583e838b1f1..8ed2056bbd9 100644 --- a/packages/core/src/security/assemble-execution-context.ts +++ b/packages/core/src/security/assemble-execution-context.ts @@ -350,12 +350,14 @@ function entryFields( // what narrows the optional away — the guard that made `agent` truthy is // this same property. performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined, - // [ADR-0090 D10/D11 — P1 shape] No transport resolves an external - // (portal/partner) audience yet; `undefined` reads as 'internal'. Named - // here rather than excluded so the gap is visible in the closed set instead - // of being invisible outside it — when an external principal type lands, - // this is the line that must change, on every face at once. - audience: undefined, + // [ADR-0090 D10/D11 · ruling of record 6355206139, letter B] The resolver's + // population stamp: `'external'` for a holder of `org_external`, absent + // (read as 'internal') otherwise. It rides an AGENT envelope too: the + // population is the human's, and the agent's on-behalf-of leg is re-run + // under this same envelope by the OWD floor (`plugin-sharing`), so an + // agent acting for an external is floored exactly as the external is. + // A guest is never external: it has no membership. + audience: anonymous ? undefined : authz.audience, userId: authz.userId, tenantId: authz.tenantId, email: authz.email, diff --git a/packages/core/src/security/external-owd-floor.test.ts b/packages/core/src/security/external-owd-floor.test.ts new file mode 100644 index 00000000000..e64fea1f8ce --- /dev/null +++ b/packages/core/src/security/external-owd-floor.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [Ruling of record 6355206139, letter B] The EXTERNAL rung's OWD seam +// vocabulary: who is external, how the grant stamp reads, and the floor. + +import { describe, it, expect } from 'vitest'; +import { + EXTERNAL_READ_GRANT_KEY, + EXTERNAL_WRITE_GRANT_KEY, + externalGrantSpeaks, + isExternalAudienceContext, + owdForAudience, +} from './external-owd-floor.js'; +import { OPERATION_PRIVATE_KEY_PREFIX } from './operation-private-keys.js'; + +const external = { userId: 'u_ext', audience: 'external', posture: 'EXTERNAL' }; + +describe('who is external', () => { + it('either carried value says so; neither, or a guest, does not', () => { + expect(isExternalAudienceContext({ audience: 'external' })).toBe(true); + expect(isExternalAudienceContext({ posture: 'EXTERNAL' })).toBe(true); + expect(isExternalAudienceContext({ audience: 'internal', posture: 'MEMBER' })).toBe(false); + expect(isExternalAudienceContext({ principalKind: 'guest' })).toBe(false); + expect(isExternalAudienceContext(undefined)).toBe(false); + expect(isExternalAudienceContext(null)).toBe(false); + }); +}); + +describe('the grant stamp', () => { + it('is operation-private: both keys carry the `__` prefix, so a forwarded envelope drops them', () => { + expect(EXTERNAL_READ_GRANT_KEY.startsWith(OPERATION_PRIVATE_KEY_PREFIX)).toBe(true); + expect(EXTERNAL_WRITE_GRANT_KEY.startsWith(OPERATION_PRIVATE_KEY_PREFIX)).toBe(true); + }); + + it('speaks for the object it names, and the class it was stamped for, only', () => { + const ctx = { ...external, [EXTERNAL_READ_GRANT_KEY]: 'po' }; + expect(externalGrantSpeaks(ctx, 'po', 'read')).toBe(true); + expect(externalGrantSpeaks(ctx, 'invoice', 'read')).toBe(false); + expect(externalGrantSpeaks(ctx, 'po', 'write')).toBe(false); + expect(externalGrantSpeaks({ ...external, [EXTERNAL_READ_GRANT_KEY]: true }, 'po', 'read')).toBe(false); + }); +}); + +describe('the OWD an operation meets', () => { + it('an external meets owner_only on every model, with no grant speaking', () => { + for (const model of ['read', 'public', 'private'] as const) { + expect(owdForAudience(model, external, 'po', 'read')).toBe('private'); + expect(owdForAudience(model, external, 'po', 'write')).toBe('private'); + } + }); + + it('a held grant’s RLS lifts the floor for its object and class: the object’s own model stands', () => { + const ctx = { ...external, [EXTERNAL_READ_GRANT_KEY]: 'po', [EXTERNAL_WRITE_GRANT_KEY]: 'po' }; + expect(owdForAudience('public', ctx, 'po', 'read')).toBe('public'); + expect(owdForAudience('read', ctx, 'po', 'write')).toBe('read'); + expect(owdForAudience('public', ctx, 'invoice', 'read')).toBe('private'); + }); + + it('CONTROL: an internal principal meets the object’s own model, stamp or no stamp', () => { + expect(owdForAudience('public', { userId: 'u1', posture: 'MEMBER' }, 'po', 'read')).toBe('public'); + expect(owdForAudience('read', { userId: 'u1' }, 'po', 'write')).toBe('read'); + }); +}); diff --git a/packages/core/src/security/external-owd-floor.ts b/packages/core/src/security/external-owd-floor.ts new file mode 100644 index 00000000000..fbc822ea196 --- /dev/null +++ b/packages/core/src/security/external-owd-floor.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [Ruling of record 6355206139, letter B · ADR-0090 D11] The OWD seam for the + * EXTERNAL rung, as vocabulary both of its halves read. + * + * ## The rule + * + * The rung admits explicit grants only: shares (share links included), sharing + * 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. + * + * 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`) + * for the object and the operation's class, the object's own model stands and + * that predicate bounds the rows, exactly as it bounds a member's (a permission + * set reading `supplier in current_user.` reaches an external + * as it reaches a member). A grant with no predicate opens the object, not its + * rows. + * + * ## Who decides what + * + * `plugin-security` resolves the grants, so it answers "does a grant's RLS speak + * for this object" and stamps the answer on the operation context, beside the + * `__readScope` / `__writeScope` depth it already hands over (step 2.6 of its + * CRUD middleware). `plugin-sharing` owns the OWD and applies the floor. Neither + * depends on the other, so the names live here, in the package both import — + * the reason `operation-private-keys.ts` gives for living here too. + * + * Each stamp carries the NAME of the object it was resolved for, never a bare + * boolean: an envelope that reaches the sharing gate for another object (a + * forwarded caller context, a stale stamp the middleware did not overwrite) + * reads as "no grant speaks", which is the floor — the narrow direction, as an + * absent depth leaves the owner-match at `own`. The read and write classes are + * stamped apart, because a write-class policy is no evidence for a read. + */ + +/** Stamp: the object a held grant's RLS speaks for on the READ class (`select`). */ +export const EXTERNAL_READ_GRANT_KEY = '__externalReadGrant'; + +/** Stamp: the object a held grant's RLS speaks for on the WRITE class (`update` / `delete`). */ +export const EXTERNAL_WRITE_GRANT_KEY = '__externalWriteGrant'; + +/** + * Is `context` an EXTERNAL principal — the organization's `external` + * population? Either carried value says so (`audience: 'external'`, or the + * `EXTERNAL` posture rung the resolver derives for the same holders); reading + * both is the narrow direction, since a hand-built context may carry one. + * A guest is not one: it has no membership, and its own deny-all rule comes + * first wherever it is asked. + */ +export function isExternalAudienceContext(context: unknown): boolean { + if (context === null || typeof context !== 'object') return false; + const c = context as { audience?: unknown; posture?: unknown }; + return c.audience === 'external' || c.posture === 'EXTERNAL'; +} + +/** + * Did `plugin-security` stamp, for THIS object and class, that a held grant's + * RLS speaks? `false` for an absent stamp, or one resolved for another object. + */ +export function externalGrantSpeaks(context: unknown, object: string, opClass: 'read' | 'write'): boolean { + if (context === null || typeof context !== 'object') return false; + const key = opClass === 'read' ? EXTERNAL_READ_GRANT_KEY : EXTERNAL_WRITE_GRANT_KEY; + return (context as Record)[key] === object; +} + +/** + * The org-wide default an operation of `opClass` meets on `object`: `model` + * itself for an internal principal, and for an external one `'private'` + * (owner_only) unless a held grant's RLS speaks for the object and class. + * `model` is the effective model the sharing service resolved for the object. + */ +export function owdForAudience( + model: M, + context: unknown, + object: string, + opClass: 'read' | 'write', +): M | 'private' { + if (model === 'private' || !isExternalAudienceContext(context)) return model; + return externalGrantSpeaks(context, object, opClass) ? model : 'private'; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index db70bc3d4bd..c739997b31e 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -453,6 +453,16 @@ export { withoutOperationPrivateKeys, } from './operation-private-keys.js'; +// Ruling of record 6355206139 — the EXTERNAL rung's OWD seam: plugin-security +// stamps whether a held grant's RLS speaks, plugin-sharing floors the OWD. +export { + EXTERNAL_READ_GRANT_KEY, + EXTERNAL_WRITE_GRANT_KEY, + isExternalAudienceContext, + externalGrantSpeaks, + owdForAudience, +} from './external-owd-floor.js'; + // The caller-scoped "which of these parent records can the caller read?" probe // every parent-derived read gate asks (comments, activity, ledger, attachments, // mail). One owner for the body two packages had hand-copied. diff --git a/packages/core/src/security/posture-ladder.test.ts b/packages/core/src/security/posture-ladder.test.ts index db80735fd7a..bdd2a95ae94 100644 --- a/packages/core/src/security/posture-ladder.test.ts +++ b/packages/core/src/security/posture-ladder.test.ts @@ -15,7 +15,10 @@ import { /** * ADR-0095 D2/D3 posture-ladder invariants. This file locks the two properties * the ADR requires as TESTED invariants — strict nesting and EXTERNAL - * deny-by-default — plus the capability-only derivation (never a role). The + * deny-by-default — plus the derivation. [Ruling of record 6355206139, letter + * B] The EXTERNAL rung is re-ruled from "explicit shares only" to explicit + * grants only (shares, sharing rules, grant RLS), with the owner_only floor, + * and the derivation now returns it for a holder of `org_external`. The * end-to-end row behavior is guarded separately by plugin-security's * `authz-matrix-gate` and the dogfood conformance matrix; here we assert the * ladder's mathematical shape. @@ -25,11 +28,12 @@ import { // p is a member of org-1. The dataset is chosen so each rung STRICTLY adds rows. const p: LadderPrincipal = { userId: 'u1', organizationId: 'org-1' }; const ROWS: LadderRow[] = [ - { id: 'r1', organization_id: 'org-1', owner_id: 'u1' }, // owned by p → MEMBER+ - { id: 'r2', organization_id: 'org-1', owner_id: 'u2', owdVisible: true }, // OWD baseline → MEMBER+ + { id: 'r1', organization_id: 'org-1', owner_id: 'u1' }, // owned by p → EXTERNAL+ (owner_only floor) + { id: 'r2', organization_id: 'org-1', owner_id: 'u2', owdVisible: true }, // OWD baseline → MEMBER+ { id: 'r3', organization_id: 'org-1', owner_id: 'u2', owdVisible: false }, // in-org, hidden → TENANT_ADMIN+ - { id: 'r4', organization_id: 'org-1', owner_id: 'u2', sharedTo: ['u1'] }, // shared to p → EXTERNAL+/MEMBER+ + { id: 'r4', organization_id: 'org-1', owner_id: 'u2', sharedTo: ['u1'] }, // shared to p → EXTERNAL+ { id: 'r5', organization_id: 'org-2', owner_id: 'u3' }, // foreign org → PLATFORM_ADMIN+ + { id: 'r6', organization_id: 'org-1', owner_id: 'u2', grantVisible: true }, // a grant's RLS → EXTERNAL+ ]; const idsOf = (rows: LadderRow[]) => new Set(rows.map((r) => r.id)); @@ -63,10 +67,10 @@ describe('AuthzPosture ladder — strict nesting invariant (ADR-0095 D2)', () => }; // The concrete visible sets for this dataset (documents the ladder). - expect(visible.EXTERNAL).toEqual(new Set(['r4'])); - expect(visible.MEMBER).toEqual(new Set(['r1', 'r2', 'r4'])); - expect(visible.TENANT_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4'])); - expect(visible.PLATFORM_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4', 'r5'])); + expect(visible.EXTERNAL).toEqual(new Set(['r1', 'r4', 'r6'])); + expect(visible.MEMBER).toEqual(new Set(['r1', 'r2', 'r4', 'r6'])); + expect(visible.TENANT_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4', 'r6'])); + expect(visible.PLATFORM_ADMIN).toEqual(new Set(['r1', 'r2', 'r3', 'r4', 'r5', 'r6'])); // Superset chain (⊇) AND strict growth (⊋) up the ladder — from low rank up. const lowToHigh = [...POSTURE_LADDER].reverse(); // EXTERNAL → PLATFORM_ADMIN @@ -79,27 +83,36 @@ describe('AuthzPosture ladder — strict nesting invariant (ADR-0095 D2)', () => }); }); -describe('AuthzPosture ladder — EXTERNAL semantics lock (ADR-0095 D2)', () => { - it('EXTERNAL sees ONLY explicitly shared rows — OWD never widens it', () => { - // A principal with zero shares sees nothing, even though r2 is OWD-visible. +describe('AuthzPosture ladder — EXTERNAL semantics lock (ADR-0095 D2, re-ruled 6355206139)', () => { + it('EXTERNAL reaches explicit grants only — the OWD baseline never widens it', () => { + // A principal with no share, no owned row and no grant sees nothing, even + // though r2 is OWD-visible. const stranger: LadderPrincipal = { userId: 'ext-1', organizationId: 'org-1' }; - expect(postureVisibleRows('EXTERNAL', ROWS, stranger)).toEqual([]); + const noGrants = ROWS.map((r) => ({ ...r, grantVisible: false })); + expect(postureVisibleRows('EXTERNAL', noGrants, stranger)).toEqual([]); - // Make EVERY row OWD-visible: EXTERNAL is still empty for the stranger — - // a permissive OWD baseline cannot widen an external principal's visibility. - const allPublic = ROWS.map((r) => ({ ...r, owdVisible: true })); + // Make EVERY row OWD-visible: still empty for the stranger — a permissive + // OWD baseline cannot widen an external principal's visibility. + const allPublic = noGrants.map((r) => ({ ...r, owdVisible: true })); expect(postureVisibleRows('EXTERNAL', allPublic, stranger)).toEqual([]); - // The only source is explicit shares: p sees exactly the row shared to p. - const seen = postureVisibleRows('EXTERNAL', allPublic, p); - expect(seen.map((r) => r.id)).toEqual(['r4']); - expect(seen.every((r) => (r.sharedTo ?? []).includes(p.userId))).toBe(true); + // The sources are the explicit channels: p's own row (the owner_only + // floor), the row shared to p, and the row a grant's RLS admits. + const seen = postureVisibleRows('EXTERNAL', ROWS.map((r) => ({ ...r, owdVisible: true })), p); + expect(seen.map((r) => r.id)).toEqual(['r1', 'r4', 'r6']); + }); + + it('a grant’s RLS reaches an external within its organization only', () => { + const foreignGrant: LadderRow = { id: 'x', organization_id: 'org-2', owner_id: 'u3', grantVisible: true }; + expect(postureVisibleRows('EXTERNAL', [foreignGrant], p)).toEqual([]); }); it('a misconfiguration can only SHRINK external visibility, never widen it', () => { - // Dropping a share removes exactly that row; nothing else appears. + // Dropping a share, or a grant, removes exactly that row; nothing else appears. const withoutShare = ROWS.map((r) => (r.id === 'r4' ? { ...r, sharedTo: [] } : r)); - expect(postureVisibleRows('EXTERNAL', withoutShare, p)).toEqual([]); + expect(postureVisibleRows('EXTERNAL', withoutShare, p).map((r) => r.id)).toEqual(['r1', 'r6']); + const withoutGrant = ROWS.map((r) => (r.id === 'r6' ? { ...r, grantVisible: false } : r)); + expect(postureVisibleRows('EXTERNAL', withoutGrant, p).map((r) => r.id)).toEqual(['r1', 'r4']); }); }); @@ -111,12 +124,21 @@ describe('AuthzPosture — derivation from capability grants only (ADR-0095 D3)' expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false })).toBe('MEMBER'); }); - it('never derives EXTERNAL (no external principal type exists yet)', () => { + it('capability evidence alone never derives EXTERNAL', () => { const combos: Array<[boolean, boolean]> = [ [true, true], [true, false], [false, true], [false, false], ]; for (const [isPlatformAdmin, isTenantAdmin] of combos) { expect(derivePosture({ isPlatformAdmin, isTenantAdmin })).not.toBe('EXTERNAL'); + expect(derivePosture({ isPlatformAdmin, isTenantAdmin, isExternal: false })).not.toBe('EXTERNAL'); } }); + + it('[ruling 6355206139] an org_external holder derives EXTERNAL — above the org-admin grant, below the platform grant', () => { + expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false, isExternal: true })).toBe('EXTERNAL'); + // An explicit organization_admin grant reaches it as a grant, but does not lift the rung. + expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: true, isExternal: true })).toBe('EXTERNAL'); + // The unscoped platform grant is deployment standing no membership role demotes. + expect(derivePosture({ isPlatformAdmin: true, isTenantAdmin: false, isExternal: true })).toBe('PLATFORM_ADMIN'); + }); }); diff --git a/packages/core/src/security/posture-ladder.ts b/packages/core/src/security/posture-ladder.ts index a7f58fcd660..c7a68f51eea 100644 --- a/packages/core/src/security/posture-ladder.ts +++ b/packages/core/src/security/posture-ladder.ts @@ -14,7 +14,9 @@ * `organization_admin` grant. The better-auth `role='admin'` is upstream a * *provisioning source* of those grants (`auto-org-admin-grant.ts`), so it * never re-enters adjudication here — the #2836 dual-track class is closed - * by construction. + * by construction. `EXTERNAL` derives from holding the reserved identity + * `org_external`, which the resolver projects from the membership row once + * (never re-read from the role here). * * 2. **The rung → injection-rule mapping + its tested invariants (D2).** Each * rung maps to EXACTLY ONE row-visibility injection rule @@ -22,16 +24,24 @@ * REFERENCE MODEL of those rules over a synthetic row-set — it locks the two * properties the ADR requires as invariants: strict nesting (rung n's * visible set ⊇ rung n−1's) and the EXTERNAL deny-by-default semantics - * (explicit shares only, OWD never widens it). + * (explicit grants only, OWD never widens it). * * This module is NOT the enforcement path. The effective read/write filter is * `Layer0(tenant) AND Layer1(business RLS)`, computed in `@objectstack/plugin- * security` (`tenant-layer.ts` + `security-plugin.ts`), and the real behavior * guard is the `authz-matrix-gate` unit snapshot + the dogfood conformance * matrix. The reference model here exists so the ladder's *mathematical* - * properties can be asserted at the unit layer without an enforcement boot, and - * so the EXTERNAL rung — which has no enforcement path yet — cannot be - * reinvented differently when portal/external membership arrives. + * properties can be asserted at the unit layer without an enforcement boot. + * + * [Ruling of record 6355206139, letter B] The EXTERNAL rung is live: its + * holders are the organization's `external` members (`sys_member.role = + * external`, projected to the reserved identity `org_external`), and its rule + * is re-ruled from "explicit shares only" to EXPLICIT GRANTS only — shares + * (share links included), sharing rules, and position and permission-set + * grants with their RLS evaluated. Enforcement: the `everyone` anchor withheld + * (`resolve-authz-context.ts`), the `external_default` baseline + * (`plugin-security`), the OWD floor (`plugin-sharing`), the directory doors + * (`plugin-auth`). */ import type { AuthzPosture } from '@objectstack/spec/security'; @@ -70,7 +80,7 @@ export const POSTURE_INJECTION_RULE: Record = { MEMBER: 'Business RLS within the organization — ownership (owner / unit depth), the OWD baseline, and explicit sharing.', EXTERNAL: - 'Explicitly shared rows ONLY — OWD baselines and sharing rules never apply; a misconfiguration can only shrink visibility, never widen it.', + 'Explicit grants ONLY — shares (share links included), sharing rules, and position and permission-set grants with their RLS evaluated, within the organization. The OWD baseline never widens it (owner_only floor), and the everyone-anchored default sets and the member directory never reach it; a misconfiguration can only shrink visibility, never widen it.', }; /** Capability-grant evidence the posture derivation consumes (ADR-0095 D3). */ @@ -87,21 +97,40 @@ export interface PostureEvidence { * owner/admin role upstream, consumed here only as a held capability. */ isTenantAdmin: boolean; + /** + * [Ruling of record 6355206139, letter B] Holds the reserved identity + * `org_external` — the projection of `sys_member.role = external` in the + * active organization (with no active organization, of any external + * membership: the fail-closed reading the resolver gives the `everyone` + * anchor). An organization MEMBERSHIP fact, not a capability grant, and the + * one input that may LOWER the rung: optional, so every caller that predates + * it derives exactly what it did. + */ + isExternal?: boolean; } /** * Resolve the principal's posture rung from held capability grants (ADR-0095 D3). * - * Returns `PLATFORM_ADMIN` | `TENANT_ADMIN` | `MEMBER`. It NEVER returns - * `EXTERNAL`: no external principal type exists yet (the sharing chain has no - * portal/guest-share concept — ADR-0095 W4). The `EXTERNAL` rung, its injection - * rule, and its semantics are defined and test-locked ({@link postureVisibleRows}, - * {@link POSTURE_INJECTION_RULE}) so that when portal/external membership lands - * (ADR-0093) the derivation gains an EXTERNAL branch HERE without the rung being - * reinvented. `MEMBER` is the authenticated-principal floor. + * `PLATFORM_ADMIN` from the unscoped platform grant, then — [ruling of record + * 6355206139, letter B] — `EXTERNAL` for a holder of `org_external`, then + * `TENANT_ADMIN` from the org-admin grant, and `MEMBER`, the authenticated + * floor. + * + * Why EXTERNAL sits ABOVE the org-admin test: the posture is EXTERNAL, not a + * restricted member and not an administrator (the ruling keeps ADR-0095's + * rejection of "EXTERNAL as a restricted MEMBER"). An external cannot carry + * `owner`/`admin` beside `external` (E5 refuses the mixed list at write), so + * the org-admin grant can only reach one through an explicit + * `organization_admin` assignment; that grant then reaches it as a grant, but + * it does not lift the rung, so the Layer 0 wall and every posture reader keep + * treating the holder as external. `PLATFORM_ADMIN` stays first: the unscoped + * platform grant is a deployment-level standing that no organization's + * membership role can demote. */ export function derivePosture(evidence: PostureEvidence): AuthzPosture { if (evidence.isPlatformAdmin) return 'PLATFORM_ADMIN'; + if (evidence.isExternal === true) return 'EXTERNAL'; if (evidence.isTenantAdmin) return 'TENANT_ADMIN'; return 'MEMBER'; } @@ -113,14 +142,24 @@ export interface LadderRow { id: string; /** The row's tenant. `undefined` = a non-tenant (platform-global) row. */ organization_id?: string; - /** The row's owner (drives the MEMBER ownership disjunct). */ + /** The row's owner (drives the ownership disjunct, the EXTERNAL owner_only floor included). */ owner_id?: string; /** - * Whether an OWD-derived source would admit this row for a member (public - * baseline / criteria sharing). EXTERNAL deliberately ignores this field. + * Whether the OWD baseline would admit this row for a member (a + * `public_read` / `public_read_write` object). EXTERNAL deliberately ignores + * this field: the OWD baseline never widens an external. */ owdVisible?: boolean; - /** User ids this row is EXPLICITLY shared to (the only EXTERNAL source). */ + /** + * Whether an explicit position or permission-set grant the principal holds + * admits this row through its own RLS (`supplier in current_user.`). + * Reaches EXTERNAL and MEMBER alike. + */ + grantVisible?: boolean; + /** + * User ids this row is shared to — a share, a share link, or a sharing rule's + * materialised grant. Reaches EXTERNAL and MEMBER alike. + */ sharedTo?: readonly string[]; } @@ -135,23 +174,27 @@ function isSharedTo(row: LadderRow, userId: string): boolean { return (row.sharedTo ?? []).includes(userId); } -/** EXTERNAL rung: explicitly shared rows ONLY — never OWD, never org-wide. */ +/** + * EXTERNAL rung: explicit grants ONLY — rows shared to it, and, within its + * organization, rows it owns (the owner_only floor) and rows an explicit + * grant's RLS admits. Never the OWD baseline, never org-wide. + */ function externalVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] { - return rows.filter((r) => isSharedTo(r, p.userId)); + return rows.filter( + (r) => + isSharedTo(r, p.userId) || + (r.organization_id === p.organizationId && (r.owner_id === p.userId || r.grantVisible === true)), + ); } /** - * MEMBER rung: business RLS within the org — ownership OR OWD baseline OR - * explicit sharing. Composed as `EXTERNAL ∪ (in-org ownership/OWD)` so the + * MEMBER rung: business RLS within the org — everything EXTERNAL reaches, plus + * the OWD baseline. Composed as `EXTERNAL ∪ (in-org OWD)` so the * EXTERNAL ⊆ MEMBER leg of the nesting invariant holds by construction. */ function memberVisible(rows: readonly LadderRow[], p: LadderPrincipal): LadderRow[] { - const shared = new Set(externalVisible(rows, p)); - return rows.filter( - (r) => - shared.has(r) || - (r.organization_id === p.organizationId && (r.owner_id === p.userId || r.owdVisible === true)), - ); + const external = new Set(externalVisible(rows, p)); + return rows.filter((r) => external.has(r) || (r.organization_id === p.organizationId && r.owdVisible === true)); } /** diff --git a/packages/core/src/security/resolve-authz-context.external-population.test.ts b/packages/core/src/security/resolve-authz-context.external-population.test.ts index cf34c0f0f3e..b00aa0fae59 100644 --- a/packages/core/src/security/resolve-authz-context.external-population.test.ts +++ b/packages/core/src/security/resolve-authz-context.external-population.test.ts @@ -8,6 +8,10 @@ // an accepted package default) never reach it. What reaches it is what its own // identity position names, plus its explicit grants. An ordinary member is // unchanged, and the stamp is per organization. +// +// Stage 2: the same holder is the EXTERNAL rung — `posture: 'EXTERNAL'` and +// `audience: 'external'` ride the grants — while an ordinary member's posture +// and audience are unchanged. import { describe, it, expect } from 'vitest'; import { countPositionHolders, resolveUserAuthzGrants } from './resolve-authz-context.js'; @@ -72,6 +76,8 @@ describe('the external population at the provisioning boundary', () => { expect(grants.positions).not.toContain('everyone'); expect(grants.positions).not.toContain('org_member'); expect([...grants.permissions].sort()).toEqual(['external_default', 'supplier_portal']); + expect(grants.posture).toBe('EXTERNAL'); + expect(grants.audience).toBe('external'); }); it('an ordinary member is unchanged: everyone, and the baseline it names', async () => { @@ -80,6 +86,8 @@ describe('the external population at the provisioning boundary', () => { expect(grants.positions).toEqual(expect.arrayContaining(['org_member', 'everyone'])); expect(grants.positions).not.toContain('org_external'); expect([...grants.permissions].sort()).toEqual(['app_default', 'member_default']); + expect(grants.posture).toBe('MEMBER'); + expect(grants.audience).toBeUndefined(); }); it('the stamp is per organization: external in one, a member in the other', async () => { @@ -90,10 +98,14 @@ describe('the external population at the provisioning boundary', () => { const inAlpha = await resolveUserAuthzGrants(ql, 'u3', { tenantId: ALPHA }); expect(inAlpha.positions).not.toContain('everyone'); expect([...inAlpha.permissions].sort()).toEqual(['external_default']); + expect(inAlpha.posture).toBe('EXTERNAL'); + expect(inAlpha.audience).toBe('external'); const inBeta = await resolveUserAuthzGrants(ql, 'u3', { tenantId: BETA }); expect(inBeta.positions).toContain('everyone'); expect(inBeta.positions).not.toContain('org_external'); expect([...inBeta.permissions].sort()).toEqual(['app_default', 'member_default']); + expect(inBeta.posture).toBe('MEMBER'); + expect(inBeta.audience).toBeUndefined(); }); it('with no active organization an external membership withholds the anchor (fail closed)', async () => { @@ -105,6 +117,8 @@ describe('the external population at the provisioning boundary', () => { expect(grants.positions).toContain('org_external'); expect(grants.positions).not.toContain('everyone'); expect(grants.permissions).not.toContain('member_default'); + expect(grants.posture).toBe('EXTERNAL'); + expect(grants.audience).toBe('external'); }); it('the holder count agrees: an external is counted under org_external, never under everyone', async () => { diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index d411331fcde..15f1409d109 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -990,8 +990,9 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { * matrix asserting the rung is DERIVED from held capability grants * (`admin_full_access` → PLATFORM_ADMIN; `organization_admin` → TENANT_ADMIN; * otherwise MEMBER), never from a better-auth role, plus the strict-nesting - * ordering (PLATFORM_ADMIN > TENANT_ADMIN > MEMBER). `EXTERNAL` is never - * resolved — no external principal type exists yet. + * ordering (PLATFORM_ADMIN > TENANT_ADMIN > MEMBER). `EXTERNAL` resolves only + * for a holder of `org_external` (ruling of record 6355206139), pinned in + * `resolve-authz-context.external-population.test.ts`; no fixture here holds it. */ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { // Each fixture returns the ql tables + the session getter for one principal. diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 7c1aec06c16..69d10904a18 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -123,12 +123,21 @@ export interface ResolvedAuthzContext { * `PLATFORM_ADMIN` (the anchoring `admin_full_access` grant, or — since #11663 * L2 — a VERIFIED `sys_user.email` on the deployment's declared * administrator list) > `TENANT_ADMIN` - * (`organization_admin`) > `MEMBER` (the authenticated floor). `EXTERNAL` is - * defined/test-locked but never resolved yet (no external principal type — + * (`organization_admin`) > `MEMBER` (the authenticated floor), and + * `EXTERNAL` for a holder of `org_external` ([ruling of record 6355206139], * see `posture-ladder.ts`). Present only for an authenticated principal; * anonymous requests carry no rung. */ posture?: AuthzPosture; + /** + * [Ruling of record 6355206139, letter B] `'external'` when the principal + * holds `org_external` — the organization's external population — and absent + * otherwise (absent reads as `'internal'`, `ExecutionContext.audience`). + * Carried beside {@link posture}, which is `EXTERNAL` for the same holders, + * so the seams that key on the population (the OWD floor in + * `plugin-sharing`) read it off the envelope instead of re-deriving it. + */ + audience?: 'external'; /** * [#23287, ruling C′ item 3] The credential that admitted {@link userId}: * `'api_key'` when an API key did (step 1 below), `'session'` when the @@ -756,6 +765,7 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise; posture?: AuthzPosture; + /** [Ruling of record 6355206139] `'external'` for a holder of `org_external`. */ + audience?: 'external'; /** The user's unique email (`sys_user`), for `current_user.email` owner RLS. */ email?: string; } @@ -2021,13 +2033,21 @@ export async function resolveUserAuthzGrants( // enforcement input, closing the #2836 dual-track class). Enforcement // behavior is unchanged: the per-object Layer 0 exemption + per-side // superuser bypass still gate access; posture is the carried, explainable - // tier. `EXTERNAL` is never derived (no external principal type yet). + // tier. + // + // [Ruling of record 6355206139, letter B] `EXTERNAL` for a holder of + // `org_external` (§3's projection of `sys_member.role = external`, the + // same fact that withheld the `everyone` anchor at §5b), and the + // population stamped as `audience: 'external'` beside it. + const external = holdsExternalIdentity(grants.positions); grants.posture = derivePosture({ isPlatformAdmin: hasPlatformAdminGrant, // [ADR-0105 D4] Either org-admin capability set resolves the rung — the // wall-less variant differs only by withholding the superuser bits. isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n: string) => grants.permissions.includes(n)), + isExternal: external, }); + if (external) grants.audience = 'external'; // 7. [cloud ADR-0024 §7] Env-side AI seat: synthesize the `ai_seat` capability from the // boolean sys_user.ai_access (sqlite returns 1/0; memory returns boolean). diff --git a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts index 744314eb8dc..492eb97df58 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-after-hook.ts @@ -218,9 +218,10 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) { } // ── Ruling 6355206139 E2: the directory doors, narrowed for an external ── - // list-members and get-full-organization check membership only, so an - // `external` caller would read every fellow member (and every pending - // invitation) there. See `external-directory-door.ts`. + // list-members, get-full-organization, list-teams and list-invitations + // check membership only, so an `external` caller would read every + // fellow member, team and pending invitation there. See + // `external-directory-door.ts`. await narrowDirectoryForExternalCaller(ctx, this.externalDirectoryDoorDeps()); // ── #24750: invite-member's unknown-role refusal carries its code ── diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 101ff51792c..ba87c3a2a9d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -7326,6 +7326,13 @@ export class AuthManager { (await first('sys_member', { user_id: userId, organization_id: organizationId }))?.role, readUserEmail: async (userId) => (await first('sys_user', { id: userId }))?.email, resolveOrganizationSlug: async (slug) => (await first('sys_organization', { slug }))?.id, + readUserTeamIds: async (userId) => { + if (!sys) return []; + const rows: any[] = await sys.find('sys_team_member', { where: { user_id: userId }, fields: ['team_id'], limit: 1000 }); + return (Array.isArray(rows) ? rows : []) + .map((r) => r?.team_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + }, isExternalRole: isExternalMembershipRole, }; } diff --git a/packages/plugins/plugin-auth/src/external-directory-door.test.ts b/packages/plugins/plugin-auth/src/external-directory-door.test.ts index 5d023637b43..6cd06e25a80 100644 --- a/packages/plugins/plugin-auth/src/external-directory-door.test.ts +++ b/packages/plugins/plugin-auth/src/external-directory-door.test.ts @@ -5,12 +5,18 @@ * doors answer an `external` caller its own member row (and its own * invitations) only. Driven with the after-hook context the vendor builds: * the response on `ctx.context.returned`, the request's `path` and `query`. + * + * [Stage 2, scope note 6371125645] …and list-teams, get-full-organization's + * `teams`, and list-invitations: the teams it is on, the invitations to its + * own email. */ import { describe, it, expect } from 'vitest'; import { GET_FULL_ORGANIZATION_PATH, + LIST_INVITATIONS_PATH, LIST_MEMBERS_PATH, + LIST_TEAMS_PATH, narrowDirectoryForExternalCaller, type ExternalDirectoryDoorDeps, } from './external-directory-door.js'; @@ -22,12 +28,15 @@ const ROLES: Record = { 'u_ext@org_b': 'member', }; const EMAILS: Record = { u_ext: 'Supplier@x.test', u_emp: 'emp@x.test' }; +/** Team rosters: the external is on the portal team only. */ +const TEAMS_OF: Record = { u_ext: ['t_portal'], u_emp: ['t_sales', 't_portal'] }; const deps = (overrides: Partial = {}): ExternalDirectoryDoorDeps => ({ resolveActor: async (ctx: any) => ctx.__actor ?? null, readMemberRole: async (userId, organizationId) => ROLES[`${userId}@${organizationId}`], readUserEmail: async (userId) => EMAILS[userId], resolveOrganizationSlug: async (slug) => (slug === 'alpha' ? 'org_a' : undefined), + readUserTeamIds: async (userId) => TEAMS_OF[userId] ?? [], isExternalRole: isExternalMembershipRole, ...overrides, }); @@ -65,11 +74,30 @@ const fullOrgCtx = (userId: string, organizationId = 'org_a') => ({ { id: 'i1', email: 'supplier@x.test', role: 'external' }, { id: 'i2', email: 'new-hire@x.test', role: 'member' }, ], - teams: [], + teams: TEAMS('org_a'), }, }, }); +const TEAMS = (organizationId: string) => [ + { id: 't_sales', name: 'Sales', organizationId }, + { id: 't_buyers', name: 'Buyers', organizationId }, + { id: 't_portal', name: 'Supplier portal', organizationId }, +]; + +const INVITATIONS = (organizationId: string) => [ + { id: 'i1', email: 'supplier@x.test', role: 'external', organizationId }, + { id: 'i2', email: 'new-hire@x.test', role: 'member', organizationId }, +]; + +/** A listing door's after-hook context: the vendor answers the bare array. */ +const listingCtx = (path: string, userId: string, returned: any[], query: Record = {}, activeOrgId = 'org_a') => ({ + path, + query, + __actor: { userId, activeOrgId }, + context: { returned }, +}); + describe('list-members, for an external caller', () => { it('answers its own member row only, with a total of one', async () => { const ctx: any = listMembersCtx('u_ext'); @@ -124,6 +152,92 @@ describe('get-full-organization, for an external caller', () => { }); }); +describe('get-full-organization teams, for an external caller', () => { + it('answers the teams it is on, and no other team name', async () => { + const ctx: any = fullOrgCtx('u_ext'); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned.teams.map((t: any) => t.id)).toEqual(['t_portal']); + }); + + it('CONTROL: an ordinary member reads every team', async () => { + const ctx: any = fullOrgCtx('u_emp'); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned.teams).toHaveLength(3); + }); + + it('withholds the teams when the caller’s own team rows cannot be read', async () => { + const ctx: any = fullOrgCtx('u_ext'); + await narrowDirectoryForExternalCaller(ctx, deps({ readUserTeamIds: async () => { throw new Error('driver down'); } })); + expect(ctx.context.returned.teams).toEqual([]); + }); + + it('withholds members, invitations and teams when the caller’s role cannot be read', async () => { + const ctx: any = fullOrgCtx('u_ext'); + await narrowDirectoryForExternalCaller(ctx, deps({ readMemberRole: async () => { throw new Error('driver down'); } })); + expect(ctx.context.returned).toMatchObject({ members: [], invitations: [], teams: [] }); + }); +}); + +describe('list-teams, for an external caller', () => { + it('answers the teams it is on, in place', async () => { + const answer = TEAMS('org_a'); + const ctx: any = listingCtx(LIST_TEAMS_PATH, 'u_ext', answer); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned).toBe(answer); + expect(answer.map((t) => t.id)).toEqual(['t_portal']); + }); + + it('judges the organization the rows name, not the session’s: a member elsewhere is unchanged there', async () => { + const ctx: any = listingCtx(LIST_TEAMS_PATH, 'u_ext', TEAMS('org_b'), {}, 'org_a'); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned).toHaveLength(3); + }); + + it('an empty listing falls back to the queried organization, then the active one', async () => { + const queried: any = listingCtx(LIST_TEAMS_PATH, 'u_ext', [], { organizationId: 'org_a' }, 'org_b'); + await narrowDirectoryForExternalCaller(queried, deps({ readMemberRole: async (_u, org) => { if (org !== 'org_a') throw new Error(`asked ${org}`); return 'external'; } })); + expect(queried.context.returned).toEqual([]); + }); + + it('CONTROL: an ordinary member reads every team', async () => { + const ctx: any = listingCtx(LIST_TEAMS_PATH, 'u_emp', TEAMS('org_a')); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned).toHaveLength(3); + }); + + it('withholds the listing when the caller’s role cannot be read (fail closed)', async () => { + const ctx: any = listingCtx(LIST_TEAMS_PATH, 'u_ext', TEAMS('org_a')); + await narrowDirectoryForExternalCaller(ctx, deps({ readMemberRole: async () => { throw new Error('driver down'); } })); + expect(ctx.context.returned).toEqual([]); + }); +}); + +describe('list-invitations, for an external caller', () => { + it('answers the invitations addressed to its own email (case-insensitive), and no invitee else', async () => { + const ctx: any = listingCtx(LIST_INVITATIONS_PATH, 'u_ext', INVITATIONS('org_a')); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned.map((i: any) => i.id)).toEqual(['i1']); + }); + + it('answers nothing when its own email cannot be read', async () => { + const ctx: any = listingCtx(LIST_INVITATIONS_PATH, 'u_ext', INVITATIONS('org_a')); + await narrowDirectoryForExternalCaller(ctx, deps({ readUserEmail: async () => undefined })); + expect(ctx.context.returned).toEqual([]); + }); + + it('CONTROL: an ordinary member reads every invitation', async () => { + const ctx: any = listingCtx(LIST_INVITATIONS_PATH, 'u_emp', INVITATIONS('org_a')); + await narrowDirectoryForExternalCaller(ctx, deps()); + expect(ctx.context.returned).toHaveLength(2); + }); + + it('withholds the listing when the caller’s role cannot be read (fail closed)', async () => { + const ctx: any = listingCtx(LIST_INVITATIONS_PATH, 'u_ext', INVITATIONS('org_a')); + await narrowDirectoryForExternalCaller(ctx, deps({ readMemberRole: async () => { throw new Error('driver down'); } })); + expect(ctx.context.returned).toEqual([]); + }); +}); + describe('every other door and answer', () => { it('leaves other paths, refusals and anonymous calls alone', async () => { const other: any = { ...listMembersCtx('u_ext'), path: '/organization/list' }; @@ -132,6 +246,11 @@ describe('every other door and answer', () => { const refused: any = { path: LIST_MEMBERS_PATH, __actor: { userId: 'u_ext' }, context: { returned: new Error('FORBIDDEN') } }; await expect(narrowDirectoryForExternalCaller(refused, deps())).resolves.toBeUndefined(); + for (const path of [LIST_TEAMS_PATH, LIST_INVITATIONS_PATH]) { + const refusedListing: any = { path, __actor: { userId: 'u_ext' }, context: { returned: new Error('FORBIDDEN') } }; + await expect(narrowDirectoryForExternalCaller(refusedListing, deps())).resolves.toBeUndefined(); + expect(refusedListing.context.returned).toBeInstanceOf(Error); + } const anonymous: any = { ...listMembersCtx('u_ext'), __actor: null }; await narrowDirectoryForExternalCaller(anonymous, deps()); diff --git a/packages/plugins/plugin-auth/src/external-directory-door.ts b/packages/plugins/plugin-auth/src/external-directory-door.ts index 6e84dcf930a..7a1a913e0e1 100644 --- a/packages/plugins/plugin-auth/src/external-directory-door.ts +++ b/packages/plugins/plugin-auth/src/external-directory-door.ts @@ -19,13 +19,48 @@ * narrowing that cannot run (no engine, no resolvable caller, a failed read) * leaves nothing wider than before — except for the one case that matters, * where the read of the caller's role itself fails: that answer is withheld - * (members and invitations emptied) rather than handed out unjudged. + * (members, invitations and teams emptied) rather than handed out unjudged. + * + * [Stage 2, scope note 6371125645] Two more doors, and the teams member of the + * full organization, measured membership-only the same way (better-auth + * 1.7.12): + * + * - `GET /organization/list-teams` (`crud-team.mjs` `listOrganizationTeams`) + * and get-full-organization's `teams` answer every team of the + * organization: no member row, but the organization's internal structure, + * which the rung's "no member directory" hides. An external gets the teams + * it is itself on (`sys_team_member`), the ones `list-team-members` would + * already answer it. + * - `GET /organization/list-invitations` (`crud-invites.mjs` + * `listInvitations`) checks `findMemberByOrgId` and answers every + * invitation of the organization, invitees' emails included. An external + * gets the invitations addressed to its own email, the rule + * get-full-organization's `invitations` already follows. + * + * Who is external, at this door and at the resolver. The door asks + * {@link ExternalDirectoryDoorDeps.isExternalRole}, which accepts exactly + * `external`; the authorization resolver projects `org_external` from any role + * list that CONTAINS `external`. The two agree on every row the engine admits, + * because E5 (`external-membership.ts`) refuses a list carrying `external` + * beside another role at write, on `sys_member` and `sys_invitation` alike. */ /** The member listing door. */ export const LIST_MEMBERS_PATH = '/organization/list-members'; /** The full-organization door. */ export const GET_FULL_ORGANIZATION_PATH = '/organization/get-full-organization'; +/** The team listing door. */ +export const LIST_TEAMS_PATH = '/organization/list-teams'; +/** The invitation listing door. */ +export const LIST_INVITATIONS_PATH = '/organization/list-invitations'; + +/** Every door this module narrows. */ +const NARROWED_PATHS: ReadonlySet = new Set([ + LIST_MEMBERS_PATH, + GET_FULL_ORGANIZATION_PATH, + LIST_TEAMS_PATH, + LIST_INVITATIONS_PATH, +]); export interface ExternalDirectoryDoorDeps { /** The caller, as the session resolves it. */ @@ -39,6 +74,8 @@ export interface ExternalDirectoryDoorDeps { readUserEmail(userId: string): Promise; /** The organization id a slug names. */ resolveOrganizationSlug(slug: string): Promise; + /** The ids of the teams the caller is on (`sys_team_member`). Throws when the read fails. */ + readUserTeamIds(userId: string): Promise; /** Is this stored role list exactly `external`? */ isExternalRole(raw: unknown): boolean; } @@ -49,23 +86,61 @@ const isPlainObject = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Error); /** The organization the door answered for. */ -async function answeredOrganization(ctx: any, returned: Record, deps: ExternalDirectoryDoorDeps, activeOrgId?: string) { - if (ctx.path === GET_FULL_ORGANIZATION_PATH) return str(returned.id); - const fromRows = Array.isArray(returned.members) ? str(returned.members[0]?.organizationId) : undefined; +async function answeredOrganization(ctx: any, returned: unknown, deps: ExternalDirectoryDoorDeps, activeOrgId?: string) { + if (ctx.path === GET_FULL_ORGANIZATION_PATH) return isPlainObject(returned) ? str(returned.id) : undefined; + // A listing names its organization on its rows; an empty one falls back to + // the query the vendor read, then the session's active organization. + const rows: unknown = Array.isArray(returned) ? returned : isPlainObject(returned) ? returned.members : undefined; + const fromRows = Array.isArray(rows) ? str(rows[0]?.organizationId) : undefined; if (fromRows) return fromRows; const query = ctx?.query ?? {}; const bySlug = str(query.organizationSlug) ? await deps.resolveOrganizationSlug(query.organizationSlug) : undefined; return bySlug ?? str(query.organizationId) ?? activeOrgId; } +/** Keep, in place, only the entries `keep` admits (a listing door answers the array itself). */ +function retainInPlace(list: any[], keep: (entry: any) => boolean): void { + const kept = list.filter(keep); + list.splice(0, list.length, ...kept); +} + +/** The answer's shape, per door: an object carrying `members`, or the bare listing array. */ +function answerShapeIsNarrowable(path: string, returned: unknown): boolean { + if (path === LIST_TEAMS_PATH || path === LIST_INVITATIONS_PATH) return Array.isArray(returned); + return isPlainObject(returned) && Array.isArray(returned.members); +} + +/** Withhold everything a door answers, for a caller whose population could not be judged. */ +function withholdAll(returned: any): void { + if (Array.isArray(returned)) { + returned.splice(0, returned.length); + return; + } + returned.members = []; + if ('total' in returned) returned.total = 0; + if (Array.isArray(returned.invitations)) returned.invitations = []; + if (Array.isArray(returned.teams)) returned.teams = []; +} + +async function ownInvitationFilter(userId: string, deps: ExternalDirectoryDoorDeps): Promise<(i: any) => boolean> { + const email = (await deps.readUserEmail(userId).catch(() => undefined))?.toLowerCase(); + return (i: any) => !!email && typeof i?.email === 'string' && i.email.toLowerCase() === email; +} + +async function ownTeamFilter(userId: string, deps: ExternalDirectoryDoorDeps): Promise<(t: any) => boolean> { + // A failed read keeps no team: the structure is withheld, never answered unjudged. + const teamIds = new Set(await deps.readUserTeamIds(userId).catch(() => [] as string[])); + return (t: any) => typeof t?.id === 'string' && teamIds.has(t.id); +} + /** * Narrow the response in place when the caller is an external member of the * organization the door answered for. No-op for every other path and caller. */ export async function narrowDirectoryForExternalCaller(ctx: any, deps: ExternalDirectoryDoorDeps): Promise { - if (ctx?.path !== LIST_MEMBERS_PATH && ctx?.path !== GET_FULL_ORGANIZATION_PATH) return; + if (!NARROWED_PATHS.has(ctx?.path)) return; const returned = ctx?.context?.returned; - if (!isPlainObject(returned) || !Array.isArray(returned.members)) return; + if (!answerShapeIsNarrowable(ctx.path, returned)) return; const actor = await deps.resolveActor(ctx).catch(() => null); if (!actor?.userId) return; @@ -79,20 +154,27 @@ export async function narrowDirectoryForExternalCaller(ctx: any, deps: ExternalD } catch { // The caller's population could not be judged: withhold the directory // rather than answer it unjudged (module doc). - returned.members = []; - if ('total' in returned) returned.total = 0; - if (Array.isArray(returned.invitations)) returned.invitations = []; + withholdAll(returned); return; } if (!external) return; + if (ctx.path === LIST_TEAMS_PATH) { + retainInPlace(returned, await ownTeamFilter(userId, deps)); + return; + } + if (ctx.path === LIST_INVITATIONS_PATH) { + retainInPlace(returned, await ownInvitationFilter(userId, deps)); + return; + } + returned.members = returned.members.filter((m: any) => str(m?.userId) === userId); // The vendor already proved the caller a member, so its directory is one row. if (ctx.path === LIST_MEMBERS_PATH) returned.total = 1; if (Array.isArray(returned.invitations)) { - const email = (await deps.readUserEmail(userId).catch(() => undefined))?.toLowerCase(); - returned.invitations = email - ? returned.invitations.filter((i: any) => typeof i?.email === 'string' && i.email.toLowerCase() === email) - : []; + returned.invitations = returned.invitations.filter(await ownInvitationFilter(userId, deps)); + } + if (Array.isArray(returned.teams)) { + returned.teams = returned.teams.filter(await ownTeamFilter(userId, deps)); } } diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts index 9e89a968819..daa6bdf4bc3 100644 --- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts +++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts @@ -113,7 +113,27 @@ const invoiceAuditor: PermissionSet = { objects: { crm_secret: { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true } }, } as any; -const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, memberBaseline, publicReader, invoiceAuditor]; +// [Ruling of record 6355206139, letter B] An EXTERNAL member's explicit grant: +// the ordinary business object, opened as an object, with no row predicate of +// its own. What it reaches is the rung's rule — `external_default` (its +// identity position's set) plus this grant, never `member_default`. +const portalGrant: PermissionSet = { + name: 'portal_grant', + label: 'Portal grant (explicit, no RLS)', + objects: { task: { allowRead: true, allowCreate: true, allowEdit: true } }, +} as any; + +// …and the same grant WITH a row predicate — the `supplier in +// current_user.` shape the ruling names — which is what lifts the OWD +// floor for its object (the stamp cells below). +const portalGrantWithRls: PermissionSet = { + name: 'portal_grant_rls', + label: 'Portal grant (explicit, with RLS)', + objects: { task: { allowRead: true, allowCreate: true, allowEdit: true } }, + rowLevelSecurity: [{ name: 'portal_rows', object: 'task', operation: 'select', using: "status == 'portal'" }], +} as any; + +const ALL_SETS: PermissionSet[] = [...defaultPermissionSets, memberBaseline, publicReader, invoiceAuditor, portalGrant, portalGrantWithRls]; // The fail-closed sentinel's marker value. `RLS_DENY_FILTER` is declared // `Record`, so the member arrives as `unknown` and the `rank()` // substring probe below cannot take it; converted once here rather than at each @@ -290,6 +310,15 @@ const ROLES = { member: { userId: 'u1', tenantId: 'org-1', positions: ['org_member'], permissions: ['member_baseline'] }, // Authenticated user with NO active organization → tenant scoping cannot resolve → fail-closed. no_org_member: { userId: 'u2', positions: ['org_member'], permissions: ['member_baseline'] }, + // [Ruling of record 6355206139, letter B] The EXTERNAL rung: an `external` + // member (the resolver's `org_external` projection, its `audience` and + // posture stamps) holding one explicit grant. Its baseline is + // `external_default`, never `member_default` — so no `owner_only_*` floor + // (that binds `org_member`) and no wildcard object grant. + external: { + userId: 'x1', tenantId: 'org-1', positions: ['org_external'], permissions: ['portal_grant'], + audience: 'external', posture: 'EXTERNAL', + }, }; // The locked snapshot of POST-EXTRACTION behavior (Layer0 AND Layer1). Read the @@ -314,6 +343,16 @@ const EXPECTED_MATRIX: Record { if (s.includes('created_by') || s.includes('"id"')) return 1; // owner/self-scoped return 2; // org-scoped }; - const LADDER_ORDER = ['platform_admin', 'org_admin', 'member'] as const; + // [Ruling 6355206139] The EXTERNAL column is the ladder's foot: never wider than MEMBER. + const LADDER_ORDER = ['platform_admin', 'org_admin', 'member', 'external'] as const; for (const [oName, col] of Object.entries(EXPECTED_MATRIX)) { for (const side of ['read', 'write'] as const) { for (let i = 1; i < LADDER_ORDER.length; i++) { @@ -695,17 +743,70 @@ describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => { } }); - // ── [D3 dead branch] capability evidence can never derive EXTERNAL ──────── - // The EXTERNAL rung activates only from `audience:'external'` (ADR-0090 D10) - // when the portal principal type ships; no combination of capability-grant - // evidence may reach it. (Full EXTERNAL × layer share-fixture cells live in - // posture-ladder.test.ts — the semantics lock — and extend at P3.) - it('[D3 dead branch] no capability-grant evidence derives EXTERNAL', () => { + // ── [D3] EXTERNAL derives from the external identity, never from capability evidence ── + // [Ruling of record 6355206139] The branch is live: a holder of `org_external` + // derives EXTERNAL; no combination of capability-grant evidence alone does. + // The rung's cells are the `external` column above, and its OWD half is + // plugin-sharing's (`external-owd-floor.test.ts`). + it('[D3] capability evidence alone never derives EXTERNAL; the external identity does', () => { for (const isPlatformAdmin of [true, false]) { for (const isTenantAdmin of [true, false]) { expect(derivePosture({ isPlatformAdmin, isTenantAdmin })).not.toBe('EXTERNAL'); } } + expect(derivePosture({ isPlatformAdmin: false, isTenantAdmin: false, isExternal: true })).toBe('EXTERNAL'); + }); + + // ── [Ruling 6355206139] The OWD seam's stamp, made where the grants resolve ── + // plugin-sharing floors an external's OWD at owner_only unless a held grant's + // RLS speaks for the object; step 2.6 says whether one does, on the context + // it hands the sharing middleware. Measured on the real middleware. + describe('[EXTERNAL] the grant-RLS stamp the OWD floor reads', () => { + const stampsAfter = async (operation: 'find' | 'update', roleCtx: any) => { + const plugin = new SecurityPlugin(); + const h = makeHarness({ + ...OBJECTS.task, + orgScoping: true, + findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1' }), + }); + await plugin.init(h.ctx); await plugin.start(h.ctx); + const context = { ...roleCtx }; + const opCtx: any = operation === 'find' + ? { object: 'task', operation, ast: { where: undefined }, context } + : { object: 'task', operation, data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context }; + try { await h.run(opCtx); } catch { /* the stamp is made before any refusal */ } + return { read: context.__externalReadGrant, write: context.__externalWriteGrant }; + }; + + // A stamp names the object it was resolved for, so a nested operation on + // another object under the same envelope (measured here: a `sys_user` + // write-class stamp beside the `task` find) can never stand in for this + // one. The cells therefore ask whether `task` is stamped. + it('a grant with no RLS opens the object, not its rows: `task` is not stamped, so the floor stands', async () => { + for (const op of ['find', 'update'] as const) { + const stamps = await stampsAfter(op, ROLES.external); + expect(stamps.read, op).not.toBe('task'); + expect(stamps.write, op).not.toBe('task'); + } + }); + + it('a grant whose RLS speaks for the object stamps it, for the read class and (derived) the write class', async () => { + const withRls = { ...ROLES.external, permissions: ['portal_grant_rls'] }; + expect((await stampsAfter('find', withRls)).read).toBe('task'); + expect((await stampsAfter('update', withRls)).write).toBe('task'); + }); + + it('a stale stamp the envelope brought is cleared, never trusted', async () => { + const stale = { ...ROLES.external, __externalReadGrant: 'task' }; + expect((await stampsAfter('find', stale)).read).not.toBe('task'); + }); + + it('CONTROL: an internal principal is never stamped', async () => { + const member = { ...ROLES.member, permissions: ['member_baseline', 'portal_grant_rls'] }; + for (const op of ['find', 'update'] as const) { + expect(await stampsAfter(op, member), op).toEqual({ read: undefined, write: undefined }); + } + }); }); }); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 7bb1d4b21a6..f4c97ed95a8 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -78,6 +78,8 @@ import { type UnresolvedPostureCause, } from './unresolved-posture.js'; import { grantSetNameOf } from './grant-permission-set-name.js'; +import { holdsExternalPosition, humanBaselineFor } from './external-baseline.js'; +import { isExternalAudienceContext } from '@objectstack/core'; const SYSTEM_CTX = { isSystem: true } as const; @@ -114,9 +116,9 @@ function isAuthzPosture(v: unknown): v is AuthzPosture { * * 1. **guest / anonymous → EXTERNAL.** Explain layers one thing on top the * enforcement resolver deliberately does NOT: a guest principal is EXTERNAL - * for the debugger. The enforcement floor is MEMBER (no external principal - * type exists yet — ADR-0095 D2), so this mapping lives here, and it wins - * first. + * for the debugger. The resolver gives a guest no rung at all (its + * EXTERNAL rung is the organization's `external` members — ruling of record + * 6355206139), so this mapping lives here, and it wins first. * 2. **Reuse `ctx.posture` verbatim when present.** A principal resolved through * the full `resolveAuthzContext` already carries the enforcement-derived * rung; consuming it directly makes drift structurally impossible. @@ -162,6 +164,9 @@ function derivePosture(context: any): AuthzPosture { return deriveAdminPosture({ isPlatformAdmin: context?.hasPlatformAdminGrant === true, isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)), + // [Ruling of record 6355206139] The same evidence the resolver reads: the + // projected `org_external` identity. + isExternal: holdsExternalPosition(context?.positions), }); } @@ -326,6 +331,14 @@ export interface ExplainEngineDeps { * Supply the enforcement composition, never a re-derivation of it: explain's * module contract is that it "matches enforcement by construction". */ + /** + * [Ruling of record 6355206139] Does a grant `sets` holds carry an RLS + * predicate for `object` on the operation class — the question the CRUD + * middleware answers for the EXTERNAL OWD seam (`external-owd-grant-stamp.ts`)? + * Asked only for an external principal. Absent → no grant speaks, so the + * report shows the owner_only floor, the narrow reading. + */ + externalGrantSpeaks?: (sets: PermissionSet[], object: string, opClass: 'read' | 'write', dataOp: string, context: any) => boolean; getPartialMaskRules: ( sets: PermissionSet[], object: string, @@ -340,6 +353,10 @@ export interface ExplainEngineDeps { * of them would label the other "resolved" — the vaguest bucket `viaOf` has, * on the grant most likely to be the answer to "why can this member read * anything at all". + * + * [Ruling of record 6355206139] The CONFIGURED list. An external principal + * resolves `external_default` in its place, and the report attributes that + * name instead (`humanBaselineFor`). */ baselinePermissionSets: string[]; /** @@ -728,6 +745,9 @@ export async function buildContextForUser( ...(grants.tabPermissions ? { tabPermissions: grants.tabPermissions } : {}), ...(grants.email != null ? { email: grants.email } : {}), ...(grants.posture ? { posture: grants.posture } : {}), + // [Ruling of record 6355206139] The population stamp the resolver made, so + // the explained context is floored at the OWD seam as enforcement is. + ...(grants.audience ? { audience: grants.audience } : {}), droppedGrants, delegatedPositions, // [ADR-0068 D2] Not a second derivation: `derivePosture` returns @@ -1008,6 +1028,27 @@ function describeMaskingRule(rule: FieldMaskingRule): string { return typeof rule === 'string' ? rule : `keepHead ${rule.keepHead}, keepTail ${rule.keepTail}`; } +/** + * [Ruling of record 6355206139] The OWD an EXTERNAL principal meets, as + * plugin-sharing applies it (`owdForAudience`, `@objectstack/core`): the + * object's own reading for an internal principal, or when a held grant's RLS + * speaks for the object; otherwise owner_only, whatever the object declares. + */ +function owdForPrincipal( + owd: ReturnType, + context: any, + grantSpeaks: boolean, +): ReturnType { + // A controlled_by_parent detail derives from its master (ADR-0055), whose + // own model meets the floor — plugin-sharing does not floor the detail. + if (owd.effect === 'private' || owd.model === 'controlled_by_parent' || !isExternalAudienceContext(context) || grantSpeaks) return owd; + return { + model: `${owd.model} → owner_only for an external principal (no held grant's RLS speaks for this object)`, + declared: owd.declared, + effect: 'private', + }; +} + /** D1-equivalent OWD reading (mirrors plugin-sharing's effectiveSharingModel). */ function describeOwd(schema: any): { model: string; declared: boolean; effect: 'private' | 'read' | 'public' } { const m = schema?.sharingModel ?? schema?.security?.sharingModel; @@ -2407,8 +2448,15 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: } const delegatorRefused = delegatorMissing || delegatorUnresolved || delegatorOutsideOrganization !== undefined; const positions: string[] = context?.positions ?? []; + // [Ruling of record 6355206139] The baseline THIS principal resolves: an + // external's is `external_default`, never the configured member baseline, + // and it is attributed by that name (`external-baseline.ts`). + const baselineNames = humanBaselineFor(positions, deps.baselinePermissionSets); + const externalBaseline = holdsExternalPosition(positions); const viaOf = (name: string): string => { - if (deps.baselinePermissionSets.includes(name)) return 'additive baseline (ADR-0090 D5)'; + if (baselineNames.includes(name)) { + return externalBaseline ? 'external baseline (org_external)' : 'additive baseline (ADR-0090 D5)'; + } if (positions.includes(name)) return `position:${name}`; if ((context?.permissions ?? []).includes(name)) return 'direct grant'; return 'resolved'; @@ -2656,7 +2704,15 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: }); // ── 5. owd_baseline ──────────────────────────────────────────────────── - const owd = describeOwd(schema); + // [Ruling of record 6355206139] An external principal meets the owner_only + // floor unless a held grant's RLS speaks — the same answer the middleware + // stamps for plugin-sharing, so the report and the query cannot disagree. + const owdOpClass: 'read' | 'write' = dataOp === 'find' ? 'read' : 'write'; + const owd = owdForPrincipal( + describeOwd(schema), + context, + isExternalAudienceContext(context) && deps.externalGrantSpeaks?.(sets, object, owdOpClass, dataOp, context) === true, + ); layers.push({ layer: 'owd_baseline', verdict: owd.effect === 'public' ? 'neutral' : 'narrows', diff --git a/packages/plugins/plugin-security/src/explain-external-principal.test.ts b/packages/plugins/plugin-security/src/explain-external-principal.test.ts new file mode 100644 index 00000000000..930c7694b0e --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-external-principal.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [Ruling of record 6355206139, letter B] What explain says about an EXTERNAL + * principal: the baseline it resolves is named `external_default` (never the + * configured member baseline), and the OWD it meets is owner_only unless a held + * grant's RLS speaks for the object — the same answer the middleware stamps for + * plugin-sharing. A member is the control on every assertion. + */ + +import { describe, it, expect } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { PermissionEvaluator } from './permission-evaluator'; +import { explainAccess, type ExplainEngineDeps } from './explain-engine'; + +const EXTERNAL_DEFAULT = PermissionSetSchema.parse({ + name: 'external_default', + objects: { board: { allowRead: true, allowEdit: true } }, +}); +const MEMBER_DEFAULT = PermissionSetSchema.parse({ + name: 'member_default', + objects: { board: { allowRead: true, allowEdit: true } }, +}); + +const BOARD = { name: 'board', sharingModel: 'public_read_write' }; + +function makeDeps(sets: any[], grantSpeaks: boolean): ExplainEngineDeps { + return { + ql: { getSchema: () => BOARD }, + resolveSets: async () => sets, + evaluator: new PermissionEvaluator(), + getObjectSecurityMeta: async () => ({ + isPrivate: false, + requiredPermissions: { all: [], read: [], create: [], update: [], delete: [] }, + fieldRequiredPermissions: {}, + }), + requiredCaps: () => [], + computeRlsFilter: async () => null, + getFieldMask: () => ({}), + getPartialMaskRules: async () => ({}), + baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', + externalGrantSpeaks: () => grantSpeaks, + }; +} + +const EXTERNAL = { userId: 'x1', tenantId: 'org-1', positions: ['org_external'], permissions: [], audience: 'external', posture: 'EXTERNAL' }; +const MEMBER = { userId: 'u1', tenantId: 'org-1', positions: ['org_member', 'everyone'], permissions: [], posture: 'MEMBER' }; + +const layer = (d: any, name: string) => d.layers.find((l: any) => l.layer === name); + +describe('explain — the external baseline is named', () => { + it('an external’s baseline is attributed as external_default, not the member baseline', async () => { + const d = await explainAccess(makeDeps([EXTERNAL_DEFAULT], false), { object: 'board', operation: 'read', context: EXTERNAL }); + expect(layer(d, 'principal')?.contributors).toContainEqual({ kind: 'permission_set', name: 'external_default', via: 'external baseline (org_external)' }); + }); + + it('CONTROL: a member’s baseline is the additive baseline', async () => { + const d = await explainAccess(makeDeps([MEMBER_DEFAULT], false), { object: 'board', operation: 'read', context: MEMBER }); + expect(layer(d, 'principal')?.contributors).toContainEqual({ kind: 'permission_set', name: 'member_default', via: 'additive baseline (ADR-0090 D5)' }); + }); +}); + +describe('explain — the OWD an external meets', () => { + for (const operation of ['read', 'update'] as const) { + it(`${operation}: public_read_write reads as owner_only for an external with no grant speaking`, async () => { + const d = await explainAccess(makeDeps([EXTERNAL_DEFAULT], false), { object: 'board', operation, context: EXTERNAL }); + const owd = layer(d, 'owd_baseline'); + expect(owd?.verdict).toBe('narrows'); + expect(owd?.detail).toContain('owner_only for an external principal'); + }); + } + + it('a held grant’s RLS speaking lifts the floor: the object’s own model stands', async () => { + const d = await explainAccess(makeDeps([EXTERNAL_DEFAULT], true), { object: 'board', operation: 'read', context: EXTERNAL }); + const owd = layer(d, 'owd_baseline'); + expect(owd?.verdict).toBe('neutral'); + expect(owd?.detail).not.toContain('owner_only'); + }); + + it('a controlled_by_parent detail is not floored: the report reads the master-derived model', async () => { + const deps = { ...makeDeps([EXTERNAL_DEFAULT], false), ql: { getSchema: () => ({ name: 'board', sharingModel: 'controlled_by_parent' }) } } as any; + const d = await explainAccess(deps, { object: 'board', operation: 'read', context: EXTERNAL }); + expect(layer(d, 'owd_baseline')?.detail).not.toContain('owner_only for an external principal'); + }); + + it('CONTROL: a member meets the object’s own model, grant or no grant', async () => { + const d = await explainAccess(makeDeps([MEMBER_DEFAULT], false), { object: 'board', operation: 'read', context: MEMBER }); + expect(layer(d, 'owd_baseline')?.verdict).toBe('neutral'); + }); +}); diff --git a/packages/plugins/plugin-security/src/external-baseline.ts b/packages/plugins/plugin-security/src/external-baseline.ts new file mode 100644 index 00000000000..91259082a7a --- /dev/null +++ b/packages/plugins/plugin-security/src/external-baseline.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [Ruling of record 6355206139, letter B] The human baseline a context + * resolves, named in one place for the three readers that must agree on it: + * the data plane's permission-set resolution + * (`security-plugin-permission-sets.ts`), the explainer's grant attribution + * (`explain-engine.ts`), and the metadata plane's describe fallback + * (`SecurityPlugin#resolveFallbackPermissionSets`). + * + * An EXTERNAL member — a holder of `org_external`, the projection of + * `sys_member.role = external` — is not handed the deployment's member + * baseline (`member_default`, an app's `isDefault` set). Its baseline is + * `external_default`: its own identity rows, and data only through explicit + * grants. Every other human gets the configured, composed baseline. An agent's + * ceiling is decided by its caller, never here. + */ + +import { BUILTIN_IDENTITY_ORG_EXTERNAL } from '@objectstack/spec'; +import { EXTERNAL_DEFAULT_PERMISSION_SET } from './objects/default-permission-sets.js'; + +/** Does a principal holding `positions` belong to the organization's external population? */ +export function holdsExternalPosition(positions: unknown): boolean { + return Array.isArray(positions) && positions.includes(BUILTIN_IDENTITY_ORG_EXTERNAL); +} + +/** + * The baseline set names a HUMAN holding `positions` resolves: `[external_default]` + * for an external, `configured` (the composed `security.baselinePermissionSets`) + * for everyone else. + */ +export function humanBaselineFor(positions: unknown, configured: readonly string[]): string[] { + return holdsExternalPosition(positions) ? [EXTERNAL_DEFAULT_PERMISSION_SET] : [...configured]; +} diff --git a/packages/plugins/plugin-security/src/external-owd-grant-stamp.test.ts b/packages/plugins/plugin-security/src/external-owd-grant-stamp.test.ts new file mode 100644 index 00000000000..bccb4b51fac --- /dev/null +++ b/packages/plugins/plugin-security/src/external-owd-grant-stamp.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [Ruling of record 6355206139, letter B] What counts as a held grant's RLS + * speaking for an object, for the EXTERNAL OWD seam — and what does not: the + * object's own declared default, the platform's ownership floor, a `check`-only + * policy, and (for a read) a write-class predicate. + */ + +import { describe, it, expect } from 'vitest'; +import type { RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import { EXTERNAL_READ_GRANT_KEY, EXTERNAL_WRITE_GRANT_KEY } from '@objectstack/core'; +import { externalGrantPatch, grantRowPolicySpeaks, stampExternalGrant } from './external-owd-grant-stamp.js'; +import { objectDefaultRowScopePolicy } from './object-default-row-scope.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { humanBaselineFor, holdsExternalPosition } from './external-baseline.js'; + +const policy = (over: Partial): RowLevelSecurityPolicy => + ({ name: 'p', object: 'po', operation: 'select', using: "supplier == 's1'", ...over }) as RowLevelSecurityPolicy; + +/** A collector answering per RLS operation, as Layer 1's collection would. */ +const collector = (byOp: Record) => (op: string) => byOp[op] ?? []; + +const floorOf = (name: 'owner_only_writes' | 'owner_only_deletes') => { + const set = defaultPermissionSets.find((s) => s.name === 'external_default')!; + return (set.rowLevelSecurity ?? []).find((p) => p.name === name)!; +}; + +describe('grantRowPolicySpeaks', () => { + it('a select predicate speaks for the read class and, as Layer 1 derives it, the write class', () => { + const collect = collector({ select: [policy({})] }); + expect(grantRowPolicySpeaks(collect, 'read')).toBe(true); + expect(grantRowPolicySpeaks(collect, 'write', 'update')).toBe(true); + }); + + it('a write-class predicate speaks for its write class, never for a read', () => { + const collect = collector({ update: [policy({ operation: 'update' })] }); + expect(grantRowPolicySpeaks(collect, 'read')).toBe(false); + expect(grantRowPolicySpeaks(collect, 'write', 'update')).toBe(true); + expect(grantRowPolicySpeaks(collect, 'write', 'delete')).toBe(false); + }); + + it('the object’s own declared default row scope is not a grant', () => { + const declared = objectDefaultRowScopePolicy('po', { defaultRowScope: { name: 'po_default', operation: 'select', using: "status == 'open'" } })!; + expect(grantRowPolicySpeaks(collector({ select: [declared] }), 'read')).toBe(false); + }); + + it('the platform’s owner_only floor is not a grant, on either limb', () => { + expect(grantRowPolicySpeaks(collector({ update: [floorOf('owner_only_writes')] }), 'write', 'update')).toBe(false); + expect(grantRowPolicySpeaks(collector({ delete: [floorOf('owner_only_deletes')] }), 'write', 'delete')).toBe(false); + }); + + it('a check-only policy gates the post-image, not which rows are reached', () => { + const checkOnly = { name: 'c', object: 'po', operation: 'update', check: "status != 'closed'" } as RowLevelSecurityPolicy; + expect(grantRowPolicySpeaks(collector({ update: [checkOnly] }), 'write', 'update')).toBe(false); + }); +}); + +describe('the stamp', () => { + it('names the object when a grant speaks, and removes the key when none does', () => { + const ctx: Record = { [EXTERNAL_READ_GRANT_KEY]: 'po' }; + stampExternalGrant(ctx, 'po', 'read', false); + expect(EXTERNAL_READ_GRANT_KEY in ctx).toBe(false); + stampExternalGrant(ctx, 'po', 'write', true); + expect(ctx[EXTERNAL_WRITE_GRANT_KEY]).toBe('po'); + }); + + it('as a patch: an external gets its stamp (undefined overwrites a carried one); an internal principal gets nothing', () => { + const speaks = collector({ select: [policy({})] }); + const silent = collector({}); + const external = { audience: 'external', [EXTERNAL_READ_GRANT_KEY]: 'po' }; + expect(externalGrantPatch(external, 'po', 'read', speaks)).toEqual({ [EXTERNAL_READ_GRANT_KEY]: 'po' }); + expect(externalGrantPatch(external, 'po', 'read', silent)).toEqual({ [EXTERNAL_READ_GRANT_KEY]: undefined }); + expect(externalGrantPatch({ posture: 'MEMBER' }, 'po', 'read', speaks)).toEqual({}); + }); +}); + +describe('the baseline an external resolves, named once', () => { + it('external_default for a holder of org_external; the configured baseline for anyone else', () => { + expect(holdsExternalPosition(['org_external', 'supplier_portal'])).toBe(true); + expect(humanBaselineFor(['org_external'], ['member_default', 'app_default'])).toEqual(['external_default']); + expect(humanBaselineFor(['org_member', 'everyone'], ['member_default', 'app_default'])).toEqual(['member_default', 'app_default']); + expect(humanBaselineFor(undefined, ['member_default'])).toEqual(['member_default']); + }); + + it('external_default carries the owner_only write floor, bound to org_external', () => { + for (const name of ['owner_only_writes', 'owner_only_deletes'] as const) { + expect(floorOf(name)).toMatchObject({ object: '*', using: 'created_by == current_user.id', positions: ['org_external'] }); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/external-owd-grant-stamp.ts b/packages/plugins/plugin-security/src/external-owd-grant-stamp.ts new file mode 100644 index 00000000000..4557f5821a7 --- /dev/null +++ b/packages/plugins/plugin-security/src/external-owd-grant-stamp.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [Ruling of record 6355206139, letter B] The producer half of the EXTERNAL + * rung's OWD seam: does a grant the external holds carry an RLS predicate for + * this object and operation class, so that the object's own org-wide default + * stands and that predicate bounds the rows? + * + * `plugin-sharing` floors the OWD of every object at owner_only for an external + * principal unless this answer, stamped on the operation context, says a grant + * speaks (`external-owd-floor.ts` in `@objectstack/core` carries the rule and + * the stamp names). The CRUD middleware stamps it at step 2.6, beside the + * access depth it already hands the sharing service. + * + * What counts as a grant's predicate, and what does not: + * - a `using` clause on a policy of a set the principal resolves (positions, + * permission sets, its own `external_default`), applicable to the object, + * the operation and the principal's positions — exactly the policies Layer 1 + * compiles; + * - ⛔ NOT the object's own `defaultRowScope`: a declaration on the object is + * not a grant to the principal (`isObjectDefaultRowScopePolicy`); + * - ⛔ NOT the platform's ownership floor (`owner_only_writes` / + * `owner_only_deletes`), which is platform policy, not a grant, and which + * binds `org_member` besides; + * - a `check`-only policy gates the post-image, not which rows are reached, + * so it is no predicate here either (#8059's reading). + * + * The write class mirrors Layer 1's #7665 derivation: when no write-class + * predicate applies, the write scope is derived from the read class, so a read + * predicate speaks for the write class too. The read class never borrows from + * the write class: an update-only predicate is no evidence for a read. + */ + +import type { RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import { EXTERNAL_READ_GRANT_KEY, EXTERNAL_WRITE_GRANT_KEY, isExternalAudienceContext } from '@objectstack/core'; +import { policyDeclaresClause } from './rls-compiler.js'; +import { isObjectDefaultRowScopePolicy } from './object-default-row-scope.js'; +import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; + +/** Collects the RLS policies Layer 1 would compile for one RLS operation. */ +export type CollectGrantPolicies = (rlsOperation: string) => readonly RowLevelSecurityPolicy[]; + +function grantPredicateApplies(policies: readonly RowLevelSecurityPolicy[]): boolean { + return policies.some( + (p) => + policyDeclaresClause(p, 'using') && + !isObjectDefaultRowScopePolicy(p as object) && + !isPlatformOwnershipFloorPolicy(p), + ); +} + +/** + * Does a held grant's RLS speak for the operation class? `rlsOperation` is the + * RLS operation of the write in flight (`update`, `delete`, …); unused on the + * read class, which always asks `select`. + */ +export function grantRowPolicySpeaks( + collect: CollectGrantPolicies, + opClass: 'read' | 'write', + rlsOperation?: string, +): boolean { + if (grantPredicateApplies(collect('select'))) return true; + if (opClass === 'read') return false; + return rlsOperation !== undefined && rlsOperation !== 'select' && grantPredicateApplies(collect(rlsOperation)); +} + +/** + * Stamp the answer for `object` on the operation context: the object's name + * when a grant speaks, and the key REMOVED when none does — so a stamp left by + * an earlier operation on the same envelope can never stand in for this one. + */ +export function stampExternalGrant( + context: Record, + object: string, + opClass: 'read' | 'write', + speaks: boolean, +): void { + const key = opClass === 'read' ? EXTERNAL_READ_GRANT_KEY : EXTERNAL_WRITE_GRANT_KEY; + if (speaks) context[key] = object; + else delete context[key]; +} + +/** + * The same answer as a spreadable patch, for the helpers that hand the sharing + * service a rebuilt context rather than the operation context (the explainer, + * the analytics read scope, the master-detail and by-id write verdicts): the + * stamp for an external principal — `undefined` when no grant speaks, which + * overwrites any stamp the context brought — and nothing for anyone else. + */ +export function externalGrantPatch( + context: unknown, + object: string, + opClass: 'read' | 'write', + collect: CollectGrantPolicies, + rlsOperation?: string, +): Record { + if (!isExternalAudienceContext(context)) return {}; + const key = opClass === 'read' ? EXTERNAL_READ_GRANT_KEY : EXTERNAL_WRITE_GRANT_KEY; + return { [key]: grantRowPolicySpeaks(collect, opClass, rlsOperation) ? object : undefined }; +} diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 6a2e35bd427..5dcc9a64569 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -8,6 +8,7 @@ import { ORGANIZATION_ADMIN_NO_BYPASS, BUILTIN_IDENTITY_ORG_ADMIN, BUILTIN_IDENTITY_ORG_OWNER, + BUILTIN_IDENTITY_ORG_EXTERNAL, MEMBERSHIP_ROLE_DELEGATED_ADMIN, } from '@objectstack/spec'; import { @@ -1266,8 +1267,18 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // external every newly declared identity table. Data beyond its own rows // comes only through explicit grants — positions, permission sets, sharing. // - // No `owner_only_*` floor either: those bind `org_member`, and an external's - // writes are exactly what its explicit grants declare, under their own RLS. + // [Stage 2 — the owner_only floor] The platform's write ownership floor, + // `owner_only_writes` / `owner_only_deletes`, in the same spelling as + // `member_default`'s and bound to `org_external`: an external may modify and + // delete the records it created, not other users', whatever object grant it + // holds. Same object, name and predicate, so the floor's provenance + // (`platform-ownership-policies.ts`) recognises it, and every composition + // that relieves a member's floor relieves this one identically — an + // edit-level share, a `public_read_write` update, a covering master gate. + // Without it an external's by-id write reached every row its grant opened + // while a member's stopped at its own: the EXTERNAL ⊆ MEMBER nesting + // (ADR-0095 D2) broken on the write side. The OWD half of the same floor is + // plugin-sharing's (`owdForAudience`, `@objectstack/core`). PermissionSetSchema.parse({ name: EXTERNAL_DEFAULT_PERMISSION_SET, label: 'External Member — Own Records', @@ -1380,6 +1391,21 @@ const baseDefaultPermissionSets: PermissionSet[] = [ }, // [ADR-0131 D7] For a ledger read an explicit grant may give. ...auditLedgerRowScope(), + // The owner_only write floor (see the note above the set). + { + name: 'owner_only_writes', + object: '*', + operation: 'update', + using: 'created_by == current_user.id', + positions: [BUILTIN_IDENTITY_ORG_EXTERNAL], + }, + { + name: 'owner_only_deletes', + object: '*', + operation: 'delete', + using: 'created_by == current_user.id', + positions: [BUILTIN_IDENTITY_ORG_EXTERNAL], + }, ], }), diff --git a/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts b/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts index b9d8b454f43..0f022e7789a 100644 --- a/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts +++ b/packages/plugins/plugin-security/src/platform-ownership-policies.test.ts @@ -19,9 +19,21 @@ import { } from './platform-ownership-policies.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; -const shippedFloorPolicies = defaultPermissionSets - .flatMap((ps) => ps.rowLevelSecurity ?? []) - .filter((p) => typeof p.using === 'string' && p.using.trim() === OWNERSHIP_FLOOR_PREDICATE); +// [Ruling of record 6355206139] `member_default` and `external_default` carry +// the SAME floor — one identity `(object, name, using)`, two carriers — so every +// relief that lifts a member's floor lifts an external's too. Deduplicated by +// that identity, as the derivation keys it. +const floorCarriers = defaultPermissionSets.filter((ps) => + (ps.rowLevelSecurity ?? []).some((p) => typeof p.using === 'string' && p.using.trim() === OWNERSHIP_FLOOR_PREDICATE), +); +const shippedFloorPolicies = [ + ...new Map( + floorCarriers + .flatMap((ps) => ps.rowLevelSecurity ?? []) + .filter((p) => typeof p.using === 'string' && p.using.trim() === OWNERSHIP_FLOOR_PREDICATE) + .map((p) => [`${p.object}\u0000${p.name}\u0000${p.using}`, p] as const), + ).values(), +]; describe('[#5492] platform ownership-floor provenance', () => { it('recognises exactly the shipped write-class floor policies (non-vacuous)', () => { @@ -31,6 +43,10 @@ describe('[#5492] platform ownership-floor provenance', () => { expect(platformOwnershipFloorPolicyCount()).toBe(shippedFloorPolicies.length); }); + it('the floor ships on the member baseline and the external baseline, and nowhere else', () => { + expect(floorCarriers.map((ps) => ps.name).sort()).toEqual(['external_default', 'member_default']); + }); + it('the shipped set is `owner_only_writes` (update) + `owner_only_deletes` (delete)', () => { expect(shippedFloorPolicies.map((p) => `${p.name}:${p.operation}`).sort()).toEqual([ 'owner_only_deletes:delete', diff --git a/packages/plugins/plugin-security/src/platform-ownership-policies.ts b/packages/plugins/plugin-security/src/platform-ownership-policies.ts index d8196979315..797fee4fba0 100644 --- a/packages/plugins/plugin-security/src/platform-ownership-policies.ts +++ b/packages/plugins/plugin-security/src/platform-ownership-policies.ts @@ -12,6 +12,10 @@ * owner_only_deletes object '*' operation 'delete' created_by == current_user.id * ``` * + * `external_default` carries the same two policies, bound to `org_external` + * (ruling of record 6355206139): the same identity key, so every relief below + * that lifts a member's floor lifts an external's as well. + * * They are the platform's answer to #1985 (a by-id write builds no `ast`, so a * row predicate would never be applied), and they are a **second implementation * of "ownership"** — the one that knows nothing about the wideners the platform diff --git a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts index 6753355f479..cf880ce726f 100644 --- a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts +++ b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts @@ -73,6 +73,8 @@ import type { ExplainDecision, ExplainOperation, PermissionSet } from '@objectst import type { SecurityPlugin } from './security-plugin.js'; import { absentUnderCallerRead, requiredCapsForOperation, unreadableUnderCallerRead } from './security-plugin.js'; import { PermissionDeniedError } from './errors.js'; +import { grantRowPolicySpeaks } from './external-owd-grant-stamp.js'; +import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js'; import { buildContextForUser, delegatedOrganizationRefusal, @@ -97,6 +99,7 @@ export interface ExplainAccessHost { readonly resolveKernelService: SecurityPlugin['resolveKernelService']; readonly tenancyPosture: SecurityPlugin['tenancyPosture']; checkControlledByParentWrite: SecurityPlugin['checkControlledByParentWrite']; + collectRLSPolicies: SecurityPlugin['collectRLSPolicies']; computeLayeredRlsFilter: SecurityPlugin['computeLayeredRlsFilter']; computeReadPartialMaskRules: SecurityPlugin['computeReadPartialMaskRules']; computeRlsFilter: SecurityPlugin['computeRlsFilter']; @@ -118,7 +121,7 @@ type RlsFilterOptions = NonNullable, + this: Pick, request: { object: string; operation: string; userId?: string; recordId?: string }, callerContext?: any, ): Promise { @@ -345,6 +348,14 @@ export async function explainAccessForCaller( return this.computeReadPartialMaskRules(meta, sets as any, delSets as any, basePerms, delBasePerms); }, baselinePermissionSets: this.baselinePermissionSets, + // [Ruling of record 6355206139] The EXTERNAL OWD seam's question, asked + // through the SAME collection the middleware's step 2.6 stamp asks. + externalGrantSpeaks: (sets, o, opClass, dataOp, c) => + grantRowPolicySpeaks( + (op) => this.collectRLSPolicies(sets as any, o, op, (c?.positions ?? []) as string[], c), + opClass, + rlsOperationForVerb(dataOp), + ), // [#25107] The prologue's posture, for its organization-membership // refusal of a delegated context ({@link delegatedOrganizationRefusal}). tenancyPosture: this.tenancyPosture, diff --git a/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts b/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts index 4129317caf1..d7f64df98e3 100644 --- a/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts +++ b/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts @@ -88,8 +88,7 @@ import { isGuestEnvelope, POSITION_NAME_FOLD_EVENT } from './security-plugin.js' import type { PermissionSet } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; import { readDisabledCatalogNames } from '@objectstack/core'; -import { BUILTIN_IDENTITY_ORG_EXTERNAL } from '@objectstack/spec'; -import { EXTERNAL_DEFAULT_PERMISSION_SET } from './objects/default-permission-sets.js'; +import { humanBaselineFor } from './external-baseline.js'; /** * The `SecurityPlugin` members the functions below read — every one of them, @@ -163,11 +162,11 @@ export async function resolvePermissionSetsForContextUnmemoized( // fellow-member directory scope, an app's `isDefault` set — here any more // than through the `everyone` anchor it does not hold. Its floor is its own // anchor-safe set: its own identity rows, and data only through explicit grants. + // One helper names it, so the explainer and the describe fallback attribute + // and resolve the same baseline (`external-baseline.ts`). const baseline: string[] = isAgent ? [MCP_AGENT_PERMISSION_SET_RESTRICTED] - : positions.includes(BUILTIN_IDENTITY_ORG_EXTERNAL) - ? [EXTERNAL_DEFAULT_PERMISSION_SET] - : this.baselinePermissionSets; + : humanBaselineFor(positions, this.baselinePermissionSets); // [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the baseline // set(s) apply to every authenticated request IN ADDITION to whatever else // resolved. The former "only when the user has nothing else" conditional diff --git a/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts b/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts index 972112532f2..2abcb3521b3 100644 --- a/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts +++ b/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts @@ -65,6 +65,8 @@ import type { SharingWriteVerdict } from '@objectstack/spec/contracts'; import type { PermissionSet } from '@objectstack/spec/security'; import type { SecurityPlugin } from './security-plugin.js'; import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; +import { isExternalAudienceContext } from '@objectstack/core'; +import { externalGrantPatch } from './external-owd-grant-stamp.js'; /** * The `SecurityPlugin` members the functions below read — every one of them, @@ -95,7 +97,7 @@ type RlsFilterOptions = NonNullable, + this: Pick, object: string, context: any, resolvedSets?: PermissionSet[], @@ -119,9 +121,11 @@ export async function resolveSharingReadFilter( // controlled_by_parent derivation) pass the sets it already resolved for // THIS identity, instead of re-resolving them from the context. let readScope: string | undefined = depth; + let heldSets: PermissionSet[] | undefined = resolvedSets; if (readScope === undefined) { try { const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); + heldSets = permissionSets; if (permissionSets.length > 0) { const meta = await this.getObjectSecurityMeta(object); readScope = this.permissionEvaluator.getEffectiveScope( @@ -138,16 +142,50 @@ export async function resolveSharingReadFilter( readScope = undefined; } } + // [Ruling of record 6355206139] …and the EXTERNAL OWD seam's stamp, as step + // 2.6 makes it, so this filter is floored exactly as the find's is. Sets + // that cannot be resolved stamp nothing: the floor, the narrow direction. + if (heldSets === undefined && isExternalAudienceContext(context)) { + heldSets = await this.resolvePermissionSetsForContext(context).catch(() => [] as PermissionSet[]); + } + const sets = heldSets ?? []; + const grantPatch = externalGrantPatch(context, object, 'read', (op) => + this.collectRLSPolicies(sets, object, op, (context?.positions ?? []) as string[], context), + ); const filter = await sharing.buildReadFilter(object, { ...context, ...(readScope ? { __readScope: readScope } : {}), + ...grantPatch, }); return (filter ?? null) as Record | null; } +/** + * [Ruling of record 6355206139] The EXTERNAL OWD seam's write-class stamp for + * a rebuilt context handed to a sharing write gate (see + * {@link resolveSharingReadFilter}); `{}` for an internal principal. + */ +async function externalWriteGrantPatch( + this: Pick, + rlsOperation: string, + object: string, + context: any, + resolvedSets?: PermissionSet[], +): Promise> { + if (!isExternalAudienceContext(context)) return {}; + const sets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context).catch(() => [] as PermissionSet[])); + return externalGrantPatch( + context, + object, + 'write', + (op) => this.collectRLSPolicies(sets, object, op, (context?.positions ?? []) as string[], context), + rlsOperation, + ); +} + /** The body of `SecurityPlugin#resolveSharingCanEdit`; the method's docblock there is its contract. */ export async function resolveSharingCanEdit( - this: Pick, + this: Pick, object: string, recordId: string, context: any, @@ -158,9 +196,10 @@ export async function resolveSharingCanEdit( | undefined; if (!sharing || typeof sharing.canEdit !== 'function') return true; const writeScope = await this.resolveWriteScopeForSharing(object, context, resolvedSets); + const grantPatch = await externalWriteGrantPatch.call(this, 'update', object, context, resolvedSets); try { return ( - (await sharing.canEdit(object, recordId, { ...context, __writeScope: writeScope })) === true + (await sharing.canEdit(object, recordId, { ...context, __writeScope: writeScope, ...grantPatch })) === true ); } catch (e) { this.logger.error?.( @@ -198,7 +237,7 @@ export async function resolveWriteScopeForSharing( /** The body of `SecurityPlugin#resolveSharingWriteVerdict`; the method's docblock there is its contract. */ export async function resolveSharingWriteVerdict( - this: Pick, + this: Pick, rlsOperation: string, object: string, recordId: string, @@ -212,10 +251,12 @@ export async function resolveSharingWriteVerdict( const probe = sharing?.[method]; if (typeof probe !== 'function') return 'abstain'; const writeScope = await this.resolveWriteScopeForSharing(object, context, resolvedSets); + const grantPatch = await externalWriteGrantPatch.call(this, rlsOperation, object, context, resolvedSets); try { const verdict = await probe.call(sharing, object, recordId, { ...context, __writeScope: writeScope, + ...grantPatch, }); return verdict === 'allow' || verdict === 'deny' ? verdict : 'abstain'; } catch (e) { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4a2c0e92c62..18e4c53198c 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; -import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError } from '@objectstack/core'; +import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, isExternalAudienceContext } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import type { AnchorBindingContext } from '@objectstack/spec/security'; @@ -332,6 +332,8 @@ import { } from './manifest.js'; import { GUEST_POSITION } from '@objectstack/spec'; import { registerBuiltinCapabilities } from './builtin-capabilities.js'; +import { humanBaselineFor } from './external-baseline.js'; +import { grantRowPolicySpeaks, stampExternalGrant } from './external-owd-grant-stamp.js'; /** * [ADR-0138 D9, D1] Whether `context` is the GUEST envelope — the anonymous @@ -2836,6 +2838,22 @@ export class SecurityPlugin implements Plugin { : this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); if (delegated) sc.__delegatorWriteScope = delegated.delegatorScope; } + // [Ruling of record 6355206139, letter B] The EXTERNAL rung's OWD seam. + // plugin-sharing floors an external principal's org-wide default at + // owner_only unless a held grant's RLS speaks for this object; whether + // one does is a question about the grants, answered here where they are + // resolved, and handed over beside the depth above + // (`external-owd-grant-stamp.ts`, `@objectstack/core` + // `external-owd-floor.ts`). An internal principal is never stamped. + if (isExternalAudienceContext(sc)) { + const collect = (rlsOperation: string) => + this.collectRLSPolicies(permissionSets, opCtx.object, rlsOperation, (sc.positions ?? []) as string[], sc); + if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { + stampExternalGrant(sc, opCtx.object, 'read', grantRowPolicySpeaks(collect, 'read')); + } else if (['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)) { + stampExternalGrant(sc, opCtx.object, 'write', grantRowPolicySpeaks(collect, 'write', rlsOperationForVerb(opCtx.operation))); + } + } } // 2.7. Row-level WRITE authorization (pre-image check). @@ -4758,9 +4776,10 @@ export class SecurityPlugin implements Plugin { const readQl = () => this.ql; const readResolveKernelService = () => this.resolveKernelService; const readTenancyPosture = () => this.tenancyPosture; - const host: Pick = { + const host: Pick = { baselinePermissionSets: this.baselinePermissionSets, checkControlledByParentWrite: (...a) => this.checkControlledByParentWrite(...a), + collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), computeLayeredRlsFilter: (...a) => this.computeLayeredRlsFilter(...a), computeReadPartialMaskRules: (...a) => this.computeReadPartialMaskRules(...a), computeRlsFilter: (...a) => this.computeRlsFilter(...a), @@ -4822,7 +4841,8 @@ export class SecurityPlugin implements Plugin { // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). const readPermissionEvaluator = () => this.permissionEvaluator; const readResolveKernelService = () => this.resolveKernelService; - const host: Pick = { + const host: Pick = { + collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), get permissionEvaluator() { return readPermissionEvaluator(); }, get resolveKernelService() { return readResolveKernelService(); }, @@ -4857,9 +4877,11 @@ export class SecurityPlugin implements Plugin { // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). const readLogger = () => this.logger; const readResolveKernelService = () => this.resolveKernelService; - const host: Pick = { + const host: Pick = { + collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), get logger() { return readLogger(); }, get resolveKernelService() { return readResolveKernelService(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), resolveWriteScopeForSharing: (...a) => this.resolveWriteScopeForSharing(...a), }; return resolveSharingCanEdit.call(host, object, recordId, context, resolvedSets); @@ -4938,9 +4960,11 @@ export class SecurityPlugin implements Plugin { // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). const readLogger = () => this.logger; const readResolveKernelService = () => this.resolveKernelService; - const host: Pick = { + const host: Pick = { + collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), get logger() { return readLogger(); }, get resolveKernelService() { return readResolveKernelService(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), resolveWriteScopeForSharing: (...a) => this.resolveWriteScopeForSharing(...a), }; return resolveSharingWriteVerdict.call(host, rlsOperation, object, recordId, context, resolvedSets); @@ -6154,9 +6178,14 @@ export class SecurityPlugin implements Plugin { * a guest's schema view relative to a deployment that declared nothing. * * Returns `[]` when no baseline is configured or none of it resolves. + * + * [Ruling of record 6355206139] …and an EXTERNAL caller (`org_external`) is + * judged against `external_default`, the baseline the data plane resolves for + * it, never the member baseline: the same helper names both + * (`external-baseline.ts`). */ private async resolveFallbackPermissionSets(context?: any): Promise { - const fallback = this.baselinePermissionSets; + const fallback = humanBaselineFor(context?.positions, this.baselinePermissionSets); if (fallback.length === 0) return []; return this.permissionEvaluator.resolvePermissionSets( fallback, diff --git a/packages/plugins/plugin-sharing/src/external-owd-floor.test.ts b/packages/plugins/plugin-sharing/src/external-owd-floor.test.ts new file mode 100644 index 00000000000..b10ebca5b97 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/external-owd-floor.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [Ruling of record 6355206139, letter B] The EXTERNAL rung's OWD floor at the + * sharing gates. For an external principal every object's org-wide default is + * owner_only — the rows it owns and the rows shared to it (a share, or a + * sharing rule's materialised grant) — unless plugin-security stamped that a + * held grant's RLS speaks for the object, in which case the object's own model + * stands and that RLS bounds the rows (Layer 1, not this service). An internal + * principal is unchanged on every model. A `controlled_by_parent` detail is + * not floored: its access derives from its master, which meets the floor. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { EXTERNAL_READ_GRANT_KEY, EXTERNAL_WRITE_GRANT_KEY } from '@objectstack/core'; +import { SharingService } from './sharing-service.js'; + +type Row = Record; + +/** A read-only fake engine: schemas, and `find` over in-memory tables with equality and `$in`. */ +function makeEngine(schemas: Record, tables: Record) { + return { + getSchema: (name: string) => schemas[name], + async find(object: string, opts?: any) { + const where = opts?.where ?? {}; + const rows = (tables[object] ?? []).filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`test double: combinator ${k} is not implemented`); + return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; + }), + ); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + }; +} + +const fields = { id: {}, name: {}, owner_id: {} }; +const SCHEMAS = { + private_doc: { name: 'private_doc', sharingModel: 'private', fields }, + catalog: { name: 'catalog', sharingModel: 'public_read', fields }, + board: { name: 'board', sharingModel: 'public_read_write', fields }, + line: { name: 'line', sharingModel: 'controlled_by_parent', fields }, + sys_widget: { name: 'sys_widget', isSystem: true, fields }, + ownerless: { name: 'ownerless', sharingModel: 'public_read_write', fields: { id: {}, name: {} } }, +}; + +const TABLES = (): Record => ({ + board: [ + { id: 'b_own', owner_id: 'u_ext' }, + { id: 'b_other', owner_id: 'u_emp' }, + { id: 'b_shared', owner_id: 'u_emp' }, + ], + sys_record_share: [ + { id: 's1', object_name: 'board', record_id: 'b_shared', recipient_type: 'user', recipient_id: 'u_ext', access_level: 'edit' }, + { id: 's2', object_name: 'catalog', record_id: 'c_shared', recipient_type: 'user', recipient_id: 'u_ext', access_level: 'read' }, + ], +}); + +const EXTERNAL = { userId: 'u_ext', tenantId: 'org_a', audience: 'external', posture: 'EXTERNAL' } as const; +const MEMBER = { userId: 'u_emp', tenantId: 'org_a', posture: 'MEMBER' } as const; + +describe('the EXTERNAL OWD floor — reads', () => { + let svc: SharingService; + beforeEach(() => { + svc = new SharingService({ engine: makeEngine(SCHEMAS, TABLES()) as any }); + }); + + for (const object of ['catalog', 'board', 'sys_widget']) { + it(`${object} (${(SCHEMAS as any)[object].sharingModel ?? 'system, unset'}): an external reads its own rows and the rows shared to it`, async () => { + const filter: any = await svc.buildReadFilter(object, { ...EXTERNAL } as any); + expect(filter).not.toBeNull(); + const ownerArm = filter.$or ? filter.$or[0] : filter; + expect(ownerArm).toEqual({ owner_id: 'u_ext' }); + }); + } + + it('a share reaches the external on a public_read_write object (OR-ed beside the owner arm)', async () => { + expect(await svc.buildReadFilter('board', { ...EXTERNAL } as any)).toEqual({ + $or: [{ owner_id: 'u_ext' }, { id: { $in: ['b_shared'] } }], + }); + }); + + it('either carried value floors it: audience alone, or the EXTERNAL rung alone', async () => { + expect(await svc.buildReadFilter('catalog', { userId: 'u_ext', audience: 'external' } as any)).not.toBeNull(); + expect(await svc.buildReadFilter('catalog', { userId: 'u_ext', posture: 'EXTERNAL' } as any)).not.toBeNull(); + }); + + it('a held grant’s RLS lifts the floor for its object: the model stands, the RLS bounds the rows', async () => { + expect(await svc.buildReadFilter('board', { ...EXTERNAL, [EXTERNAL_READ_GRANT_KEY]: 'board' } as any)).toBeNull(); + }); + + it('a stamp for another object, or for the write class, does not lift it', async () => { + expect(await svc.buildReadFilter('board', { ...EXTERNAL, [EXTERNAL_READ_GRANT_KEY]: 'catalog' } as any)).not.toBeNull(); + expect(await svc.buildReadFilter('board', { ...EXTERNAL, [EXTERNAL_WRITE_GRANT_KEY]: 'board' } as any)).not.toBeNull(); + }); + + it('a controlled_by_parent detail is not floored: its rows derive from the master, which meets the floor', async () => { + expect(await svc.buildReadFilter('line', { ...EXTERNAL } as any)).toBeNull(); + expect(await svc.buildWriteFilter('line', { ...EXTERNAL } as any, 'update')).toBeNull(); + expect(await svc.checkEdit('line', 'l1', { ...EXTERNAL } as any)).toBe('abstain'); + expect(await svc.checkDelete('line', 'l1', { ...EXTERNAL } as any)).toBe('abstain'); + }); + + it('private stays owner ∪ shares, as for a member', async () => { + expect(await svc.buildReadFilter('private_doc', { ...EXTERNAL } as any)).toEqual({ owner_id: 'u_ext' }); + }); + + it('an object with no owner column is not under OWD: the sharing layer abstains, for an external as for a member', async () => { + expect(await svc.buildReadFilter('ownerless', { ...EXTERNAL } as any)).toBeNull(); + }); + + it('CONTROL: a member reads every public model unfiltered', async () => { + for (const object of ['catalog', 'board', 'line', 'sys_widget']) { + expect(await svc.buildReadFilter(object, { ...MEMBER } as any)).toBeNull(); + } + }); +}); + +describe('the EXTERNAL OWD floor — writes', () => { + let svc: SharingService; + beforeEach(() => { + svc = new SharingService({ engine: makeEngine(SCHEMAS, TABLES()) as any }); + }); + + it('bulk writes on public_read_write are owner ∪ edit-shares for an external, open for a member', async () => { + expect(await svc.buildWriteFilter('board', { ...EXTERNAL } as any, 'update')).toEqual({ + $or: [{ owner_id: 'u_ext' }, { id: { $in: ['b_shared'] } }], + }); + expect(await svc.buildWriteFilter('board', { ...EXTERNAL } as any, 'delete')).toEqual({ owner_id: 'u_ext' }); + expect(await svc.buildWriteFilter('board', { ...MEMBER } as any, 'update')).toBeNull(); + }); + + it('by-id edit: its own row and an edit-share admit, another’s row is refused; a member abstains', async () => { + expect(await svc.checkEdit('board', 'b_own', { ...EXTERNAL } as any)).toBe('allow'); + expect(await svc.checkEdit('board', 'b_shared', { ...EXTERNAL } as any)).toBe('allow'); + expect(await svc.checkEdit('board', 'b_other', { ...EXTERNAL } as any)).toBe('deny'); + expect(await svc.checkEdit('board', 'b_other', { ...MEMBER } as any)).toBe('abstain'); + }); + + it('by-id delete: its own row only (a share never opens delete)', async () => { + expect(await svc.checkDelete('board', 'b_own', { ...EXTERNAL } as any)).toBe('allow'); + expect(await svc.checkDelete('board', 'b_shared', { ...EXTERNAL } as any)).toBe('deny'); + }); + + it('a held grant’s write-class RLS lifts the floor for its object: sharing abstains, the RLS gates the row', async () => { + const stamped = { ...EXTERNAL, [EXTERNAL_WRITE_GRANT_KEY]: 'board' } as any; + expect(await svc.checkEdit('board', 'b_other', stamped)).toBe('abstain'); + expect(await svc.buildWriteFilter('board', stamped, 'update')).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 52704b19f94..4a87201a20e 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -29,6 +29,9 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; // by?" — the twin of `SqlDriver.computeTenantField`, so the organization a grant // is stamped from is read off the SAME column the wall scopes the record by. import { resolveTenantFieldName } from '@objectstack/objectql'; +// [Ruling of record 6355206139] The EXTERNAL rung's OWD floor — the stamp +// plugin-security makes and this service reads, named once in core. +import { owdForAudience } from '@objectstack/core'; import { WRITE_ACCESS_LEVELS, normalizeAccessLevel } from './access-level.js'; import { hasPhantomOwnerAnchor } from './federated-phantom-anchors.js'; import { @@ -188,6 +191,30 @@ export function effectiveSharingModel(schema: any): 'private' | 'read' | 'public return 'private'; } +/** + * [Ruling of record 6355206139, letter B] The org-wide default THIS principal + * meets on `object`: {@link effectiveSharingModel}, floored to owner_only for + * an EXTERNAL principal unless a held grant's RLS speaks for the object + * (`owdForAudience`, `@objectstack/core`). + * + * A `controlled_by_parent` detail is never floored here: its record access + * derives from its master (ADR-0055) — the security plugin's master read leaf + * and master write gate — and the master's own model already meets this floor + * for the same principal. Flooring the detail too would refuse an external the + * lines of a master shared to it. + */ +function owdForPrincipal( + schema: any, + context: ExecutionContext, + object: string, + opClass: 'read' | 'write', +): 'private' | 'read' | 'public' { + const model = effectiveSharingModel(schema); + const declared = schema?.sharingModel ?? schema?.security?.sharingModel; + if (declared === 'controlled_by_parent') return model; + return owdForAudience(model, context, object, opClass); +} + /** * [ADR-0138 D1/D3] The anonymous principal the guest entry assembles — * `principalKind: 'guest'` with no user id. A system context is never one: the @@ -545,7 +572,13 @@ export class SharingService implements ISharingService { // scopes, the answer is deny-all, whatever its bound sets admit at the // object level. if (isGuestPrincipal(context)) return { id: '__deny_all__' }; - if (effectiveSharingModel(schema) !== 'private') return null; + // [Ruling of record 6355206139, letter B] The EXTERNAL rung's OWD floor: + // for an external principal the object's org-wide default is owner_only + // unless a held grant's RLS speaks for the object ({@link owdForPrincipal}) + // — so a `public_read` / `public_read_write` object + // answers it the rows it owns and the rows shared to it, and the OWD + // baseline never widens it. + if (owdForPrincipal(schema, context, object, 'read') !== 'private') return null; if (!hasOwnerField(schema)) return null; // [#7858] …and an `owner_id` the REGISTRY injected into a FEDERATED object // is not an owner column at all: the platform provisions no storage for one, @@ -630,7 +663,10 @@ export class SharingService implements ISharingService { const schema = this.engine.getSchema?.(object); if (!schema) return null; - if (effectiveSharingModel(schema) === 'public') return null; + // [Ruling of record 6355206139] The write half of the EXTERNAL OWD floor + // (see buildReadFilter): a `public_read_write` object is not write-open to + // an external unless a held grant's RLS speaks for it. + if (owdForPrincipal(schema, context, object, 'write') === 'public') return null; if (!hasOwnerField(schema)) return null; // [#7858] The write half of the same phantom-anchor test the read filter // applies — the card measured BOTH gates. Fixing only the read half would @@ -861,7 +897,8 @@ export class SharingService implements ISharingService { try { const schema = this.engine.getSchema?.(object); if (!schema) return 'abstain'; - const model = effectiveSharingModel(schema); + // [Ruling of record 6355206139] The EXTERNAL OWD floor (buildReadFilter). + const model = owdForPrincipal(schema, context, object, 'write'); if (model === 'public') return 'abstain'; if (!hasOwnerField(schema)) return 'abstain'; if (!context.userId) return 'deny'; @@ -944,7 +981,8 @@ export class SharingService implements ISharingService { try { const schema = this.engine.getSchema?.(object); if (!schema) return 'abstain'; - if (effectiveSharingModel(schema) === 'public') return 'abstain'; + // [Ruling of record 6355206139] The EXTERNAL OWD floor (buildReadFilter). + if (owdForPrincipal(schema, context, object, 'write') === 'public') return 'abstain'; if (!hasOwnerField(schema)) return 'abstain'; if (!context.userId) return 'deny'; diff --git a/packages/qa/dogfood/test/external-member-directory.dogfood.test.ts b/packages/qa/dogfood/test/external-member-directory.dogfood.test.ts index 915d28de590..a922c3f0a44 100644 --- a/packages/qa/dogfood/test/external-member-directory.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-member-directory.dogfood.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // [#25233] The `external` organization role on a real boot, at the HTTP door -// (ruling 6355206139, letter B, stage 1). +// (ruling 6355206139, letter B, stages 1 and 2). // // ## What the card measured // @@ -22,6 +22,17 @@ // - an ordinary member is unchanged: the directory, and the app default; // - E5: a role list carrying `external` beside another role is refused. // +// Stage 2 (the EXTERNAL rung): +// +// - the OWD baseline never widens an external: on a `public_read` object it +// reads the rows it owns and the rows shared to it, not every row; +// - a held grant's RLS lifts that floor for its object, and the RLS bounds +// the rows (`supplier in current_user.` is this shape); +// - a `controlled_by_parent` detail is not floored on its own: an external +// reads the lines of a master shared to it, and no other master's lines; +// - list-teams and get-full-organization's teams answer an external the +// teams it sits on; list-invitations answers it its own invitations. +// // Fixtures are synthetic. ⚠️ No test title states a value. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -47,6 +58,31 @@ const portalSet = PermissionSetSchema.parse({ objects: { [GRANTED]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }, }); +/** A `public_read_write` master and its `controlled_by_parent` detail (ADR-0055). */ +const MASTER = 'showcase_invoice'; +const DETAIL = 'showcase_invoice_line'; + +/** Read on the master and its detail, nothing else. */ +const portalLinesSet = PermissionSetSchema.parse({ + name: 'supplier_portal_lines_25233', + label: 'Supplier portal, invoices (fixture)', + objects: { + [MASTER]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + [DETAIL]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + }, +}); + +/** Title the RLS-bounded portal grant admits. */ +const PORTAL_TITLE = 'portal-25233'; + +/** The same read, bounded by a row policy — a held grant whose RLS speaks for the object. */ +const portalRlsSet = PermissionSetSchema.parse({ + name: 'supplier_portal_rls_25233', + label: 'Supplier portal, row-bounded (fixture)', + objects: { [GRANTED]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }, + rowLevelSecurity: [{ name: 'portal_rows_25233', object: GRANTED, operation: 'select', using: `title == '${PORTAL_TITLE}'` }], +}); + type Row = Record; const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? (Array.isArray(body) ? body : []); @@ -61,6 +97,9 @@ describe('[#25233] an external organization member reads its own identity rows a let orgId = ''; const token: Record = {}; const uid: Record = {}; + const row: Record = {}; + const team: Record = {}; + const line: Record = {}; const ids = async (who: string, path: string) => { const res = await stack.apiAs(token[who], 'GET', path); @@ -79,7 +118,7 @@ describe('[#25233] an external organization member reads its own identity rows a stack = await bootShowcase({ security: new SecurityPlugin({ ...appSecurityPluginOptions(showcaseStack), - defaultPermissionSets: [...securityDefaultPermissionSets, portalSet], + defaultPermissionSets: [...securityDefaultPermissionSets, portalSet, portalRlsSet, portalLinesSet], }), }); ql = await stack.kernel.getServiceAsync('objectql'); @@ -103,6 +142,8 @@ describe('[#25233] an external organization member reads its own identity rows a ['member', 'member'], ['supplierA', 'external'], ['supplierB', 'external'], + ['supplierC', 'external'], + ['supplierD', 'external'], ] as const) { const email = `${who.toLowerCase()}@${DOMAIN}`; token[who] = await stack.signUp(email, 'External!Pass123', `External ${who}`); @@ -113,6 +154,64 @@ describe('[#25233] an external organization member reads its own identity rows a const portal = await catalogPermissionSet(ql, portalSet.name); expect(portal, 'fixture permission set served by the security catalog').toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: uid.supplierA, permission_set: portal.name, organization_id: orgId }, { context: SYS }); + const portalRls = await catalogPermissionSet(ql, portalRlsSet.name); + expect(portalRls, 'fixture row-bounded permission set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: uid.supplierC, permission_set: portalRls.name, organization_id: orgId }, { context: SYS }); + + // Stage 2 rows on the `public_read` object: the owner's plain row, the + // owner's row shared to supplierA, the owner's row the RLS grant admits, + // and supplierA's own row. + const announce = async (key: string, title: string, owner: string) => { + const r = await ql.insert(GRANTED, { title, owner_id: uid[owner], organization_id: orgId }, { context: SYS }); + row[key] = String(r.id); + }; + await announce('plain', 'plain-25233', 'owner'); + await announce('shared', 'shared-25233', 'owner'); + await announce('portal', PORTAL_TITLE, 'owner'); + await announce('ownA', 'own-a-25233', 'supplierA'); + await ql.insert('sys_record_share', { + object_name: GRANTED, record_id: row.shared, recipient_type: 'user', recipient_id: uid.supplierA, + access_level: 'read', source: 'manual', organization_id: orgId, + }, { context: SYS }); + + // Two of the owner's invoices, two lines each; the first invoice is shared + // to supplierD. supplierD and the ordinary member (the control) hold the + // same read on both objects, and neither owns any of the rows. + const portalLines = await catalogPermissionSet(ql, portalLinesSet.name); + expect(portalLines, 'fixture invoice permission set served by the security catalog').toBeTruthy(); + for (const who of ['supplierD', 'member'] as const) { + await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: portalLines.name, organization_id: orgId }, { context: SYS }); + } + const account = await ql.insert('showcase_account', { name: 'Account 25233', status: 'active', organization_id: orgId }, { context: SYS }); + const product = await ql.insert('showcase_product', { name: 'Product 25233', organization_id: orgId }, { context: SYS }); + for (const key of ['shared', 'other'] as const) { + const inv = await ql.insert(MASTER, { + name: `INV-25233-${key}`, account: String(account.id), status: 'draft', owner_id: uid.owner, organization_id: orgId, + }, { context: SYS }); + row[`inv_${key}`] = String(inv.id); + for (const n of [1, 2]) { + const l = await ql.insert(DETAIL, { invoice: String(inv.id), product: String(product.id), description: `${key}-${n}`, owner_id: uid.owner, organization_id: orgId }, { context: SYS }); + line[`${key}${n}`] = String(l.id); + } + } + await ql.insert('sys_record_share', { + object_name: MASTER, record_id: row.inv_shared, recipient_type: 'user', recipient_id: uid.supplierD, + access_level: 'read', source: 'manual', organization_id: orgId, + }, { context: SYS }); + + // Two teams; supplierA sits on the first only. One invitation to + // supplierA's address, one to an outsider. + for (const [key, name] of [['onA', 'Team A 25233'], ['other', 'Team Other 25233']] as const) { + const t = await ql.insert('sys_team', { name, organization_id: orgId }, { context: SYS }); + team[key] = String(t.id); + } + await ql.insert('sys_team_member', { team_id: team.onA, user_id: uid.supplierA }, { context: SYS }); + for (const email of [`suppliera@${DOMAIN}`, `outsider@${DOMAIN}`]) { + await ql.insert('sys_invitation', { + organization_id: orgId, email, role: 'member', status: 'pending', + inviter_id: uid.owner, expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }, { context: SYS }); + } await assertArmed([ armedWhen({ @@ -139,6 +238,37 @@ describe('[#25233] an external organization member reads its own identity rows a armed: (s) => s === 200, describe: (s) => `status ${s}`, }), + armedWhen({ + control: 'the ordinary member reads every fixture row on the public_read object (the OWD baseline applies)', + disarmedBy: 'rows a member cannot read either would let the external floor below pass over an empty answer', + observe: async () => ids('member', `/data/${GRANTED}?limit=200`), + armed: (o) => o.status === 200 && ['plain', 'shared', 'portal', 'ownA'].every((k) => o.ids.includes(row[k])), + describe: (o) => `status ${o.status}; rows ${o.ids.length}`, + }), + armedWhen({ + control: 'the ordinary member reads both fixture invoices and all four of their lines', + disarmedBy: 'lines nobody may read would let the master-detail case below pass over an empty answer', + observe: async () => ({ + invoices: await ids('member', `/data/${MASTER}?limit=200`), + lines: await ids('member', `/data/${DETAIL}?limit=200`), + }), + armed: (o) => + o.invoices.status === 200 && [row.inv_shared, row.inv_other].every((id) => o.invoices.ids.includes(id)) && + o.lines.status === 200 && Object.values(line).every((id) => o.lines.ids.includes(id)), + describe: (o) => `invoices ${o.invoices.status}/${o.invoices.ids.length}; lines ${o.lines.status}/${o.lines.ids.length}`, + }), + armedWhen({ + control: 'the ordinary member is answered both teams and both invitations', + disarmedBy: 'a door that answered nobody would let the external narrowing below pass for free', + observe: async () => ({ + teams: (await auth('member', `list-teams?organizationId=${orgId}`)).body, + invitations: (await auth('member', `list-invitations?organizationId=${orgId}`)).body, + }), + armed: (o) => + Array.isArray(o.teams) && [team.onA, team.other].every((id) => o.teams.some((t: any) => t.id === id)) && + Array.isArray(o.invitations) && o.invitations.length >= 2, + describe: (o) => JSON.stringify({ teams: o.teams?.length, invitations: o.invitations?.length }), + }), ]); }, 180_000); @@ -184,15 +314,54 @@ describe('[#25233] an external organization member reads its own identity rows a it('CONTROL: an ordinary member is unchanged — the directory at both doors, and the app default', async () => { const { ids: seen } = await ids('member', '/data/sys_user'); - for (const who of ['owner', 'member', 'supplierA', 'supplierB']) expect(seen).toContain(uid[who]); + for (const who of ['owner', 'member', 'supplierA', 'supplierB', 'supplierC', 'supplierD']) expect(seen).toContain(uid[who]); const list = await auth('member', `list-members?organizationId=${orgId}`); expect(list.status).toBe(200); expect(list.body.members.map((m: any) => m.userId).sort()).toEqual( - ['owner', 'member', 'supplierA', 'supplierB'].map((w) => uid[w]).sort(), + ['owner', 'member', 'supplierA', 'supplierB', 'supplierC', 'supplierD'].map((w) => uid[w]).sort(), ); expect(await statusOf('member', `/data/${APP_DEFAULT_ONLY}`)).toBe(200); }); + it('stage 2: the OWD baseline never widens an external — its own rows and the rows shared to it', async () => { + const { status, ids: seen } = await ids('supplierA', `/data/${GRANTED}?limit=200`); + expect(status).toBe(200); + expect(seen.sort()).toEqual([row.shared, row.ownA].sort()); + }); + + it('stage 2: a held grant’s RLS lifts the floor for its object, and the RLS bounds the rows', async () => { + const { status, ids: seen } = await ids('supplierC', `/data/${GRANTED}?limit=200`); + expect(status).toBe(200); + expect(seen).toEqual([row.portal]); + }); + + it('stage 2: a controlled_by_parent detail follows its master — the lines of the shared master, and no other', async () => { + const invoices = await ids('supplierD', `/data/${MASTER}?limit=200`); + expect(invoices.status).toBe(200); + expect(invoices.ids).toEqual([row.inv_shared]); + const lines = await ids('supplierD', `/data/${DETAIL}?limit=200`); + expect(lines.status).toBe(200); + expect(lines.ids.sort()).toEqual([line.shared1, line.shared2].sort()); + }); + + it('stage 2: list-teams and get-full-organization answer an external the teams it sits on', async () => { + const teams = await auth('supplierA', `list-teams?organizationId=${orgId}`); + expect(teams.status).toBe(200); + expect(teams.body.map((t: any) => t.id)).toEqual([team.onA]); + const full = await auth('supplierA', `get-full-organization?organizationId=${orgId}`); + expect(full.status).toBe(200); + expect((full.body.teams ?? []).map((t: any) => t.id)).toEqual([team.onA]); + // An external on no team is answered none. + expect((await auth('supplierB', `list-teams?organizationId=${orgId}`)).body).toEqual([]); + }); + + it('stage 2: list-invitations answers an external its own invitations only', async () => { + const mine = await auth('supplierA', `list-invitations?organizationId=${orgId}`); + expect(mine.status).toBe(200); + expect(mine.body.map((i: any) => i.email)).toEqual([`suppliera@${DOMAIN}`]); + expect((await auth('supplierB', `list-invitations?organizationId=${orgId}`)).body).toEqual([]); + }); + it('E5: an owner cannot give a member external beside another role', async () => { const [row] = await findRows(ql, 'sys_member', { user_id: uid.member, organization_id: orgId }, 1); const res = await stack.apiAs(token.owner, 'POST', '/auth/organization/update-member-role', { diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 6e95132c5b0..6047c6fb9fa 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -181,10 +181,16 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ principalKind: z.enum(['human', 'agent', 'service', 'guest', 'system']).optional(), /** - * [ADR-0090 D10/D11 — P1 shape] Whether the principal is an internal org - * member or an external (portal/partner) identity. Externals reach - * explicitly shared rows only (the ADR-0095 `EXTERNAL` rung, never - * OWD-derived) and skip the BU depth axis. Absent = 'internal'. + * [ADR-0090 D10/D11 · ruling of record 6355206139] Whether the principal is + * an internal org member or an external (portal/partner) one. The shared + * authz resolver stamps `'external'` for the organization's `external` + * members (`sys_member.role = external`, the reserved identity + * `org_external`), whose posture rung is `EXTERNAL`. Externals reach rows + * through explicit grants only — shares (share links included), sharing + * rules, and position and permission-set grants with their RLS evaluated — + * never the OWD baseline (owner_only floor), the `everyone`-anchored default + * sets or the member directory, and they are never placed in the BU tree. + * Absent = 'internal'. */ audience: z.enum(['internal', 'external']).optional(), diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 14b732373fe..ae5c22a9384 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -57,9 +57,10 @@ export type ExplainOperation = z.input; * rule, and visibility is strictly nested down the ladder * (PLATFORM_ADMIN ⊇ TENANT_ADMIN ⊇ MEMBER ⊇ EXTERNAL). Explain reports which * rung the principal evaluated at so a consumer can answer "was this decided by - * the tenant-admin tier or ordinary business RLS?". `EXTERNAL` is defined and - * reservable now; its enforcement path ships when an external principal type - * exists (ADR-0095 D2). + * the tenant-admin tier or ordinary business RLS?". `EXTERNAL` is the rung of + * the organization's `external` members (ruling of record 6355206139): explicit + * grants only — shares, sharing rules, and position and permission-set grants + * with their RLS — never the OWD baseline or the `everyone` default sets. */ export const AuthzPostureSchema = z.enum([ 'PLATFORM_ADMIN', @@ -68,7 +69,8 @@ export const AuthzPostureSchema = z.enum([ 'EXTERNAL', ]).describe( 'ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; ' + - 'TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows.', + 'TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL (an external organization member) ' + + 'reaches explicit grants only — shares, sharing rules, and grant RLS — never the OWD baseline.', ); export type AuthzPosture = z.input;