Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/25233-external-posture-owd-floor.md
Original file line number Diff line number Diff line change
@@ -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.<key>`), 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.
19 changes: 13 additions & 6 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`). 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.

Expand Down
24 changes: 12 additions & 12 deletions content/docs/references/data/data-engine.mdx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]`

Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/security/assemble-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
14 changes: 8 additions & 6 deletions packages/core/src/security/assemble-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/security/external-owd-floor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading