Skip to content
11 changes: 11 additions & 0 deletions .changeset/24775-permission-set-assignment-person-title.md
Original file line number Diff line number Diff line change
@@ -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. 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// 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 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: ExecutionContext = { userId: 'usr_reader', tenantId: 'org_1' };

/** Every `record.<path>` 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<string>();

const user = async (name: string, email: string): Promise<string> => {
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<void>) => {
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 });
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 })).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 });
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand All @@ -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)',
}),

Expand Down
Loading