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
22 changes: 22 additions & 0 deletions .changeset/18024-permission-set-collision-compile-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/cli": patch
---

`os build` and `os validate` now report a **permission-set name collision** — the compile-time half of the #17516 refusal, raised behind the SAME predicate and the SAME sentence as the runtime door so the two cannot drift (#18024).

When two packages in one artifact declare a permission set under the same name, `bootstrapDeclaredPermissions` refuses to write into the row the first one owns. That refusal is correct under ADR-0086 D4 and is **unchanged here** — the whole declared set (its object, field, tab and system permissions) is dropped at boot, and nothing else reports it. #17516 gave that drop a runtime door; until now no door said anything at compile time, so the first an author heard of it was a boot warning on a deployed environment.

Measured on the pre-change tree (`origin/main` 8fe5cb8e5), by grep over `packages/cli/src`, `packages/spec/src` and `packages/metadata/src`:

```
permission-set collision diagnostic, compile time = 0 files
control: `collision|duplicate` in packages/cli/src = 20 files (so the zero is a reading,
not a dead grep)
```

Both commands now compute it, and the findings ride the `warnings` key both payloads already declare — no new top-level key, and no new published export.

- **Reports; it never refuses.** `severity: 'warning'` is declared at the producer and the failure direction is CLOSED: the set is not installed, so nothing is over-granted. Exiting non-zero would narrow what `os build` accepts, which is the option #14553's ruling weighed for `navigationContributions` and did not take.
- **One derivation, so the two doors cannot drift.** The owner comparison is `permissionSetNameIsForeign` and the sentence is `permissionSetNameCollisionDiagnostic` + `formatPermissionSetNameCollisionDiagnostic`, both consumed from `@objectstack/plugin-security`'s package entry — where #17516 published them for exactly this consumer. No second predicate, no retyped sentence: two doors phrasing one refusal differently is the defect, not the fix.
- **Only the composed case is judged.** A name owned by a package some *other* artifact installed is invisible without a database and stays unreported — the same bound the navigation-contribution check keeps for a contribution aimed at an app no package here ships.
- **A package re-declaring its own set name is not a collision.** That is an idempotent re-seed at runtime, which is why the check asks the shipped ownership predicate rather than counting duplicate names. Ablated on disk: removing that one call leaves the suite at 1 failed / 9 passed, and restoring it returns 10 / 10.
47 changes: 47 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// Reports; never refuses — the runtime still relocates, deliberately.
import { findNavGroupDiagnostics } from '../utils/nav-contribution-groups.js';
import type { NavContributionGroupDiagnostic } from '@objectstack/objectql';
// [#18024] The compile-time half of the #17516 permission-set name-collision
// ruling. Reports; never refuses — the runtime still drops the set, deliberately
// (ADR-0086 D4), and what was missing was the author hearing about it.
import {
findPermissionSetNameCollisions,
formatPermissionSetNameCollisions,
} from '../utils/permission-set-name-collisions.js';
import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security';

/** Identity of one finding, for the per-package de-duplication below. */
const findingKey = (f: { rule: string; where: string; path: string; message: string }): string =>
Expand Down Expand Up @@ -153,12 +161,20 @@ export default class Compile extends Command {
// third pin in that file asserts ("nothing rides in build that validate
// does not also report") holds rather than being weakened to fit.
let navGroupWarnings: NavContributionGroupDiagnostic[] = [];
// [#18024] The permission-set name-collision half, a member of
// `warningsSoFar()` for the same three reasons the list above it records:
// the two undeclared-key pins refuse a new top-level payload key by name,
// `os validate` computes the identical list so the residue pin holds, and
// `severity: 'warning'` is declared at the producer — this reports, it
// never refuses.
let permissionSetCollisionWarnings: PermissionSetNameCollisionDiagnostic[] = [];
const warningsSoFar = () => [
...ruleAdvisories,
...docWarnings,
...unknownKeyWarnings,
...capProviderWarnings,
...navGroupWarnings,
...permissionSetCollisionWarnings,
];
// [#12125] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
// and under the SAME ruling as the four lists above — one field over. The
Expand Down Expand Up @@ -490,6 +506,37 @@ export default class Compile extends Command {
);
}

// 3b-ter. [#18024] Permission sets declared under a name another package
// in this same artifact already owns. The RUNTIME door (#17516)
// refuses the write and says so at boot; this is the compile-time
// half of that ruling, behind the SAME predicate and the SAME
// sentence so the two cannot drift.
//
// ⛔ REPORTS, NEVER REFUSES, and ⛔ does not change what is skipped.
// Refusing a foreign set is correct under ADR-0086 D4 and stays; the
// producer declares `severity: 'warning'` and the failure direction
// is CLOSED (the set is not installed, so nothing is over-granted).
// Exiting non-zero here would narrow what `os build` accepts, which
// is a different decision from the one this card carries.
//
// Only the COMPOSED case can be judged: a name a package installed
// from some OTHER artifact owns is invisible without a database, and
// the same bound the nav check keeps one step above.
permissionSetCollisionWarnings =
await findPermissionSetNameCollisions(result.data as Record<string, unknown>);
if (permissionSetCollisionWarnings.length > 0 && !flags.json) {
console.log('');
printWarning(
`Permission sets declared under a name another package in this artifact owns ` +
`(${permissionSetCollisionWarnings.length}) — at runtime the ENTIRE declared set is ` +
`dropped, not merged (ADR-0086 D4)`,
);
printBulletList(
await formatPermissionSetNameCollisions(permissionSetCollisionWarnings),
{ noun: 'permission-set collision diagnostic' },
);
}

// 3c. [#3366] Installable-provider preflight. Every capability the app
// DECLARES in `requires: [...]` must have a provider resolvable in the
// active edition. A `requires` entry whose provider has NO installable
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// Reports; never refuses — the runtime still relocates, deliberately.
import { findNavGroupDiagnostics } from '../utils/nav-contribution-groups.js';
import type { NavContributionGroupDiagnostic } from '@objectstack/objectql';
// [#18024] The permission-set name-collision check, shared with `os compile`.
// Reports; never refuses — the runtime still drops the foreign set, correctly
// (ADR-0086 D4); what was missing was the author hearing about it.
import {
findPermissionSetNameCollisions,
formatPermissionSetNameCollisions,
} from '../utils/permission-set-name-collisions.js';
import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security';

export default class Validate extends Command {
static override description =
Expand Down Expand Up @@ -135,6 +143,10 @@ export default class Validate extends Command {
// validate does not also report, and the two commands being one wall with
// two doors is the #4409 / #4463 discipline this list already follows.
let navGroupWarnings: NavContributionGroupDiagnostic[] = [];
// [#18024] Computed HERE as well as in `os compile`, and for the same
// reason the line above it gives: the #11727 residue pin asserts that
// nothing rides in build's `warnings` that validate does not also report.
let permissionSetCollisionWarnings: PermissionSetNameCollisionDiagnostic[] = [];
const warningsSoFar = () => [
...ruleAdvisories,
...docWarnings,
Expand All @@ -149,6 +161,8 @@ export default class Validate extends Command {
// end and the pin keeps guarding exactly what it was written to guard.
// ⛔ Do not "fix" that pin by loosening its regex.
...navGroupWarnings,
// [#18024] APPENDED for the same reason, one member later.
...permissionSetCollisionWarnings,
];
// [#12125] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
// and under the SAME ruling as the five lists above — one field over. The
Expand Down Expand Up @@ -397,6 +411,28 @@ export default class Validate extends Command {
);
}

// [#18024] Permission sets declared under a name another package in this
// same compilation unit already owns. Reports, never refuses, and
// ⛔ changes nothing about the skip: refusing to write into a foreign
// row is correct under ADR-0086 D4 and unchanged — the whole declared
// set is dropped at runtime and until now no door said so before the
// deployment. A name owned by a package some OTHER artifact installed
// is NOT reported: that is the cross-artifact case a build cannot see.
permissionSetCollisionWarnings =
await findPermissionSetNameCollisions(result.data as Record<string, unknown>);
if (permissionSetCollisionWarnings.length > 0 && !flags.json) {
console.log('');
printWarning(
`Permission sets declared under a name another package in this artifact owns ` +
`(${permissionSetCollisionWarnings.length}) — at runtime the ENTIRE declared set is ` +
`dropped, not merged (ADR-0086 D4)`,
);
printBulletList(
await formatPermissionSetNameCollisions(permissionSetCollisionWarnings),
{ noun: 'permission-set collision diagnostic' },
);
}

if (!flags.json) printStep('Checking capability providers (#3366)...');
const capProviderPreflight = preflightRequiredCapabilities({
requires: Array.isArray((config as { requires?: unknown[] }).requires)
Expand Down
Loading
Loading