Skip to content

Commit c64e65f

Browse files
hotlongclaude
andauthored
fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) (#15311)
* fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298) `declaredPermissionSets`' docblock stated a short-circuit the code did not have: it pushed the flattened top level and then every package body unconditionally, collecting each permission set twice on today's additive artifact. The duplication was unobservable at the sole (private) call site, which takes the first `isDefault` set, so this corrects a false written contract on a security-path reader rather than a live defect. The reader now walks the discipline its docblock claims — start from the expression the card replaced (`appDefaultPermissionSetName(config.permissions)`) and consult `packages[]` only where it came back `undefined`. The condition is the resolved NAME, never the `permissions` container: branching on the container re-creates the silent loss the card removed one shape further along, since a flattened level that carries sets but marks none of them `isDefault` is legal and hand-authorable. `resolveArtifactPackageOrder` is called before the top level is consulted, so its ADR-0112 refusal of a malformed `packages` stays unconditional instead of depending on which level happened to answer first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * chore(changeset): record the plugin-security default-permission-set reader correction (#15298) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * docs(plugin-security): say why this reader branches on the answer where the sibling branches on the container (#15298) `resolveStackCollection` (packages/cli/src/utils/stack-collections.ts, #15006) landed on main branching on the container, and is right to: it returns a whole collection, so a present top-level key has already answered. This reader extracts a distinguished element out of the collection, so "present" and "answers" are different facts here. Recording the reason in the docblock so the convergence pass reads two readers that differ AND say why, rather than two that differ while appearing to agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * chore(changeset): name the #15226 sentence this entry supersedes in the same unreleased batch (#15298) Both entries are patch bumps on @objectstack/plugin-security and will compile into one release. Leaving the earlier one as written keeps the record honest; naming the superseded sentence keeps the compiled notes from carrying a contradiction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5964124 commit c64e65f

3 files changed

Lines changed: 192 additions & 36 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298)
6+
7+
`declaredPermissionSets` carried a docblock stating a short-circuit its code did
8+
not have:
9+
10+
> The `packages[]` pass only supplies a set where the top level had none — which
11+
> is precisely the option-B artifact.
12+
13+
The code pushed the flattened top level and then **every** package body
14+
unconditionally, so on today's additive artifact (flattened level *and*
15+
`packages[]` both present) every permission set was collected twice. Nothing
16+
observable came of it — the sole caller is private and takes the first
17+
`isDefault` set, which the flattened copy still supplied — so this corrects a
18+
false written contract on a security-path reader, not a live defect. That
19+
distinction is the point: the sentence was load-bearing, because it was the
20+
stated reason the reader half was revertible on its own and safe to land before
21+
the emitter half (#14512), and the next reader would have believed the mechanism
22+
was there.
23+
24+
⚠️ Release-notes note: this supersedes one sentence of the #15226 entry in this same
25+
unreleased batch — "The resolution now reads the flattened top level FIRST and then each
26+
package body". That described #15226 accurately when it landed; after this change the
27+
`packages[]` pass runs only where the top level named no default. The earlier entry is
28+
left as written rather than retro-edited, so whoever compiles the notes collapses the two
29+
deliberately instead of reading a contradiction.
30+
31+
The reader now walks the discipline the docblock claims — start from the
32+
expression this program replaced, `appDefaultPermissionSetName(config.permissions)`,
33+
and consult `packages[]` only where it came back `undefined`.
34+
35+
- **The condition is the resolved NAME, never the `permissions` container.**
36+
Branching on the container re-creates the silent loss the reader program
37+
exists to remove, one shape further along: a flattened level that carries
38+
permission sets but marks none of them `isDefault` is legal today and
39+
hand-authorable in any `objectstack.config.ts`, and a container-shaped
40+
condition (`Array.isArray(flattened)`, with or without `&& length > 0`) shorts
41+
it past the whole `packages[]` pass and answers `undefined` — nothing thrown,
42+
nothing logged, every member of the app back down to the platform floor alone.
43+
Reading the answer also retires the `[]`-is-truthy trap rather than patching
44+
around it.
45+
- **The package order is resolved BEFORE the top level is consulted.**
46+
`resolveArtifactPackageOrder` refuses a malformed `packages` — not an array,
47+
an entry inlined instead of wrapped under `manifest:`, a duplicate package id
48+
— with an ADR-0112 envelope this reader does not catch, and that refusal must
49+
not become conditional on whether the flattened level happened to name a
50+
default first. An artifact is either loadable or refused; which level answered
51+
is not part of that question.
52+
- **No emitted artifact changes its answer.** Measured, not argued: 26 shapes —
53+
the composed additive artifact, its option-B derivative, the collection-zoo
54+
fixtures behind the #15004 acceptance pin, every config the unit suite drives,
55+
the three malformed-`packages` refusals, and the hand-authored mixed shapes —
56+
return byte-identical results before and after, with `@objectstack/plugin-security`
57+
rebuilt and the change proven present in `dist/` on each leg.

packages/plugins/plugin-security/src/app-default-permission-set.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,56 @@ describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', ()
259259
expect(appSecurityPluginOptions({ permissions: [permissionSet('top')] })).toEqual({ fallbackPermissionSet: 'top' });
260260
});
261261

262+
/**
263+
* [#15007 follow-up] "The top level had none" is the resolved NAME coming
264+
* back `undefined` — never the `permissions` CONTAINER being absent or empty.
265+
*
266+
* Branching on the container re-creates the silent loss this card removed,
267+
* one shape further along. A flattened level that carries permission sets but
268+
* marks none of them `isDefault` is legal today and hand-authorable in any
269+
* `objectstack.config.ts`; a container-shaped condition shorts it past the
270+
* whole `packages[]` pass and answers `undefined` — nothing thrown, nothing
271+
* logged, every member of the app back down to the platform floor alone.
272+
*/
273+
describe('the `packages[]` pass runs wherever the top level named no default', () => {
274+
const corePackage = {
275+
manifest: {
276+
id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app',
277+
permissions: [permissionSet(CORE_PROFILE)],
278+
},
279+
};
280+
281+
it('an EMPTY flattened array does not short-circuit it', () => {
282+
expect(appSecurityPluginOptions({ permissions: [], packages: [corePackage] }))
283+
.toEqual({ fallbackPermissionSet: CORE_PROFILE });
284+
});
285+
286+
it('a NON-EMPTY flattened array that marks no default does not either', () => {
287+
// A `permissions.length > 0` guard passes the case above and fails this
288+
// one — which is the whole reason the condition is the resolved name.
289+
expect(
290+
appSecurityPluginOptions({
291+
permissions: [{ name: 'core_read_only', label: 'Read only', objects: {} }],
292+
packages: [corePackage],
293+
}),
294+
).toEqual({ fallbackPermissionSet: CORE_PROFILE });
295+
});
296+
297+
it('a `permissions` key that is not an array at all does not either', () => {
298+
expect(appSecurityPluginOptions({ permissions: null, packages: [corePackage] }))
299+
.toEqual({ fallbackPermissionSet: CORE_PROFILE });
300+
});
301+
302+
it('and once the top level DOES name one, the packages pass cannot change the answer', () => {
303+
expect(
304+
appSecurityPluginOptions({
305+
permissions: [permissionSet('flattened_wins')],
306+
packages: [corePackage],
307+
}),
308+
).toEqual({ fallbackPermissionSet: 'flattened_wins' });
309+
});
310+
});
311+
262312
/**
263313
* The gate travels with the read: `resolveArtifactPackageOrder` refuses a
264314
* malformed `packages` with an ADR-0112 envelope, and this reader does not
@@ -293,5 +343,21 @@ describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', ()
293343
expect(err.code).toBe('DUPLICATE_ARTIFACT_PACKAGE');
294344
expect(err.status).toBe(422);
295345
});
346+
347+
it('…and refused just the same when the flattened top level already named a default', () => {
348+
// The package order is resolved BEFORE the top level is consulted, so an
349+
// artifact is either loadable or refused independently of which level
350+
// happens to answer. Move that resolution below the early return and this
351+
// pair turns into a silent accept: a permission surface resolved out of an
352+
// artifact the manifest service refuses moments later.
353+
const notAnArray = refusalOf({ permissions: [permissionSet('flattened_wins')], packages: 'nope' });
354+
expect(notAnArray.code).toBe('INVALID_ARTIFACT_PACKAGES');
355+
expect(notAnArray.status).toBe(422);
356+
357+
const entry = { manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } };
358+
const duplicate = refusalOf({ permissions: [permissionSet('flattened_wins')], packages: [entry, entry] });
359+
expect(duplicate.code).toBe('DUPLICATE_ARTIFACT_PACKAGE');
360+
expect(duplicate.status).toBe(422);
361+
});
296362
});
297363
});

packages/plugins/plugin-security/src/app-default-permission-set.ts

Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ export function appDefaultPermissionSetName(permissions: unknown): string | unde
8686
}
8787

8888
/**
89-
* [ADR-0130 D4, #15007] Every permission set a stack config DECLARES — from the
90-
* flattened top level, and from `packages[]`.
89+
* [ADR-0130 D4, #15007] The app-declared default permission-set NAME, resolved
90+
* from wherever the artifact carries the declaration — the flattened top
91+
* level, or `packages[]`.
9192
*
9293
* ## What this exists to stop
9394
*
@@ -114,52 +115,83 @@ export function appDefaultPermissionSetName(permissions: unknown): string | unde
114115
* this function has to be a superset of the old read rather than a replacement
115116
* for it: for every artifact the platform emits today the flattened level
116117
* answers first and this returns exactly what it returned before. The
117-
* `packages[]` pass only supplies a set where the top level had none — which is
118-
* precisely the option-B artifact. That is what makes this card revertible on
119-
* its own and safe to land before the emitter half (#14512).
118+
* `packages[]` pass is consulted ONLY where the top level named no default —
119+
* which is precisely the option-B artifact. That is what makes this card
120+
* revertible on its own and safe to land before the emitter half (#14512).
121+
*
122+
* ## The condition is the ANSWER, never the container
123+
*
124+
* "The top level had none" is spelled as `appDefaultPermissionSetName` coming
125+
* back `undefined`, and deliberately NOT as the `permissions` array being
126+
* absent or empty. Branching on the container re-creates the silent loss this
127+
* card exists to remove, one shape further along: a config whose flattened
128+
* level carries permission sets but marks none of them `isDefault` — legal
129+
* today, and expressible by hand in any `objectstack.config.ts` — would
130+
* short-circuit the whole `packages[]` pass and resolve `undefined`, with
131+
* nothing thrown and nothing logged. Reading the container also hands back the
132+
* `[]`-is-truthy trap for free. The answer is the only condition that cannot
133+
* be wrong in either direction, so the answer is what this branches on.
134+
*
135+
* ⚠️ That is deliberately NOT the shape of the sibling reader's condition, and
136+
* the difference is a property of the readers, not an inconsistency to
137+
* converge away. `resolveStackCollection` (`packages/cli/src/utils/
138+
* stack-collections.ts`, #15006) branches on the CONTAINER — `if
139+
* (Array.isArray(top)) return top;` — and is right to: it returns a whole
140+
* collection, so a top level that carries the key has, by construction,
141+
* already answered, and `composeStacks` flattened that array into the union.
142+
* This reader extracts a DISTINGUISHED ELEMENT out of the collection instead,
143+
* so "the key is present" and "the key answers" are two different facts here
144+
* and one of them is the wrong one to branch on. Same discipline — start from
145+
* the expression this program replaced, consult `packages[]` only where it came
146+
* back empty — read against what each reader's expression actually returns.
120147
*
121148
* ## The order is `resolveArtifactPackageOrder`'s, not the array's
122149
*
123-
* `appDefaultPermissionSetName` resolves the FIRST `isDefault` set, so with more
124-
* than one package declaring one, "first" has to mean the same thing here as it
125-
* does everywhere else the artifact is read. `resolveArtifactPackageOrder`
150+
* The first package body that names a default wins, so with more than one
151+
* package declaring one, "first" has to mean the same thing here as it does
152+
* everywhere else the artifact is read. `resolveArtifactPackageOrder`
126153
* (`@objectstack/core`, ADR-0130 D4+D5, #14643) is the ONE place that turns an
127154
* artifact into its ordered package list — dependency-topological, so a package
128155
* that extends another is read after it regardless of which array slot it
129156
* occupies. ⛔ Do not iterate `config.packages` directly here; a second
130157
* traversal is a second ordering, and the depended-upon package would win or
131158
* lose by authoring accident.
132159
*
133-
* ## Two things it deliberately does NOT do
134-
*
135-
* • It does not look inside the SINGULAR `manifest`. That constraint is
136-
* #7001's and it still holds — the harness must not honour a declaration
137-
* `serve.ts` ignores. Note this is not a special case bolted on: an
138-
* artifact carrying no `packages` key makes `resolveArtifactPackageOrder`
139-
* return the caller's own object as the single package body (D4's second
140-
* branch, D7's compatibility term), so that branch reads `permissions` from
141-
* exactly where the old code read it and nowhere else.
142-
* • It does not catch `resolveArtifactPackageOrder`'s refusals. A malformed
143-
* `packages` (not an array, an unwrapped entry, a duplicate package id)
144-
* raises an ADR-0112 envelope here, the same one the manifest service
145-
* raises when it registers that artifact moments later. Swallowing it would
146-
* resolve a permission surface out of an artifact the loader refuses to
147-
* load — the gate travels with the read.
160+
* ## The package order is resolved BEFORE the top level is consulted
161+
*
162+
* Reading that line as a misplaced statement is the expected mistake, so: it is
163+
* placed there on purpose, and moving it below the early return is a behaviour
164+
* change. `resolveArtifactPackageOrder` REFUSES a malformed `packages` (not an
165+
* array, an unwrapped entry, a duplicate package id) with an ADR-0112 envelope,
166+
* and this reader does not catch it — swallowing it would resolve a permission
167+
* surface out of an artifact the loader refuses to load. Resolving the order
168+
* first is what keeps that refusal unconditional: an artifact is either
169+
* loadable or refused, and which answer this reader gives about it must not
170+
* depend on whether its flattened level happened to name a default first.
171+
*
172+
* ## One thing it deliberately does NOT do
173+
*
174+
* It does not look inside the SINGULAR `manifest`. That constraint is #7001's
175+
* and it still holds — the harness must not honour a declaration `serve.ts`
176+
* ignores. Note this is not a special case bolted on: an artifact carrying no
177+
* `packages` key never reaches the package pass at all, so that branch reads
178+
* `permissions` from exactly where the old code read it and nowhere else.
148179
*/
149-
function declaredPermissionSets(config: unknown): unknown[] {
150-
const sets: unknown[] = [];
180+
function declaredDefaultPermissionSetName(config: unknown): string | undefined {
181+
const packages = (config as { packages?: unknown } | null | undefined)?.packages;
182+
const bodies =
183+
packages === undefined || packages === null ? [] : resolveArtifactPackageOrder(config);
151184

152185
const flattened = (config as { permissions?: unknown } | null | undefined)?.permissions;
153-
if (Array.isArray(flattened)) sets.push(...flattened);
154-
155-
const packages = (config as { packages?: unknown } | null | undefined)?.packages;
156-
if (packages === undefined || packages === null) return sets;
186+
const fromFlattened = appDefaultPermissionSetName(flattened);
187+
if (fromFlattened !== undefined) return fromFlattened;
157188

158-
for (const body of resolveArtifactPackageOrder(config)) {
189+
for (const body of bodies) {
159190
const declared = (body as { permissions?: unknown } | null | undefined)?.permissions;
160-
if (Array.isArray(declared)) sets.push(...declared);
191+
const fromPackage = appDefaultPermissionSetName(declared);
192+
if (fromPackage !== undefined) return fromPackage;
161193
}
162-
return sets;
194+
return undefined;
163195
}
164196

165197
/**
@@ -191,13 +223,14 @@ function declaredPermissionSets(config: unknown): unknown[] {
191223
* the result straight through — `new SecurityPlugin(appSecurityPluginOptions(config))`
192224
* — and a caller cannot get the undefined case subtly wrong.
193225
*
194-
* Reads the sets through {@link declaredPermissionSets} — the flattened top
195-
* level `serve.ts` has always read, and, for a multi-package artifact, the
196-
* `packages[]` bodies that carry the same declaration under ADR-0130 D4.
226+
* Resolves the name through {@link declaredDefaultPermissionSetName} — the
227+
* flattened top level `serve.ts` has always read, and, for a multi-package
228+
* artifact, the `packages[]` bodies that carry the same declaration under
229+
* ADR-0130 D4.
197230
*/
198231
export function appSecurityPluginOptions(
199232
config: unknown,
200233
): { fallbackPermissionSet: string } | undefined {
201-
const name = appDefaultPermissionSetName(declaredPermissionSets(config));
234+
const name = declaredDefaultPermissionSetName(config);
202235
return name ? { fallbackPermissionSet: name } : undefined;
203236
}

0 commit comments

Comments
 (0)