From f3be076a33064166a3213ebca1471b2f3dfe9b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:28:16 +0000 Subject: [PATCH 1/5] fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys_user_permission_set.display_title` read `record.user_id + ' → ' + record.permission_set`, so Setup listed and headed every assignment with the stored user id. It now reads the user's name (their email where the name is blank) one hop through `user_id`, the traversal the formula read path serves under the reader's own authority (ADR-0079 addendum). No stored column. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- ...-security-assignment-display-title.test.ts | 13 +- ...s-user-permission-set-person-title.test.ts | 203 ++++++++++++++++++ .../objects/sys-user-permission-set.object.ts | 37 ++-- 3 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index 2590775ca7e..5be7c09c7d0 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -15,8 +15,10 @@ * page's H1. * * Each object now points at `display_title`, a text formula over the columns - * its `titleFormat` names. Through the real engine this file asserts, per - * object: + * its `titleFormat` names. `sys_user_permission_set`'s title names the PERSON + * through `user_id` instead, so its pins live in + * `sys-user-permission-set-person-title.test.ts`; this file holds the rest. + * Through the real engine this file asserts, per object: * * 1. the body the registry holds after registration names `display_title`; * 2. a seeded row's H1 is the `titleFormat` text, not the id, and the @@ -46,7 +48,6 @@ import { } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; -import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; import { SysUserPosition } from './sys-user-position.object.js'; const SYS = { context: { isSystem: true } } as any; @@ -58,12 +59,6 @@ interface Case { } const CASES: Case[] = [ - { - schema: SysUserPermissionSet, - // [ADR-0131 D4] A grant names its set; the title shows that name. - row: { user_id: 'usr_alice', permission_set: 'crm_edit' }, - title: 'usr_alice → crm_edit', - }, { schema: SysUserPosition, row: { user_id: 'usr_alice', position: 'sales_manager' }, diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts new file mode 100644 index 00000000000..1c28b3b4cd1 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_user_permission_set`'s record title names the PERSON a set is granted + * to, not the raw user id. + * + * Setup's list of a user's permission sets, and the assignment's record page, + * read the title the object declares (`nameField: 'display_title'`). That + * formula used to compose the stored foreign key, `record.user_id + ' → ' + + * record.permission_set`, so every row and every H1 read like + * `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → qa_fls_rep`. It now reads the user's + * `name` — their `email` where the name is blank — ONE hop through + * `user_id`, the traversal the formula read path serves (ADR-0079 addendum, + * 2026-10-18). No column is stored for it. + * + * Through the real engine, the real `sys_user` and the real assignment + * definition, this file asserts: + * + * 1. the registered body points at `display_title`, a text formula that reads + * `user_id` only through the hop, and only columns `sys_user` declares; + * 2. the title a read serves names the person — on the write's response, on + * `findOne`, on a `find` whose projection names only the title — and the + * server's title accessors answer the same text; + * 3. a user whose stored name is blank is titled by their email, the + * declared fallback; + * 4. this is a permission-assignment table, so the property the hop must not + * break: it is read as the READER. A user the reader may not read leaves + * the title null, and neither the name nor the email reaches the row; + * SYSTEM reads the name. A user that no longer exists leaves it null too; + * 5. a row missing a title column is refused by the write path; + * 6. the formula adds no stored column and no search companion column. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { + ObjectQL, + SEARCH_COMPANION_FIELD, + provisionSearchCompanion, + resolveSearchCompanionSources, + servedRecordTitle, +} from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysUser } from '@objectstack/platform-objects'; +import { resolveDisplayField, resolveRecordDisplayName } from '@objectstack/spec/data'; +import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; + +const SYS = { context: { isSystem: true } } as any; +const OBJECT = 'sys_user_permission_set'; + +/** A reader who is not SYSTEM: the shape every request's context has. */ +const READER = { userId: 'usr_reader', tenantId: 'org_1' }; + +/** Every `record.` the `display_title` expression reads, as written. */ +function formulaReads(): string[] { + const source = (SysUserPermissionSet as any).fields.display_title?.expression?.source; + if (typeof source !== 'string') throw new Error('display_title carries no expression source'); + return [...new Set([...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]))].sort(); +} + +describe('sys_user_permission_set is titled by the person, not the raw user id', () => { + let engine: ObjectQL; + let driver: SqlDriver; + /** Ids of `sys_user` rows a reader that is not SYSTEM may read. */ + const readable = new Set(); + + const user = async (name: string, email: string): Promise => { + const row = await engine.insert('sys_user', { name, email }, SYS); + return String(row.id); + }; + const grant = async (userId: string, permissionSet = 'crm_edit') => + engine.insert(OBJECT, { user_id: userId, permission_set: permissionSet }, SYS); + + beforeAll(async () => { + engine = new ObjectQL(); + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(SysUser as any, 'com.objectstack.test.person-title'); + engine.registry.registerObject(SysUserPermissionSet as any, 'com.objectstack.test.person-title'); + await engine.syncSchemas(); + // Row-level security on `sys_user` for a reader that is not SYSTEM — the + // composition the security plugin makes (its own row scope is "own row or + // an organization member"; here, the ids in `readable`). + engine.registerMiddleware(async (ctx: any, next: () => Promise) => { + if (ctx.object === 'sys_user' && ['find', 'findOne'].includes(ctx.operation) && !ctx.context?.isSystem) { + const scoped = { id: { $in: [...readable] } }; + ctx.ast = { ...(ctx.ast ?? {}), where: ctx.ast?.where ? { $and: [ctx.ast.where, scoped] } : scoped }; + } + await next(); + }); + }); + + beforeEach(() => readable.clear()); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the registered body points at display_title, a text formula reading user_id only through the hop', () => { + const registered = engine.registry.getObject(OBJECT) as any; + expect(registered.nameField).toBe('display_title'); + expect(registered.displayNameField).toBe('display_title'); + expect(resolveDisplayField(registered)).toBe('display_title'); + expect(registered.fields.display_title?.type).toBe('formula'); + expect(registered.fields.display_title?.returnType).toBe('text'); + // `user_id` itself is never read bare: a bare read beside a traversal is + // the `traversal-bare-and-traversed` refusal. + expect(formulaReads()).toEqual(['permission_set', 'user_id.email', 'user_id.name']); + const userFields = (engine.registry.getObject('sys_user') as any).fields; + for (const column of ['name', 'email']) expect(userFields[column], column).toBeDefined(); + }); + + it('a read serves the person\'s name — on the write response, findOne and a projected find', async () => { + const alice = await user('Alice Liddell', 'alice@example.com'); + const created = await grant(alice); + expect(created.display_title).toBe('Alice Liddell → crm_edit'); + + const registered = engine.registry.getObject(OBJECT) as any; + const row = await engine.findOne(OBJECT, { where: { id: created.id } }, SYS); + expect(row!.display_title).toBe('Alice Liddell → crm_edit'); + expect(row!.display_title).not.toContain(alice); + // The row still holds the foreign KEY; only the title reads through it. + expect(row!.user_id).toBe(alice); + expect(resolveRecordDisplayName(registered, row)).toBe('Alice Liddell → crm_edit'); + expect(servedRecordTitle(registered, row)).toBe('Alice Liddell → crm_edit'); + + // The list's title column: a projection naming only the title. + const listed = await engine.find(OBJECT, { where: { id: created.id }, fields: ['display_title'] }, SYS); + expect(listed).toEqual([{ display_title: 'Alice Liddell → crm_edit' }]); + }); + + it('a user whose stored name is blank is titled by their email — the declared fallback', async () => { + // The write door refuses a blank `name` (it is required), so a nameless + // user is a row written past it: an import, or a row older than the rule. + const empty = await user('Placeholder', 'no-name@example.com'); + const spaces = await user('Placeholder', 'spaces@example.com'); + await driver.getKnex()('sys_user').where({ id: empty }).update({ name: '' }); + await driver.getKnex()('sys_user').where({ id: spaces }).update({ name: ' ' }); + + const a = await grant(empty); + const b = await grant(spaces, 'crm_read'); + expect((await engine.findOne(OBJECT, { where: { id: a.id } }, SYS))!.display_title).toBe('no-name@example.com → crm_edit'); + expect((await engine.findOne(OBJECT, { where: { id: b.id } }, SYS))!.display_title).toBe('spaces@example.com → crm_read'); + }); + + describe('the hop is read as the reader', () => { + it('a user the reader may read is named for that reader', async () => { + const bob = await user('Bob Kinney', 'bob@example.com'); + const g = await grant(bob); + readable.add(bob); + const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER } as any); + expect(row!.display_title).toBe('Bob Kinney → crm_edit'); + }); + + it('a user the reader may not read leaves the title null, and nothing of the user reaches the row', async () => { + const carol = await user('Carol Hidden', 'carol.hidden@example.com'); + const g = await grant(carol); + // The reader's own read of that user returns nothing… + expect(await engine.find('sys_user', { where: { id: carol } }, { context: READER } as any)).toEqual([]); + // …so the title over it has no value, and neither the name nor the email leaks. + const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER } as any); + expect(row!.display_title).toBeNull(); + expect(JSON.stringify(row)).not.toContain('Carol Hidden'); + expect(JSON.stringify(row)).not.toContain('carol.hidden@example.com'); + // SYSTEM reads the user directly, so it reads the name through the hop. + expect((await engine.findOne(OBJECT, { where: { id: g.id } }, SYS))!.display_title).toBe('Carol Hidden → crm_edit'); + }); + + it('a grant whose user no longer exists has no title — never one computed in its place', async () => { + const g = await grant('usr_gone'); + const row = await engine.findOne(OBJECT, { where: { id: g.id } }, SYS); + expect(row!.display_title).toBeNull(); + }); + }); + + it('a row missing a title column is refused, so the formula never sees a NULL part', async () => { + const dan = await user('Dan Ortiz', 'dan@example.com'); + for (const [omit, partial] of [ + ['user_id', { permission_set: 'crm_edit' }], + ['permission_set', { user_id: dan }], + ] as const) { + await expect(engine.insert(OBJECT, partial, SYS)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]), + }); + } + }); + + it('adds no stored column and no search companion column', async () => { + const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo()); + expect(columns).toContain('user_id'); + expect(columns).not.toContain('display_title'); + // The companion (`__search`) is a real column fed by the title field, and + // a formula title is never a source — not even where pinyin search is on. + const registered = engine.registry.getObject(OBJECT) as any; + expect(resolveSearchCompanionSources(registered)).toEqual([]); + expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index b13bb2de077..72b5b4afc13 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -36,13 +36,14 @@ export const SysUserPermissionSet = ObjectSchema.create({ // needed — the DelegatedAdminGate is the authz. managedBy: 'system-data', description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).', - // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the - // registry's designate-only pass stamped `nameField: 'id'` (the first - // title-eligible field), so a renderer honouring ADR-0079's order (an - // explicit `nameField` wins over `titleFormat`) drew the raw id as the record - // page's H1. `titleFormat` stays for renderers that still read it first; - // `sys-security-assignment-display-title.test.ts` holds the two to the same text. + // [ADR-0079] The record title is `display_title`, a text formula naming the + // user and the set. With no pointer declared, the registry's designate-only + // pass stamped `nameField: 'id'` (the first title-eligible field), so a + // renderer honouring ADR-0079's order (an explicit `nameField` wins over + // `titleFormat`) drew the raw id as the record page's H1. `titleFormat` is + // the rung below the pointer: a renderer reaches it only where the title has + // no value (the user is not readable by this reader, or no longer exists). + // `sys-user-permission-set-person-title.test.ts` pins the title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} → {permission_set}', @@ -57,17 +58,23 @@ export const SysUserPermissionSet = ObjectSchema.create({ }), // [ADR-0079] The record title (`nameField` above). A formula is computed on - // read and has no stored column. It reads only this row's own columns — - // the user and the set's name, never a field of a looked-up record — and - // neither is hidden, permission-guarded or masked on this object, so the - // title carries nothing the declared read path withholds. Both are - // required (the set by name, ADR-0131 D4), so the expression needs no null - // guard; a legacy grant the v18 ceremony's rewrite names is the only row - // that ever stored the name NULL. + // read and has no stored column. It names the PERSON, not the raw user id: + // the user's `name`, or their `email` where the name is blank (the + // platform's own `name || email` rule for a person's handle), read ONE hop + // through `user_id` (ADR-0079 addendum, 2026-10-18). That hop is read + // under the READER's own authority, through every layer a direct read of + // `sys_user` applies, so the title carries nothing that reader could not + // read on the user itself: a user the reader may not read, or one that no + // longer exists, leaves the title null — never a value computed in its + // place — and a renderer falls to `titleFormat` below it. + // `user_id` is read only through the hop (a bare read beside a traversal + // is refused, `traversal-bare-and-traversed`). Both columns are required + // (the set by name, ADR-0131 D4); a legacy grant the v18 ceremony's + // rewrite names is the only row that ever stored the set's name NULL. display_title: Field.formula({ label: 'Title', returnType: 'text', - expression: F`record.user_id + ' → ' + record.permission_set`, + expression: F`(isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' → ' + record.permission_set`, description: 'Record title: the user and the permission set assigned to them (computed on read)', }), From 07c9bf231516d0eea3e35bd053370367b48cc0cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:37:34 +0000 Subject: [PATCH 2/5] chore(changeset): plugin-security patch for the person-named permission-set assignment title Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../24775-permission-set-assignment-person-title.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/24775-permission-set-assignment-person-title.md diff --git a/.changeset/24775-permission-set-assignment-person-title.md b/.changeset/24775-permission-set-assignment-person-title.md new file mode 100644 index 00000000000..18e861718ad --- /dev/null +++ b/.changeset/24775-permission-set-assignment-person-title.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id + +Clause-②: no (no accept set moves: the value a declared formula computes on read changes; no key, column, write path or authorization answer moves) + +- **Before.** `sys_user_permission_set`'s record title (`display_title`, the object's `nameField`) composed the stored user id, so Setup's list of a user's permission sets and the assignment's record page read like `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → crm_edit`. +- **Now.** The title names the person: the user's `name`, or their `email` where the name is blank, then the set, as in `Alice Liddell → crm_edit`. The name is read one hop through `user_id`, under the reader's own permissions (the ADR-0079 addendum on one-hop formula titles). A reader who may not read that user, and a grant whose user no longer exists, get no title (`null`), never the user's name or email; a renderer then falls to the object's `titleFormat`, `{user_id} → {permission_set}`. No column is stored, and the title is still neither sortable nor searchable. +- **Cost.** A read of `sys_user_permission_set` that computes its formulas (no field projection, or a projection naming `display_title`) now issues one batched `sys_user` read per page. That includes the grant read every authenticated request makes: for a principal holding a direct grant, the request's authorization prelude is 9 reads in 5 round trips instead of 8 in 4. A principal with no direct grant reads nothing more. From 2deb9e32e64981dea09d0e471ba9f4b2332c2d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:03:26 +0000 Subject: [PATCH 3/5] test(plugin-security): type the reader's read options in the person-title pin instead of erasing them check:query-options-erasure counted three erased option bags (236 to 239). Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../objects/sys-user-permission-set-person-title.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts index 1c28b3b4cd1..02afb741012 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts @@ -42,13 +42,14 @@ import { import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser } from '@objectstack/platform-objects'; import { resolveDisplayField, resolveRecordDisplayName } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; const SYS = { context: { isSystem: true } } as any; const OBJECT = 'sys_user_permission_set'; /** A reader who is not SYSTEM: the shape every request's context has. */ -const READER = { userId: 'usr_reader', tenantId: 'org_1' }; +const READER: ExecutionContext = { userId: 'usr_reader', tenantId: 'org_1' }; /** Every `record.` the `display_title` expression reads, as written. */ function formulaReads(): string[] { @@ -152,7 +153,7 @@ describe('sys_user_permission_set is titled by the person, not the raw user id', const bob = await user('Bob Kinney', 'bob@example.com'); const g = await grant(bob); readable.add(bob); - const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER } as any); + const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER }); expect(row!.display_title).toBe('Bob Kinney → crm_edit'); }); @@ -160,9 +161,9 @@ describe('sys_user_permission_set is titled by the person, not the raw user id', const carol = await user('Carol Hidden', 'carol.hidden@example.com'); const g = await grant(carol); // The reader's own read of that user returns nothing… - expect(await engine.find('sys_user', { where: { id: carol } }, { context: READER } as any)).toEqual([]); + expect(await engine.find('sys_user', { where: { id: carol } }, { context: READER })).toEqual([]); // …so the title over it has no value, and neither the name nor the email leaks. - const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER } as any); + const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER }); expect(row!.display_title).toBeNull(); expect(JSON.stringify(row)).not.toContain('Carol Hidden'); expect(JSON.stringify(row)).not.toContain('carol.hidden@example.com'); From 894860ed7ab4cf9003a4d29463b7b88a5aa8c159 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:39:17 +0000 Subject: [PATCH 4/5] test(plugin-auth): the prelude pin's as-declared grant title is the person-named one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic merge conflict with #24941's pin, resolved in one line. Its direct-grant case "as declared" read the declared sys_user_permission_set title as the raw user id, a precondition asserted before the count. On this branch the declared title IS the one-hop person title, so both cases of the pair now read 'Prelude → prelude_editor'. The counts the pin holds (8 reads in 4 round trips) are unchanged and green. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../plugin-auth/src/auth-prelude-query-count.pin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts index 5cad4acbf59..19789ef8072 100644 --- a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts @@ -281,7 +281,7 @@ describe('[#24061] one authenticated request\'s prelude, counted on a real engin // not project it — so the hop, where declared, is live on this engine. const [held] = (await engine.find('sys_user_permission_set', { where: { user_id: userId }, ...SYS })) as any[]; expect(held?.permission_set).toBe('prelude_editor'); - expect(held?.display_title).toBe(oneHopGrantTitle ? 'Prelude → prelude_editor' : `${userId} → prelude_editor`); + expect(held?.display_title).toBe('Prelude → prelude_editor'); const run = await probe.measure(prelude); From 66a590fd88f7ba138d739e10c721c15d152a3139 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:39:17 +0000 Subject: [PATCH 5/5] chore(changeset): the grant title's cost sentence matches main's projected prelude read Since #24941 the authz prelude's grant read names the columns it decides on, so it computes no display_title and the prelude stays 8 reads in 4 round trips for a principal holding a direct grant. The changeset said 9 in 5, a figure measured on the pre-#24941 base. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24775-permission-set-assignment-person-title.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24775-permission-set-assignment-person-title.md b/.changeset/24775-permission-set-assignment-person-title.md index 18e861718ad..e935a57bffd 100644 --- a/.changeset/24775-permission-set-assignment-person-title.md +++ b/.changeset/24775-permission-set-assignment-person-title.md @@ -8,4 +8,4 @@ Clause-②: no (no accept set moves: the value a declared formula computes on re - **Before.** `sys_user_permission_set`'s record title (`display_title`, the object's `nameField`) composed the stored user id, so Setup's list of a user's permission sets and the assignment's record page read like `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → crm_edit`. - **Now.** The title names the person: the user's `name`, or their `email` where the name is blank, then the set, as in `Alice Liddell → crm_edit`. The name is read one hop through `user_id`, under the reader's own permissions (the ADR-0079 addendum on one-hop formula titles). A reader who may not read that user, and a grant whose user no longer exists, get no title (`null`), never the user's name or email; a renderer then falls to the object's `titleFormat`, `{user_id} → {permission_set}`. No column is stored, and the title is still neither sortable nor searchable. -- **Cost.** A read of `sys_user_permission_set` that computes its formulas (no field projection, or a projection naming `display_title`) now issues one batched `sys_user` read per page. That includes the grant read every authenticated request makes: for a principal holding a direct grant, the request's authorization prelude is 9 reads in 5 round trips instead of 8 in 4. A principal with no direct grant reads nothing more. +- **Cost.** A read of `sys_user_permission_set` that computes its formulas (no field projection, or a projection naming `display_title`) now issues one batched `sys_user` read per page. The grant read every authenticated request makes is not one of them: it names the columns it decides on, so it computes no title, and the request's authorization prelude stays 8 reads in 4 round trips for a principal holding a direct grant.