Repository navigation
Commit 45f9962
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
File tree
- .changeset
- content/docs
- permissions
- references
- data
- kernel
- security
- packages
- core/src/security
- plugins
- plugin-auth/src
- plugin-security/src
- objects
- plugin-sharing/src
- qa/dogfood/test
- spec/src
- kernel
- security
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
125 | 125 | | |
126 | 126 | | |
127 | 127 | | |
128 | | - | |
129 | | - | |
130 | | - | |
131 | | - | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
132 | 139 | | |
133 | 140 | | |
134 | | - | |
135 | | - | |
| 141 | + | |
| 142 | + | |
136 | 143 | | |
137 | 144 | | |
138 | 145 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | | - | |
| 63 | + | |
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
87 | | - | |
| 87 | + | |
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
123 | | - | |
| 123 | + | |
124 | 124 | | |
125 | 125 | | |
126 | 126 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
541 | 541 | | |
542 | 542 | | |
543 | 543 | | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
350 | 350 | | |
351 | 351 | | |
352 | 352 | | |
353 | | - | |
354 | | - | |
355 | | - | |
356 | | - | |
357 | | - | |
358 | | - | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
359 | 361 | | |
360 | 362 | | |
361 | 363 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
0 commit comments