Repository navigation
Tracking: ADR-0105 — group tenancy posture; organization scope as a first-class authorization dimension #3541
Description
Tracking issue for ADR-0105 (proposed via #3499).
Status: Phase 0, Phase 1, and Phase 2 D8+D9 COMPLETE (re-evaluated 2026-07-30 against ADR-0108/0111/0112/0113 — see the re-evaluation comment). Engine landed in #3559 (activation corrected in #3570, ADR amended in #3603; driver union scope fixed in #3631); console affordances in objectstack-ai/objectui#2858; acceptance met by the three-plants-one-group dogfood (objectstack-ai/cloud#882 + objectstack-ai/cloud#883 — 9/9 assertions passing in BOTH entitlement states). D8 complete end-to-end (#3645 → #3663 → #3674 → #3695 → #3722 → #3767; the membership-role channel its placement replaces is closed by ADR-0108/#3802); D9 landed (#3824 + #3873). Remaining: D10 (blocked on its follow-up ADR), D13, "all my organizations" affordances, Phase 3. ADR status line updated in #4028.
Summary
Group-shaped customers (multi-plant manufacturing, multi-branch retail, holding structures) need one shared database, organization as the membership boundary, group-wide visibility, and cross-org workflows — a middle posture between today's single and multi. The ADR adds a group tenancy posture with MOAC-style union semantics (organization_id IN accessible_org_ids), engine-enforced, preserving ADR-0095 W1/W2 invariants.
Plan evaluation (2026-07-27)
Findings verified against main: F1 (security-plugin.ts substring strip — #3539), F2 (organization_admin wildcard VAMA + auto-org-admin-grant.ts — #3540), F3 (PermissionSet.contextVariables, ExecutionContext.rlsMembership, territory.zod.ts — all consumer-less), F4 (ADR-0092 whitelist has one entry). The technical premises held.
Design assessment: sound, and it survived implementation. D2 widened the existing Layer 0 predicate from equality to set membership; computeTenantLayer0Filter was already a pure function with the PLATFORM_ADMIN-rung exemption and a fail-closed empty-org path, so the delta stayed contained.
Two corrections to the ADR's own line references, found during implementation:
PermissionSet.contextVariableswas atpermission.zod.ts:308, not:282.- The approval org-scoping seam is
approval-node.ts:147(and substantivelyapproval-service.ts:748-768/:967), not:118.
One ADR claim not confirmed: the "existing 60s cache staleness envelope" the Consequences section attributes to the resolver does not exist there — resolveAuthzContext re-runs per request with no TTL. The 60s window belongs to the better-auth session snapshot cache (auth-manager.ts). D2's resolver therefore adds a per-request read, not a cached one; if that cost matters at scale it needs its own decision.
One implementation finding beyond the ADR: the #2937 write post-image check required a non-array payload, so a BULK insert could carry a forged organization_id per row — the same defect one call site down. Closed as part of D5.
Dogfood findings (the acceptance run paid for itself): #3623 (driver-level native tenant scoping collapsed group union reads to active-org equality — fixed in #3631), #3624 (org creation over HTTP 500s with teams enabled: better-auth 1.7 memberCount vs sys_team — CLOSED by #3647, see the 07-27 comment for the full sweep it triggered), cloud#881 (per-org seed replay primary path never engaged — fixed in cloud#884).
Repo split (per cloud ADR-0016 iron rule 强制免费、治理收费, cloud ADR-0081)
This repo (open, framework): all enforcement primitives (code) — shipped in #3559 + #3631; posture activation is entitled (#3570). Scoped-invitation and delegated-admin console UX live open in objectui's app-shell (correction recorded in the 07-27 D8 comment — cloud sells the runtime that entitles the posture, not the console screens).
cloud repo (commercial, @objectstack/organizations): org lifecycle management, grouping/registry UI, cross-org approval templates, master-data distribution management, per-org seed/config replay, org analytics, D13 promotion tooling. Cloud-side tracking issue: objectstack-ai/cloud#874; acceptance/regression harness: objectstack-ai/cloud#880 (apps/ee-group-showcase, DONE).
D12 line — corrected (#3570; ADR amended in #3603): the open-activation reading shipped in #3559 was reviewed by the founder and ruled wrong — it made the stronger multi-org posture free while isolated stayed entitled, inverting ADR-0081 D2, and it opened a silent-degradation hole (OS_TENANCY_POSTURE=group skipped both the enterprise package load and the ADR-0093 D5 fail-fast). #3570 made group an entitlement again: it probes org-scoping exactly like isolated; without the enterprise runtime the posture resolves to single + degraded, and an os serve boot configured for group refuses to start instead of silently running single-org. Iron-rule safety is satisfied by refusing to run an unwalled group boot, not by free activation — open code is not free activation. Both multi-org postures are @objectstack/organizations capability, and the runtime declares WHICH shapes it entitles (supportedPostures, cloud#875) with the open core failing closed on anything undeclared.
Phases
Phase 0 — correctness (open, immediate, independent of D1) — ✅ landed in #3559
- P0: collectRLSPolicies silently strips authored org-scoped RLS policies when org-scoping is inactive (ADR-0105 F1 → D3) #3539 — D3: strip only the platform's own tenant policies (provenance, not substring)
- P0: organization_admin wildcard VAMA is contained only by the tenant wall — wall-less postures yield env-wide superusers (ADR-0105 F2 → D4) #3540 — D4: VAMA bounded by org scope in every posture
- D11: remove
PermissionSet.contextVariablesfrom spec - D11: remove
spec/src/security/territory.zod.ts - D7: collision guard for extension fields on better-auth-managed objects (derives better-auth's real surface from
getAuthTables()) - Conformance-matrix rows: wall-less-VAMA, authored-policy-survival (+3 more)
Phase 1 — group posture MVP — ✅ COMPLETE
- D1:
single | group | isolatedin the tenancy service (OS_TENANCY_POSTURE; existingmulti→isolated) - D2: core-resolved
ExecutionContext.accessible_org_ids; group Layer 0 filterorganization_id IN accessible_org_ids; empty/absent ⇒RLS_DENY_FILTER. Driver-level union scope (DriverOptions.tenantIds) fixed in fix(authz): widen the driver's native tenant scope to the group membership union — ADR-0105 D2 reaches the wire (#3623) #3631 after the dogfood caught the equality collapse (group posture: SQL driver's native tenant scoping collapses union reads to active-org equality (ADR-0105 D2 never reaches the wire) #3623) - D5: engine stamping + write validation in
group/isolated(incl. the bulk-insert hole) - D6: org grouping metadata fields + red-line lints (
validateOrgAxisRedLines, wired into os lint/compile/validate) - D7: whitelist entries for org/member extension fields
- D11: productize
rlsMembership—IRlsMembershipResolverunderrls-membership-resolver - Group-mode default permission sets (
organization_admin_no_bypass, derived) - UI: org switcher as write context; "all my orgs" read views — landed in feat(console): group tenancy posture affordances — org switcher as write context + org attribution (ADR-0105 Phase 1) objectui#2858 (
features.tenancyPosture-keyed; non-group renders unchanged) - Acceptance: three-plants-one-group dogfood — objectstack-ai/cloud#880 / objectstack-ai/cloud#882 / objectstack-ai/cloud#883: plant admins mutually invisible, group member reads all plants via union, active-org switch moves the write target only, forged cross-org
organization_idnever lands in the victim org, zero custom security code; PLUS the unentitled-state boundary regression (degrade tosingle+degraded; per-posture entitlement narrowing viasupportedPostures)
Phase 2 — group product depth
- D8: scoped invitations — ✅ COMPLETE: feat(auth): onInvitationAccepted host seam — afterAcceptInvitation forwarded to the host (ADR-0105 D8 prerequisite) #3645 (host seam) → feat(authz): scoped invitations — placement intent gated by the issuer's adminScope, applied on acceptance (ADR-0105 D8) #3663 (placement engine:
sys_invitationplacement fields via the ADR-0092 whitelist,beforeCreateInvitationissuance gate, idempotent accept-time application) → feat(authz): expose the caller's delegable scope — the read half of the delegated-admin gate (ADR-0090 D12 / ADR-0105 D8) #3674 (GET /security/my-delegable-scope) → fix(security): resolve the issuer's real grants when authorizing invitation placement (ADR-0105 D8) #3695 (issuance resolves the ISSUER's real grants) → feat(auth): give ADR-0105 D8's scope-bounded issuance a caller —delegated_admin, capped (#3697) #3722 (delegated_adminorg role + invitation role cap — closes ADR-0105 D8: the scope-bounded issuance path has no caller — every principal who may invite is already a tenant admin #3697's no-caller gap AND its escalation chain) → fix(security): governsys_memberwrites — membership is not a delegable capability (#3697 follow-up) #3767 (sys_memberjoinsGOVERNED_OBJECTS); console feat(console): scoped-invitation placement — invite straight into a unit and positions (framework ADR-0105 D8) objectui#2868 + feat(console): makedelegated_adminreachable and narrow both role pickers (framework#3697) objectui#2891; e2e objectstack-ai/cloud#886. The membership-role capability channel placement replaces is closed by ADR-0108 (feat(auth)!: membership grade is not a capability channel — close thesys_member.rolevocabulary (ADR-0108, #3723) #3802) - D9: cross-org approval targeting — ✅ landed in feat(approvals): cross-organization approver targeting (ADR-0105 D9) #3824 (per-approver
organization:$root/$parentsymbols + slug over D6's tree; legality = shares aparent_organization_idroot, independent of the submitter; non-grouppostures refuse at runtime; ADR text amended in-PR) + fix(approvals): refuse cross-org targeting on directory-less approver types (ADR-0105 D9) #3873 (directory-less approver typesuser/field/managerrefuse instead of staying silently inert — found by the cloud dogfood, not review). Cloud e2e confirmed the two links unit tests structurally can't: union-wall reads by the cross-org approver, anddecide()by a foreign-org actor - D10: layered master data — blocked on its follow-up ADR (the ADR reserves the concept only; none of ADR-0106…0113 is it — this is the one remaining framework-side design item in Phase 2)
- D13: BU-subtree → organization promotion tool (+ verification harness) — not started; repo split places the tooling cloud-side (cloud#874)
- "All my organizations" console affordances — read views landed with feat(console): group tenancy posture affordances — org switcher as write context + org attribution (ADR-0105 Phase 1) objectui#2858 (Phase 1); remaining scope (deeper console/registry affordances) undefined, cloud-side pieces in cloud#874
Phase 3 — governance (commercial, cloud repo)
- SoD constraints, certification campaigns (ADR-0091 data), deny/muting layer (ADR-0066 ⑦⑧), generalized dimension security (territory's successor — ADR-0111 has since reserved the per-verb capability-mask direction for it)
Refs: ADR-0093, ADR-0095, ADR-0090, ADR-0091, ADR-0092, ADR-0049, ADR-0108, ADR-0111; cloud ADR-0016, cloud ADR-0081.
Activity
feat(authz)!: group tenancy posture + org scope as a first-class dime…
docs(adr): ADR-0105 D12 amendment — group posture activation is entit…
feat(auth): onInvitationAccepted host seam — afterAcceptInvitation fo…
os-zhuang commentedon Jul 27, 2026
Dogfood findings 更新:#3624 已关闭
正文 "Dogfood findings" 一节里 #3624 仍标着 OPEN,现在已修复并合并,顺带把它牵出的一串问题一并处理了。更新如下:
实际修掉的比报告的多
原报告只提到
team.memberCount。同一次 better-auth 1.7 升级实际漏了三个 model 的四个列:teammemberCountorganization/create500(org 行已提交,留下半成品组织)teamMembermembershipKeytwoFactorfailedVerificationCount/lockedUntiltwo-factor 那一对不是报告发现的,是新建的 parity gate 第一次运行时抓出来的 —— 那条路径当时零测试覆盖,所以列能凭空消失而没有任何东西变红。
相关落地
os i18n extract --check新鲜度 gatesys_scim_provider.provider_keyapps/ee-group-showcase现在用真实organization/create路由跑 group posture 多组织建组织cloud#885 顺带订正了
group-posture.dogfood.test.ts里seedOrg的注释 —— 它此前把这个 500 记作活跃缺陷,现在改回"设计取舍"(Phase 1 断言的是 membership 对墙的含义,不是建组织 UX)。由此新开、尚未决策的
@better-auth/scim的四个 group model 完全没有平台对象,而 spec 已经对外发布SCIMGroup类型且零运行时消费者。declared ≠ enforced,需要"实现 or 裁掉"的决定。platform-objects一行生成文件 = 89 个 turbo task / 约 60 分钟 affected 测试(实测)。Phase 1 的 checklist 本身无需改动 —— 上述都不影响 "Phase 1 COMPLETE" 的结论。
Generated by Claude Code
os-zhuang commentedon Jul 27, 2026
D8 status — engine + console complete; scope-bounded issuance blocked on #3697
The Phase 2 D8 line can be updated. Everything except one thing is done, and that thing turned out to be a design gap rather than remaining work.
Landed:
onInvitationAcceptedhost seamsys_invitationplacement fields (business_unit_id,positions) via the ADR-0092 whitelist, thebeforeCreateInvitationissuance gate, and idempotent accept-time application throughinvitation-placementGET /security/my-delegable-scope, the read surface a constrained picker needs (self-scoped, no target-user parameter, sibling to/security/explain){ userId, tenantId }context, which carries nopositions/permissions, so the gate refused every issuer. Fail-closed but dead.assertIssuablenow takesactorUserIdand resolves grants throughresolveUserAuthzGrants.InviteMemberDialog, narrowed bymy-delegable-scope, hidden when the caller may delegate nothing or the deployment exposes no delegated-administration runtime)group-posture bootA correction to this issue's repo split. The body assigns "scoped-invitation UX" to cloud. That was wrong and I moved it:
cloudhas no invite UI at all —OrganizationsPage/MembersPage/InvitationsPage/InviteMemberDialogall already live open in objectui's app-shell. Cloud sells the runtime that entitles the posture, not the console screens.Blocked, and the reason is worth recording: #3697. The scope-bounded issuance path has no caller. better-auth grants
invitation: ["create"]toowner/adminonly, and undergroupposture the framework auto-elevates exactly those roles toorganization_admin— whose wildcardmodifyAllRecordsisisTenantAdmin(), for whomDelegatedAdminGate.assertshort-circuits. The two sets are disjoint, so placement is bounded by the Layer 0 org wall, never byadminScope.Not a security hole (the wall still bounds every issuer to their own org), but it hollows out D8's motivating story — "a plant admin invites into their own subtree" only bites when the plant admin is not an org admin, and that principal cannot issue at all. #3697 proposes registering a
delegated_adminorg role carrying that one statement; the gate, the read surface, and the picker are all already built.Both findings came from the cloud dogfood, not from review. #3695's own unit tests passed because they asserted the context was forwarded, never that it carried authority — the same class of miss as #3623 earlier in this issue. That is twice now the showcase has paid for itself.
Generated by Claude Code
os-zhuang commentedon Jul 30, 2026
重新评估(2026-07-30)— 对照新增 ADR-0108 / 0111 / 0112 / 0113
上次评估(7-27/28)以来 main 上新增了四篇 ADR。逐一对照本 tracking 的计划,结论:计划方向不变;Phase 2 的 D8/D9 已于 7-28 全部落地,正文 checklist 已同步。真正因新 ADR 而变化的是两点:D8 的收口方式(升级成了一篇独立 ADR),和 Phase 3 一项拿到了方向锚点。ADR-0105 自己的 status 行还停在 "Phase 0/1 implemented",随 #4028 补记。
ADR-0108 —— D8 线的正式收口(本次重评的核心)
D8 落地后暴露的 #3697(scope-bounded issuance 无调用者)沿 #3722(
delegated_admin角色 + invitation role cap,二者必须同船——裸角色版本会打开四步提权链)→ #3767(sys_member进GOVERNED_OBJECTS,"membership 不是可委派能力")修完,最终上升为 ADR-0108(Accepted 2026-07-28,#3802 实现):sys_member.role词表封闭为owner / admin / delegated_admin / member,additionalOrgRoles整体移除(逆转未发布的 #3747/#3779),并确立 D8 的 invitation placement 是"带能力入场"的唯一通道——对被废通道是严格超集:delegated admin 也能发起、写真实sys_user_position行、带granted_by+ ADR-0091 validity window、受 subtree/allowlist/strict containment 约束。对本 tracking 的意义:D8 从"engine seam 已落地、其余待做"变为全链路完成且被第二篇 ADR 结构性锁死——不是修好了,是这一类问题不可再现。#3697 的两个 non-goal 中
sys_member纳管已被 #3767 关闭;attributed cancel 仍开放但已记录。剩余唯一跟进在 objectui:词表镜像从@objectstack/spec派生(ADR-0108 consequences;standing instruction 已在 objectui#2907 修正)。D9 —— 已落地,ADR 文本随 PR 修订
#3824 落地时对原文补了四个实现裁决:per-approver 而非 per-node(否则
per_group并行会签无法表达);$root/$parent符号优先(流程元数据可移植,org id 是部署期数据,AI 作者在 authoring 时不可能知道);合法性 = 同parent_organization_id根、只看组织树不看 submitter;非groupposture 运行时拒绝而非静默忽略(posture 是环境配置,lint 看不见)。#3873 随后修复 directory-less 类型(user/field/manager)early-return 绕过 D9 检查造成的 silently-inert——又是 cloud dogfood 抓到的,不是 review。showcase 第三次收回成本(前两次:#3623、#3695)。ADR-0111 —— 相邻授权面,三个交界点
canManageShares、D1 DEPTH 的resolveWriteScope)走 BU 树;D6 把层级留在 org 内,Layer 0 墙仍是外边界,所以 DEPTH 管理权限不会跨 org。但 group dogfood(cloud#880/chore: version packages #882/[Bug] Auth 服务 /api/v1/auth/* 路由无法访问(插件注册顺序导致未注册) #883)全部写于 ADR-0111 之前,sharing 管理权限在groupposture 的 union 读语义下零断言。建议:给 ee-group-showcase 加一组 sharing-authority × group posture 行——plant A 的 share-manager 不能管到 plant B 记录上的 share;group 侧成员经 union 读到 plant 记录后,canManageShares/DEPTH 不因可见而放行。这延续 showcase 三次抓真缺陷的记录,属于低成本高回报。hierarchy-scope-resolver(framework 无 resolver 时 fail closed)复用了 D12 的 entitlement 模式——这是第二个按该模式落地的 enterprise seam,模式经受住了复用。ADR-0112 / 0113 —— 无影响
本线错误码(
RLS_DENY_FILTER等)本就 SCREAMING_SNAKE,两轮 sweep(#3967/#4003/#4021)未触及;ADR-0113(required write contract)与 D5 的organization_idstamping 不相交。剩余项
建议的下一步排序: ① sharing-authority × group posture 的 showcase 断言(上面第 1 点,验证债);② D10 follow-up ADR(设计债,解锁 Phase 2 收尾);③ D13 归 cloud 排期。
Generated by Claude Code
os-zhuang commentedon Jul 30, 2026
收官(2026-07-30)
本 issue 的验收范围——ADR-0105 框架侧执行面(Phase 0、Phase 1、Phase 2 D8/D9)——已全部落地并经 dogfood 验证(9/9 断言、双 entitlement 状态;D8/D9 各自的 e2e 见正文)。按 7-30 重新评估的结论收官:剩余项没有一项是框架侧可动工的代码,且彼此没有共同交付弧线,不再保留 umbrella,拆分如下:
ADR-0105 status 行的补记在 #4028(独立 docs PR,不受本次关闭影响)。后续凡涉及组织轴的新决策,以 ADR-0105/0108/0111 三篇为基线各自开 tracking。
Generated by Claude Code