Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .changeset/24757-fold-warning-retirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): the `position_name_fold_grant` warning says the same-name grant is removed in the next major version, and what holders lose then

Clause-②: no (a runtime warning's wording plus one added field in its log payload; no accept set, served shape, resolution result or error code moves)

- **What it was.** A position resolves the permission set that has its own name even when the position's definition does not list that set in `permissionSets`. `plugin-security` has warned about this since 17.3 (`position_name_fold_grant`), once per position. The warning said the grant was "in force and ungoverned" and asked for `permissionSets: ['P']` or a rename. It did not say that this name-based grant is being retired, or what happens to holders when it is.
- **What changed.** The warning now says three things. The set is in force for holders of position `P` only because the two share a name. This name-based grant is deprecated and is removed in the next major version: from then on, holders of `P` do not receive set `P` unless `P`'s definition names it. To keep the grant, declare `permissionSets: ['P']` on position `P`; if the shared name is a coincidence, rename one of the two. The structured payload adds `removedIn: 'next_major'`. `event`, `position` and `permissionSet` are unchanged.
- **Why only a warning in 18.x.** No ADR-0087 conversion can carry this retirement: the platform cannot write `permissionSets` into a sealed managed position definition (ADR-0131 D6). So 18.x ships the warning, and the removal of the name-based grant waits for 19.0.
- **Unchanged.** Every resolution result. A holder of `P` still receives set `P` in 18.x. The warning still fires under the same conditions, including for a position bound to a different set that also shares its name with one. It stays silent for a declared pair, a baseline set and a position whose name matches no set, and it still fires once per position name until the next restart.
- **What to do.** For each position named in a `position_name_fold_grant` warning, add the same-named set to that position's `permissionSets` before upgrading to the next major version. Setup's Permission Sets field and the set's Assigned Users panel then show the binding as well.
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13419 执行要点 3, warning half] The position-name fold, said out loud.
* [#13419 执行要点 3; #24757 under 「甲」] The position-name fold, said out loud —
* and, since 18.x, said as the loud half of its retirement.
*
* ## What is pinned
*
* `resolvePermissionSetsForContextUnmemoized` requests
* `[...positions, ...explicitPermissionSets]`, so a POSITION name resolves a
* same-named PERMISSION SET with no `sys_position_permission_set` row behind
* it. The maintainer ruling (2026-08-31, 「同意」) makes the junction table the
* one governed channel; 要点 5 permits a warning and nothing else until the fold
* itself is deleted — 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」.
* same-named PERMISSION SET that the position's definition does not name in
* `permissionSets` (ADR-0131 D3/D4: the definition is the binding). The
* maintainer ruling on #13419 (2026-08-31, 「同意」 to B) retires the fold; 要点 5
* permits a warning and nothing else until the fold itself is deleted —
* 「任何行为差异只能表现为拒绝/告警,永不静默改变解析结果」.
*
* The standing ruling 「甲」 (ruling record 6292005854 on #6015) places that
* deletion in 19.0, because no ADR-0087 conversion can carry it, and ships its
* loud half in 18.x. This file pins the loud half in four directions:
*
* - the TEXT says the grant is in force only because the names match, that
* the name-based grant is removed in the next major version and what
* holders lose then, and the fix (`permissionSets: ['P']`, or a rename);
* - the PAYLOAD carries the consequence as `removedIn: 'next_major'` beside
* its unchanged `event` / `position` / `permissionSet`, and no other key;
* - the TRIGGER and CADENCE are unchanged: an undeclared pair warns, also
* for a position bound to some OTHER set; a declared pair, a baseline name
* and an inert position stay silent; once per position, not per request;
* - ⛔ the GRANT is unchanged: the holder still resolves set `P`.
*
* ## Where the tuples come from
*
Expand All @@ -19,9 +35,12 @@
* below are that classification transcribed. This file pins the runtime
* PREDICATE against that classification in BOTH directions; the census pins the
* classification against the repository. Neither substitutes for the other.
* The census called the declared bindings "junction" rows; since ADR-0131 the
* same pairs are the definitions' `permissionSets`, which reach this resolver
* as set names in `context.permissions` — the shape every case below feeds.
*
* ⚠️ `sales_rep` and `sales_manager` appear in BOTH the fold list and the
* junction list, and that is the finding slice 1 exists for: each is bound to
* binding list, and that is the finding slice 1 exists for: each is bound to
* `crm_sales_user`, and is folded onto its own same-name set anyway. A
* predicate that asked "is this position bound to anything?" would report
* neither of the repository's two real folds while looking complete.
Expand All @@ -46,15 +65,15 @@ import type { ISecurityService } from '@objectstack/spec/contracts';
const EVENT = 'position_name_fold_grant';

/**
* The census's `NAME-FOLD DEPENDENCIES` block — grants in force with no
* junction row. Position half declared by `examples/app-crm/src/security/
* The census's `NAME-FOLD DEPENDENCIES` block — grants in force that no
* position definition declares. Position half declared by `examples/app-crm/src/security/
* sales-positions.ts`; permission-set half by the vendored HotCRM artifact.
*/
const CENSUS_NAME_FOLDS = ['sales_rep', 'sales_manager'] as const;

/**
* The census's `JUNCTION BINDINGS` block — 13 rows, the governed channel. Every
* one binds a position to a DIFFERENTLY named set, which is why none of them is
* The census's `JUNCTION BINDINGS` block — 13 rows, the governed channel
* (since ADR-0131, the definitions' `permissionSets`). Every one binds a position to a DIFFERENTLY named set, which is why none of them is
* a fold: the fold is about a position's own name.
*/
const CENSUS_JUNCTION_BINDINGS: ReadonlyArray<readonly [position: string, set: string]> = [
Expand Down Expand Up @@ -139,38 +158,64 @@ async function resolveWith(context: Record<string, unknown>, universe: Permissio
return { sets: (sets ?? []).map((s) => s.name), events, warn, svc };
}

describe('[#13419] MUST FIRE — a position folded onto its own same-name set with no junction row', () => {
/** Every key the structured report carries — the three it has always carried,
* which a log consumer may key on, plus the retirement (「甲」, 18.x). */
const PAYLOAD_KEYS = ['event', 'permissionSet', 'position', 'removedIn'];

describe('[#13419] MUST FIRE — a position folded onto its own same-name set that its definition does not name', () => {
for (const position of CENSUS_NAME_FOLDS) {
it(`warns for '${position}', the census's own cross_scope fold`, async () => {
// The measured shape: the position IS junction-bound — to `crm_sales_user`,
// not to itself — so `permissions` carries that other set. The grant on
// the same-name set has no junction row behind it at all.
it(`warns for '${position}', the census's own cross_scope fold, and still grants the set`, async () => {
// The measured shape: the position IS bound — to `crm_sales_user`, not
// to itself — so `permissions` carries that other set. The grant on the
// same-name set has no declaration behind it at all.
const { sets, events } = await resolveWith({
userId: 'u1',
positions: [position],
permissions: ['crm_sales_user'],
});

// Reported once, naming both halves of the pair.
// Reported once, naming both halves of the pair and the retirement.
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ event: EVENT, position, permissionSet: position });
expect(events[0]).toEqual({ event: EVENT, position, permissionSet: position, removedIn: 'next_major' });

// ⛔ Purely additive: the fold still grants exactly what it granted before.
// A warning that also changed the answer would be the silent behaviour
// change 要点 5 forbids.
// 「甲」 moves the removal to 19.0, so in 18.x the holder still resolves
// set `P`. A warning that also changed the answer would be the silent
// behaviour change 要点 5 forbids.
expect(sets.sort()).toEqual(['crm_sales_user', 'member_default', position].sort());
});
}

it('names the ungoverned grant and the two ways out, not just the collision', async () => {
it('carries the consequence as data: the unchanged fields plus removedIn, and no other key', async () => {
const { events, sets } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] });
expect(events).toHaveLength(1);
// Exact keys, so a renamed or dropped legacy field fails here as loudly as
// a missing retirement field does.
expect(Object.keys(events[0]).sort()).toEqual(PAYLOAD_KEYS);
expect(events[0].removedIn).toBe('next_major');
expect(sets.sort()).toEqual(['member_default', 'sales_rep'].sort());
});

it('says why the grant is in force, that it is removed in the next major and what holders lose, and the fix', async () => {
const { warn } = await resolveWith({ userId: 'u1', positions: ['sales_rep'], permissions: [] });
const message = warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes(EVENT))!;
// [ADR-0131 D3/D4] The governed way out is the position definition's
// `permissionSets` — a junction row no longer grants or governs anything.
// ① In force today only because the names match.
expect(message).toContain(
"permission set 'sales_rep' is in force for holders of position 'sales_rep' only because the two share a name",
);
// ② Deprecated, removed in the next major, and what holders lose then.
expect(message).toContain('This name-based grant is deprecated and is removed in the next major version');
expect(message).toContain(
"holders of position 'sales_rep' do not receive permission set 'sales_rep' unless the position's definition names it",
);
// ③ The fix. [ADR-0131 D3/D4] The governed way out is the position
// definition's `permissionSets` — a junction row no longer grants or
// governs anything.
expect(message).toContain("permissionSets: ['sales_rep']");
expect(message).toMatch(/rename one of the two/);
expect(message).not.toContain('sys_position_permission_set');
expect(message).toContain('ungoverned');
expect(message).toMatch(/rename/i);
// An operator reading the log has no tracker to resolve an issue id against.
expect(message).not.toMatch(/#\d/);
});

it('is LOUD ONCE per position, not once per request', async () => {
Expand All @@ -180,11 +225,14 @@ describe('[#13419] MUST FIRE — a position folded onto its own same-name set wi
const svc = ctx.registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as Partial<ISecurityService>;
// Distinct context OBJECTS, so the per-context memo cannot be what silences
// the second call — the deduplication under test has to be the one in the
// reporter.
// reporter. Each request still resolves the same-name set.
for (let i = 0; i < 3; i++) {
await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any);
const sets = await svc.resolvePermissionSetsForContext?.({ userId: `u${i}`, positions: ['sales_rep'] } as any);
expect((sets ?? []).map((s) => s.name)).toContain('sales_rep');
}
expect(warn.mock.calls.filter((c) => String(c[0]).includes(EVENT))).toHaveLength(1);
const reports = warn.mock.calls.filter((c) => String(c[0]).includes(EVENT));
expect(reports).toHaveLength(1);
expect(reports[0][1]).toMatchObject({ position: 'sales_rep', removedIn: 'next_major' });
});
});

Expand Down Expand Up @@ -219,10 +267,12 @@ describe('[#13419] ⛔ MUST NOT FIRE — the census groups the ruling protects',
},
);

it('stays silent once 要点 2 materialises the pair (position N, set N)', async () => {
// The exact row the ruling's 要点 2 would create. This is the forward pin:
// when materialisation lands, the warning must retire itself for the pairs
// it covers rather than needing a second edit.
it('stays silent once the definition declares the pair (position N: permissionSets: [N])', async () => {
// The fix the warning prescribes, and what objectstack-ai/hotcrm#2200 does
// for HotCRM's five: the definition's `permissionSets` names the same-name
// set, so it arrives in `permissions` (§6b) and the grant no longer depends
// on the fold. This is the forward pin: a declared pair is silent without a
// second edit here.
const { events, sets } = await resolveWith({
userId: 'u1',
positions: ['sales_rep'],
Expand Down
Loading
Loading