Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/user-directory-sign-in-phone-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@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 (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the shipped non-admin permission sets' field read on two `sys_user` columns: the two fields change their declared field group, and the group is the declaration those sets already read. The remedy is a permission set's existing `fields` entry, never a rewrite of anyone's code or metadata. No exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only); the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered). -->

**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.

**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`.

**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.
19 changes: 17 additions & 2 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -58,6 +59,12 @@ const DECLARED_ADMIN_GROUP: string[] = Object.entries(SysUser.fields as Record<s
*/
const PEER_DIRECTORY_FIELDS = ['name', 'email', 'image', 'banned'];

/**
* The better-auth phone sign-in identifier and its verified flag — account
* administration data, declared inside the group so org peers are withheld it.
*/
const SIGN_IN_PHONE_FIELDS = ['phone_number', 'phone_number_verified'];

const ADMIN_SETS = [ADMIN_FULL_ACCESS, ...ORGANIZATION_ADMIN_GRANTS];
const WITHHOLDING_SETS = ['member_default', 'viewer_readonly', 'external_default'];

Expand Down Expand Up @@ -90,6 +97,12 @@ describe('[#21237] the identity object Admin group — withheld from org peers,
for (const control of PEER_DIRECTORY_FIELDS) expect(DECLARED_ADMIN_GROUP).not.toContain(control);
});

it('the sign-in phone number and its verified flag are declared inside the group (a sign-in identifier is not directory data)', () => {
// 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);
Expand Down Expand Up @@ -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<string, unknown>[];
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -63,7 +66,13 @@ const SYNTHETIC: Record<string, unknown> = {
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<string, unknown> = { last_login_ip: 'SYNTHETIC-ADDRESS-TWO', failed_login_count: 3 };

/** Object-level activity read, granted to the member for the activity door. */
Expand Down Expand Up @@ -225,6 +234,53 @@ 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=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();
expect(row).toHaveProperty('name');
expect(row).toHaveProperty('email');
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);
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);
Expand Down
Loading