Repository navigation
feat(security): an external organization role — portal users read their own identity rows and reach data only through explicit grants - #25515
Conversation
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The external member's own anchor-safe set: member_default's own-row carve-outs without the fellow-member directory scope, plus its own sys_member row. The org_external identity position names it, and the additive human baseline is swapped for it on an external context, so neither member_default nor an app isDefault set reaches an external. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
…doors Registers `external` with better-auth beside owner/admin/member, holding no organization statement. Engine hooks refuse a role list carrying external with another role (sys_member, sys_invitation) and keep an external out of the organization's business units (BU membership, a BU-anchored position, an invitation's business_unit_id, a role change while a placement stands). list-members and get-full-organization answer an external caller its own member row and own invitations only. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The External option label on the membership role pickers (zh-CN, ja-JP and es-ES translated) and org_external in the reserved-identity message; the spec api-surface and export-origins entries for the two new members. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
…only On a real showcase boot: an external holder reads only its own sys_user and sys_member rows at the data door and at better-auth's list-members and get-full-organization; with one permission set it reads that object and not the showcase isDefault objects; an ordinary member is unchanged; a role list mixing external with member is refused. Adds the changeset. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The two new in-memory find doubles throw on a combinator key instead of reading it as a field name (check:where-matcher), and the engine-double ledger records the new external-population findOne double. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
📓 Docs Drift CheckThis PR changes 6 package(s): 63 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 — 161 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 e9418d9715ac36987622d49476ce6893f7e3717f && git checkout e9418d9715ac36987622d49476ce6893f7e3717f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dc3e5b9d7b0d978afebd8f12f4155744bc53e32a 1576d4a1d5e12ecc0db8dacb67845b7b7f3eb13c && git checkout -B drift-repro dc3e5b9d7b0d978afebd8f12f4155744bc53e32a && git merge --no-ff 1576d4a1d5e12ecc0db8dacb67845b7b7f3eb13c
node scripts/docs-audit/affected-docs.mjs --json dc3e5b9d7b0d978afebd8f12f4155744bc53e32a
|
Contract reviewServed-tier: Inputs read at 2026-10-23T17:41Z: card #25233 body and all 8 comments (ruling 6355206139 letter B of 2026-10-23T01:30Z; os-dev-report 6344121515 with E1–E5; app-side measurement 6366488714; os-dev-report 6370873115), PR #25515 body, its 45-file list, the net diff ① Derived judgmentsSecurity change, stage 1 only of ruling B. Each accept-set / public-surface change the diff implies, judged against the ruling's E1–E6 and the card's acceptance:
② Semver levelChangeset
③ Boundary flagsDev flags (PR body + report 6370873115), each answered:
Implemented-by: VERDICT: PASS Generated by Claude Code |
The two hand-written pages that state the vocabulary, and the spec describe strings the organization reference page is generated from. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
The ownership inventory holds SHIPPED_PERMISSION_SET_NAMES equal to the security plugin's shipped sets, which gained external_default. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN
席位核对 · ACCEPT ·
|
Requested by Tesla · project thread
Part of #25233 (stage 1 of the ruling of record 6355206139, letter B; stage 2 follows in its own PR).
Clause-②: yes (widening)
Metadata-protocol: added
What this does
A supplier's or customer's portal user can now be added to an organization with a new
externalrole. Before this, such a user had to be a plainmember. The member baseline'ssys_user_org_membersscope (id in current_user.org_user_ids) is OR-combined with any application policy. So the portal user read every employee and every other supplier's users throughGET /data/sys_user, and better-auth's list-members and get-full-organization doors showed them too.plugin-authauth-manager.tsexternalbeside owner/admin/member, with an empty statement set.coreresolve-authz-context.tssys_member.role = externalin the active organization projects to the new reserved identityorg_external. Its holder does not get theeveryoneanchor, andcountPositionHoldersagrees. The stamp is per organization, and with no active organization an external membership withholds the anchor (fail closed). There is nosys_usercolumn.plugin-securityexternal_default, bound by theorg_externalbuilt-in position. It holdsmember_default's own-row carve-outs withoutsys_user_org_members, plus asys_memberown-row policy. The plugin's additive human baseline ([appDefault, member_default]) is swapped for[external_default]on an external context. So neither an app'sisDefaultset nor an accepted package default reaches it, by either channel. It is not inMANAGED_DENY_TARGET_SETS, so no registry-driven read injection reaches it either.plugin-authexternal-directory-door.ts/organization/list-membersand/organization/get-full-organization. When the caller's membership in the answered organization isexternal, it gets its own member row only (total: 1) and only invitations addressed to its own email. Every other caller is untouched.specinvitation-role-grant.ts[external]: 'any', so the invite dialog offers it to every issuer.plugin-authexternal-membership.tsexternalwith another role is refused at write, onsys_memberandsys_invitation, for every context. The refusal is400 VALIDATION_FAILED, withinvalid_valueonrole.sys_business_unit_memberrow, asys_user_positionanchored at one of that organization's units, an invitation conferringexternalwith abusiness_unit_id, and a role change toexternalwhile a placement stands. An organization-wide position or a permission set is how an external gets access.Spec additions:
MEMBERSHIP_ROLE_EXTERNALandBUILTIN_IDENTITY_ORG_EXTERNAL(with metadata), in the closed vocabulary and the reserved identity names. The i18n bundles carry theExternallabel (zh-CN 外部成员, ja-JP 外部メンバー, es-ES Externo). The api-surface and export-origins files are regenerated.Acceptance notes
The app-side acceptance (hotsrm 6366488714) is pinned on a real showcase boot in
packages/qa/dogfood/test/external-member-directory.dogfood.test.ts(6/6). It boots with the showcase's ownisDefaultset wired, asobjectstack servedoes:sys_userandsys_memberrows. Two suppliers' externals each get exactly their own id fromGET /data/sys_userandGET /data/sys_member.list-membersandget-full-organizationanswer each one its own member row.showcase_announcement(200) and is refusedshowcase_product(403). The showcase'sisDefaultset opensshowcase_productto every member. The supplier without a grant is refused both.list-members, and still reads the app-default object.update-member-rolewith['external','member']answers 400, and the stored role staysmember.Armed controls guard each negative case. The member really reads its peers, the app default really opens the object to members, and the fixture roles really landed.
Unit pins:
coreresolve-authz-context.external-population.test.ts(5). Four of them were red before the resolver change.plugin-securityexternal-population.test.ts(11). These cover the set's shape, own-row filters, no directory scope, the structure tables closed, one grant only, no managed-deny injection, the position binding, and resolution through the service locator with an app default configured.plugin-authexternal-membership.test.ts(16) andexternal-directory-door.test.ts(8).specmembership-role-external.test.ts.Existing pins moved deliberately:
org_externalis the one built-in identity that names a set. A delegate whose allowlist lacksexternal_defaulttherefore meets the delegated-admin gate's 403 before the reserved-name 400. Either refusal stores nothing, and the posture test says so.external_defaultjoins the field-withholding sets and the canonical set list.external.external.Notes for review:
org_externalrefuses a new position by that name. No narrowing was measured for an existing holder. better-auth's doors refusedexternalbefore this change, and no shipped set changed for an existing population. One case is unmeasured: asys_memberrow written directly (not through better-auth) with the tokenexternalused to project to a position namedexternal, and now projects toorg_external. No such row exists in this repo's fixtures or seeds.list-teamsand theteamsmember of get-full-organization still check organization membership only, so an external sees the organization's team names (not rosters:list-team-membersalso requires the caller to be on that team).member_defaultreadssys_team,sys_sso_providerand the OAuth resource tables with no row scope. That is not an external issue now, since externals no longer hold it.baselinePermissionSets. This is the stage 2 explainer work.owner_onlyOWD floor yet. That belongs to the OWD seam in stage 2.Verification: the plugin-security (4478), plugin-auth (3607) and core security (1092) suites pass after merging main. The role and identity pin tests in thirteen other packages pass.
typecheckis clean for spec, core, plugin-security, plugin-auth, platform-objects and dogfood. All 109 derived gates were run and reconciled withdispatch-gates --ran(0 not measured).🤖 Generated with Claude Code
https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN