From 592b434dbd4077db0d69e5883dfb6fd184a96ae0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:23:41 +0000 Subject: [PATCH 1/5] fix(platform-objects): withhold the sign-in phone fields from org peers in the user directory phone_number and phone_number_verified move into the sys_user Admin field group, which the shipped non-admin permission sets mark readable: false and the admin sets keep. An org member reading the user directory is served a colleague's name and email, no longer their sign-in phone number. Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../src/identity/sys-user.object.ts | 19 +++++++++++-- .../src/identity-admin-field-group.test.ts | 28 +++++++++++++++++++ .../src/objects/default-permission-sets.ts | 10 +++++-- ...tity-admin-fields-org-peer.dogfood.test.ts | 22 +++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 3c402b0a022..67491e481b6 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -916,13 +916,28 @@ export const SysUser = ObjectSchema.create({ // #2766 V1.5 — phone-number sign-in identifier (better-auth phoneNumber // plugin, mapped via buildPhoneNumberPluginSchema). Unique when present; // null for email-only accounts. Written through better-auth, not CRUD. + // + // Declared in the `Admin` group, with `phone_number_verified` below, on + // purpose: a sign-in identifier is account-administration data, not + // directory data. The group is the declaration the shipped permission sets + // read (`identityAdminFieldSecurity` in plugin-security's + // `default-permission-sets.ts`): the non-admin sets (`member_default`, + // `viewer_readonly`, `external_default`) mark every group field + // `readable: false` and the admin sets keep it, so an org member reading + // the user directory is served a colleague's name and email but not their + // sign-in phone number. Field-level read security is row-blind (ADR-0066, + // 2026-10-26 note), so the member's OWN row on the generic data API is + // withheld it too; the holder's own number is read through auth context + // (better-auth's session user, which returns both phone fields), as every + // other group field is. Moving either field out of the group serves it to + // every org peer again. phone_number: Field.text({ label: 'Phone Number', required: false, readonly: true, searchable: true, maxLength: 32, - group: 'Account', + group: 'Admin', description: 'Sign-in phone number (E.164 recommended). Unique per user; managed by ' + 'better-auth when the phoneNumber plugin is enabled.', @@ -932,7 +947,7 @@ export const SysUser = ObjectSchema.create({ label: 'Phone Verified', defaultValue: false, readonly: true, - group: 'Account', + group: 'Admin', // withheld from org peers with `phone_number` (see the note above it) description: 'Whether the phone number has been verified (OTP verification requires ' + 'SMS infrastructure; false until that ships). System-managed.', diff --git a/packages/plugins/plugin-security/src/identity-admin-field-group.test.ts b/packages/plugins/plugin-security/src/identity-admin-field-group.test.ts index 6f7f922a7f9..525ed530cb0 100644 --- a/packages/plugins/plugin-security/src/identity-admin-field-group.test.ts +++ b/packages/plugins/plugin-security/src/identity-admin-field-group.test.ts @@ -39,6 +39,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; import { SysUser } from '@objectstack/platform-objects/identity'; import { PermissionEvaluator } from './permission-evaluator.js'; +import { FieldMasker } from './field-masker.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; const IDENTITY = 'sys_user'; @@ -58,6 +59,12 @@ const DECLARED_ADMIN_GROUP: string[] = Object.entries(SysUser.fields as Record { + // Moving either back out of the group serves a colleague's sign-in phone + // number to every org member through the user directory. + for (const field of SIGN_IN_PHONE_FIELDS) expect(DECLARED_ADMIN_GROUP, field).toContain(field); + }); + it.each(WITHHOLDING_SETS)('%s withholds exactly the declared group (readable: false), and nothing else on the identity object', (name) => { const entries = identityFieldEntries(setByName(name)); expect(Object.keys(entries).sort()).toEqual(DECLARED_ADMIN_GROUP); @@ -144,6 +157,21 @@ describe('[#21237] the identity object Admin group — withheld from org peers, for (const field of DECLARED_ADMIN_GROUP) expect(perms[field]?.readable, field).toBe(false); }); + it('an org member is withheld the sign-in phone fields, and the masker strips them from a colleague row it serves', () => { + const perms = mask(['member_default']); + for (const field of SIGN_IN_PHONE_FIELDS) expect(perms[field], field).toEqual({ readable: false, editable: false }); + const colleague = { id: 'u2', name: 'Colleague', email: 'c@example.test', phone_number: '+15550000002', phone_number_verified: true }; + const served = new FieldMasker().maskResults([colleague], perms, IDENTITY) as Record[]; + expect(served[0]).toEqual({ id: 'u2', name: 'Colleague', email: 'c@example.test' }); + }); + + it.each(ADMIN_SETS)('an admin holding %s keeps the sign-in phone fields, and the masker serves them', (name) => { + const perms = mask(['member_default', name]); + for (const field of SIGN_IN_PHONE_FIELDS) expect(perms[field], field).toEqual({ readable: true, editable: true }); + const colleague = { id: 'u2', name: 'Colleague', phone_number: '+15550000002', phone_number_verified: true }; + expect(new FieldMasker().maskResults([colleague], perms, IDENTITY)).toEqual([colleague]); + }); + it.each(ADMIN_SETS)('an admin holding %s on top of the baseline keeps every group field, readable and editable', (name) => { const perms = mask(['member_default', name]); for (const field of DECLARED_ADMIN_GROUP) { diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index ad0cf934fdf..f5fd7d52890 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -278,11 +278,15 @@ function identityAdminGroupFields(): string[] { * member's user-context write naming one is refused by the field-level write * gate. Every reader of these fields on a member's OWN row reads through system * or auth context (the auth manager's gates and stamps, the better-auth - * adapter, the authorization resolver's `ai_seat` synthesis), which no + * adapter and its session user — which carries the holder's own sign-in phone + * number — the authorization resolver's `ai_seat` synthesis), which no * permission set reaches. The deactivation flag is declared OUTSIDE the group * on purpose (#21237): it is directory status that every user picker filters - * on, so withholding it would refuse each member's picker query. A fresh - * object per set, like `denyWritesOnManagedObjects()`. + * on, so withholding it would refuse each member's picker query. The sign-in + * phone number and its verified flag are declared INSIDE it on purpose: a + * sign-in identifier is not directory data, and a user picker's `$search` + * stops scanning it for a member by the queryable-field narrowing, not by a + * refusal. A fresh object per set, like `denyWritesOnManagedObjects()`. */ const identityAdminFieldSecurity = ( posture: 'withhold' | 'keep', diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index ac4ce79fe7e..afafcf6d624 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -63,7 +63,13 @@ const SYNTHETIC: Record = { last_login_ip: 'SYNTHETIC-ADDRESS-ONE', failed_login_count: 2, ban_reason: 'SYNTHETICREASON21237', + // The better-auth sign-in identifier, declared in the group: a synthetic + // number in the reserved fictional 555-01xx range. + phone_number: '+12025550137', + phone_number_verified: true, }; +/** The sign-in phone fields — account administration data, not directory data. */ +const SIGN_IN_PHONE = ['phone_number', 'phone_number_verified']; const SYNTHETIC_NEXT: Record = { last_login_ip: 'SYNTHETIC-ADDRESS-TWO', failed_login_count: 3 }; /** Object-level activity read, granted to the member for the activity door. */ @@ -225,6 +231,22 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a expect(servedGroup(row!)).toEqual([]); }); + it('a member directory read projecting the sign-in phone is served the colleague name and email without it', async () => { + const res = await stack.apiAs(token.member, 'GET', '/data/sys_user?fields=name,email,phone_number,phone_number_verified&$top=100'); + expect(res.status).toBe(200); + const row = rowsOf(await res.json()).find((r) => String(r.id) === uid.colleague); + expect(row, 'colleague listed').toBeTruthy(); + expect(row).toHaveProperty('name'); + expect(row).toHaveProperty('email'); + for (const field of SIGN_IN_PHONE) expect(row, field).not.toHaveProperty(field); + }); + + it.each(['owner', 'admin'])('the %s reads the colleague sign-in phone fields as stored', async (who) => { + const { status, row } = await readRow(who, 'colleague'); + expect(status).toBe(200); + for (const field of SIGN_IN_PHONE) expect(row[field], field).toEqual(SYNTHETIC[field]); + }); + it('through the activity door the member is served the colleague rows with no group key in any recorded change', async () => { const { status, rows } = await activityRows('member', 'colleague'); expect(status).toBe(200); From b99fb8cc734a6c9921f6b17168cbbe73595039e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:34:28 +0000 Subject: [PATCH 2/5] chore(changeset): user directory sign-in phone fields Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../user-directory-sign-in-phone-fields.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/user-directory-sign-in-phone-fields.md diff --git a/.changeset/user-directory-sign-in-phone-fields.md b/.changeset/user-directory-sign-in-phone-fields.md new file mode 100644 index 00000000000..40ebd123db1 --- /dev/null +++ b/.changeset/user-directory-sign-in-phone-fields.md @@ -0,0 +1,17 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): the user directory no longer returns another user's sign-in phone number to non-admin readers + +Clause-②: no +Metadata-protocol: none + +`sys_user.phone_number` and `sys_user.phone_number_verified` are the better-auth phone sign-in identifier and its verified flag — account administration data, not directory data. Both are now declared in the identity object's `Admin` field group, which the shipped permission sets already treat as field-level security: the non-admin sets (`member_default`, `viewer_readonly`, `external_default`) mark every field of that group `readable: false`, and the admin sets (`admin_full_access`, `organization_admin` and its no-bypass variant) keep it. + +- **Org members** reading the user directory (`GET /api/v1/data/sys_user`, a record read, the Setup → Users list) are served each colleague's name, email, image and deactivation flag as before, without the two phone fields. A user picker's `$search` stops scanning the phone column for them; a filter or sort a member writes on either field is refused like any other withheld field. +- **Platform admins and organization admins** read and manage both fields exactly as before. +- **A member's own row.** Field-level read security is row-blind, so the generic data API withholds the member's own phone fields too. The signed-in user's own number is read from the auth session (better-auth's session user carries both phone fields when the phone-number sign-in plugin is enabled); system-context readers (sign-in, SMS delivery, the admin import) are unaffected. +- **Field group.** Wherever a form lays fields out by their declared group, the two fields now sit in `Admin` instead of `Account`. + +No configuration change is needed. A deployment that wants members to keep reading colleagues' phone numbers grants a permission set naming `sys_user.phone_number` (and `sys_user.phone_number_verified`) with `readable: true`. From a195fe983106c961a97e302a7511fa7e52dff007 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:47:00 +0000 Subject: [PATCH 3/5] test(dogfood): project the record id in the member directory read Claude-Session: https://claude.ai/code/session_017xRBcUoS2G4jgqRskpExS7 Co-authored-by: Claude --- .../dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index afafcf6d624..8c7304b4b83 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -232,7 +232,7 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a }); it('a member directory read projecting the sign-in phone is served the colleague name and email without it', async () => { - const res = await stack.apiAs(token.member, 'GET', '/data/sys_user?fields=name,email,phone_number,phone_number_verified&$top=100'); + const res = await stack.apiAs(token.member, 'GET', '/data/sys_user?fields=id,name,email,phone_number,phone_number_verified&$top=100'); expect(res.status).toBe(200); const row = rowsOf(await res.json()).find((r) => String(r.id) === uid.colleague); expect(row, 'colleague listed').toBeTruthy(); From 99bfe6d4e0846e9e3840f3f7309d8dd0b444ea7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:42:22 +0000 Subject: [PATCH 4/5] chore(changeset): declare the phone-field withhold as an accept-set narrowing Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../user-directory-sign-in-phone-fields.md | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.changeset/user-directory-sign-in-phone-fields.md b/.changeset/user-directory-sign-in-phone-fields.md index 40ebd123db1..cb4303f1b24 100644 --- a/.changeset/user-directory-sign-in-phone-fields.md +++ b/.changeset/user-directory-sign-in-phone-fields.md @@ -4,14 +4,32 @@ fix(platform-objects): the user directory no longer returns another user's sign-in phone number to non-admin readers -Clause-②: no +Clause-②: no (narrowing) Metadata-protocol: none + + +**BREAKING** (an accept-set narrowing), shipped as `patch`: a non-protocol narrowing takes `patch` and keeps this banner and its ADR-0087 disposition. + `sys_user.phone_number` and `sys_user.phone_number_verified` are the better-auth phone sign-in identifier and its verified flag — account administration data, not directory data. Both are now declared in the identity object's `Admin` field group, which the shipped permission sets already treat as field-level security: the non-admin sets (`member_default`, `viewer_readonly`, `external_default`) mark every field of that group `readable: false`, and the admin sets (`admin_full_access`, `organization_admin` and its no-bypass variant) keep it. -- **Org members** reading the user directory (`GET /api/v1/data/sys_user`, a record read, the Setup → Users list) are served each colleague's name, email, image and deactivation flag as before, without the two phone fields. A user picker's `$search` stops scanning the phone column for them; a filter or sort a member writes on either field is refused like any other withheld field. -- **Platform admins and organization admins** read and manage both fields exactly as before. -- **A member's own row.** Field-level read security is row-blind, so the generic data API withholds the member's own phone fields too. The signed-in user's own number is read from the auth session (better-auth's session user carries both phone fields when the phone-number sign-in plugin is enabled); system-context readers (sign-in, SMS delivery, the admin import) are unaffected. -- **Field group.** Wherever a form lays fields out by their declared group, the two fields now sit in `Admin` instead of `Account`. +**FROM.** On `origin/main`, an org member holding only the non-admin sets could, on the generic data door (`GET /api/v1/data/sys_user`, `POST /api/v1/data/sys_user/query`, the record read and the activity stream): + +- read both fields on every org peer's row and on their own; +- write a **filter** naming either field, and have it evaluated; +- **sort** by either field; +- **group by** either field, or name it as an **aggregate** operand; +- run a `$search` whose default field set scanned `phone_number` (it is `searchable: true`, one of the object's three searchable fields beside `name` and `email`). + +**TO.** For the same member: + +- **Row position.** Both fields are withheld from every `sys_user` row served — a colleague's direct read, the list read, a `fields=` projection naming them, and the activity stream's recorded changes. The member's **own row** is withheld them too: field-level read security does not distinguish rows (ADR-0066). +- **Filter, sort, group-by, aggregate.** A query naming either field in its filter (`where` / `having`), its sort, its group-by or an aggregation is refused `403 PERMISSION_DENIED` before the engine is asked, at every verb that carries a query — a bulk write's own predicate included. +- **`$search`.** The default search set is narrowed **silently**: `phone_number` is dropped from the fields a member's `$search` scans, so a user picker keeps answering on `name` and `email`, and a search for a colleague's number returns no row rather than a refusal. +- **Writes.** Both fields stay `readonly: true` and are written through better-auth only, so no reachable write changes. + +**Unaffected.** Platform admins and organization admins read, filter, sort and search both fields exactly as before. System- and auth-context readers — sign-in by phone, SMS delivery, the admin import's match on `phone_number`, the better-auth adapter — read under a context no permission set reaches. The signed-in user's own number is read from the auth session: better-auth's session user carries both phone fields when the phone-number sign-in plugin is enabled. + +**Field group.** Wherever a form lays fields out by their declared group, the two fields now sit in `Admin` instead of `Account`. -No configuration change is needed. A deployment that wants members to keep reading colleagues' phone numbers grants a permission set naming `sys_user.phone_number` (and `sys_user.phone_number_verified`) with `readable: true`. +**Grant-back.** No configuration change is needed for shipped apps. A deployment that wants org members to keep reading, filtering on or searching colleagues' phone numbers grants a permission set naming `sys_user.phone_number` (and `sys_user.phone_number_verified`) with `readable: true` in its `fields` entries; field grants merge most-permissively, so that one set out-votes the baseline's withhold. A client that filtered or sorted members' `sys_user` queries on either field drops that predicate or runs it with an administrator's grant. From a77f981d835a424b30360744bb07b49973d84fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:44:24 +0000 Subject: [PATCH 5/5] test(dogfood): pin the member user-picker search over the sign-in phone narrowing Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- ...tity-admin-fields-org-peer.dogfood.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index 8c7304b4b83..44dd3911135 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -34,6 +34,9 @@ // field-level write gate, and a mixed payload does not partially succeed; // - the user picker's candidate query (the not-deactivated filter) as the // member: served, deactivated peers excluded; +// - the user picker's `$search` as the member: a colleague's name finds the +// colleague, a colleague's sign-in phone number finds no row (the search +// set is narrowed, not refused), with the admin's search as the control; // - the ORG OWNER and a PLATFORM ADMIN (who holds no org-admin grant): the // full group on both doors, and the platform admin's write unchanged. // @@ -241,6 +244,37 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a for (const field of SIGN_IN_PHONE) expect(row, field).not.toHaveProperty(field); }); + // The user picker's `$search`: `phone_number` is `searchable: true`, so it is + // in the object's default search set. For a member the set is NARROWED to the + // fields the member may query (the queryable-field narrowing), never refused: + // the picker keeps answering on the name, and the colleague's number answers + // with no row. The admin search for the same number is the control that the + // term really matches the stored value when the column is scanned. + const searchIds = async (who: string, term: string) => { + const res = await stack.apiAs(token[who], 'GET', `/data/sys_user?$search=${encodeURIComponent(term)}&$top=100`); + return { status: res.status, ids: rowsOf(await res.json()).map((r) => String(r.id)) }; + }; + const COLLEAGUE_NAME = 'Org Peer colleague'; + const COLLEAGUE_PHONE_DIGITS = String(SYNTHETIC.phone_number).replace(/^\+/, ''); + + it('a member user-picker search by a colleague name is served the colleague', async () => { + const { status, ids } = await searchIds('member', COLLEAGUE_NAME); + expect(status).toBe(200); + expect(ids).toContain(uid.colleague); + }); + + it('a member search by a colleague sign-in phone number is answered with no row, not refused', async () => { + const { status, ids } = await searchIds('member', COLLEAGUE_PHONE_DIGITS); + expect(status).toBe(200); + expect(ids).toEqual([]); + }); + + it('the platform admin search by the same phone number is served the colleague (the control for the empty answer above)', async () => { + const { status, ids } = await searchIds('admin', COLLEAGUE_PHONE_DIGITS); + expect(status).toBe(200); + expect(ids).toContain(uid.colleague); + }); + it.each(['owner', 'admin'])('the %s reads the colleague sign-in phone fields as stored', async (who) => { const { status, row } = await readRow(who, 'colleague'); expect(status).toBe(200);