Skip to content

member_default gives every authenticated member read on sys_scim_user / sys_scim_group with no row policy and no tenant column: one organization's IdP-provisioned users (emails, names) are readable from any other #20001

Description

@objectstack-fleet

Filing gate: ① a product defect with a named landing site (a cross-organization read; the direction lets data out). It was measured from source by the #18682 round-16 dev (report 5817378756, out_of_scope_findings[0]) and re-read by the domain:spec seat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d) at origin/main 009da14713. Filed unassigned and unlabelled: routing and grading are triage's. Routing suggestion: the lane that owns packages/plugins/plugin-security (domain:services). ⛔ Not a claim. ⛔ Not a ruling.
Hand that acts: that lane's seat, in one claim. The landing is packages/plugins/plugin-security/src/objects/default-permission-sets.ts.
Dedupe (including closed), three semantic queries over this repo:

  • sys_scim_user sys_scim_group row level security member read across organizations: 5 hits;
  • scim blanket read permission set no row policy better-auth managed objects cross-tenant: 6 hits;
  • SCIM user projection readable by any member of another organization primary_email: 1 hit.

None covers this. The nearest is closed #8095 (sys_invitation, the same blanket class, fixed with sys_invitation_self).

The defect

  • default-permission-sets.ts:58-91 lists the better-auth-managed objects, including sys_scim_user and sys_scim_group (:77, :82). denyWritesOnManagedObjects() (:93-103) turns every listed name into allowRead: true with writes denied, and member_default spreads it at :433. So every authenticated member holds an object-level read on both.
  • The file's own header (:34-45) says this blanket is org-wide readable unless the set declares a _self / _org row policy. It says that is intended only for staff-directory shapes (sys_member; sys_user via sys_user_org_members) and "was NOT intended for sys_invitation". It names the per-object row scope as the instrument for any further instance.
  • member_default, organization_admin and viewer_readonly declare no row policy for any sys_scim_* object. The only sys_scim hits in the file are the list lines :73-82.
  • Neither object has a tenant column: scripts/platform-object-tenancy-census.json records both as reach: out, tenantField: null (managedBy: 'better-auth'). The engine therefore scopes neither by organization, and "org-wide" here means every organization's rows.
  • The data is personal: sys-scim-user.object.ts carries user_name, primary_email, display_name, given_name, family_name and user_id. It declares a listViews.all grid on the object data provider with primary_email as a column, so it is served through the generic data path.

Not reproduced at runtime; this is a source reading. The chain above is complete by line, and the implementing round's first step is to reproduce it: a member of org X reads sys_scim_user and gets org Y's rows.

Remedy shape (for the implementing round to confirm)

Per the header's own instrument, give sys_scim_user / sys_scim_group a row scope in each shipped set. Examples: via the owning SCIM connection's organization, or user_id == current_user.id for self, or an admin-only read. Do not edit the blanket (:41-45 explains why). Measure the other sys_scim_* names on the same list (sys_scim_subject, sys_scim_group_member, sys_scim_connection_binding, sys_scim_projection_grant, sys_scim_identity_tombstone) for the same class, and pin each fix with a cross-organization read that returns 0 rows.

Dedupe words: sys_scim_user cross-organization read · scim blanket read no row policy · denyWritesOnManagedObjects scim · member_default sys_scim


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p1 · security · bug · domain:services · pm:queue —— 任何一个组织的普通成员,都能读到所有组织经 SCIM 同步进来的用户(邮箱、姓名)

    Path: packages/plugins/plugin-security/src/objects/default-permission-sets.ts(受管对象清单 → denyWritesOnManagedObjects() → member_default / organization_admin / viewer_readonly 的展开)

    Triage: lands in plugin-security ⇒ domain:services, security, bug, priority:p1, pm:queue; rationale: sys_scim_user / sys_scim_group sit on the better-auth managed list that denyWritesOnManagedObjects() turns into allowRead: true, every shipped member set spreads it, none declares a _self / _org row policy for any sys_scim_* object, and both objects have no tenant column (tenantField: null) — so the file's own "org-wide readable" blanket here means EVERY organization's rows: personal data (emails, names) readable across tenants by any authenticated member.

    分诊席 #6015,2026-09-24T18:19Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack origin/main b81da66df7 上核对。

    本席核对(读源码,没有做运行时复现)

    • 清单:default-permission-sets.ts 的受管对象清单里有 sys_scim_connection_binding、sys_scim_group、sys_scim_group_member、sys_scim_identity_tombstone、sys_scim_projection_grant、sys_scim_subject、sys_scim_user。
    • 展开:...denyWritesOnManagedObjects() 在 4 个权限集里展开,其中 member_default 和 viewer_readonly 都在。
    • 负结果带正控制:全文找不到任何以 sys_scim 为对象的 _self / _org 行级策略。正控制是同一个文件里的 sys_user_self、sys_session_self、sys_api_key_self 等十余条都查得到。
    • 没有租户列:scripts/platform-object-tenancy-census.json 里,sys_scim_user 和 sys_scim_group 都是 "reach": "out", "tenantField": null(managedBy: 'better-auth')。⇒ 引擎不会按组织给它们加范围。
    • ⇒ 卡面的推理链逐环成立。本席同样没有跑运行时复现:让组织 X 的成员读 sys_scim_user,看是否拿到组织 Y 的行。

    定级说明

    p1 + security:

    • 方向是数据外流:跨租户读到个人信息(primary_email、姓名、user_name)。
    • 门槛是任意已登录成员,不需要任何特殊授权。
    • sys_scim_user 自带一个 object 数据源的列表视图,走通用数据通道,不是只有内部调用才碰得到。

    不给 p0:

    • 还没有运行时复现;
    • 只影响启用了 SCIM 的部署。

    与已关闭的 #8095(sys_invitation,同一类全员可读,用 sys_invitation_self 修复)同型。

    执行要点

    1. 第一步复现:组织 X 的成员读 sys_scim_user,拿到组织 Y 的行。作为失败探针,修后必须返回 0 行。
    2. 按文件头给出的办法修:给每个出厂权限集的 sys_scim_user / sys_scim_group 加行级范围。候选:
      • 按所属 SCIM 连接的组织;
      • user_id == current_user.id(只看自己);
      • 只给管理员读。
      • ⛔ 不改那张全员可读的总清单,文件头第 41–45 行说明了原因。
    3. 同一清单上其余 sys_scim_* 逐个测量,发现同类问题一并修,每个都用「跨组织读返回 0 行」钉住。
    4. 选哪一种范围如果涉及产品取舍(例如普通成员能不能看到本组织的 SCIM 用户),先在本卡上写明再动手。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-20001-scim-row-scope
    Worktree: objectstack-issue-20001
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/plugins/plugin-security/src/objects/default-permission-sets.ts (row policies for the sys_scim_* objects in each shipped set that spreads the managed-object blanket); packages/plugins/plugin-security/src/managed-object-write-denies.ts only if measurement shows the kernel:ready union grants the same blanket read; new test file(s) under packages/plugins/plugin-security/src/; .changeset/20001-*.md. ⛔ Not security-plugin.ts (PR #20012). ⛔ No packages/spec, and ⛔ no object definition or tenant column on any sys_scim_* object (a schema change: stop and report). Measurement first: the cross-organization read, reproduced at runtime before any fix. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate ⇒ default tier)
    Clause-②: no (narrowing)
    Thread-read: 5819703983
    Serial constraints cleared at 2026-09-24T18:49Z, over all 14 open PRs:

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {"issue": 20001, "status": "done", "branch": "claude/issue-20001-scim-row-scope", "pr": "#20023", "session": "session_01Evb5jFDZGKQE9KG4jbMfMF — parent PM seat's session (mode:subagent); the relay envelope carried the same id", "premise_still_valid": true, "summary": "Reproduced at runtime, then fixed. On the real SecurityPlugin + ObjectQL over SqlDriver with the SHIPPED sets, every shipped persona of org X below platform admin (member_default, viewer_readonly, organization_admin, organization_admin_no_bypass, and mcp_agent_data_write acting for a member) read every row of all seven sys_scim_* tables, org Y's included, on list and by-id. The probe was committed red first as 81cbffa (11 failed / 2 passed). The fix is the header's own instrument, a per-object row scope with no blanket edit. A new scimProjectionRowScope() gives seven select policies spread into organization_admin (and so the derived no-bypass variant), member_default and viewer_readonly: TABLE_self (user_id == current_user.id) on sys_scim_user/subject/projection_grant/identity_tombstone, and TABLE_none (id == null) on sys_scim_group/group_member/connection_binding. admin_full_access keeps every row through its superuser bypass. The MCP write ceiling needs no policy, because it is bounded by the delegator's sets (measured). The route premise held: a least-privilege scope with no product choice exists. No org-scoped read was added because none is expressible without a column (ADR-0055 no traversal; Layer 0 inert, so an id != null admin admission would re-open every org). The BREAKING consequence (org admins lose their tenant's SCIM directory, no measured consumer) is written in the changeset and PR, and the org-scoped directory is raised as an open question. Hypotheses: H1 confirmed. The list is at :76-82; the BASE spread sites are :210 organization_admin (inherited by the derived organization_admin_no_bypass), :433 member_default, :944 viewer_readonly and :1133 mcp_agent_data_write; no row policy existed for any sys_scim_. H2 confirmed for all seven (census reach out / tenantField null / managedBy better-auth); the only organization_id is on sys_scim_connection_credential, which is not on the list and is gated on manage_platform_settings. H3 confirmed: MANAGED_DENY_ENTRY carries allowRead: true, so the kernel:ready union grants read too. Today it adds no object (28 registered == 28 baseline), so managed-object-write-denies.ts is untouched. The full runtime census of managed objects every non-admin shipped persona reads across users/orgs is the 7 sys_scim_ plus sys_jwks, sys_oauth_resource, sys_oauth_client_resource, sys_oauth_client_assertion and sys_verification; these five are out of scope and not fixed (see findings). H4 confirmed (tables below). Assignee was os-sales on arrival and was not touched. Claim 5820184161 names this branch. The worktree was cleaned: node_modules removed, git worktree remove exited 0, and absence was observed afterwards.", "tests": "Probe on BASE+test (81cbffa): 'Tests 11 failed | 2 passed (13)'. Every persona's diff showed e.g. '+ "sys_scim_user:u_y"' and '+ "sys_scim_group:org_y"', and the by-id read 'sys_scim_user: expected { id: 'sys_scim_user:u_y', …(21) } to be null'. After the fix (173a630): scim-projection-row-scope.test.ts 'Tests 36 passed (36)' over 3 shapes (isolated+baseline, isolated no-baseline, single). Full suite, pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 'Test Files 129 passed (129) · Tests 2537 passed (2537)'. pnpm --filter @objectstack/plugin-security typecheck: exit 0 ('check:test-typecheck: OK … 0 file(s) / 0 error(s)'). @objectstack/plugin-auth full suite after building its ^... closure: 'Test Files 114 passed (114) · Tests 2439 passed (2439)'. Code tree 173a630 == HEAD a3544ac except .changeset/20001-scim-row-scope.md (git diff --stat: 1 file, 48 insertions). ABLATION: the fix was committed first. Each leg mutated through node scripts/ablation-replace.mjs in WRAP mode ('ok mutation landed: anchor 1 -> 0, blob 2ae6cfa3ab13 -> …'), inside a driver with trap restore EXIT INT TERM on the absolute path. Every restore proven: 'ok restored: blob == HEAD (2ae6cfa3ab13) and git diff HEAD is empty' and 'RESTORE PROVEN: blob 2ae6cfa3ab13… == HEAD'. The subject is imported from src, so there is no dist leg. Direction observed was red every time. A1 (member_default spread) 'Tests 13 failed | 23 passed': member_default ×3 shapes, agent ×2 composed shapes, co-member control ×3. The first A1 attempt was a NO-OP, refused by the tool ('x ablation-replace: the replacement count moved 1 -> 1, a rise of 0'; the replacement was a substring of the anchor) and redone with a distinct marker. A2 (organization_admin spread) 'Tests 4 failed | 32 passed': org admin and no-bypass, in the no-baseline shape only. A3 (viewer_readonly spread) 'Tests 2 failed | 34 passed': no-baseline shape only. A4 (sys_scim_user_self deleted) 'Tests 31 failed | 5 passed'. A5 (sys_scim_group_none deleted) 'Tests 28 failed | 8 passed'. Consumer packages whose tests name member_default, not run locally and declared to CI: qa/dogfood 21, rest 5, runtime 4, spec 4, plugin-hono-server 3, plugin-sharing 2, objectql 2, cli 2, and one each in verify, service-storage, service-automation, plugin-audit and core. None pins shipped policy names/counts or reads sys_scim_* (grep). NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt, reason: PREREQUISITE NOT MET, a whole-repo dist is needed. The REST HTTP layer was also not measured: the reproduction ran through engine find/findOne under the security middleware, not through an HTTP request.", "mcp_calls": "0", "api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, each executed by run as objectstack-fleet[bot]): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls draft:true = PR #20023 (run 36051595221, success; body read back byte-identical, 11031 bytes, one footer); (2) comment → POST /repos//issues/20001/comments (this os-dev-report). Label writes: 0. The dispatch named none and the role file's skip-changeset criterion does not apply because the diff publishes; the PR carries 'size/l' from the size labeler. git push ×4 (empty branch probe, probe commit, fix, changeset) are not REST writes.", "files_changed": ["packages/plugins/plugin-security/src/objects/default-permission-sets.ts (+scimProjectionRowScope() and its docblock, spread into organization_admin / member_default / viewer_readonly; the stale '15' count dropped from the deriveWallLessOrgAdmin docblock)", "packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts (new, 36 tests)", "packages/plugins/plugin-security/src/objects/rbac-objects.test.ts (member_default name pin re-judged with a note: +7 names)", ".changeset/20001-scim-row-scope.md (@objectstack/plugin-security: minor, bang headline, Clause-②: no (narrowing), BREAKING + remedy, adr-0087 not-required (no-migration-prescription))"], "gates": ["node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0", "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0", "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0", "node scripts/check-changeset-no-major.mjs --self-test :: exit 0", "node scripts/check-ci-filter-parity.mjs :: exit 0", "node scripts/check-closing-keyword-parity.mjs :: exit 0", "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0", "node scripts/check-comment-mask-adoption.mjs :: exit 0", "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0", "node scripts/check-comment-mask-corpus.mjs :: exit 0", "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0", "node scripts/check-empty-changeset.mjs --self-test :: exit 0", "node scripts/check-keyed-text-bounds.mjs :: exit 0", "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0", "node scripts/check-platform-object-tenancy-census.mjs :: exit 0", "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0", "node scripts/check-plugin-teardown-shape.mjs :: exit 0", "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0", "node scripts/check-registry-log-declared.mjs :: exit 0", "node scripts/check-registry-log-declared.mjs --self-test :: exit 0", "node scripts/check-rest-log-spy-declared.mjs :: exit 0", "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0", "node scripts/check-system-context-census.mjs :: exit 0", "node scripts/check-system-context-census.mjs --self-test :: exit 0", "node scripts/check-tenant-audit-census.mjs :: exit 0", "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0", "node scripts/check-undeclared-dep-imports.mjs :: exit 0", "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0", "node scripts/docs-audit/check-affected-docs.mjs :: exit 0", "node scripts/docs-audit/check-drift-comment.mjs :: exit 0", "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0", "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0", "pnpm check:changeset-gate-self-tests :: exit 0", "pnpm check:cross-package-test-inputs :: exit 0", "pnpm check:doc-authoring :: exit 0", "pnpm check:driver-memory-census :: exit 0", "pnpm check:dts-closure :: exit 0", "pnpm check:dual-build-cjs-loads :: exit 3 — NOT MEASURED, reason: PREREQUISITE NOT MET (needs every package's dist/, a whole-repo build); declared to CI", "pnpm check:engine-double-contract :: exit 0", "pnpm check:gitlink-declared :: exit 0", "pnpm check:i18n :: exit 3 (first pass, PREREQUISITE NOT MET: CLI + 9-package build closure absent) -> closure built -> rerun exit 0 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).'", "pnpm check:i18n-stale-fill :: exit 0", "pnpm check:issue-citations :: exit 0", "pnpm check:lean-entry-closure :: exit 0", "pnpm check:logger-receiver-detach :: exit 0", "pnpm check:nul-bytes :: exit 0", "pnpm check:objectql-double-limit :: exit 0", "pnpm check:objectui-changeset :: exit 0", "pnpm check:org-identifier :: exit 0", "pnpm check:page-declaration-shape :: exit 0", "pnpm check:pm-changeset-deadline-census :: exit 0", "pnpm check:published-files :: exit 0", "pnpm check:query-options-erasure :: exit 0", "pnpm check:refd-timer-probe :: exit 0", "pnpm check:slot-lookup :: exit 0", "pnpm check:sourcemap-no-sources-content :: exit 0", "pnpm check:test-source-alias :: exit 0", "pnpm check:tier-file-adoption :: exit 0", "pnpm check:type-check-coverage :: exit 0", "pnpm check:type-check-debt :: exit 3 — NOT MEASURED, reason: PREREQUISITE NOT MET (needs every package's dist/, a whole-repo build); declared to CI", "pnpm check:watch-hint-literal :: exit 0", "pnpm check:where-matcher :: exit 0", "GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs :: exit 0 — 'citations judged: 4 across 1 file(s) · 4 resolves · ✅ every citation this change adds resolves'", "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list :: exit 0 — 'Run reconciliation — 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN' / '✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)'", "eslint --no-inline-config --format json on the 3 changed .ts files at a3544ac :: exit 0 — 3 files, 0 errors, 0 warnings. Population: eslint.config.mjs files '/.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' minus NEVER_LINTED (none of the 3 ignored); count read from the JSON output (3 entries); invariance: the config enables no type-aware linting (no parserOptions.project, eslint.config.mjs:326-328), so per-file verdicts on untouched files cannot move. Repo-wide pnpm lint is CI's."], "measurement_tables": {"probe_BASE_81cbffa259": "Tests 11 failed | 2 passed (13) — failed: {member_default, viewer_readonly, organization_admin, organization_admin_no_bypass, mcp_agent_data_write} × {list table, by-id org-Y row} + co-member control; passed: platform-admin control, sys_member control", "org_admin_of_X_reads": "BASE: all 5 seeded rows of each user-keyed sys_scim_ table (4 org-X people + u_y) and both rows (org_x, org_y) of each connection-keyed one, by-id org-Y row returned. FIX: only sys_scim_:a_x (its own) on the four user-keyed tables, [] on the three connection-keyed ones, by-id org-Y null — identical for organization_admin_no_bypass, and in the no-baseline shape (policy carried by organization_admin itself).", "census_28_managed_objects (one seeded row per org/user, each persona is org X's u_x; cell = engine find count [incl-orgY if org Y's row returned] | by-id read of org Y's row)": ["sys_account · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_account · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_api_key · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_api_key · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_device_code · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_device_code · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_invitation · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_invitation · platform_admin · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_jwks · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_jwks · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_member · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_member · platform_admin · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_oauth_access_token · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_oauth_access_token · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_application · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_oauth_application · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_client_assertion · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_client_assertion · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_client_resource · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_client_resource · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_consent · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_oauth_consent · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_refresh_token · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_oauth_refresh_token · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_resource · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_oauth_resource · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_organization · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_organization · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_connection_binding · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 0 | byId-orgY null · FIX-single: find 0 | byId-orgY null", "sys_scim_connection_binding · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_group · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 0 | byId-orgY null · FIX-single: find 0 | byId-orgY null", "sys_scim_group · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_group_member · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 0 | byId-orgY null · FIX-single: find 0 | byId-orgY null", "sys_scim_group_member · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_identity_tombstone · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_scim_identity_tombstone · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_projection_grant · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_scim_projection_grant · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_subject · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_scim_subject · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_scim_user · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_scim_user · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_session · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_session · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_sso_provider · 5 non-admin personas · BASE-isolated: DENIED PERMISSION_DENIED · FIX-isolated: DENIED PERMISSION_DENIED · FIX-single: DENIED PERMISSION_DENIED", "sys_sso_provider · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_team · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_team · platform_admin · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_team_member · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_team_member · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_two_factor · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_two_factor · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_user · 5 non-admin personas · BASE-isolated: find 1 | byId-orgY null · FIX-isolated: find 1 | byId-orgY null · FIX-single: find 1 | byId-orgY null", "sys_user · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_verification · 5 non-admin personas · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW", "sys_verification · platform_admin · BASE-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-isolated: find 2 incl-orgY | byId-orgY ROW · FIX-single: find 2 incl-orgY | byId-orgY ROW"], "census_note": "Only the sys_scim_ cells differ BASE→FIX (script check: 'non-scim cells changed BASE->FIX (isolated): []'). The harness was a temporary test file, run and deleted, never committed."}, "deviations": ["The probe was committed RED on the branch before the fix (81cbffa), as the dispatch required. The branch history carries a red commit, and HEAD is green.", "Attribution: commits end with the AGENTS.md model-free pair (Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF + Co-authored-by: Claude with the anthropic noreply address), not the harness's model-named Co-Authored-By. The PR body ends with the AGENTS.md session-URL footer, not the harness's 'Generated with' line. Per the role file the harness attribution yields.", "managed-object-write-denies.ts was not edited: H3 was measured, and the union adds no object today.", "origin/main was not merged. It advanced 2 commits (a0920b4 driver-turso, 4463966) after the branch point, and they are disjoint from this diff. CI's merge ref covers it.", "A comment-only touch outside the scim block: the count '15' was dropped from the deriveWallLessOrgAdmin docblock (18 policies before this change, 25 after). It sits in the claimed file.", "The first A1 ablation was a no-op (the tool refused an anchor/replacement overlap) and was redone. It is reported, not hidden."], "open_questions": [{"question": "Should org admins (or members) get an organization-scoped SCIM directory? This PR leaves self/none for everyone below platform admin, which is the only scope expressible without a schema change.", "options": ["A: keep self/none (this PR). SCIM stays a platform-admin surface, matching sys_scim_connection_credential's manage_platform_settings gate.", "B: add a tenant column (organization_id) to the SCIM projections so an _org policy and Layer 0 can bound them. This is an object/spec change that diverges from the upstream @better-auth/scim schema the adapter bridges.", "C: add an RLS membership resolver that resolves the caller organization's SCIM connection ids from sys_scim_connection_credential.organization_id, then grant org admins connection_id in current_user.RESOLVED_SET. No column is needed, but it adds a resolver and a membership key."], "recommendation": "A, until a named consumer pulls for it. On the axes: no product surface reads these tables under a user context today (no Setup nav, no docs); A adds no declaration the runtime does not enforce; B and C each add a capability with no measured pull. If a pull appears, C keeps the upstream schema untouched and B does not."}], "out_of_scope_findings": ["class: b · A managed object declared access: { default: 'private' } whose rows are live credentials is readable to non-admin principals: sys_verification (and sys_jwks, same mechanism). Contract text, sys-verification.object.ts:18-23: 'rows are LIVE one-time credentials … reading one is account takeover. Not covered by the wildcard '*' grant; admins retain access via the superuser bypass'. And member_default's own comment in default-permission-sets.ts: 'Tables without user_id (sys_verification, sys_jwks, empty sys_passkey) stay DENY for non-admins by design'. Measured at fc6ddb8 and unchanged at a3544ac (the census above): every non-admin shipped persona's engine find returned both seeded sys_verification rows, and a by-id read of another user's row returned it with the token column present (row keys: created_at, expires_at, id, identifier, updated_at, value); same for sys_jwks. The explicit per-object entry from denyWritesOnManagedObjects() reaches a private object, because resolveObjectPermission returns an explicit entry before the private check, and no row policy narrows it. REST reach NOT MEASURED: sys_verification declares apiMethods ['get']; sys_jwks declares apiEnabled false. Seam: spec:ObjectSchema access.default 'private' (platform-objects identity/sys-verification.object.ts:25, sys-jwks.object.ts:30) → runtime:plugin-security permission-evaluator.ts resolveObjectPermission (:154-155) fed by default-permission-sets.ts denyWritesOnManagedObjects(). Dedupe words: sys_verification private managed blanket read · denyWritesOnManagedObjects private object explicit grant · stay DENY for non-admins sys_verification sys_jwks · access private explicit entry resolveObjectPermission", "carrier: 承接者:无 · noted, not filed — sys_oauth_resource / sys_oauth_client_resource / sys_oauth_client_assertion sit on the same blanket with no row policy and are engine-readable to every shipped persona, but all declare apiEnabled: false, so no user-facing reach was demonstrated. Written in the PR's Acceptance notes.", "carrier: 承接者:无 · noted, not filed — applyManagedWriteDenies grants allowRead: true to any future managedBy 'better-auth' object with no row policy, so the class recurs by construction for the next identity table. No guard was added (new gates default to no). Written in the PR's Acceptance notes."], "pr_body_new": "Fixes #20001\n\nClause-②: no (narrowing)\n\n## What was wrong\n\nThe seven @better-auth/scim projection tables are on BETTER_AUTH_MANAGED_OBJECTS, so every shipped set that spreads denyWritesOnManagedObjects() grants an object-level read on them. None of the seven carries a tenant column (scripts/platform-object-tenancy-census.json: reach: out, tenantField: null for all seven), so the organization wall (Layer 0) is inert on them, and no shipped set declared a row policy for any of them. The file header's "org-wide readable" blanket therefore meant every organization's rows, for every principal below platform admin, organization admins included.\n\n## Measurement, recorded before the fix\n\nCommit 81cbffa259 adds the probe (scim-projection-row-scope.test.ts) on the unfixed tree. It uses the real SecurityPlugin + ObjectQL over SqlDriver, the isolated posture and the shipped sets. It went red: Tests 11 failed | 2 passed (13). Every persona of organization X read every row of all seven tables, including the rows organization Y's connection wrote. The failing personas were member_default, viewer_readonly, organization_admin, organization_admin_no_bypass, and mcp_agent_data_write acting for a member. The two controls passed: the platform admin reads both organizations' rows, and sys_member stays within organization X.\n\nA census over all 28 better-auth-managed objects was taken on the same harness, before and after the fix, on engine find plus a by-id read of the other organization's row. For the seven SCIM tables:\n\n| table | every non-admin shipped persona, before | after (isolated and single) | admin_full_access |\n|---|---|---|---|\n| sys_scim_user, sys_scim_subject, sys_scim_projection_grant, sys_scim_identity_tombstone | 2 rows (incl. org Y's) / by-id: row | 1 row (own) / by-id: null | 2 rows / row (unchanged) |\n| sys_scim_group, sys_scim_group_member, sys_scim_connection_binding | 2 rows (incl. org Y's) / by-id: row | 0 rows / by-id: null | 2 rows / row (unchanged) |\n\nOutside the seven tables, no cell of the census changed between before and after.\n\n## Fix\n\nThis uses the instrument the file header names: the per-object row scope, and ⛔ no edit of the blanket. A new scimProjectionRowScope() in default-permission-sets.ts returns seven select policies, spread into every shipped set that spreads the blanket and carries row-level security. Those sets are organization_admin (so also the derived organization_admin_no_bypass), member_default and viewer_readonly.\n\n- TABLE_self (user_id == current_user.id) goes on sys_scim_user, sys_scim_subject, sys_scim_projection_grant and sys_scim_identity_tombstone: a principal reads the rows about themselves.\n- TABLE_none (id == null, i.e. no row) goes on sys_scim_group, sys_scim_group_member and sys_scim_connection_binding, which name no user.\n- admin_full_access is unchanged. Its wildcard superuser read bypass skips Layer 1 on a better-auth-managed object, so the platform admin still reads every row. SCIM is administered by the platform admin: sys_scim_connection_credential is gated on manage_platform_settings.\n- The MCP write ceiling (mcp_agent_data_write) also holds the blanket. It carries no row-level security by design (ADR-0090 D10), and its bound is the delegating user's sets, which now carry the scope. That is measured, not assumed.\n- Why every set carries the scope and not only the baseline: with fallbackPermissionSet: null no platform baseline is composed, and a set with no policy for an object leaves that object unfiltered. The probe's no-baseline shape is what catches this, as the ablation below shows.\n\nWhy there is no organization-scoped read, for admins or members.** None is expressible without a schema change, and the dispatch forbids one:\n\n- an _org predicate needs a column to compare, and only sys_scim_connection_credential knows a connection's organization; a predicate cannot traverse connection_id (ADR-0055);\n- an admin admission shaped like sys_invitation_org_admin (id != null) relies on Layer 0 for the organization bound, and Layer 0 is inert here, so it would re-open every organization's rows;\n- user_id in current_user.org_user_ids is not an organization scope for this data. A projection belongs to the connection that wrote it, so a user in two organizations would expose one organization's identity-provider record to the other.\n\nBreaking: an organization admin no longer reads their own organization's SCIM users or groups; they read only the rows about themselves. We found no product surface that reads these tables under a user context. There is no Setup nav entry and no docs page for them. SCIM provisioning reads and writes through better-auth's adapter under system context, which no row policy reaches. The changeset states this, with the remedy.\n\nA comment-only touch in the same file: the deriveWallLessOrgAdmin docblock said "the 15 identity RLS carve-outs" (18 before this change, 25 after). The number is dropped rather than re-counted.\n\n## Tests\n\nThe code tree is identical between 173a6307c9 and HEAD a3544ace60. The last commit adds only .changeset/20001-scim-row-scope.md (git diff --stat 173a6307c9 a3544ace60: 1 file, 48 insertions).\n\n- New: packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts, 36 tests over three deployment shapes:\n - isolated with the platform baseline composed (the stock shape);\n - isolated with no baseline, where each set stands on its own;\n - single, where the policies carry no tenant token and so survive the ADR-0105 D3 strip.\n\n Each shipped persona is asserted by table: its own rows admitted, the other organization's rows absent (list and by-id). There are three controls: the platform admin reads both organizations' rows, a co-member reads its own rows, and sys_member is unchanged. Result: Tests 36 passed (36).\n- Re-judged, not deleted: objects/rbac-objects.test.ts. Its exact member_default policy-name list gains the seven names, with a note.\n- @objectstack/plugin-security, full suite at 173a6307c9: Test Files 129 passed (129) · Tests 2537 passed (2537).\n- @objectstack/plugin-security typecheck, all three tsc programs plus check:test-typecheck: exit 0, "0 file(s) / 0 error(s)".\n- @objectstack/plugin-auth, full suite: Test Files 114 passed (114) · Tests 2439 passed (2439). Its vitest config aliases @objectstack/plugin-security to source.\n- Other packages whose tests name member_default: qa/dogfood (21 files), rest (5), runtime (4), spec (4), plugin-hono-server (3), plugin-sharing (2), objectql (2), cli (2), and one file each in verify, service-storage, service-automation, plugin-audit and core. These were not run locally and are declared to CI. None of them pins the shipped sets' policy names or counts, and none reads a sys_scim_* table (grep).\n\n### Ablation of every negative pin\n\nThe fix was committed before any ablation. Each ablation mutated the committed file through scripts/ablation-replace.mjs, ran the probe, then restored. Every restore was proven as blob 2ae6cfa3ab13 == HEAD with git diff HEAD empty. The subject is imported from src, so there is no dist leg.\n\n| ablation | result |\n|---|---|\n| A1 remove the spread from member_default | 13 failed: member_default (find + by-id) in all 3 shapes, the MCP agent in both composed shapes, and the co-member control in all 3 |\n| A2 remove the spread from organization_admin | 4 failed: organization_admin and organization_admin_no_bypass, in the no-baseline shape only. The stock shape stays green through member_default, which is why the no-baseline shape exists |\n| A3 remove the spread from viewer_readonly | 2 failed: viewer_readonly, in the no-baseline shape only |\n| A4 delete the sys_scim_user_self policy | 31 failed: every persona in every shape, plus the co-member control. The 5 remaining are the platform-admin and sys_member controls |\n| A5 delete the sys_scim_group_none policy | 28 failed: every persona in every shape. The co-member control stays green because it reads only the user-keyed tables |\n\nThe first A1 attempt was a no-op. The replacement was a substring of the anchor, ablation-replace refused it, and the test never ran. The attempt was redone with a distinct marker, and the result above comes from that second run.\n\n### Gates at HEAD a3544ace60\n\n- node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 62 gates, all run. --ran reconciliation: 62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED.\n- 59 exited 0 on the first pass, including check-adr-0087-registration (not-required (no-migration-prescription) accepted) and check-changeset-no-major. The first pass of pnpm check:i18n exited 3 because its build prerequisite was missing. After that closure was built it exited 0 ("all bundles in sync").\n- NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Both exit 3 with PREREQUISITE NOT MET, because they need every package's dist/ (a whole-repo build), and they are declared to CI.\n- GITHUB_TOKEN=\"$GH_TOKEN\" node scripts/check-issue-citations.mjs: exit 0, 4 citations resolve.\n- ESLint, narrowed to the three changed TypeScript files (--no-inline-config --format json): 3 files, 0 errors, 0 warnings.\n - Population: the config's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED, and none of the three files is ignored.\n - Invariance: the config enables no type-aware linting (no parserOptions.project), so this diff cannot move any untouched file's verdict.\n - The repo-wide pnpm lint is CI's.\n\n## Acceptance notes\n\n- The class recurs by construction. At kernel:ready, applyManagedWriteDenies (managed-object-write-denies.ts) gives MANAGED_DENY_ENTRY (allowRead: true, writes denied) to every registered managedBy: 'better-auth' object that a target set does not name. So the next better-auth-managed table arrives readable by every member with no row scope, unless its author also adds a policy here. Today the union adds nothing beyond this baseline, because default-permission-sets.test.ts pins the two lists as equal. No guard is added here: new gates default to no. Carrier: whoever adds the next managed object (承接者:无).\n- sys_oauth_resource, sys_oauth_client_resource and sys_oauth_client_assertion are on the same blanket with no row policy, and every shipped persona reads them at engine level. All three declare apiEnabled: false, so no REST surface was measured. Noted, not filed.\n- Open, not decided here: an organization-scoped SCIM directory for org admins (or members). It needs a tenant column on the projections or a resolved connection-to-organization set. It is a capability to decide and build, and no pull for it was measured.\n"}


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20023 · head a3544ace60 · domain:services seat · 2026-09-24T20:07Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main (44639665ee), ⛔ not against the report's own account.

    Checklist

    • PR shape. Draft, targeting main. The first line is Fixes #20001, and Clause-②: no (narrowing) starts its own line. No other closing keyword.

    • Scope. 4 files, +510 / −1:

      • default-permission-sets.ts: the new scimProjectionRowScope(), spread into organization_admin, member_default and viewer_readonly; plus one comment word;
      • a new 36-case test file;
      • the member_default name pin in rbac-objects.test.ts, re-judged with a note;
      • .changeset/20001-scim-row-scope.md (@objectstack/plugin-security minor).

      No blanket edit, no managed-object-write-denies.ts, no object definition, no packages/spec. check-governed-merges: NOT governed, 511 lines. It merges clean over origin/main (after fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012).

    • Diff, read line by line.

      • The instrument is the one the file header names: a per-object row scope.
      • _self (user_id == current_user.id) goes on the four tables that name a user. _none (id == null, no row, the mirror of sys_invitation_org_admin's id != null) goes on the three that name none.
      • It is spread into every shipped set that holds the blanket and carries row-level security, so the no-baseline shape is covered as well as the stock one.
      • admin_full_access keeps every row through its superuser bypass. The MCP ceiling is bounded by the delegator's sets, which was measured.
    • Tests.

      • The probe was committed red first (81cbffa259): 11 red. Every non-platform persona of org X read org Y's rows, on list and by-id.
      • After the fix, 36/36 green over three deployment shapes. Ablations A1–A5 are all red, and every restore is blob-identical.
      • plugin-security 2537 and plugin-auth 2439 are green.
      • The 28-object census changes no cell outside the seven SCIM tables.
    • Changeset prose. I read it against the diff; every claim matches, including the remedy and the 「Unchanged」 list.

    The product trade-off (triage point 4), recorded on the card before landing.

    • Org admins (and members) lose sight of their own organization's SCIM rows; they keep only the rows about themselves. They never had a correct organization-scoped read: it was every organization's rows.
    • An organization-scoped read is not expressible without a schema change. There is no tenant column, and a predicate cannot traverse connection_id (ADR-0055). An id != null admission would re-open every organization's rows, because Layer 0 is inert on these tables.
    • So there is no expressible alternative to choose between. The dev's option A is the only scope available. B (a tenant column) and C (a connection-set resolver) are new capabilities with no measured consumer: no Setup nav, no docs, and provisioning runs under system context.
    • Under the startup-scope axis this is not a decision-box item. An organization-scoped SCIM directory gets a card when a named consumer pulls for it. The BREAKING consequence goes into the round report for the maintainer to read.

    Out-of-scope findings, one line each:

    Landing. No Clause-② limb is hit: no packages/spec/src, and the declaration is a narrowing whose changeset shape I checked (minor, !, BREAKING, remedy, adr-0087 not-required (no-migration-prescription)). The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR waits on every check going green, then goes ready and auto-merge.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T20:39Z


    Generated by Claude Code

  6. added a commit that references this issue on Sep 28, 2026
    26550c6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions