Repository navigation
Commit e3948ae
fix(platform-objects): name an organization membership, a two-factor enrollment and the SCIM join rows by the people and groups they point at (#30520)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg3V8xjevigoU3sh2gDhRorg)_
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
## 维护者速读
- 改了什么:组织成员、两步验证、SCIM 组成员、SCIM 投影授权四类记录的标题,从拼接原始 id 改为经 lookup
读一跳的名字(人名为空时用邮箱;SCIM 用户名与组显示名),写法与业务单元成员、团队成员一致;授权解析器读成员表的三处改为只取决策所需的列。
- 为什么改:这四类记录的页头、列表标题列和被引用处都显示原始
id,管理员看不出是谁;而成员标题一旦读一跳,不投影的授权读会让每个已登录请求多读两次用户表,所以同一改动里把这三处读收窄。
- 风险与回滚:只改平台自带对象的声明和一处内部读投影,不存列、不迁移;标题按读者权限读,读不到时为空而不是
id;认证前奏的读次数和往返次数保持原钉数(8 读 4 往返)。回滚即撤本 PR。
- 先看:`packages/core/src/security/resolve-authz-context.ts` 的
`MEMBER_ROW_COLUMNS` 与三处 `sys_member`
读;`packages/platform-objects/src/identity/sys-member.object.ts` 的
`display_title`;`resolve-authz-context.grant-projection.test.ts` 新增的成员投影
describe。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/auth-prelude-query-count.pin.test.ts`,预期 8 条全绿;在只改标题、不收窄读的树上其中 6
条红(sys_user 2 读变 4 读)。
- 需要你做的:无,席位落地。
Fixes #30454
Clause-②: no — no exported member, accepted value or authorable key
changes: four shipped platform objects' computed `display_title` now
serves names instead of ids, and the authorization resolver's internal
`sys_member` reads name their columns
Metadata-protocol: none — shipped platform metadata and an internal
read, not what a metadata author writes
Skills-docs: none — `git grep -e sys_member -e sys_two_factor -e
sys_scim_group_member -e sys_scim_projection_grant -- 'skills/**'` hits
`skills/objectstack-automation/SKILL.md:168` and
`skills/objectstack-automation/evals/flows-triggers-approvals.json:194`,
both about a system-context write to `sys_member` being refused, which
stays true
## What changes
**1. Four record titles read names one hop through their lookups**
(ADR-0079 addendum 2026-10-18), in the form `sys_business_unit_member`
and `sys_team_member` use. A person is
`isBlank(trim(record.user_id.name)) ? record.user_id.email :
record.user_id.name`. Each hop is read under the reader's authority; an
unreadable or missing target leaves the title null, never an id. Each
title keeps its shape, and the fields' labels and descriptions are
unchanged, so no translation bundle moves.
| Object | Was | Now |
|---|---|---|
| `sys_member` | `USER_ID (role)` | `Ada Lovelace (admin)`; `Ada
Lovelace` with no role |
| `sys_two_factor` | `Two-factor for USER_ID` | `Two-factor for Ada
Lovelace` |
| `sys_scim_projection_grant` | `role → USER_ID` | `billing_admin → Ada
Lovelace` |
| `sys_scim_group_member` | `SCIM_USER_ID in GROUP_ID` |
`ada.lovelace@idp.example in Operations` |
For the SCIM join, each hop names its target's own title pointer:
`sys_scim_user.nameField` is `user_name` and `sys_scim_group.nameField`
is `display_name`. Both are required stored text, so there is no second
hop and no null guard. The pins assert this.
**2. The authorization resolver's `sys_member` reads name their
columns** (`packages/core/src/security/resolve-authz-context.ts`; seat
ruling A, Claim amendment 6658119458). With no projection, a read
computes every formula field. `sys_member`'s title now reads the person,
so each unprojected read added a batched `sys_user` read: two per
authenticated request, measured by
`auth-prelude-query-count.pin.test.ts` (`sys_user` 2 → 4, 5 of 7 red).
Each read now names what its caller reads, as `GRANT_ROW_COLUMNS`
already does for `sys_user_permission_set`:
- the caller's memberships (`{ user_id }`, Leg 1): `MEMBER_ROW_COLUMNS`
= `organization_id`, `role`, `valid_from`, `valid_until`. These are read
by `accessible_org_ids` (§3b), `heldPositionNames` and
`nextGrantValidityBoundary`;
- the active organization's first page (`{ organization_id }`, Leg 1):
`ORG_MEMBER_PAGE_COLUMNS` = `id`, `user_id`, the columns its completion
pages already ask for. §5 keeps only `user_id`;
- `countPositionHolders`: `MEMBER_HOLDER_ROW_COLUMNS` = `user_id` plus
the four above.
`sys_member` does not declare `valid_from` or `valid_until` today. They
are named on purpose. Measured on the real engine (ObjectQL +
driver-sql), a projection that names an undeclared column leaves it out
with no error: `['organization_id','role','valid_from','valid_until']`
returns `{ organization_id, role }`. So nothing changes today, and the
window is read as soon as the object declares those columns. That is
what §3's comment promises ("correct the moment they do"). Leaving them
out would silently drop that promise. Lines 515–660 (draft PR #30287)
are untouched.
**Changeset:** `@objectstack/platform-objects` and `@objectstack/core`,
patch, both declaration lines.
## Tests
- `identity-display-title.test.ts` (the shared title harness): the four
cases now carry the related rows that the read path preloads, are titled
by names, and still equal the text of the retired template. They never
contain an id. A new top-level `describe` pins the following for each
object:
- every path the formula reads, with each lookup read only through its
hop;
- the email fallback when the name is blank;
- a null title for every spelling of an unresolved lookup;
- for SCIM, that each hop names its target's `nameField` and that the
field is required text.
- `resolve-authz-context.grant-projection.test.ts` (the shared
projection harness): a new top-level `describe` pins the two prelude
membership reads' column sets. It also pins that the envelope resolved
over projected rows equals the envelope over full rows. That equality is
checked for seven membership states: owner, another organization,
external, two roles, `valid_until` past, `valid_from` future, and no
active organization. The states resolve at least five distinct
envelopes. It further pins that `countPositionHolders` counts the same
from projected rows and reads `user_id` beside the four columns. The
first case's "no other read names any" is narrowed to exclude
`sys_member`.
- `auth-prelude-query-count.pin.test.ts`: a new case shows that the
shipped `sys_member` title reads `record.user_id.name`, that a full read
computes it (`Prelude (owner)`), and that the prelude still makes 8
reads in 4 round trips. The pinned counts are unchanged.
## Acceptance notes
- **Related lists (the order's measurement).** No Setup page derives a
related list from these four objects' lookups. Both pages that list
`sys_member` are authored `kind: 'full'` pages, and they name their own
columns: `sys_organization_detail` (Members, `user_id, role,
created_at`) and `sys_user_detail` (Organizations, `organization_id,
role, created_at`). `sys_user_detail` does not list `sys_two_factor`. No
Setup navigation reaches `sys_scim_user` or `sys_scim_group`. So no
`relatedListColumns` is added.
- **Other `sys_member` readers that name no columns** now pay the
title's hop: one batched `sys_user` read per such `find` (measured `[1,
0]`, unprojected vs projected). The prelude read is the per-request one
and is fixed here. Readers elsewhere (`git grep "'sys_member'"` lists
plugin-auth, plugin-sharing, plugin-approvals, service-messaging and
others) were not swept for projections. The plugin-auth, plugin-sharing
and plugin-approvals suites are green on this head. Noted, not filed ·
carrier: seat.
- **Reader-dependent titles.** A reader who may read the row but not its
target sees an empty title. That is the rule `sys_business_unit_member`
follows. `sys_scim_group` and `sys_scim_group_member` both default to
the row scope `id == null`, so without an explicit grant a reader sees
neither the rows nor their titles.
- `sys_scim_subject` (`nameField: 'user_id'`, a bare lookup pointer) is
outside this card's four objects and unchanged.
## Verification
Readings on head `2d857e278e` (the fix's six commits plus one merge of
`main` at `f506b79d85`, which brings in #30452) unless a leg names its
own commit.
<details><summary>Red on main, green, before and after the projection,
ablation</summary>
```
red (3bb8bb0: the new pins over main's object files):
identity-display-title.test.ts -> Tests 16 failed | 25 passed (41)
green (671545a): same file -> Tests 41 passed (41)
authz suites BEFORE the projection (main's resolve-authz-context.ts restored over the tree, core rebuilt,
dist preflight --absent MEMBER_HOLDER_ROW_COLUMNS ok; tests at 5b2ba4e):
core src/security -> Tests 2 failed | 1211 passed (1213) (the two column-set pins)
plugin-auth auth-prelude-query-count.pin -> Tests 6 failed | 2 passed (8) (sys_user 2 -> 4)
restored: git checkout HEAD; blob 2ff6828 == HEAD, git status clean
authz suites AFTER (5b2ba4e, core rebuilt, preflight present):
core src/security -> Test Files 62 passed (62), Tests 1213 passed (1213)
plugin-auth auth-prelude-query-count.pin -> Tests 8 passed (8)
ablation 1 (5b2ba4e): MEMBER_ROW_COLUMNS without 'role'
anchor x1 -> x0, replacement x0 -> x1 -> grant-projection: Tests 27 failed | 8 passed (35); restored, blob == HEAD
ablation 2 (5b2ba4e): sys_member's hop expression -> the retired id splice
anchor x1 -> x0, replacement x0 -> x1 -> identity-display-title: Tests 5 failed | 36 passed (41); restored, blob == HEAD
```
</details>
<details><summary>The titles a read serves, through the real engine
(one-off measurement, not committed)</summary>
ObjectQL + SqlDriver (better-sqlite3 in memory) over the built `dist/`
at `671545ad`, registering `SysUser`, `SysOrganization`, the four
objects and their SCIM targets, with a reader-scoping middleware (the
setup of `plugin-security/src/business-unit-member-title.test.ts`):
```
PASS sys_member / sys_two_factor / sys_scim_group_member / sys_scim_projection_grant registered nameField: "display_title"
PASS sys_member write response title: "Ada Lovelace (admin)"
PASS sys_member findOne title: "Ada Lovelace (admin)"
PASS sys_member servedRecordTitle: "Ada Lovelace (admin)"
PASS sys_member projected find: [{"display_title":"Ada Lovelace (admin)"}]
PASS sys_member blank name -> email (role defaults to member on write): "grace@example.com (member)"
PASS sys_member follows rename: "Ada King (admin)"
PASS sys_member reader may not read user: title null: null
PASS sys_member no hidden name leaks: false
PASS sys_member reader may read user: "Ada Lovelace (admin)"
PASS sys_member missing user: null: null
PASS sys_member hop reads of sys_user: unprojected find / stored-column find: [1,0]
PASS sys_two_factor write response title: "Two-factor for Ada Lovelace"
PASS sys_two_factor blank name -> email: "Two-factor for grace@example.com"
PASS sys_scim_group_member write response title: "ada.lovelace@idp.example in Operations"
PASS sys_scim_group_member reader may read neither: title null: null
PASS sys_scim_group_member reader may read both: "ada.lovelace@idp.example in Operations"
PASS sys_scim_projection_grant write response title: "billing_admin → Ada Lovelace"
PASS (each of the four) adds no stored column
MEASURE: all checks hold
```
Probe on the same engine: `find('sys_member', { fields:
['organization_id','role','valid_from','valid_until'] })` returns
`[{"organization_id":"org_default","role":"admin"}]`; an undeclared
projected column is left out, not refused.
</details>
<details><summary>Suites, typecheck, lint</summary>
```
after the merge of main (2d857e2, whole workspace rebuilt: turbo build 119/119):
@objectstack/platform-objects Tests 1537 passed (1537)
@objectstack/core Tests 3099 passed (3099)
@objectstack/plugin-auth Tests 3975 passed | 10 skipped (3985)
@objectstack/plugin-security Tests 5357 passed | 45 skipped (5402)
@objectstack/plugin-sharing Tests 1405 passed (1405)
@objectstack/plugin-approvals Tests 1469 passed (1469)
typecheck (5b2ba4e): platform-objects exit 0, core exit 0, plugin-auth exit 0
pnpm exec eslint --no-inline-config on the eight changed .ts files: exit 0
```
</details>
<details><summary>Gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `2d857e278e`: 64 commands; each run with
its exit code recorded before any pipe. `--ran`: 64 derived, 64 run, 0
NOT-MEASURED, 0 UNRUN.
- All 64 exit 0, including `pnpm check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:dts-closure` after a
whole-workspace build, `pnpm check:i18n`, `check:authz-resolver` and
`check:engine-double-contract`.
- `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 0
after fetching its control commit into this shallow clone.
- The four artifact-roster gates the derivation flags as keeping their
roster in a directory this diff touches were also run:
`check:auth-mount-ledger`, `check:changeset-fixed`,
`check:error-code-casing` and `check:filter-alias-parity`, all exit 0.
</details>
Dogfood: not owed (no `!` in the title, no `(narrowing)` arm).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CZdTcFgv9MUk1tpKUo)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d5f3bc5 commit e3948ae
9 files changed
Lines changed: 325 additions & 45 deletions
File tree
- .changeset
- packages
- core/src/security
- platform-objects/src/identity
- plugins/plugin-auth/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
| 33 | + | |
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| |||
173 | 173 | | |
174 | 174 | | |
175 | 175 | | |
176 | | - | |
| 176 | + | |
177 | 177 | | |
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
181 | | - | |
182 | | - | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
183 | 184 | | |
184 | 185 | | |
185 | 186 | | |
| |||
209 | 210 | | |
210 | 211 | | |
211 | 212 | | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
954 | 954 | | |
955 | 955 | | |
956 | 956 | | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
957 | 980 | | |
958 | 981 | | |
959 | 982 | | |
| |||
969 | 992 | | |
970 | 993 | | |
971 | 994 | | |
972 | | - | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
973 | 998 | | |
974 | 999 | | |
975 | 1000 | | |
| |||
1526 | 1551 | | |
1527 | 1552 | | |
1528 | 1553 | | |
1529 | | - | |
| 1554 | + | |
1530 | 1555 | | |
1531 | 1556 | | |
1532 | 1557 | | |
| |||
1858 | 1883 | | |
1859 | 1884 | | |
1860 | 1885 | | |
1861 | | - | |
| 1886 | + | |
1862 | 1887 | | |
1863 | | - | |
| 1888 | + | |
1864 | 1889 | | |
1865 | 1890 | | |
1866 | 1891 | | |
| |||
0 commit comments