Repository navigation
feat(security): the external organization role is the EXTERNAL principal — owner-only org-wide default, explicit grants only - #25541
Conversation
…pal — owner-only org-wide default, explicit grants only (#25233 stage 2) Ruling of record 6355206139, letter B, item 2. - The resolver stamps audience 'external' on a holder of the external role and derivePosture returns EXTERNAL (platform admin still outranks it); the assembled context carries the audience to agents. - plugin-sharing floors an external's org-wide default to owner-only on every object. plugin-security lifts that floor per object and operation class when a held grant's RLS speaks for it, by an operation-private stamp naming the object. - external_default carries the platform's owner-only write floor, bound to org_external, so EXTERNAL never writes wider than MEMBER. - The authz matrix gains the EXTERNAL column; explain and describe name the external baseline and report the owner-only floor. - list-teams and get-full-organization teams answer an external the teams it sits on; list-invitations, membership-only in better-auth 1.7.12, answers it its own email's invitations. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
📓 Docs Drift CheckThis PR changes 5 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 156 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 16cf9bd1f14b8d30d5fb3516f1692be47d454130 && git checkout 16cf9bd1f14b8d30d5fb3516f1692be47d454130
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4df174bcc48d694e654f90105ebd9e68d3545136 225d5bc02c4dcb441fd812288de8209d98192a48 && git checkout -B drift-repro 4df174bcc48d694e654f90105ebd9e68d3545136 && git merge --no-ff 225d5bc02c4dcb441fd812288de8209d98192a48
node scripts/docs-audit/affected-docs.mjs --json 4df174bcc48d694e654f90105ebd9e68d3545136
|
…hannels (#25233 stage 2) An external principal is an organization member holding the external role; its org-wide default is owner-only, and shares, sharing rules, share links and held grant RLS widen it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
…word (#25233 stage 2) check:role-word ratchets the word in hand-written docs (ADR-0090 D3). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
Contract reviewServed-tier: Inputs read at 2026-10-23T22:19Z: card #25233 body and all 11 comments (ruling 6355206139 letter B; design report 6344121515 with options A/B/C and E1–E5; stage-2 scope note 6371125645; the stage-1 contract review 6371057486 on #25515 as context; the stage-2 os-dev-report 6375701518), PR #25541 body, its 36-file list, the net diff ① Derived judgmentsStage 2 of ruling B. Each accept-set and public-surface change the diff implies, judged against the ruling's channel list (shares incl. share links, sharing rules, position and permission-set grants with their RLS evaluated; never the internal OWD baseline, the
② Semver levelChangeset
③ Boundary flagsDev flags (PR body and report 6375701518), each answered:
Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT ·
|
…ternal-role # Conflicts: # packages/plugins/plugin-auth/src/auth-manager.ts
… external, not the owner-only floor (#25233 stage 2) The external OWD floor read a controlled_by_parent detail's effective model ('public') and floored it to owner-only, on top of the master derivation (ADR-0055) that already applies the master's floor. An external shared a master therefore read none of its lines. The floor now skips a controlled_by_parent detail in plugin-sharing and in explain's OWD layer. A showcase-boot dogfood case pins it (invoice shared to an external: its two lines, not the other invoice's), red with the exemption removed. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
Contract reviewServed-tier: Inputs read at 2026-10-23T23:16Z: the prior ① Derived judgments
② Semver levelThe changeset keeps ③ Boundary flags
④ Check-runs on
|
席位核对 · ACCEPT (re-issued) ·
|
Requested by Tesla · project thread
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/coregainsowdForAudience,isExternalAudienceContext,externalGrantSpeaksand the two grant-stamp keys; every narrowing below applies only to theexternalrole, which no release has shipped yet)Metadata-protocol: semantic
What this does
A holder of the
externalorganization 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.coreresolve-authz-context.ts,posture-ladder.ts,assemble-execution-context.tsaudience: 'external'on a holder andderivePosturereturnsEXTERNAL. Precedence is PLATFORM_ADMIN, then EXTERNAL, then TENANT_ADMIN, then MEMBER. The assembled context carries the audience, on agent envelopes too.POSTURE_INJECTION_RULE.EXTERNALstates the channel list.coreexternal-owd-floor.ts;plugin-sharingsharing-service.tsowdForAudiencefloors an external's effective model toprivate(owner plus shares) inbuildReadFilter,buildWriteFilter,checkEditandcheckDelete. The floor lifts for one object and one operation class when plugin-security has stamped that a held grant's RLS speaks for it.plugin-securityexternal-owd-grant-stamp.ts,security-plugin.tsstep 2.6,security-plugin-sharing-verdicts.ts__externalReadGrant/__externalWriteGranthold the object name when a held set'susingpolicy 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.plugin-securitydefault-permission-sets.tsexternal_defaultcarriesowner_only_writesandowner_only_deletes, bound toorg_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).plugin-securityauthz-matrix-gate.test.tstaskthe external reads in-org rows its grant opens and writes only rows it created;private_objandplatform_globalare denied; onbetter_authit reads and writes its own row only. The ladder order includes it.plugin-securityexplain-engine.ts,security-plugin-explain-access.ts,security-plugin.tshumanBaselineFor), so an external's isexternal_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.plugin-authexternal-directory-door.ts,auth-manager.tslist-teamsandget-full-organization'steamsanswer an external only the teams it sits on (read fromsys_team_member).list-invitationsanswers it only invitations to its own email. The door docblock notes that the resolver usesincludes('external')while the door requires exactly'external', which E5 makes the same population.specexecution-context.zod.ts,explain.zod.tsaudiencedocblock 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.listOrganizationTeamsand get-full-organization'steams(withteams.enabled, set inauth-manager.ts) check membership only too.Acceptance notes
On a real showcase boot,
packages/qa/dogfood/test/external-member-directory.dogfood.test.tsadds five stage 2 cases (11/11 pass):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.title == '<portal title>'as its select policy. It gets exactly the one matching row, which it neither owns nor was shared.list-teamsand get-full-organization'steamsanswer the external only the team it sits on. An external on no team gets[].list-invitationsanswers 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:
coreposture-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-sharingexternal-owd-floor.test.ts(15): reads and writes on every public model, stamp scoping by object and class, ownerless objects, and member controls.plugin-securityexternal-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-authexternal-directory-door.test.ts(teams, full-org teams and invitations, with member controls).Existing pins moved deliberately:
platform-ownership-policies.test.tsnow deduplicates the floor by its identity key. The floor has two carriers,member_defaultandexternal_default, and a new case pins exactly those two.Notes for review:
externalrole has not shipped in a release yet: its changeset is still pending in.changeset/. No principal without the role changes.controlled_by_parentdetail. 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'sexternal-owd-floor.test.tshad pinned that wrong answer, and the first contract review (6375883728) passed it. The fix isowdForPrincipalinsharing-service.tsand the same exemption in explain's OWD layer. A showcase dogfood case pins it:showcase_invoiceis shared to an external, who reads its twoshowcase_invoice_linerows 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 refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts (#25224 cut 12) #25536, which moved the after-hook.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.public(public_read_write,controlled_by_parent, or a model-less system object) is refused at create as "inert" (inertGrantReason;public_readis 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).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
tscand its external door and membership tests (37) re-pass.tscis clean for core, plugin-security (including the test program), plugin-sharing, plugin-auth and dogfood. All 121 derived gates were run and reconciled withdispatch-gates --ran(0 not measured). The diff is 1543 insertions and 142 deletions across 35 files.🤖 Generated with Claude Code
https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN