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
25 changes: 25 additions & 0 deletions .changeset/24446-u2-marketplace-access-upgrade-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/cloud-connection': patch
'@objectstack/plugin-security': patch
'@objectstack/spec': patch
'@objectstack/cli': patch
---

feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3)

Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening).
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one install door's answer on an upgrade that would un-make an access choice; the caller's migration is the confirmation the refusal itself names (`confirmAccessUpgrade` with the finding set's token), stated below. -->

**BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty.

- **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those.
- **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off.
- **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry.
- **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": "<findingSet>"` (CLI: `os package install <package> --confirm-access-upgrade <findingSet>`). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands.
- **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`).
- **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran.
- **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before.

The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`.
17 changes: 17 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2982,6 +2982,7 @@ credentials do not apply here.
| `--email <email>` | `OS_RUNTIME_EMAIL` | Account email on the target runtime |
| `--password <password>` | `OS_RUNTIME_PASSWORD` | Account password on the target runtime |
| `--confirm-global-uniques` | — | Affirm this app's installation-wide `unique` constraints are genuinely platform-wide — see below |
| `--confirm-access-upgrade <token>` | — | Confirm an upgrade's access findings with the finding-set token the runtime's refusal printed — see below |
| `--timeout <ms>` | `OS_CLOUD_TIMEOUT_MS` | HTTP timeout in milliseconds, default `120000`. `0` disables it |

**`--confirm-global-uniques` answers a stop; it does not force one past.**
Expand All @@ -2997,6 +2998,22 @@ recurring nag. It is deliberately not called `--force`, and deliberately not
default-on. The other answer is to edit the app's metadata to
`unique: 'organization'` and rebuild.

**`--confirm-access-upgrade <token>` confirms exactly the findings it names.**
Upgrading an installed package stops with `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
when the new version would un-make an access choice made in the runtime
(ADR-0126 §6): a permission set, position or sharing rule switched off there
that the upgrade renames, so the new name is active again; a switch whose name
it drops; a sharing rule name a rule authored there already holds; an object,
field or capability a permission set or sharing rule authored there names and
the upgrade drops; a field deny the upgraded set gains that a set authored there
lacks. Nothing is installed. The command prints each finding and the exact
retry, carrying the finding set's token. Review the findings first: the switch
you meant to keep is fixed by switching the new name off too, after the upgrade.
The token is not a standing yes — once the findings change it no longer matches
and the upgrade stops again with the new set; the confirmation is recorded in
the install's ledger entry. Under `group` and `isolated` only the platform
operator reaches this check. A fresh install is never checked.

A `404` means the target runtime does not mount `MarketplaceInstallLocalPlugin`
(from `@objectstack/cloud-connection`). The endpoint is opt-in, so a runtime
composed without it will not accept installs.
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |

### Allowed Values: `FlowInnerRefusal.code`
Expand Down Expand Up @@ -406,6 +406,7 @@ const result = AutomationApiErrorCode.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
* `ACCOUNT_LOCKED`
* `ACTION_CONFIRMATION_REQUIRED`
* `ACTION_DISABLED`
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -89,6 +89,7 @@ const result = ApiErrorSchema.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
* `ACCOUNT_LOCKED`
* `ACTION_CONFIRMATION_REQUIRED`
* `ACTION_DISABLED`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ const result = ErrorCode.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED`
* `ACCOUNT_LOCKED`
* `ACTION_CONFIRMATION_REQUIRED`
* `ACTION_DISABLED`
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/commands/package/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ export default class PackageInstall extends Command {
+ "installing into an 'isolated'-posture runtime (ADR-0120 D5e). Recorded in the install manifest; asked once.",
default: false,
}),
// [ADR-0126 §6 wall 3] The confirmation of an upgrade's access findings —
// a switch-off a vendor rename re-enables, a choice whose name the
// package no longer ships. A TOKEN, not a boolean: the finding set's
// digest the runtime's refusal printed, so it confirms exactly the
// findings shown, and a set that changed since is refused again.
'confirm-access-upgrade': Flags.string({
description:
"Confirm the access findings of this upgrade with the finding-set token the runtime's refusal printed "
+ '(ACCESS_UPGRADE_CONFIRMATION_REQUIRED). It confirms exactly those findings; a changed set is refused again.',
}),
};

async run(): Promise<void> {
Expand Down Expand Up @@ -198,6 +208,7 @@ export default class PackageInstall extends Command {
// ---- Install ---------------------------------------------------------
printStep(`Installing ${label} into ${runtime}...`);
if (flags['confirm-global-uniques']) body.confirmGlobalUniques = true;
if (flags['confirm-access-upgrade'] !== undefined) body.confirmAccessUpgrade = flags['confirm-access-upgrade'];
const installMode = flags['install-mode'] as InstallMode | undefined;
if (installMode !== undefined) body.installMode = installMode;
const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, {
Expand Down Expand Up @@ -227,6 +238,20 @@ export default class PackageInstall extends Command {
this.exit(1);
return;
}
// [ADR-0126 §6 wall 3] The access upgrade stop: every finding, then
// the exact retry — the decision is the installer's, per finding.
if (res.body?.error?.code === 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED') {
const details = res.body.error.details ?? {};
const findings: Array<{ describe?: string }> = Array.isArray(details.findings) ? details.findings : [];
printError("Upgrade stopped — it would change what this system's access choices cover");
console.log('');
for (const finding of findings) console.log(` • ${finding.describe ?? JSON.stringify(finding)}`);
console.log('');
console.log(' Review each finding. To upgrade anyway, confirm exactly these findings:');
console.log(` os package install ${args.package} --confirm-access-upgrade ${details.findingSet}`);
this.exit(1);
return;
}
if (res.status === 401) {
printError(
'The runtime rejected the call as unauthenticated. Pass --email/--password ' +
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/test/package-install-access-upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0126 §6 wall 3] `os package install --confirm-access-upgrade <token>` —
* the install-local body's `confirmAccessUpgrade`, and how the command renders
* the runtime's `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` refusal.
*
* Pinned:
* - the flag reaches the body as `confirmAccessUpgrade`, the token verbatim;
* without the flag the body carries no such key;
* - the refusal prints every finding's `describe` line and the exact retry,
* naming the finding set's token, and exits 1.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import PackageInstall from '../src/commands/package/install.js';

type Answer = { ok: boolean; status: number; body: unknown };

function stubRuntime(answer: Answer): { sent: Array<Record<string, unknown>> } {
const sent: Array<Record<string, unknown>> = [];
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: { body?: string }) => {
if (init?.body) sent.push(JSON.parse(init.body));
return {
ok: answer.ok,
status: answer.status,
statusText: answer.ok ? 'OK' : 'Refused',
headers: { get: () => null },
json: async () => answer.body,
} as any;
}));
return { sent };
}

async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> {
const lines: string[] = [];
const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); };
vi.spyOn(console, 'log').mockImplementation(capture);
vi.spyOn(console, 'error').mockImplementation(capture);
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any);
let exit: number | undefined;
try {
await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code ?? 1;
}
return { out: lines.join('\n'), exit };
}

const TOKEN = 'security.access_upgrade:0123456789abcdef0123456789abcdef';
const INSTALLED = {
manifestId: 'com.acme.crm', version: '2.0.0', versionId: 'ver_2', installedAt: '2026-10-23T00:00:00.000Z',
hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages',
};

describe('os package install --confirm-access-upgrade (ADR-0126 §6 wall 3)', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('sends the token verbatim as `confirmAccessUpgrade`', async () => {
const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: INSTALLED } });
await runInstall(['--confirm-access-upgrade', TOKEN]);
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ packageId: 'com.acme.crm', confirmAccessUpgrade: TOKEN });
});

it('sends no `confirmAccessUpgrade` without the flag', async () => {
const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: INSTALLED } });
await runInstall([]);
expect(sent[0]).not.toHaveProperty('confirmAccessUpgrade');
});

it('renders the refusal: every finding, then the exact retry with the token, and exits 1', async () => {
stubRuntime({
ok: false,
status: 409,
body: {
success: false,
error: {
code: 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED',
message: 'Upgrading "com.acme.crm" would change what access choices made in this system cover',
details: {
manifestId: 'com.acme.crm',
findingSet: TOKEN,
findings: [
{ kind: 're-enablement', describe: "permission 'crm_rep' is switched off in this system and the upgrade no longer ships it" },
{ kind: 'broken-reference', describe: "permission set 'cust_exporter', authored in this system, names capability 'crm.export'" },
],
},
},
},
});
const { out, exit } = await runInstall([]);
expect(exit).toBe(1);
expect(out).toContain("permission 'crm_rep' is switched off in this system");
expect(out).toContain("names capability 'crm.export'");
expect(out).toContain(`os package install com.acme.crm --confirm-access-upgrade ${TOKEN}`);
});
});
1 change: 1 addition & 0 deletions packages/cloud-connection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
},
"devDependencies": {
"@objectstack/lint": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@types/node": "^26.6.3",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
Expand Down
9 changes: 9 additions & 0 deletions packages/cloud-connection/src/local-manifest-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ export interface InstalledManifestEntry {
* boot-time nagging #4884 forbids.
*/
globalUniqueAttestation?: GlobalUniqueAttestation;
/**
* [ADR-0126 §6 wall 3] The installer's confirmation of the access-class
* findings this upgrade created — a switch-off a vendor rename re-enables,
* a choice whose name the package no longer ships. Recorded for the audit:
* the finding set confirmed (its digest), by whom, when. It answers only
* the install that wrote it; the next upgrade is checked afresh, and the
* boot report still raises whatever stands.
*/
accessUpgradeConfirmation?: { findingSet: string; confirmedBy: string; confirmedAt: string };
}

/**
Expand Down
Loading
Loading