Skip to content

Commit 6440cf1

Browse files
fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id (#24885)
Fixes #24775 Clause-②: no This PR covers one of the card's two objects, `sys_user_permission_set` (`plugin-security`). The other, `sys_notification_preference` (`service-messaging`), needs a ruling first, so #24775 remains open after this merges. Open choice 1 below gives that ruling's options. Related: #24701 (Record Shares recipient, closed; its seat ruling `6282390889` is the precedent read for this card). ## What changes `sys_user_permission_set.display_title` is the object's `nameField`. It composed the stored foreign key, `record.user_id + ' → ' + record.permission_set`. So Setup's list of a user's permission sets and the assignment's record page read like `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → qa_fls_rep` (HotCRM pass, F-25). It now names the person: ``` (isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' → ' + record.permission_set ``` - The user's `name`, or their `email` where the name is blank. That is the platform's own `name || email` rule for a person's handle (`auth-manager.ts`, `approval-service.ts`). - The value is read one hop through `user_id`. That is the formula read path's one-hop traversal (ADR-0079 addendum, 2026-10-18), read under the reader's own authority. - No stored column, no spec change, no objectui change, no console name mapping. - `user_id` is read only through the hop. A bare read beside the traversal would be the `traversal-bare-and-traversed` refusal. - `titleFormat` (`{user_id} → {permission_set}`) is unchanged. It is the rung below the declared pointer in the console's H1 ladder: objectui `packages/components/src/renderers/layout/containers.tsx:2542` at the pin `616df6598b` ("The declared pointer OUTRANKS `titleFormat`"). A renderer reaches it only when the title has no value. - The field's label and description are unchanged, so no translation bundle moves. Files: - `packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts`: the expression and its comments. - `packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts` (new): the pins. - `packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts`: `sys_user_permission_set` leaves its case table, since the title no longer equals the `titleFormat` text. `sys_user_position` keeps every assertion it had. - `.changeset/24775-permission-set-assignment-person-title.md`: `@objectstack/plugin-security` patch. ## Premises, measured (on `main` at `6b9e6df909`, then merged to `b1676441ba`) - **H1, holds.** Both objects declare `nameField: 'display_title'`, a text formula with no stored column. The read door serves it: on a real engine (driver-sql, better-sqlite3), `findOne`, a `find` projecting only `display_title`, and the insert response all carry it. - **H2, read.** `packages/spec/src/data/display-name.ts` lists `lookup` and `user` in `TITLE_INELIGIBLE_TYPES`. So the pointer stays on the text formula; no pointer moves. - **H3.** The console already names the person in a `lookup` column's chip. For the title, though, the platform's server-side resolvers read the title field's value: `resolveRecordDisplayName`, and `servedRecordTitle` in `record-title.ts`. So the person's name has to be in the formula's value. The formula's one hop is the platform's own way to put it there. - **H4, holds (it was NOT MEASURED in #24701's report).** A formula reads one hop through a reference. This shipped with `d537a318b` and `1bf5f6e17c`, and the ADR-0079 addendum records it. Measured on the real engine with the real `sys_user`, the hop formula gives `Alice Liddell → crm_edit` on the insert response, on `findOne` and on a projected `find`. A grant whose user does not exist reads `null`. - **`sys_notification_preference` cannot take the same route.** Its `user_id` is `Field.text` (maxLength 255). It holds a `sys_user.id` or the literal `*`, the admin-global default `preference-resolver.ts` reads as `WILDCARD`. The read path serves a hop only through a reference type (`REFERENCE_VALUE_TYPES`: lookup, master_detail, user, tree, in `field-value.zod.ts`). Measured: a text `user_id` read as `record.user_id.name` gives `null`. See open choice 1. ## Open choice 1: how `sys_notification_preference` can name the person No route names the person without one of three things: re-typing `user_id`, a stored copy, or a formula-language feature. The dispatch's hard stops forbid the last two in this lane. - **A. Re-type `user_id` as a `user` reference, and spell the admin-global default as an empty reference instead of `*`.** That is ADR-0118 D1's representation of a non-user in a user column: null, never a sentinel; the display is a render rule. The title then reads the person one hop, as here, and shows the empty reference as the default's label. The Setup form gets a user picker instead of a raw-id text box, and the list column a person chip. - Business need: the Setup grid is the measured producer (HotCRM F-25). Zero code producers write `*` rows: `git grep` finds the resolver's constant, its tests and one driver-sql test. - Long-term: contract-first. It removes a sentinel from a user column. - AI mistakes: a typed reference refuses a non-user id at the write door. Today any string is stored as a "user id" and silently matches nobody. - Startup scope: no new column or capability. But it narrows the accept set (`*` and any non-user string refused), so it is `Clause-②: yes (narrowing)` and BREAKING. It owes a stored-row conversion (`*` to null), the resolver change, and a NULL-safe user key part in the unique index (ADR-0120 D3 makes only the organization key part NULL-safe). Effort M, as its own card. - **B. A console display rule for the column only** (a widget hint on `user_id`). No such widget exists at the pin. The shipped hints are `recipient-picker`, `object-ref`, `position-ref`, `permission-ref` and `filter-condition`. And a hint on `user_id` cannot reach the composite title, so the H1 and the list's title column stay raw. It does not close the finding. - **C. A stored name column:** refused by the dispatch (it goes stale on rename). - **D. A by-id lookup function over a text column:** the spec and formula lane's. It would be a second traversal dialect beside the one-hop rule. - **Recommendation: A, as its own `service-messaging` card.** The long-term and AI-safety axes both favour typing the column. The cost is a breaking narrowing with a data conversion, and that is the maintainer's to rule. ## Open choice 2: the hop costs a read on every authenticated request of a grant holder The authorization prelude reads the caller's grants with no field projection: `tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100)` in `packages/core/src/security/resolve-authz-context.ts:1457`. A read with no projection computes every formula, so `display_title`'s hop now costs one batched `sys_user` read there. Measured with `auth-prelude-query-count.pin.test.ts`'s own rig (real engine, real `AuthManager`, `resolveAuthzContext`), old formula against new. The probe was a scratch file and is not committed: | principal | old formula | this PR | | --- | --- | --- | | no direct grant | 8 reads, 4 round trips | 8 reads, 4 round trips | | one direct grant | 8 reads, 4 round trips | 9 reads, 5 round trips (`sys_user` 2 to 3) | The committed pin measures a principal with no direct grant, so it stays green and cannot see this. The grants cache that would amortise it is off by default (`OS_AUTHZ_GRANTS_CACHE_TTL_MS=0`). Other reads of the table that name no projection pay the same single read per page: the explain engine, the platform-admin scan, the last-admin guard and the auth manager's grant reads. They are admin-time or boot-time paths. - **A. Land as is.** Every request of an organization admin or platform admin pays one more indexed read and one more round trip, for a title nobody on that path reads. - **B. Land after (or with) a `core`-lane change that projects the prelude's grant read to the columns the resolver reads,** plus a grant-holder case in the prelude pin. The title is still declared once, on the object, and the hot path reads only what it uses. Which columns the resolver reads is that lane's to measure. A wrong projection would drop a column a security decision reads. - **C. Withdraw the hop.** The title stays the raw id. - **Recommendation: B.** - Business need: the title serves Setup pages, while the cost lands on every request. - Long-term: B avoids a hidden coupling, where a display formula costs authorization a round trip. - AI mistakes: the extended pin makes the next such cost visible. - Startup scope: one projection and one pin case, and no new gate. ## Tests (head `2deb9e32e6`; every run under `scripts/pm/os-verify-lock.sh`, exit read from its `VERDICT` line) - Closure: `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' --filter '@objectstack/service-messaging^...' build` gave `VERDICT command-exit 0`. Then `pnpm --filter @objectstack/plugin-security build` gave `VERDICT command-exit 0`; `check-dts-emitted`: 1/1, and `dist/index.mjs` carries the new expression (count 1). - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2` (whole package, code tree of `07c9bf2315`): `Test Files 196 passed (196)`, `Tests 4049 passed | 45 skipped (4094)`. - `pnpm --filter @objectstack/plugin-security typecheck` (on `2deb9e32e6`'s tree): `VERDICT command-exit 0`. It includes `check:test-typecheck: OK`, whose `tsconfig.test.json` includes `src/**/*`, the new pin file among them. - The two title files on `2deb9e32e6`'s tree: `Test Files 2 passed (2)`, `Tests 14 passed (14)` (8 in the new file, 6 for `sys_user_position`). - The new pins, with real `sys_user` and the real assignment definition on driver-sql: - the registered pointer, and the formula reads `permission_set`, `user_id.email` and `user_id.name`, nothing bare; - the person's name on the insert response, on `findOne` and on a `find` projecting only `display_title`. `resolveRecordDisplayName` and `servedRecordTitle` agree, and the row still holds the foreign key; - the control: a user whose stored name is empty, or only spaces, is titled by their email; - read as the reader, under a `sys_user` row-scope middleware in the shape the security plugin composes: - a readable user is named; - an unreadable one leaves the title `null`, and neither the name nor the email appears in the served row; - SYSTEM reads the name; - a missing user reads `null`; - the write door refuses a row missing either title column; - no stored column and no search companion. - `sys_notification_preference` is untouched, so no `service-messaging` test or typecheck is owed. ## Ablation (one leg: the one pinned behaviour this PR changes) - The mutation: `node scripts/ablation-replace.mjs` replaced the new expression with the old one, inside a script carrying `trap 'git checkout HEAD -- "$ABS"' EXIT INT TERM` over an absolute path. - Proof it landed on disk: anchor count 1 to 0, replacement count 0 to 1, blob `72b5b4afc1` to `712f8d6419`. - The subject is imported from `src/` by a relative path, so no `dist/` was involved. - Mutated run of `sys-user-permission-set-person-title.test.ts`: `Tests 6 failed | 2 passed (8)`. That is the expected direction, red. The two still green pin unchanged behaviour: the missing-column refusal and no stored column. - Restored with `git checkout HEAD -- "$ABS"`: blob after restore `72b5b4afc1`, equal to HEAD's blob; `git diff HEAD` empty; `git status --porcelain` empty. ## Gates - **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on `2deb9e32e6` derived 68 commands. That is 4 changed paths against merge base `b1676441b`, 264 changed lines. The seat's planned-surface count was 51. - **First sweep on `9ffef3b9a4`:** 65 commands exit 0. - `pnpm check:query-options-erasure` exit 1: the new pin erased three option bags (236 to 239 test sites). Typed in `2deb9e32e6`; the re-run on that tree exits 0 at 236. - `pnpm check:dual-build-cjs-loads` exit 3: PREREQUISITE NOT MET, every package's `dist/` (`pnpm build`). NOT MEASURED locally. - `pnpm check:i18n` exit 3: PREREQUISITE NOT MET, the CLI plus the closure of ten packages. NOT MEASURED locally. No label or description moved, so no bundle should. - **Re-run of the union on `2deb9e32e6`:** in the report on the card. CI's check-runs are the record for every family. ## Acceptance notes - Same family, not in this card's file surface. The seat should fold these into the family's close-out card, not file single cards: - `sys_user_position.display_title` (`record.user_id + ' → ' + record.position`, `sys-user-position.object.ts:76`). `user_id` is a lookup there, so this PR's route applies. It is pinned today, on the engine read door, as `usr_alice → sales_manager`. - `sys_notification_delivery` (`record.channel + ' → ' + record.recipient_id`) and `sys_notification_subscription` (`record.principal + ' · ' + record.topic`). Both are text columns, so they share open choice 1's fork. - The prelude pin's blind spot (a principal with no direct grant) is open choice 2's. Noted, not filed. > **Seat's append** (`domain:services` seat 1, `session_011TdZ8GjixNZFLz1xJD9zzK`): > - **First line changed** from `Part of #24775` to `Fixes #24775`, by seat ruling `6291928519` and resume note `6298203362`. #24896 landed (PR #24941, `644e4b2c54`), and the card's other object, `sys_notification_preference`, is carried by #24895. > - `sys_user_position` does not ride this PR: the prelude reads it unprojected, `resolve-authz-context.ts:1484`. > - Below is the dev's round-2 markdown from os-dev report `6299091948` on #24775, verbatim. The dev writes the body once and never PATCHes it. Where the sections above say this PR is `Part of` or that #24775 stays open, this append supersedes them. ## Round 2 (head `66a590fd88`) **Merge.** `origin/main` at `865d2c8cf4` is merged in as `9474db510e`, with no rebase and no force-push. It brings #24941 (`644e4b2c54`): the authz prelude's grant read now names `permission_set`, `organization_id`, `valid_from` and `valid_until`. Git reported no conflict and no `os-regen` deferral. **One semantic conflict, settled in one line** (`894860ed7a`). #24941's pin, `packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts`, has a pair of direct-grant cases: "as declared" and "with a title that reads one hop through user_id". Before it counts anything, the "as declared" case checks the value of the declared title, and it expected the raw user id. On this branch the declared title is the person-named one. So on the merge commit that check read `Prelude → prelude_editor`, and the case went red (`Expected: "USER_ID → prelude_editor"`, `Received: "Prelude → prelude_editor"`). The count assertions after it never ran. The line now expects `Prelude → prelude_editor` in both cases. No count, rig or other assertion changed. **One sentence the merge made false** (`66a590fd88`). The changeset's Cost bullet said the prelude rises to 9 reads in 5 round trips for a grant holder. It now says the prelude's grant read names its columns, so it computes no title and stays at 8 reads in 4 round trips. The rest of the changeset is unchanged. **The prelude, re-measured on the merged tree.** This uses this PR's real `sys_user_permission_set` definition, on the rig of the pin above, by round 1's method. The probe was a scratch file, deleted and never committed. | read, for a principal holding one direct grant | reads | round trips | | --- | --- | --- | | the prelude (`resolveAuthzContext`), round 1 on `b1676441ba` | 9 | 5 | | the prelude (`resolveAuthzContext`), now | 8 | 4 | | control: the same grant read, no projection | 2 (`sys_user_permission_set` 1, `sys_user` 1) | 2 | | control: the same grant read, projected to the four columns | 1 | 1 | - The prelude's reads match the pin's `PRELUDE_READS` exactly: `sys_session` 1, `sys_user` 2, `sys_member` 2, `sys_user_position` 1, `sys_user_permission_set` 1, `sys_metadata_activation` 1. - The unprojected control returns `Prelude → prelude_editor`. So the hop is live on this engine, and the instrument counts it. The projected control returns exactly the four columns. - So on `main` now, the hop adds no read and no round trip to the prelude. - After the one-line change, the pin passes on the merged tree: all 7 cases, both direct-grant cases at 8 reads in 4 round trips. **Tests on `66a590fd88`.** Every run went through `scripts/pm/os-verify-lock.sh`, and each exit is read from its VERDICT line. - Closure build: `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' --filter '@objectstack/plugin-auth^...' build` gave VERDICT command-exit 0 (30 of 85 projects). - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: `Test Files 197 passed (197)`, `Tests 4077 passed | 45 skipped (4122)`. - `pnpm --filter @objectstack/plugin-security typecheck`: VERDICT command-exit 0, `check:test-typecheck: OK`. - The two title files: `Test Files 2 passed (2)`, `Tests 14 passed (14)`. - `plugin-auth` is now a touched package: - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: `Test Files 172 passed (172)`, `Tests 3289 passed | 10 skipped (3299)`. - `pnpm --filter @objectstack/plugin-auth typecheck`: VERDICT command-exit 0, after `pnpm --filter @objectstack/plugin-auth build`. The first attempt stopped at TS2307 in `examples/basic-usage.ts` because the package's own `dist/` was missing, so it measured nothing. - No ablation this round, because no tested behaviour changed. Round 1's ablation of the title stands. **Gates on `66a590fd88`.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 commands: 5 paths against merge base `865d2c8cf`, 267 changed lines. - They ran as one runner under the lock. 67 exited 0 on the first pass. `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n` exited 3 (PREREQUISITE NOT MET, no `dist/`). Both exited 0 on a second pass, after the union's `check:type-check-debt` had built the workspace. - `--ran` exited 0: `Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.` - CI on `66a590fd88`: `Lint & Repo Gates`, `TypeScript Type Check`, `Build Core` and `Governed Surface Queue Guard` succeeded. `Test Core (1/6)` through `(6/6)` succeeded; the aggregate `Test Core` was in_progress at report time. **Superseded in the body above.** - "Files" should also list `packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts` (the one line above). - The opening paragraph's "#24775 remains open after this merges", and open choice 1: the preference half is now carried by #24895. - Open choice 2: ruled B in seat ruling `6291928519` and landed by #24941. The table above is its measurement on `main`. - "Tests" and "Gates": these are round-1 figures on `2deb9e32e6`. The round-2 figures are above. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 693352e commit 6440cf1

5 files changed

Lines changed: 242 additions & 25 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id
6+
7+
Clause-②: no (no accept set moves: the value a declared formula computes on read changes; no key, column, write path or authorization answer moves)
8+
9+
- **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`.
10+
- **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.
11+
- **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.

‎packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ describe('[#24061] one authenticated request\'s prelude, counted on a real engin
281281
// not project it — so the hop, where declared, is live on this engine.
282282
const [held] = (await engine.find('sys_user_permission_set', { where: { user_id: userId }, ...SYS })) as any[];
283283
expect(held?.permission_set).toBe('prelude_editor');
284-
expect(held?.display_title).toBe(oneHopGrantTitle ? 'Prelude → prelude_editor' : `${userId} → prelude_editor`);
284+
expect(held?.display_title).toBe('Prelude → prelude_editor');
285285

286286
const run = await probe.measure(prelude);
287287

‎packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
* page's H1.
1616
*
1717
* Each object now points at `display_title`, a text formula over the columns
18-
* its `titleFormat` names. Through the real engine this file asserts, per
19-
* object:
18+
* its `titleFormat` names. `sys_user_permission_set`'s title names the PERSON
19+
* through `user_id` instead, so its pins live in
20+
* `sys-user-permission-set-person-title.test.ts`; this file holds the rest.
21+
* Through the real engine this file asserts, per object:
2022
*
2123
* 1. the body the registry holds after registration names `display_title`;
2224
* 2. a seeded row's H1 is the `titleFormat` text, not the id, and the
@@ -46,7 +48,6 @@ import {
4648
} from '@objectstack/objectql';
4749
import { SqlDriver } from '@objectstack/driver-sql';
4850
import { resolveDisplayField } from '@objectstack/spec/data';
49-
import { SysUserPermissionSet } from './sys-user-permission-set.object.js';
5051
import { SysUserPosition } from './sys-user-position.object.js';
5152

5253
const SYS = { context: { isSystem: true } } as any;
@@ -58,12 +59,6 @@ interface Case {
5859
}
5960

6061
const CASES: Case[] = [
61-
{
62-
schema: SysUserPermissionSet,
63-
// [ADR-0131 D4] A grant names its set; the title shows that name.
64-
row: { user_id: 'usr_alice', permission_set: 'crm_edit' },
65-
title: 'usr_alice → crm_edit',
66-
},
6762
{
6863
schema: SysUserPosition,
6964
row: { user_id: 'usr_alice', position: 'sales_manager' },
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sys_user_permission_set`'s record title names the PERSON a set is granted
5+
* to, not the raw user id.
6+
*
7+
* Setup's list of a user's permission sets, and the assignment's record page,
8+
* read the title the object declares (`nameField: 'display_title'`). That
9+
* formula used to compose the stored foreign key, `record.user_id + ' → ' +
10+
* record.permission_set`, so every row and every H1 read like
11+
* `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → qa_fls_rep`. It now reads the user's
12+
* `name` — their `email` where the name is blank — ONE hop through
13+
* `user_id`, the traversal the formula read path serves (ADR-0079 addendum,
14+
* 2026-10-18). No column is stored for it.
15+
*
16+
* Through the real engine, the real `sys_user` and the real assignment
17+
* definition, this file asserts:
18+
*
19+
* 1. the registered body points at `display_title`, a text formula that reads
20+
* `user_id` only through the hop, and only columns `sys_user` declares;
21+
* 2. the title a read serves names the person — on the write's response, on
22+
* `findOne`, on a `find` whose projection names only the title — and the
23+
* server's title accessors answer the same text;
24+
* 3. a user whose stored name is blank is titled by their email, the
25+
* declared fallback;
26+
* 4. this is a permission-assignment table, so the property the hop must not
27+
* break: it is read as the READER. A user the reader may not read leaves
28+
* the title null, and neither the name nor the email reaches the row;
29+
* SYSTEM reads the name. A user that no longer exists leaves it null too;
30+
* 5. a row missing a title column is refused by the write path;
31+
* 6. the formula adds no stored column and no search companion column.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
35+
import {
36+
ObjectQL,
37+
SEARCH_COMPANION_FIELD,
38+
provisionSearchCompanion,
39+
resolveSearchCompanionSources,
40+
servedRecordTitle,
41+
} from '@objectstack/objectql';
42+
import { SqlDriver } from '@objectstack/driver-sql';
43+
import { SysUser } from '@objectstack/platform-objects';
44+
import { resolveDisplayField, resolveRecordDisplayName } from '@objectstack/spec/data';
45+
import type { ExecutionContext } from '@objectstack/spec/kernel';
46+
import { SysUserPermissionSet } from './sys-user-permission-set.object.js';
47+
48+
const SYS = { context: { isSystem: true } } as any;
49+
const OBJECT = 'sys_user_permission_set';
50+
51+
/** A reader who is not SYSTEM: the shape every request's context has. */
52+
const READER: ExecutionContext = { userId: 'usr_reader', tenantId: 'org_1' };
53+
54+
/** Every `record.<path>` the `display_title` expression reads, as written. */
55+
function formulaReads(): string[] {
56+
const source = (SysUserPermissionSet as any).fields.display_title?.expression?.source;
57+
if (typeof source !== 'string') throw new Error('display_title carries no expression source');
58+
return [...new Set([...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]))].sort();
59+
}
60+
61+
describe('sys_user_permission_set is titled by the person, not the raw user id', () => {
62+
let engine: ObjectQL;
63+
let driver: SqlDriver;
64+
/** Ids of `sys_user` rows a reader that is not SYSTEM may read. */
65+
const readable = new Set<string>();
66+
67+
const user = async (name: string, email: string): Promise<string> => {
68+
const row = await engine.insert('sys_user', { name, email }, SYS);
69+
return String(row.id);
70+
};
71+
const grant = async (userId: string, permissionSet = 'crm_edit') =>
72+
engine.insert(OBJECT, { user_id: userId, permission_set: permissionSet }, SYS);
73+
74+
beforeAll(async () => {
75+
engine = new ObjectQL();
76+
driver = new SqlDriver({
77+
client: 'better-sqlite3',
78+
connection: { filename: ':memory:' },
79+
useNullAsDefault: true,
80+
});
81+
engine.registerDriver(driver, true);
82+
await engine.init();
83+
engine.registry.registerObject(SysUser as any, 'com.objectstack.test.person-title');
84+
engine.registry.registerObject(SysUserPermissionSet as any, 'com.objectstack.test.person-title');
85+
await engine.syncSchemas();
86+
// Row-level security on `sys_user` for a reader that is not SYSTEM — the
87+
// composition the security plugin makes (its own row scope is "own row or
88+
// an organization member"; here, the ids in `readable`).
89+
engine.registerMiddleware(async (ctx: any, next: () => Promise<void>) => {
90+
if (ctx.object === 'sys_user' && ['find', 'findOne'].includes(ctx.operation) && !ctx.context?.isSystem) {
91+
const scoped = { id: { $in: [...readable] } };
92+
ctx.ast = { ...(ctx.ast ?? {}), where: ctx.ast?.where ? { $and: [ctx.ast.where, scoped] } : scoped };
93+
}
94+
await next();
95+
});
96+
});
97+
98+
beforeEach(() => readable.clear());
99+
100+
afterAll(async () => {
101+
try { await engine?.destroy(); } catch { /* noop */ }
102+
});
103+
104+
it('the registered body points at display_title, a text formula reading user_id only through the hop', () => {
105+
const registered = engine.registry.getObject(OBJECT) as any;
106+
expect(registered.nameField).toBe('display_title');
107+
expect(registered.displayNameField).toBe('display_title');
108+
expect(resolveDisplayField(registered)).toBe('display_title');
109+
expect(registered.fields.display_title?.type).toBe('formula');
110+
expect(registered.fields.display_title?.returnType).toBe('text');
111+
// `user_id` itself is never read bare: a bare read beside a traversal is
112+
// the `traversal-bare-and-traversed` refusal.
113+
expect(formulaReads()).toEqual(['permission_set', 'user_id.email', 'user_id.name']);
114+
const userFields = (engine.registry.getObject('sys_user') as any).fields;
115+
for (const column of ['name', 'email']) expect(userFields[column], column).toBeDefined();
116+
});
117+
118+
it('a read serves the person\'s name — on the write response, findOne and a projected find', async () => {
119+
const alice = await user('Alice Liddell', 'alice@example.com');
120+
const created = await grant(alice);
121+
expect(created.display_title).toBe('Alice Liddell → crm_edit');
122+
123+
const registered = engine.registry.getObject(OBJECT) as any;
124+
const row = await engine.findOne(OBJECT, { where: { id: created.id } }, SYS);
125+
expect(row!.display_title).toBe('Alice Liddell → crm_edit');
126+
expect(row!.display_title).not.toContain(alice);
127+
// The row still holds the foreign KEY; only the title reads through it.
128+
expect(row!.user_id).toBe(alice);
129+
expect(resolveRecordDisplayName(registered, row)).toBe('Alice Liddell → crm_edit');
130+
expect(servedRecordTitle(registered, row)).toBe('Alice Liddell → crm_edit');
131+
132+
// The list's title column: a projection naming only the title.
133+
const listed = await engine.find(OBJECT, { where: { id: created.id }, fields: ['display_title'] }, SYS);
134+
expect(listed).toEqual([{ display_title: 'Alice Liddell → crm_edit' }]);
135+
});
136+
137+
it('a user whose stored name is blank is titled by their email — the declared fallback', async () => {
138+
// The write door refuses a blank `name` (it is required), so a nameless
139+
// user is a row written past it: an import, or a row older than the rule.
140+
const empty = await user('Placeholder', 'no-name@example.com');
141+
const spaces = await user('Placeholder', 'spaces@example.com');
142+
await driver.getKnex()('sys_user').where({ id: empty }).update({ name: '' });
143+
await driver.getKnex()('sys_user').where({ id: spaces }).update({ name: ' ' });
144+
145+
const a = await grant(empty);
146+
const b = await grant(spaces, 'crm_read');
147+
expect((await engine.findOne(OBJECT, { where: { id: a.id } }, SYS))!.display_title).toBe('no-name@example.com → crm_edit');
148+
expect((await engine.findOne(OBJECT, { where: { id: b.id } }, SYS))!.display_title).toBe('spaces@example.com → crm_read');
149+
});
150+
151+
describe('the hop is read as the reader', () => {
152+
it('a user the reader may read is named for that reader', async () => {
153+
const bob = await user('Bob Kinney', 'bob@example.com');
154+
const g = await grant(bob);
155+
readable.add(bob);
156+
const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER });
157+
expect(row!.display_title).toBe('Bob Kinney → crm_edit');
158+
});
159+
160+
it('a user the reader may not read leaves the title null, and nothing of the user reaches the row', async () => {
161+
const carol = await user('Carol Hidden', 'carol.hidden@example.com');
162+
const g = await grant(carol);
163+
// The reader's own read of that user returns nothing…
164+
expect(await engine.find('sys_user', { where: { id: carol } }, { context: READER })).toEqual([]);
165+
// …so the title over it has no value, and neither the name nor the email leaks.
166+
const row = await engine.findOne(OBJECT, { where: { id: g.id } }, { context: READER });
167+
expect(row!.display_title).toBeNull();
168+
expect(JSON.stringify(row)).not.toContain('Carol Hidden');
169+
expect(JSON.stringify(row)).not.toContain('carol.hidden@example.com');
170+
// SYSTEM reads the user directly, so it reads the name through the hop.
171+
expect((await engine.findOne(OBJECT, { where: { id: g.id } }, SYS))!.display_title).toBe('Carol Hidden → crm_edit');
172+
});
173+
174+
it('a grant whose user no longer exists has no title — never one computed in its place', async () => {
175+
const g = await grant('usr_gone');
176+
const row = await engine.findOne(OBJECT, { where: { id: g.id } }, SYS);
177+
expect(row!.display_title).toBeNull();
178+
});
179+
});
180+
181+
it('a row missing a title column is refused, so the formula never sees a NULL part', async () => {
182+
const dan = await user('Dan Ortiz', 'dan@example.com');
183+
for (const [omit, partial] of [
184+
['user_id', { permission_set: 'crm_edit' }],
185+
['permission_set', { user_id: dan }],
186+
] as const) {
187+
await expect(engine.insert(OBJECT, partial, SYS)).rejects.toMatchObject({
188+
code: 'VALIDATION_FAILED',
189+
fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]),
190+
});
191+
}
192+
});
193+
194+
it('adds no stored column and no search companion column', async () => {
195+
const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo());
196+
expect(columns).toContain('user_id');
197+
expect(columns).not.toContain('display_title');
198+
// The companion (`__search`) is a real column fed by the title field, and
199+
// a formula title is never a source — not even where pinyin search is on.
200+
const registered = engine.registry.getObject(OBJECT) as any;
201+
expect(resolveSearchCompanionSources(registered)).toEqual([]);
202+
expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined();
203+
});
204+
});

‎packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,14 @@ export const SysUserPermissionSet = ObjectSchema.create({
3636
// needed — the DelegatedAdminGate is the authz.
3737
managedBy: 'system-data',
3838
description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).',
39-
// [ADR-0079] The record title is `display_title`, a text formula over the
40-
// same two columns `titleFormat` names. With no pointer declared, the
41-
// registry's designate-only pass stamped `nameField: 'id'` (the first
42-
// title-eligible field), so a renderer honouring ADR-0079's order (an
43-
// explicit `nameField` wins over `titleFormat`) drew the raw id as the record
44-
// page's H1. `titleFormat` stays for renderers that still read it first;
45-
// `sys-security-assignment-display-title.test.ts` holds the two to the same text.
39+
// [ADR-0079] The record title is `display_title`, a text formula naming the
40+
// user and the set. With no pointer declared, the registry's designate-only
41+
// pass stamped `nameField: 'id'` (the first title-eligible field), so a
42+
// renderer honouring ADR-0079's order (an explicit `nameField` wins over
43+
// `titleFormat`) drew the raw id as the record page's H1. `titleFormat` is
44+
// the rung below the pointer: a renderer reaches it only where the title has
45+
// no value (the user is not readable by this reader, or no longer exists).
46+
// `sys-user-permission-set-person-title.test.ts` pins the title.
4647
displayNameField: 'display_title',
4748
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
4849
titleFormat: '{user_id} → {permission_set}',
@@ -57,17 +58,23 @@ export const SysUserPermissionSet = ObjectSchema.create({
5758
}),
5859

5960
// [ADR-0079] The record title (`nameField` above). A formula is computed on
60-
// read and has no stored column. It reads only this row's own columns —
61-
// the user and the set's name, never a field of a looked-up record — and
62-
// neither is hidden, permission-guarded or masked on this object, so the
63-
// title carries nothing the declared read path withholds. Both are
64-
// required (the set by name, ADR-0131 D4), so the expression needs no null
65-
// guard; a legacy grant the v18 ceremony's rewrite names is the only row
66-
// that ever stored the name NULL.
61+
// read and has no stored column. It names the PERSON, not the raw user id:
62+
// the user's `name`, or their `email` where the name is blank (the
63+
// platform's own `name || email` rule for a person's handle), read ONE hop
64+
// through `user_id` (ADR-0079 addendum, 2026-10-18). That hop is read
65+
// under the READER's own authority, through every layer a direct read of
66+
// `sys_user` applies, so the title carries nothing that reader could not
67+
// read on the user itself: a user the reader may not read, or one that no
68+
// longer exists, leaves the title null — never a value computed in its
69+
// place — and a renderer falls to `titleFormat` below it.
70+
// `user_id` is read only through the hop (a bare read beside a traversal
71+
// is refused, `traversal-bare-and-traversed`). Both columns are required
72+
// (the set by name, ADR-0131 D4); a legacy grant the v18 ceremony's
73+
// rewrite names is the only row that ever stored the set's name NULL.
6774
display_title: Field.formula({
6875
label: 'Title',
6976
returnType: 'text',
70-
expression: F`record.user_id + ' → ' + record.permission_set`,
77+
expression: F`(isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' → ' + record.permission_set`,
7178
description: 'Record title: the user and the permission set assigned to them (computed on read)',
7279
}),
7380

0 commit comments

Comments
 (0)