Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a6f50f3
fix(plugin-security)!: retire the position-name fold; a namesake set …
claude Oct 26, 2026
adef3a4
test(plugin-security): pin the retired fold; move name-granted fixtur…
claude Oct 26, 2026
f951faf
test(plugin-security): sort the delegable listing expectation
claude Oct 26, 2026
4a2e24f
chore(changeset): plugin-security retires the position-name fold (#24…
claude Oct 26, 2026
aa5c5b2
docs(pm): dispatch-gates no longer cites the deleted position-name fo…
claude Oct 26, 2026
94185a7
Merge origin/main into claude/project-thread-6wuxt3
claude Oct 26, 2026
c57b18f
feat(core,plugin-security): explain names both channels for a set hel…
claude Oct 26, 2026
67100cd
feat(plugin-security): a human self-explanation reads the resolver's …
claude Oct 26, 2026
6287ce3
Merge origin/main into claude/project-thread-6wuxt3
claude Oct 26, 2026
621e6af
test(core,plugin-security): directPermissions in the envelope goldens…
claude Oct 26, 2026
5df2f50
chore(gates): the pinned engine-double ledger records the new self-ex…
claude Oct 26, 2026
32fe4f1
Merge origin/main into claude/project-thread-6wuxt3
claude Oct 26, 2026
cc26ba6
fix(tests): envelopes carry directPermissions; the verify fixture dec…
claude Oct 26, 2026
df4eb65
test(cli): the parent-write-reach fixture's position names its set (#…
claude Oct 26, 2026
6d0d586
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
0239ff1
test(plugin-security): the owner-may-edit delegator holds its set by …
claude Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/24757-retire-position-name-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/plugin-security': patch
'@objectstack/core': patch
---

fix(plugin-security)!: a permission set that shares a held position's name is no longer granted; declare it in the position's `permissionSets` (#24757)

Clause-②: yes (narrowing: a holder of position `P` is no longer granted the permission set named `P` unless `P`'s definition names it in `permissionSets`; widened: `@objectstack/core`'s `UserAuthzGrants` and `ResolvedAuthzContext` gain the additive field `directPermissions`)
Metadata-protocol: none
Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); order and unlock on #24757 (`6290781571`, `6392096931`); #25799 seat ruling A (claims `6414302166`, `6414315157`)

ADR-0131 D3/D4: a position grants only the permission sets its definition's `permissionSets` names — the binding is written, not inferred. The security plugin's set resolution still requested every held position's NAME as a permission-set name too (the position-name fold), so a set that happened to share a position's name was granted to its holders without anyone writing the binding. #13419 ruling B retires that channel; HotCRM declared its bindings first (objectstack-ai/hotcrm#2200, released in hotcrm `v6.0.0`).

What changes:

- `resolvePermissionSetsForContext` requests only the context's `permissions` and the baseline. A held position's name reaches no set. `dropDeactivatedFoldedSets`, the second activation read that only the fold needed, is gone.
- The `position_name_fold_grant` warning keeps its event name and fields and now says the opposite: the same-named set is **not** granted, and how to grant it (`permissionSets: ['P']`) or to rename one of the two. Its payload carries `granted: false`. It is still logged once per position name per process.
- `describePositionGrants` lists only the sets a position's definition names, so it no longer serves `via: 'name'`. The `'name'` member of `PositionGrantVia` (`@objectstack/spec`) and its UI badge are retired separately in #26022; until then the value is published but never served.
- The delegated-administration gate judges an assignment of position `P` on the sets `P`'s definition names only. A set that only shares `P`'s name is no longer walked for the allowlist, the containment check or the organization boundary, because no assignment grants it.
- `explainAccess` attributes a set to `position:P` when `P`'s definition names it, and says `direct grant` otherwise. When the position definitions cannot be read it says so, rather than guessing either. A set held both through the user's own grant row and through a held position's definition reads `direct grant + position:P`, so revoking the position is not mistaken for removing the access (#25799).
- `@objectstack/core`: `UserAuthzGrants` and `ResolvedAuthzContext` gain `directPermissions: string[]`, the names conferred by the user's own `sys_user_permission_set` rows that apply in the active organization (a set a position definition also names included, a set only a definition names excluded). It is additive and grants nothing; the explainer reads it. Code that builds either object by hand must now set it (`directPermissions: []` when there are no grant rows).
- The lint gate `check-position-name-fold-loaders` and the census script `measure-position-name-fold-census` are deleted. They guarded the fold's only route, which no longer exists.

**BREAKING** for an environment that relied on a shared name to grant a set.

- **Before.** A user holding position `sales_manager` was granted the permission set `sales_manager` whether or not the position's definition named it.
- **Now.** That user is granted only the sets the `sales_manager` definition lists in `permissionSets`. The first resolution that would have folded the set logs `position_name_fold_grant` with `granted: false`, naming the position and the fix.
- **Fix.** Name the set in the position: `permissionSets: ['sales_manager']`. If the shared name is a coincidence, nothing needs to change.
- **Cloud ordering.** An environment running HotCRM must be on the HotCRM release that declares its bindings (`v6.0.0` or later) before it takes this release.

<!-- adr-0087: not-required (no-migration-prescription) a run-time narrowing in the security plugin's permission-set resolution: a held position's name is no longer requested as a permission-set name. No authorable key, spelling or stored shape moves, the position and permission-set schemas parse exactly as before, and no stored row is read, rewritten or converted. The repair is the author's choice between naming the set in the position's `permissionSets` and leaving it ungranted, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); nothing exported from `@objectstack/spec` moves in this change; and the one type change, the additive `directPermissions` on `@objectstack/core`'s resolver output, is a per-request value no stored artifact or authored metadata carries, so no conversion has a source to rewrite. -->
23 changes: 0 additions & 23 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -947,29 +947,6 @@ jobs:
continue-on-error: true
run: pnpm check:stack-collection-maps

# The vendored HotCRM permissions artifact has NO non-test loader (#13419).
# `security-plugin.ts` folds a POSITION name into the permission-set
# request, so a permission set named after a position is granted with no
# `sys_position_permission_set` row behind it. The #13419 census reports
# exactly two such folds — `sales_rep` and `sales_manager` — and both take
# their permission-set half from a fixture that only one TEST reads. That
# makes them a collision between an example app's position names and a
# test fixture's set names rather than grants any deployment holds, which
# is why the ruling's 物化 worklist is empty in this repo. The empty
# worklist is the thing being pinned: wire that artifact into a real
# composition and this reddens, instead of two live authorization grants
# appearing with nothing said. Its `--self-test` supplies the loader cases
# a clean tree by construction does not contain — without them the
# production zero could equally be a scanner that stopped matching.
# Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION
# IDIOM note at the top of this file. ~1s, no spawns.
- name: 'The #13419 name-fold fixture has no non-test loader'
id: the-name-fold-fixture-has-no-non-test-loader
continue-on-error: true
run: |
node scripts/check-position-name-fold-loaders.mjs --self-test
node scripts/check-position-name-fold-loaders.mjs

# The dashboard widget `options` census vs the spec (#12926). The parser's
# `CONSUMED_WIDGET_OPTION_KEYS` decides which `options` keys get a
# `unconsumed-widget-option` warning, and five of its six members are the
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/test/verify-parent-write-reach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ const PERMISSIONS = [
},
];

const POSITIONS = [{ name: 'vpw_manager', label: 'Manager' }];
// [ADR-0131 D3/D4; #24757] The position names its set: a set that only shares
// a position's name is granted by nothing.
const POSITIONS = [{ name: 'vpw_manager', label: 'Manager', permissionSets: ['vpw_manager'] }];

/** The same app, plus a hook that refuses every edit of a contract with the platform's own code. */
const CONTROL_SOURCE = `{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ function observable(ctx: any): Record<string, unknown> {
const baseAuthz = (over: Partial<ResolvedAuthzContext> = {}): ResolvedAuthzContext => ({
positions: [],
permissions: [],
directPermissions: [],
systemPermissions: [],
org_user_ids: [],
accessible_org_ids: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"everyone"
],
"permissions": [],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_empty"
Expand Down Expand Up @@ -75,6 +76,7 @@
"everyone"
],
"permissions": [],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_multi",
Expand Down Expand Up @@ -156,6 +158,7 @@
"everyone"
],
"permissions": [],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_lapsed",
Expand Down Expand Up @@ -241,6 +244,7 @@
"read_all",
"base_access"
],
"directPermissions": [],
"systemPermissions": [
"record_write"
],
Expand Down Expand Up @@ -337,6 +341,10 @@
"admin_full_access",
"org_tools"
],
"directPermissions": [
"admin_full_access",
"org_tools"
],
"systemPermissions": [
"manage_users"
],
Expand Down Expand Up @@ -436,6 +444,9 @@
"low",
"organization_admin"
],
"directPermissions": [
"low"
],
"systemPermissions": [],
"org_user_ids": [
"u_ta"
Expand Down Expand Up @@ -523,6 +534,7 @@
"permissions": [
"ai_seat"
],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_ai"
Expand Down Expand Up @@ -602,6 +614,7 @@
"everyone"
],
"permissions": [],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_noai"
Expand Down Expand Up @@ -676,6 +689,9 @@
"ai_seat",
"extra"
],
"directPermissions": [
"extra"
],
"systemPermissions": [],
"org_user_ids": [
"u_seed"
Expand Down Expand Up @@ -749,6 +765,7 @@
"everyone"
],
"permissions": [],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_lim",
Expand Down Expand Up @@ -2029,6 +2046,7 @@
"everyone"
],
"permissions": [],
"directPermissions": [],
"systemPermissions": [],
"org_user_ids": [
"u_noorg"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ interface Golden { sequentialLegs: number; grants: unknown; queries: RecordedCal
* — the last mainline commit before the batch (post-#10982). `sequentialLegs`
* is that run's leg count, which equalled its query count because every read
* awaited the one before it.
*
* [#25799] `directPermissions` postdates that capture: each fixture's value was
* added from the current resolver, beside the captured envelope it leaves
* unchanged (the names the fixture's own grant rows confer in its tenant).
*/
const GOLDEN: Record<string, Golden> = JSON.parse(
readFileSync(new URL('./resolve-authz-context.batch-equivalence.golden.json', import.meta.url), 'utf8'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25799] `resolveUserAuthzGrants` reports its own split of the two channels a
* set reaches a user through: `directPermissions` lists the names conferred by
* the user's OWN grant rows (`sys_user_permission_set`, §6), and never a name
* only a held position's definition names (§6a). The explainer reads it to
* tell an assignment row from a position definition.
*
* The rows it lists are the rows §6 confers, by §6's own rules: the row applies
* in the active organization, and its set is one the catalog holds and the
* activation ledger has not switched off.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { resetPlatformAdminEmailMemo } from './platform-admin.js';
import { resolveUserAuthzGrants } from './resolve-authz-context.js';
import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js';
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';

const ORG_A = 'org_a';
const ORG_B = 'org_b';

const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL';
const POSTURE_ENV = 'OS_TENANCY_POSTURE';
let ambient: Record<string, string | undefined> = {};
beforeAll(() => {
ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] };
delete process.env[OWNER_ENV];
delete process.env[POSTURE_ENV];
resetPlatformAdminEmailMemo();
});
afterAll(() => {
for (const [k, v] of Object.entries(ambient)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
resetPlatformAdminEmailMemo();
});

/** `everyone` (the anchor every member holds) names two sets: one also granted directly below, one not. */
const definitions = {
permissions: [
{ name: 'tools', systemPermissions: ['cap_tools'] },
{ name: 'elsewhere', systemPermissions: ['cap_elsewhere'] },
{ name: 'both_channels', systemPermissions: ['cap_both'] },
{ name: 'definition_only', systemPermissions: ['cap_definition'] },
],
positions: [{ name: 'everyone', permissionSets: ['both_channels', 'definition_only'] }] as Array<Record<string, unknown>>,
};

/** The user's own grant rows: two in ORG_A, one in ORG_B. */
const GRANTS = [
{ permission_set: 'tools', organization_id: ORG_A },
{ permission_set: 'both_channels', organization_id: ORG_A },
{ permission_set: 'elsewhere', organization_id: ORG_B },
];

async function resolve(tenantId: string | undefined, ledger: unknown[] = []) {
const ql = bindStaticSecurityCatalog(makeRecordingQl({
sys_user: [{ id: 'u1', email: 'u1@example.com' }],
sys_member: [{ user_id: 'u1', organization_id: ORG_A, role: 'member' }, { user_id: 'u1', organization_id: ORG_B, role: 'member' }],
sys_user_position: [],
sys_position: [],
sys_position_permission_set: [],
sys_user_permission_set: GRANTS.map((g) => ({ user_id: 'u1', ...g })),
sys_permission_set: [],
sys_metadata_activation: ledger,
}), definitions);
return resolveUserAuthzGrants(ql, 'u1', tenantId ? { tenantId } : {});
}

describe('[#25799] directPermissions is the resolver\'s own direct channel', () => {
it('lists the sets the user\'s own rows confer in this organization, a set a definition also names included, and no set only a definition names', async () => {
const out = await resolve(ORG_A);
// The control: every one of these is in force for the user.
expect(out.permissions).toEqual(expect.arrayContaining(['tools', 'both_channels', 'definition_only']));
expect(out.directPermissions).toEqual(['tools', 'both_channels']);
});

it('follows the tenant rule: a row of another organization is not a direct grant here, and is one there', async () => {
expect((await resolve(ORG_A)).directPermissions).not.toContain('elsewhere');
const inB = await resolve(ORG_B);
expect(inB.directPermissions).toEqual(['elsewhere']);
expect(inB.permissions).toContain('elsewhere');
});

it('with no active organization no stored row applies, so nothing is direct while the definition-bound sets stand', async () => {
const out = await resolve(undefined);
expect(out.directPermissions).toEqual([]);
expect(out.permissions).toEqual(expect.arrayContaining(['both_channels', 'definition_only']));
});

it('a set the activation ledger switched off is not listed: the row confers nothing', async () => {
const out = await resolve(ORG_A, [{ metadata_type: 'permission', name: 'tools', package_id: null, active: false }]);
expect(out.permissions).not.toContain('tools');
expect(out.directPermissions).toEqual(['both_channels']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,13 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg
requestPosture(p);
envelopes[p] = await resolveUserAuthzGrants(makeQl(tables()), 'usr_1', { nowMs: NOW });
}
expect(envelopes.single).toEqual(envelopes.isolated);
// [#25799] `directPermissions` names the CHANNEL a set arrives through,
// not the standing: under \`single\` the legacy grant row anchors and so
// applies with no active organization, while walled it applies nowhere
// and the config confers the same set. The standing envelope is compared
// without it.
const standing = ({ directPermissions: _channel, ...rest }: any) => rest;
expect(standing(envelopes.single)).toEqual(standing(envelopes.isolated));
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe('[#10757 leg 4] the Default Organization read and the activation ledger
expect(grants).toEqual({
positions: ['platform_admin', 'org_owner', 'everyone'],
permissions: [ADMIN_FULL_ACCESS],
directPermissions: [ADMIN_FULL_ACCESS],
systemPermissions: expect.any(Array),
org_user_ids: ['usr_1'],
accessible_org_ids: ['org_default'],
Expand Down
Loading
Loading