Skip to content

Commit 9044dba

Browse files
committed
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
1 parent 9690d11 commit 9044dba

2 files changed

Lines changed: 122 additions & 36 deletions

File tree

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: 56 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,70 @@ 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.
120134
*
121135
* ## The order is `resolveArtifactPackageOrder`'s, not the array's
122136
*
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`
137+
* The first package body that names a default wins, so with more than one
138+
* package declaring one, "first" has to mean the same thing here as it does
139+
* everywhere else the artifact is read. `resolveArtifactPackageOrder`
126140
* (`@objectstack/core`, ADR-0130 D4+D5, #14643) is the ONE place that turns an
127141
* artifact into its ordered package list — dependency-topological, so a package
128142
* that extends another is read after it regardless of which array slot it
129143
* occupies. ⛔ Do not iterate `config.packages` directly here; a second
130144
* traversal is a second ordering, and the depended-upon package would win or
131145
* lose by authoring accident.
132146
*
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.
147+
* ## The package order is resolved BEFORE the top level is consulted
148+
*
149+
* Reading that line as a misplaced statement is the expected mistake, so: it is
150+
* placed there on purpose, and moving it below the early return is a behaviour
151+
* change. `resolveArtifactPackageOrder` REFUSES a malformed `packages` (not an
152+
* array, an unwrapped entry, a duplicate package id) with an ADR-0112 envelope,
153+
* and this reader does not catch it — swallowing it would resolve a permission
154+
* surface out of an artifact the loader refuses to load. Resolving the order
155+
* first is what keeps that refusal unconditional: an artifact is either
156+
* loadable or refused, and which answer this reader gives about it must not
157+
* depend on whether its flattened level happened to name a default first.
158+
*
159+
* ## One thing it deliberately does NOT do
160+
*
161+
* It does not look inside the SINGULAR `manifest`. That constraint is #7001's
162+
* and it still holds — the harness must not honour a declaration `serve.ts`
163+
* ignores. Note this is not a special case bolted on: an artifact carrying no
164+
* `packages` key never reaches the package pass at all, so that branch reads
165+
* `permissions` from exactly where the old code read it and nowhere else.
148166
*/
149-
function declaredPermissionSets(config: unknown): unknown[] {
150-
const sets: unknown[] = [];
167+
function declaredDefaultPermissionSetName(config: unknown): string | undefined {
168+
const packages = (config as { packages?: unknown } | null | undefined)?.packages;
169+
const bodies =
170+
packages === undefined || packages === null ? [] : resolveArtifactPackageOrder(config);
151171

152172
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;
173+
const fromFlattened = appDefaultPermissionSetName(flattened);
174+
if (fromFlattened !== undefined) return fromFlattened;
157175

158-
for (const body of resolveArtifactPackageOrder(config)) {
176+
for (const body of bodies) {
159177
const declared = (body as { permissions?: unknown } | null | undefined)?.permissions;
160-
if (Array.isArray(declared)) sets.push(...declared);
178+
const fromPackage = appDefaultPermissionSetName(declared);
179+
if (fromPackage !== undefined) return fromPackage;
161180
}
162-
return sets;
181+
return undefined;
163182
}
164183

165184
/**
@@ -191,13 +210,14 @@ function declaredPermissionSets(config: unknown): unknown[] {
191210
* the result straight through — `new SecurityPlugin(appSecurityPluginOptions(config))`
192211
* — and a caller cannot get the undefined case subtly wrong.
193212
*
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.
213+
* Resolves the name through {@link declaredDefaultPermissionSetName} — the
214+
* flattened top level `serve.ts` has always read, and, for a multi-package
215+
* artifact, the `packages[]` bodies that carry the same declaration under
216+
* ADR-0130 D4.
197217
*/
198218
export function appSecurityPluginOptions(
199219
config: unknown,
200220
): { fallbackPermissionSet: string } | undefined {
201-
const name = appDefaultPermissionSetName(declaredPermissionSets(config));
221+
const name = declaredDefaultPermissionSetName(config);
202222
return name ? { fallbackPermissionSet: name } : undefined;
203223
}

0 commit comments

Comments
 (0)