Skip to content

Commit 27b3f1f

Browse files
feat: the position list says who holds each position and which permission sets it grants (#25369)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoWnNJDmFSk7m68Yejj9bevh)_ Fixes #25333 Clause-②: yes (widening) Metadata-protocol: none ## What Setup → Positions can now say who holds each position and which permission sets it grants. Both answers come from the rules the authorization resolver enforces with. They are not a second copy of those rules. - `GET /meta/position?include=grants` adds two read-only keys to each position: `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Both transports serve it, through the shared list chain (`createMetaListAnswer`). Without `?include=grants`, the list is byte-for-byte unchanged, so the pickers and assignment pages that read it pay no extra read. - `via: 'definition'` marks a set the position's `permissionSets` names. `via: 'name'` marks the set that shares the position's name, which the position-name fold grants although the definition does not list it. That is the set #24757 found in force and invisible. The fold's retirement is on hold, so the list shows it rather than hiding a grant that is live. - `_holderCount` counts holders in the caller's active organization by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, in these cases: - there is no active organization; - the position is `platform_admin` or `guest`; - the read was truncated; - the caller may not read `sys_user_position`. ## How - **`@objectstack/core`.** Two readers are lifted out of `resolveUserAuthzGrants`: `readPositionGrantsInForce` (the §6a rule: the definition's sets in force, with disabled positions and sets dropped) and `countPositionHolders`, which uses the §3/§4 holding rule through the shared `heldPositionNames`. The resolver now calls the same helpers and resolves exactly what it resolved before; the core security suite is green. - **`@objectstack/spec`.** `ISecurityService` gains the optional `describePositionGrants(positions, context)` and its `PositionGrantSummary` type. A security service without it serves the list undecorated, never with empty values. - **`@objectstack/plugin-security`.** It serves `describePositionGrants` by running the evaluator's own `resolvePermissionSets` plus `dropDeactivatedFoldedSets` over `[position, ...declared]`, the same steps `resolvePermissionSetsForContext` takes. The holder count is gated on `canReadObject('sys_user_position', context)`. - **`@objectstack/rest`.** Step 8 of `createMetaListAnswer` decorates a `position` list when asked with `include=grants`. An anonymous caller, or a service without the method, gets the list unchanged. ## Acceptance (the card's *Done when*) - **Holder count and sets per row.** Served as above. The console half (the columns and the whole-row click) is objectstack-ai/objectui's own draft PR on the same branch name, `claude/project-thread-kuevbz`. - **Matches what the server enforces.** This is pinned as agreement, not as the reader's own output: - `position-catalog-readers.test.ts` checks that, user by user, `countPositionHolders` counts a user for P exactly when `resolveUserAuthzGrants` gives that user P, and that a position's sets in force are exactly what the resolver grants a holder of it alone. - `describe-position-grants.test.ts` checks that each described position's sets equal `resolvePermissionSetsForContext` for a holder of that position alone, the same-named set included. - **Same-named set case.** `sales_manager` lists `crm_sales_user` and `forecasting` (`via: 'definition'`) and `sales_manager` (`via: 'name'`). Three controls go with it: - a set declared under the position's own name is a declaration, not a fold; - a fold whose set is switched off grants nothing; - `guest` folds nothing. - **Both transports.** `meta-list-projection-parity.test.ts` runs the `?include=grants` probe through RestServer and the runtime dispatcher. ## Ablations Each was run with `scripts/ablation-replace.mjs` and restored to the HEAD blob. - Removing the fold from `describePositionGrants` turns 2 tests red. - Removing the `canReadObject` gate on the holder count turns 1 test red (the null-for-a-member-without-the-read case). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K9LFkQaGHiensHNgGGgKvD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee63b47 commit 27b3f1f

12 files changed

Lines changed: 783 additions & 26 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/core': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/rest': patch
6+
---
7+
8+
feat: the position list can say who holds each position and which permission sets it grants, read where the resolver enforces both
9+
10+
Clause-②: yes (widening: `GET /meta/position?include=grants` adds two read-only served keys per position, `_holderCount` and `_grantedPermissionSets`; `ISecurityService` gains the optional `describePositionGrants`)
11+
Metadata-protocol: none
12+
13+
Setup → Positions could not answer "who is a sales manager, and what do they get?". The list now can, and its answer is the enforcement path's, not a second copy of it.
14+
15+
- **`GET /meta/position?include=grants`** (`@objectstack/rest`, both transports through the shared list chain). Each position gains `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Without `?include=grants` the list is unchanged, so the pickers and assignment pages that read it pay no extra read.
16+
- **`via: 'definition'`** marks a set the position's `permissionSets` names. **`via: 'name'`** marks the set that shares the position's name and that the definition does not list. The position-name fold grants it anyway (the `position_name_fold_grant` warning), so the list shows it rather than hiding a grant that is in force.
17+
- **The sets** are the ones the resolver grants a holder of that position alone. A position or set switched off in the activation ledger grants nothing, and a name the catalog holds no set for confers nothing.
18+
- **`_holderCount`** counts the people who hold the position in the caller's active organization, by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, with no active organization, for `platform_admin` and `guest`, after a truncated read, and for a caller who may not read `sys_user_position`.
19+
- **`ISecurityService.describePositionGrants`** (`@objectstack/spec` contract, served by `@objectstack/plugin-security`) is optional. A security service without it serves the list undecorated, never with empty values.
20+
- **`@objectstack/core`** exports `readPositionGrantsInForce` and `countPositionHolders`. `resolveUserAuthzGrants` now asks the same two rules for a caller, with no change in what it resolves.

‎packages/core/src/security/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@ export {
207207
// security catalog — the `sys_metadata_activation` ledger, never a catalog
208208
// row's `active` column — shared with the explainer and the security plugin.
209209
readDisabledCatalogNames,
210+
// [ADR-0131 D3/D4, ADR-0095 D3] A position, described as the resolver
211+
// enforces it for a holder: the sets its definition grants in force, and how
212+
// many people hold it in an organization — the position catalog's list reads
213+
// these, never a second copy of the rule.
214+
readPositionGrantsInForce,
215+
countPositionHolders,
210216
// [ADR-0138 D4] The guest entry's organization step, shared with the CRUD
211217
// gate that raises D4's located refusal — one step, two askers.
212218
resolveGuestOrganization,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The two readers the position catalog's list describes a position with —
5+
* `readPositionGrantsInForce` (the sets its definition grants, in force) and
6+
* `countPositionHolders` (who holds it in an organization) — pinned against the
7+
* resolver that enforces both, `resolveUserAuthzGrants`, on the recording double
8+
* the batch-equivalence suite drives.
9+
*
10+
* The pin is the agreement, not the reader's own output: for every user of the
11+
* fixture, a user is counted for position P exactly when the resolver gives that
12+
* user P, and a position's sets in force are exactly the sets the resolver
13+
* grants a user who holds that position alone.
14+
*/
15+
16+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17+
import { resetPlatformAdminEmailMemo } from './platform-admin.js';
18+
import {
19+
countPositionHolders,
20+
readPositionGrantsInForce,
21+
resolveUserAuthzGrants,
22+
} from './resolve-authz-context.js';
23+
import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js';
24+
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';
25+
26+
const ORG_A = 'org_a';
27+
const ORG_B = 'org_b';
28+
29+
const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL';
30+
const POSTURE_ENV = 'OS_TENANCY_POSTURE';
31+
let ambient: Record<string, string | undefined> = {};
32+
beforeAll(() => {
33+
ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] };
34+
delete process.env[OWNER_ENV];
35+
delete process.env[POSTURE_ENV];
36+
resetPlatformAdminEmailMemo();
37+
});
38+
afterAll(() => {
39+
for (const [k, v] of Object.entries(ambient)) {
40+
if (v === undefined) delete process.env[k];
41+
else process.env[k] = v;
42+
}
43+
resetPlatformAdminEmailMemo();
44+
});
45+
46+
const definitions = {
47+
permissions: [
48+
{ name: 'crm_sales_user', systemPermissions: ['cap_sales'] },
49+
{ name: 'forecasting', systemPermissions: ['cap_forecast'] },
50+
{ name: 'switched_off_set', systemPermissions: ['cap_off'] },
51+
],
52+
positions: [
53+
{ name: 'everyone', permissionSets: [] },
54+
{ name: 'sales_rep', permissionSets: ['crm_sales_user'] },
55+
// One live set, one switched off, one the catalog holds no set for.
56+
{ name: 'sales_manager', permissionSets: ['crm_sales_user', 'forecasting', 'switched_off_set', 'no_such_set'] },
57+
{ name: 'retired_position', permissionSets: ['forecasting'] },
58+
{ name: 'unbound', permissionSets: [] },
59+
],
60+
};
61+
62+
const off = (metadata_type: 'position' | 'permission', name: string) =>
63+
({ metadata_type, name, package_id: null, active: false });
64+
65+
const PAST = '2000-01-01T00:00:00.000Z';
66+
67+
function tables() {
68+
return {
69+
sys_user: ['u1', 'u2', 'u3', 'u4', 'u5'].map((id) => ({ id, email: `${id}@example.com` })),
70+
sys_member: [
71+
{ user_id: 'u1', organization_id: ORG_A, role: 'owner' },
72+
{ user_id: 'u2', organization_id: ORG_A, role: 'member' },
73+
{ user_id: 'u3', organization_id: ORG_A, role: 'member,admin' },
74+
{ user_id: 'u4', organization_id: ORG_A, role: 'member', valid_until: PAST },
75+
{ user_id: 'u5', organization_id: ORG_B, role: 'member' },
76+
],
77+
sys_user_position: [
78+
{ user_id: 'u1', organization_id: ORG_A, position: 'sales_manager' },
79+
{ user_id: 'u2', organization_id: ORG_A, position: 'sales_rep' },
80+
{ user_id: 'u3', organization_id: ORG_A, position: 'sales_rep' },
81+
// Lapsed: holds nothing.
82+
{ user_id: 'u3', organization_id: ORG_A, position: 'sales_manager', valid_until: PAST },
83+
{ user_id: 'u2', organization_id: ORG_A, position: 'retired_position' },
84+
// Another organization's row: counted there, not here.
85+
{ user_id: 'u5', organization_id: ORG_B, position: 'sales_rep' },
86+
// An organization-less row applies in no organization.
87+
{ user_id: 'u2', organization_id: null, position: 'unbound' },
88+
],
89+
sys_user_permission_set: [],
90+
sys_metadata_activation: [off('permission', 'switched_off_set'), off('position', 'retired_position')],
91+
};
92+
}
93+
94+
const ql = () => bindStaticSecurityCatalog(makeRecordingQl(tables()), definitions);
95+
96+
const ledgerWithoutPositions = () => bindStaticSecurityCatalog(makeRecordingQl({
97+
...tables(),
98+
sys_metadata_activation: tables().sys_metadata_activation.filter((r) => r.metadata_type !== 'position'),
99+
}), definitions);
100+
101+
const POSITIONS = ['everyone', 'sales_rep', 'sales_manager', 'retired_position', 'unbound', 'org_owner', 'org_admin', 'org_member'];
102+
103+
describe('countPositionHolders agrees with the resolver, user by user', () => {
104+
it('counts a user for P exactly when resolveUserAuthzGrants gives that user P in the organization', async () => {
105+
for (const org of [ORG_A, ORG_B]) {
106+
const counts = await countPositionHolders(ql(), org, POSITIONS);
107+
const expected = new Map<string, number>(POSITIONS.map((p) => [p, 0]));
108+
for (const userId of ['u1', 'u2', 'u3', 'u4', 'u5']) {
109+
// Holding is the assignment: a switched-off position keeps its holders
110+
// (ADR-0049 — "users keep their assignment but the position stops
111+
// granting permissions"), and the resolver drops its NAME only to stop
112+
// the grant. So the resolver is asked over the same rows with no
113+
// position switched off; what a switched-off position grants (nothing) is
114+
// the other reader's, below.
115+
const grants = await resolveUserAuthzGrants(ledgerWithoutPositions(), userId, { tenantId: org });
116+
// A user who is not an active member of the organization holds nothing in it
117+
// (the resolver still projects `everyone`, the authenticated audience).
118+
const member = tables().sys_member.some((m) => m.user_id === userId && m.organization_id === org && !m.valid_until);
119+
for (const p of POSITIONS) {
120+
if (grants.positions.includes(p) && (member || p !== 'everyone')) expected.set(p, expected.get(p)! + 1);
121+
}
122+
}
123+
expect(Object.fromEntries(counts), org).toEqual(Object.fromEntries(expected));
124+
}
125+
});
126+
127+
it('reads the measured numbers for the fixture', async () => {
128+
const counts = await countPositionHolders(ql(), ORG_A, POSITIONS);
129+
expect(Object.fromEntries(counts)).toEqual({
130+
everyone: 3, // u1, u2, u3; u4's membership lapsed
131+
sales_rep: 2, // u2, u3
132+
sales_manager: 1, // u1; u3's row lapsed
133+
retired_position: 1, // u2 holds the row; what it grants is the other reader's question
134+
unbound: 0, // u2's organization-less row applies nowhere
135+
org_owner: 1,
136+
org_admin: 1,
137+
org_member: 2, // u2, u3
138+
});
139+
});
140+
141+
it('has no count without an organization, for the derived and anonymous positions, or past a truncated read', async () => {
142+
expect((await countPositionHolders(ql(), undefined, POSITIONS)).size).toBe(0);
143+
const counts = await countPositionHolders(ql(), ORG_A, ['platform_admin', 'guest', 'sales_rep']);
144+
expect([...counts.keys()]).toEqual(['sales_rep']);
145+
expect((await countPositionHolders(ql(), ORG_A, ['sales_rep'], { limit: 2 })).size).toBe(0);
146+
});
147+
});
148+
149+
describe('readPositionGrantsInForce agrees with the resolver, position by position', () => {
150+
it('a position grants exactly the definition sets a holder of it alone is granted', async () => {
151+
const { inForce, disabledPositions } = await readPositionGrantsInForce(ql(), [
152+
'sales_rep', 'sales_manager', 'retired_position', 'unbound',
153+
]);
154+
expect(Object.fromEntries(inForce)).toEqual({
155+
sales_rep: ['crm_sales_user'],
156+
// switched_off_set is off in the ledger; no_such_set names nothing.
157+
sales_manager: ['crm_sales_user', 'forecasting'],
158+
unbound: [],
159+
});
160+
expect([...disabledPositions]).toEqual(['retired_position']);
161+
162+
// The resolver's own answer for a user holding each position alone.
163+
for (const position of ['sales_rep', 'sales_manager', 'retired_position', 'unbound']) {
164+
const alone = {
165+
...tables(),
166+
sys_member: [{ user_id: 'solo', organization_id: ORG_A, role: '' }],
167+
sys_user_position: [{ user_id: 'solo', organization_id: ORG_A, position }],
168+
};
169+
const grants = await resolveUserAuthzGrants(
170+
bindStaticSecurityCatalog(makeRecordingQl(alone), definitions), 'solo', { tenantId: ORG_A },
171+
);
172+
expect(grants.permissions, position).toEqual(inForce.get(position) ?? []);
173+
}
174+
});
175+
});

0 commit comments

Comments
 (0)