Skip to content

Commit 45f9962

Browse files
feat(security): the external organization role is the EXTERNAL principal — owner-only org-wide default, explicit grants only (#25541)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFqCf6db2fM7iSSM3dJb5rf)_ Closes #25233 Stage 2 of the ruling of record 6355206139, letter B (item 2, with the scope note 6371125645). Stage 1 landed in #25515. The Tier H ADR text follows in its own docs PR once this lands. 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 ## What this does A holder of the `external` organization role is now the EXTERNAL principal. It reaches rows through explicit grants only: shares (share links included), sharing rules, and the position and permission-set grants it holds, with their RLS evaluated. The internal org-wide default never widens it. | Item | Where | What | |---|---|---| | audience and posture | `core` `resolve-authz-context.ts`, `posture-ladder.ts`, `assemble-execution-context.ts` | The resolver stamps `audience: 'external'` on a holder and `derivePosture` returns `EXTERNAL`. Precedence is PLATFORM_ADMIN, then EXTERNAL, then TENANT_ADMIN, then MEMBER. The assembled context carries the audience, on agent envelopes too. `POSTURE_INJECTION_RULE.EXTERNAL` states the channel list. | | OWD seam | `core` `external-owd-floor.ts`; `plugin-sharing` `sharing-service.ts` | `owdForAudience` floors an external's effective model to `private` (owner plus shares) in `buildReadFilter`, `buildWriteFilter`, `checkEdit` and `checkDelete`. The floor lifts for one object and one operation class when plugin-security has stamped that a held grant's RLS speaks for it. | | grant stamp | `plugin-security` `external-owd-grant-stamp.ts`, `security-plugin.ts` step 2.6, `security-plugin-sharing-verdicts.ts` | For an external, `__externalReadGrant` / `__externalWriteGrant` hold the object name when a held set's `using` policy applies (the write class follows Layer 1's select-derives-write rule). The object's declared default row scope and the platform ownership floor are not grants. The keys are operation-private, and a stamp naming another object reads as no grant. | | write floor | `plugin-security` `default-permission-sets.ts` | `external_default` carries `owner_only_writes` and `owner_only_deletes`, bound to `org_external`. They share the member floor's provenance key, so every relief of a member's floor relieves an external's. Without it an external's write reach on a public object was org-wide, wider than a member's (I2). | | access matrix | `plugin-security` `authz-matrix-gate.test.ts` | The EXTERNAL column, at Layers 0 and 1 (the sharing floor is pinned in plugin-sharing): on `task` the external reads in-org rows its grant opens and writes only rows it created; `private_obj` and `platform_global` are denied; on `better_auth` it reads and writes its own row only. The ladder order includes it. | | explain and describe | `plugin-security` `explain-engine.ts`, `security-plugin-explain-access.ts`, `security-plugin.ts` | Explain and describe resolve the baseline the principal actually gets (`humanBaselineFor`), so an external's is `external_default`, attributed as "external baseline (org_external)". The OWD layer reports "owner_only for an external principal" when no held grant speaks, using the same predicate as the stamp. | | directory doors | `plugin-auth` `external-directory-door.ts`, `auth-manager.ts` | `list-teams` and `get-full-organization`'s `teams` answer an external only the teams it sits on (read from `sys_team_member`). `list-invitations` answers it only invitations to its own email. The door docblock notes that the resolver uses `includes('external')` while the door requires exactly `'external'`, which E5 makes the same population. | | spec prose | `spec` `execution-context.zod.ts`, `explain.zod.ts` | The `audience` docblock and the EXTERNAL posture description name the channels. Generated reference docs are regenerated. | Measured for the scope note: better-auth 1.7.12's `listInvitations` (`crud-invites.mjs`) checks membership only and returns every invitation of the organization, so it is narrowed. `listOrganizationTeams` and get-full-organization's `teams` (with `teams.enabled`, set in `auth-manager.ts`) check membership only too. ## Acceptance notes On a real showcase boot, `packages/qa/dogfood/test/external-member-directory.dogfood.test.ts` adds five stage 2 cases (11/11 pass): - **The OWD baseline never widens an external.** On `showcase_announcement` (`public_read`), the external with a plain read grant gets exactly its own row and the row shared to it, out of four. - **A held grant's RLS lifts the floor and bounds the rows.** A second external holds a read grant with `title == '<portal title>'` as its select policy. It gets exactly the one matching row, which it neither owns nor was shared. - **Teams.** `list-teams` and get-full-organization's `teams` answer the external only the team it sits on. An external on no team gets `[]`. - **Invitations.** `list-invitations` answers the external only the invitation to its own email. Another external gets `[]`. Armed controls: the member reads all four fixture rows, and the member is answered both teams and both invitations. Unit pins: - `core` `posture-ladder.test.ts` (EXTERNAL ⊆ MEMBER with the new grant row, the injection rule, and derivation precedence), `external-owd-floor.test.ts` (6), the resolver's external-population audience and posture cells, and assembly audience carriage. - `plugin-sharing` `external-owd-floor.test.ts` (15): reads and writes on every public model, stamp scoping by object and class, ownerless objects, and member controls. - `plugin-security` `external-owd-grant-stamp.test.ts` (9), `explain-external-principal.test.ts` (6), and the matrix's EXTERNAL column with an end-to-end stamp check. - `plugin-auth` `external-directory-door.test.ts` (teams, full-org teams and invitations, with member controls). Existing pins moved deliberately: - `platform-ownership-policies.test.ts` now deduplicates the floor by its identity key. The floor has two carriers, `member_default` and `external_default`, and a new case pins exactly those two. Notes for review: - **Clause-②:** for an external holder this is a narrowing (no OWD widening, the write floor, and the narrowed doors), but stage 1's `external` role has not shipped in a release yet: its changeset is still pending in `.changeset/`. No principal without the role changes. - **Fix after the first review (225d5bc):** the floor no longer applies to a `controlled_by_parent` detail. Its rows follow the master (ADR-0055), and the master already meets the floor. Before the fix, an external who was shared a master read none of its lines. `plugin-sharing`'s `external-owd-floor.test.ts` had pinned that wrong answer, and the first contract review (6375883728) passed it. The fix is `owdForPrincipal` in `sharing-service.ts` and the same exemption in explain's OWD layer. A showcase dogfood case pins it: `showcase_invoice` is shared to an external, who reads its two `showcase_invoice_line` rows and not the other invoice's. The case is red with the exemption removed (measured) and green with it. The merge of main (c70863f) also resolved the conflict with #25536, which moved the after-hook. - **Reading of "access matrix":** I took it as the EXTERNAL role column of `authz-matrix-gate`. ADR-0090 D11 also mentions a "D6 access matrix external-audience column", which is the explain matrix. If that was meant, it is a follow-up. - **Out of scope, found while measuring:** a share on an object whose effective model is `public` (`public_read_write`, `controlled_by_parent`, or a model-less system object) is refused at create as "inert" (`inertGrantReason`; `public_read` is not refused). Now that such an object is owner-only for an external, a share to an external there is no longer inert, so the create gate, which also runs for sharing-rule materialisation, closes those two channels on that set of objects. Existing rows are honoured, and the direction is fail-closed. This needs its own card and a ruling (contract review 6375883728). - **#25234 PR ②** also touches plugin-security. Whichever lands second merges main. Verification: the plugin-security (4602), core (2806), plugin-sharing (1224), plugin-auth (3620), plugin-approvals (1233) and rest (6608) suites pass. They ran before the merge of main, which touched only plugin-auth among these packages; after it, plugin-auth's `tsc` and its external door and membership tests (37) re-pass. `tsc` is clean for core, plugin-security (including the test program), plugin-sharing, plugin-auth and dogfood. All 121 derived gates were run and reconciled with `dispatch-gates --ran` (0 not measured). The diff is 1543 insertions and 142 deletions across 35 files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19d3720 commit 45f9962

37 files changed

Lines changed: 1660 additions & 148 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/plugin-sharing': minor
6+
'@objectstack/plugin-auth': minor
7+
---
8+
9+
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
10+
11+
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)
12+
Metadata-protocol: semantic
13+
14+
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:
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
- **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.
20+
- **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.
21+
- **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.
22+
23+
Nothing changes for a principal that does not hold the `external` role.

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,21 @@ only narrows, so it cannot make a `private` row visible. On `public_read` and
125125
## External principals — no external org-wide default
126126

127127
There is one org-wide default, `sharingModel`. An external (portal or
128-
partner) principal reaches **explicitly shared rows only** — the authorization
129-
kernel's `EXTERNAL` rung (ADR-0095), never derived from an org-wide default.
130-
Grant it a row with a manual share or a sharing rule, or publish a record
131-
through a share link (`publicSharing`).
128+
partner) principal — an organization member whose membership is `external` —
129+
reaches rows through **explicit grants only**: the authorization kernel's
130+
`EXTERNAL` rung (ADR-0095), never derived from an org-wide default. For an
131+
external, every object's org-wide default is owner-only; it reads and writes
132+
the rows it owns and the rows granted to it:
133+
134+
- a manual share or a sharing rule;
135+
- a share link (`publicSharing`);
136+
- a permission set or position it holds whose `rowLevelSecurity` speaks for
137+
the object (for example `supplier in current_user.<key>`). Such a policy
138+
lifts the owner-only floor for that object, and the policy bounds the rows.
132139

133140
`externalSharingModel`, the external dial ADR-0090 D11 declared, was removed
134-
in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): no request ever
135-
resolved an external principal, so the value changed no answer. The schema
141+
in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): while it existed no
142+
request resolved an external principal, so the value changed no answer. The schema
136143
refuses it with the prescription "delete the key";
137144
`os migrate meta --from 17` lists the mechanical edits for existing sources.
138145

‎content/docs/references/data/data-engine.mdx‎

Lines changed: 12 additions & 12 deletions
Large diffs are not rendered by default.

‎content/docs/references/kernel/execution-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const result = AuthGateCodeSchema.parse(data);
6060
| **positions** | `string[]` | optional (default: `[]`) | |
6161
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
6262
| **audience** | `Enum<'internal' \| 'external'>` | optional | |
63-
| **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. |
63+
| **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. |
6464
| **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. |
6565
| **onBehalfOf** | `{ userId: string; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'> }` | optional | |
6666
| **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. |

‎content/docs/references/security/explain.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const result = AccessMatrixSchema.parse(data);
8484

8585
## AuthzPosture
8686

87-
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.
87+
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.
8888

8989
### Allowed Values
9090

@@ -120,7 +120,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
120120
| **permissionSets** | `string[]` | optional (default: `[]`) | |
121121
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
122122
| **onBehalfOf** | `{ userId: string }` | optional | |
123-
| **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. |
123+
| **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. |
124124

125125
### Nested Shape: `ExplainDecision.layers[number]`
126126

‎packages/core/src/security/assemble-execution-context.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,3 +541,51 @@ describe('#6216 — the measured residual: keys that were present-with-undefined
541541
expect(observable(now)).toEqual(observable(before));
542542
});
543543
});
544+
545+
// [Ruling of record 6355206139, letter B] The population stamp is an ENTRY
546+
// decision made once, by the resolver: an `external` member's envelope carries
547+
// `audience: 'external'` on every face, its agent's envelope too (the OWD floor
548+
// re-runs the on-behalf-of leg under that envelope), and an internal principal
549+
// or a guest never carries it.
550+
describe('ruling 6355206139 — the resolver’s audience stamp rides the envelope', () => {
551+
const EXTERNAL_MEMBER = baseAuthz({
552+
userId: 'u_ext',
553+
tenantId: 'org1',
554+
positions: ['org_external'],
555+
permissions: ['external_default'],
556+
posture: 'EXTERNAL',
557+
audience: 'external',
558+
accessible_org_ids: ['org1'],
559+
});
560+
const assemble = (authz: ResolvedAuthzContext, oauth?: OAuthTokenProvenance) =>
561+
assembleExecutionContextOrGuest({
562+
authz,
563+
oauth,
564+
localization: undefined,
565+
requestLocale: undefined,
566+
accessToken: undefined,
567+
authGate: undefined,
568+
});
569+
570+
it('an external member’s envelope carries audience external beside its EXTERNAL rung, and parses', () => {
571+
const ctx = assemble(EXTERNAL_MEMBER);
572+
expect(ctx.audience).toBe('external');
573+
expect(ctx.posture).toBe('EXTERNAL');
574+
expect(ExecutionContextSchema.safeParse(ctx).success).toBe(true);
575+
});
576+
577+
it('an agent acting for an external member carries the human’s audience', () => {
578+
const ctx = assemble(EXTERNAL_MEMBER, {
579+
userId: 'u_ext', scopes: ['data:read'], clientId: 'cli1',
580+
scopePermissions: ['agent_data_read'], delegatesActions: false,
581+
} as OAuthTokenProvenance);
582+
expect(ctx.principalKind).toBe('agent');
583+
expect(ctx.audience).toBe('external');
584+
});
585+
586+
it('CONTROL: an internal principal and a guest carry no audience', () => {
587+
expect(Object.keys(assemble(HUMAN_FULL))).not.toContain('audience');
588+
expect(Object.keys(assemble(ANONYMOUS))).not.toContain('audience');
589+
expect(Object.keys(assemble({ ...ANONYMOUS, audience: 'external' }))).not.toContain('audience');
590+
});
591+
});

‎packages/core/src/security/assemble-execution-context.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -350,12 +350,14 @@ function entryFields(
350350
// what narrows the optional away — the guard that made `agent` truthy is
351351
// this same property.
352352
performedBy: agent?.clientId ? { clientId: agent.clientId } : undefined,
353-
// [ADR-0090 D10/D11 — P1 shape] No transport resolves an external
354-
// (portal/partner) audience yet; `undefined` reads as 'internal'. Named
355-
// here rather than excluded so the gap is visible in the closed set instead
356-
// of being invisible outside it — when an external principal type lands,
357-
// this is the line that must change, on every face at once.
358-
audience: undefined,
353+
// [ADR-0090 D10/D11 · ruling of record 6355206139, letter B] The resolver's
354+
// population stamp: `'external'` for a holder of `org_external`, absent
355+
// (read as 'internal') otherwise. It rides an AGENT envelope too: the
356+
// population is the human's, and the agent's on-behalf-of leg is re-run
357+
// under this same envelope by the OWD floor (`plugin-sharing`), so an
358+
// agent acting for an external is floored exactly as the external is.
359+
// A guest is never external: it has no membership.
360+
audience: anonymous ? undefined : authz.audience,
359361
userId: authz.userId,
360362
tenantId: authz.tenantId,
361363
email: authz.email,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// [Ruling of record 6355206139, letter B] The EXTERNAL rung's OWD seam
4+
// vocabulary: who is external, how the grant stamp reads, and the floor.
5+
6+
import { describe, it, expect } from 'vitest';
7+
import {
8+
EXTERNAL_READ_GRANT_KEY,
9+
EXTERNAL_WRITE_GRANT_KEY,
10+
externalGrantSpeaks,
11+
isExternalAudienceContext,
12+
owdForAudience,
13+
} from './external-owd-floor.js';
14+
import { OPERATION_PRIVATE_KEY_PREFIX } from './operation-private-keys.js';
15+
16+
const external = { userId: 'u_ext', audience: 'external', posture: 'EXTERNAL' };
17+
18+
describe('who is external', () => {
19+
it('either carried value says so; neither, or a guest, does not', () => {
20+
expect(isExternalAudienceContext({ audience: 'external' })).toBe(true);
21+
expect(isExternalAudienceContext({ posture: 'EXTERNAL' })).toBe(true);
22+
expect(isExternalAudienceContext({ audience: 'internal', posture: 'MEMBER' })).toBe(false);
23+
expect(isExternalAudienceContext({ principalKind: 'guest' })).toBe(false);
24+
expect(isExternalAudienceContext(undefined)).toBe(false);
25+
expect(isExternalAudienceContext(null)).toBe(false);
26+
});
27+
});
28+
29+
describe('the grant stamp', () => {
30+
it('is operation-private: both keys carry the `__` prefix, so a forwarded envelope drops them', () => {
31+
expect(EXTERNAL_READ_GRANT_KEY.startsWith(OPERATION_PRIVATE_KEY_PREFIX)).toBe(true);
32+
expect(EXTERNAL_WRITE_GRANT_KEY.startsWith(OPERATION_PRIVATE_KEY_PREFIX)).toBe(true);
33+
});
34+
35+
it('speaks for the object it names, and the class it was stamped for, only', () => {
36+
const ctx = { ...external, [EXTERNAL_READ_GRANT_KEY]: 'po' };
37+
expect(externalGrantSpeaks(ctx, 'po', 'read')).toBe(true);
38+
expect(externalGrantSpeaks(ctx, 'invoice', 'read')).toBe(false);
39+
expect(externalGrantSpeaks(ctx, 'po', 'write')).toBe(false);
40+
expect(externalGrantSpeaks({ ...external, [EXTERNAL_READ_GRANT_KEY]: true }, 'po', 'read')).toBe(false);
41+
});
42+
});
43+
44+
describe('the OWD an operation meets', () => {
45+
it('an external meets owner_only on every model, with no grant speaking', () => {
46+
for (const model of ['read', 'public', 'private'] as const) {
47+
expect(owdForAudience(model, external, 'po', 'read')).toBe('private');
48+
expect(owdForAudience(model, external, 'po', 'write')).toBe('private');
49+
}
50+
});
51+
52+
it('a held grant’s RLS lifts the floor for its object and class: the object’s own model stands', () => {
53+
const ctx = { ...external, [EXTERNAL_READ_GRANT_KEY]: 'po', [EXTERNAL_WRITE_GRANT_KEY]: 'po' };
54+
expect(owdForAudience('public', ctx, 'po', 'read')).toBe('public');
55+
expect(owdForAudience('read', ctx, 'po', 'write')).toBe('read');
56+
expect(owdForAudience('public', ctx, 'invoice', 'read')).toBe('private');
57+
});
58+
59+
it('CONTROL: an internal principal meets the object’s own model, stamp or no stamp', () => {
60+
expect(owdForAudience('public', { userId: 'u1', posture: 'MEMBER' }, 'po', 'read')).toBe('public');
61+
expect(owdForAudience('read', { userId: 'u1' }, 'po', 'write')).toBe('read');
62+
});
63+
});

0 commit comments

Comments
 (0)