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
28 changes: 28 additions & 0 deletions .changeset/15206-managed-content-sealed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/metadata-protocol': minor
---

feat(metadata-protocol)!: managed content is sealed — `OS_METADATA_WRITABLE` no longer opens a write onto, or a removal of, an item a managed package ships (ADR-0131 D6)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the change narrows what an operator environment variable opens at the runtime doors; no spec key, stored metadata shape or export a manifest names changes, every stored row loads and serves unchanged, and objectstack migrate meta has nothing to rewrite -->

**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.

ADR-0131 D6: no door edits a managed definition. The operator hatch `OS_METADATA_WRITABLE` (and its legacy spelling `OBJECTSTACK_METADATA_WRITABLE`) used to open one: an item a managed package ships, of a type whose registry entry allows no environment overlay, could be overlaid and its overlay row removed once the type was named in the variable. Measured on the CRM example with `OS_METADATA_WRITABLE=flow,object,permission,position`, a shipped flow, an object field and a position each took an overlay row, `PUT /api/v1/automation/:name` re-registered the shipped flow, and `DELETE /api/v1/meta/object/crm_lead?dropStorage=true` took the managed object off the data plane.

**What changes.** With the hatch open or shut, the metadata doors now answer the same thing for an item a managed package ships: `403 NOT_OVERRIDABLE` on a write, and on a removal of a type whose overlay does not merge at read. The protocol's two package doors (`PUT` / `DELETE /api/v1/meta/:type/:name`, and the `/automation` definition doors that ask them), the repository's write path (draft promotion, restore, revert) and the read envelope (`editable` / `deletable`) all read one predicate: the item is artifact-backed and its type's registry entry opens no overlay channel.

- The refusal's first sentence names the managed package ("… is provided by a managed package and is sealed …"). A type with an ADR-0126 regime row (`flow`, `action`, `permission`) still names its sanctioned route, the clone or the switch. Every other type now reads the seal, says the hatch does not open a managed item, and keeps the source remedy; it no longer prescribes setting `OS_METADATA_WRITABLE`, which would no longer help.
- A write naming a read-only package (`?package=`) still answers `403 ITEM_LOCKED`, now without a hatch-dependent remedy. `SysMetadataRepository.readOnlyBaseOverrideError` takes `(type, packageId)`; its third `hatchOpen` parameter is gone, because nothing it chose survives the seal.

**What does not change.**

- The environment overlay of a `view`, `dashboard`, `report`, `translation` or `email_template` a package ships, which the registry allows.
- Everything about items no managed package ships: the hatch still opens their runtime creation for a type that allows none, and their organization-scoped write.
- Disabling a managed flow or action (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/:object/:action`), operator-gated under a wall as before, and cloning a flow under a new name, which records no linkage.
- Removing a stored overlay row of a type whose loader merges it at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`): that removal restores the package's definition and stays allowed.
- Every overlay row a deployment already holds keeps loading and serving as before.

**For an operator who set the hatch to customize a managed item.** Customize it through its type's route: an environment overlay for the five presentational types, the switch or a clone under a new name for a flow, the clone for a permission set, an extension package for an object. An overlay row the hatch wrote earlier onto a flow, action, hook, object or another type whose overlay does not merge at read keeps serving, and can no longer be edited or removed through the metadata API, with the hatch set or not.
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ that bypassed write hooks, `rebuildSearchCompanion` (from
| `OS_MARKETPLACE_CACHE` | enum | `on` | `off` disables the in-memory marketplace listing cache. |
| `OS_MARKETPLACE_PUBLIC_BASE_URL` | url | — | Public base URL of the marketplace registry (proxied from this runtime when set). |
| `OS_ALLOW_UNMASKED_OBJECT_METADATA` | boolean | `false` | Escape hatch for the metadata-plane field-level security mask ([ADR-0106](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0106-metadata-plane-fls-object-schema-masking.md) D8). By default every object schema served by `/meta` and `/metadata` is projected onto the fields the **calling user** may read, so a field they cannot read does not appear at all — not its name, label, type, picklist options, formula, `visibleWhen` predicate, `defaultValue`, or the `requiredPermissions` capability guarding it. Set to `1` to serve the full schema to every authenticated caller, as releases before this one did. This changes **disclosure only**: the data plane still masks values and refuses forbidden writes either way, and the console reads field affordances from `/auth/me/permissions`, so toggling it never changes UI correctness. The REST layer also honours a per-server `metadata.maskObjectFields: false`; this variable is the deployment-wide knob and covers the runtime `/metadata` dispatcher, which has no REST config to read. |
| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,validation`) granted a runtime escape hatch that treats them as `allowOrgOverride: true`, letting artifact-backed items of those protected types be overridden per-org outside their static registry declaration. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). |
| `OS_METADATA_WRITABLE` | csv | — (none) | Comma-separated metadata type names (e.g. `hook,job`) granted a runtime escape hatch that treats them as `allowOrgOverride: true` for items **no managed package ships**: it opens runtime creation of a type whose registry entry allows none, and an organization-scoped write of a type with no per-organization channel. It **never opens an item a managed package ships**: managed content is sealed ([ADR-0131](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0131-total-organization-ownership-no-null-organization-id.md) D6), so an overlay or a removal of a shipped flow, object, field, permission set, position or any other type without an environment overlay is refused with `403 NOT_OVERRIDABLE` whether the hatch is set or not. Customize managed content through its type's own route instead: an environment overlay for `view`, `dashboard`, `report`, `translation` and `email_template`; disable, or clone under a new name, for a flow; clone for a permission set. Overlay rows this hatch wrote before are still served, and a row of a type that merges overlays at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`) can still be removed to restore the package's definition. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md). |

---

Expand Down
12 changes: 7 additions & 5 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -479,11 +479,13 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
pre-persistence `registerAuthoringGate` seam): the packaged-baseline rule
no lint rule can judge, enforced on every runtime-authored object body —
Studio drafts, REST saves, AI builders. An environment overlay of a
**packaged** object may only *tighten* `sharingModel` /
`externalSharingModel`, never widen them beyond the packaged declaration
(`403 owd_widening_forbidden` — widen it in the package source and
publish instead; this closes the `OS_METADATA_WRITABLE=object` escape
hatch as an unvalidated widening path, ADR-0086 D1). Write-path only:
**packaged** object is refused outright, whether it tightens or widens
`sharingModel` / `externalSharingModel`, and with `OS_METADATA_WRITABLE`
set or not (`403 NOT_OVERRIDABLE` — managed content is sealed, ADR-0131
D6): change the posture in the package source and publish instead. The
package door answers ahead of this gate, so its packaged-baseline rule
(`403 owd_widening_forbidden`, ADR-0086 D1) is not reached through the
metadata API. Write-path only:
stored metadata keeps loading unchanged. The gate's former second rule
(`403 owd_external_wider`, external ≤ internal) was **retired as a
duplicate** when the lint block crossed to the runtime door (#8310
Expand Down
87 changes: 68 additions & 19 deletions packages/metadata-protocol/src/packaged-base-regime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,26 @@
* persists it, so a stored row under its name is never a layer of it — it is
* residue a runtime write left — and removing it restores the code definition.
*
* ⛔ No sentence built here names the `OS_METADATA_WRITABLE` hatch. The hatch
* still opens these locks exactly as before, so which writes are refused does
* not move — only what the refusal prescribes. ⛔ Nor does a Regime C sentence
* prescribe editing the source and redeploying: the administrator of an
* installed package cannot do that, and a Regime C type has a runtime route
* instead. The origin-gated row has none, so the source is the only remedy
* there is to name.
* ⛔ No sentence built here prescribes the `OS_METADATA_WRITABLE` hatch. [ADR-0131
* D6] Managed content is sealed: the hatch opens no write onto, and no removal
* of, an item a managed package ships, on any door, so a sentence that named it
* as a remedy would send the operator to a door that does not open. The one
* sentence that names it at all is {@link managedItemSealedSentence}'s, for a
* type with no regime row, and it names it to say it does not apply: an operator
* who set it and read a refusal that never mentioned it would conclude it was
* ignored and set it again. ⛔ Nor does a Regime C sentence prescribe editing the
* source and redeploying: the administrator of an installed package cannot do
* that, and a Regime C type has a runtime route instead. The origin-gated row
* has none, so the source is the only remedy there is to name.
*
* ## "A managed package"
*
* [ADR-0131 D6] A package reaches a deployment in one of two install modes, and
* only the managed one registers its content as code. The install mode is not
* modelled yet (the manifest declaration of permitted modes is later work), so
* every package whose items the artifact loader registered is managed, and every
* sentence here names it so: the install mode is what the refusal is about, not
* the fact that the item happens to be code.
*/

import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
Expand Down Expand Up @@ -262,20 +275,20 @@ export function isOriginGatedType(type: string): boolean {
* `undefined` when the type declares no regime and the emitter keeps its own
* sentence. Read on the canonical type, and spoken with it.
*
* The lock is the regime's: a Regime C item "is provided by a code package, and
* its packaged base is locked"; an origin-gated item "is code-defined and cannot
* be edited (removed) at runtime: it is read-only" — the datasource-admin
* service's own verdict on the same item, so the two doors onto one code-defined
* datasource state one verdict and one remedy.
* The lock is the regime's: a Regime C item "is provided by a managed package and
* is sealed" ([ADR-0131 D6] — the install mode, see the module header); an
* origin-gated item "is code-defined and cannot be edited (removed) at runtime:
* it is read-only" — the datasource-admin service's own verdict on the same item,
* so the two doors onto one code-defined datasource state one verdict and one
* remedy.
*
* Kept under the REST door's 500-character client-message bound
* (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which
* the tail is truncated. Characters before the item's name, save / removal:
* `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310, `datasource`
* 192 / 193 — so a name of up to 88 characters arrives whole for every row
* (pinned). A `flow`'s sentence is byte-identical to the one the row table
* replaced (pinned literally). [#21944] A row's `hostOwned` name is a fixed,
* short name with its own remedy (`default`: under 300 characters whole).
* the tail is truncated. Characters outside the item's name, save / removal:
* `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294,
* `datasource` 192 / 193 — so a name of up to 88 characters arrives whole for
* every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name
* with its own remedy (`default`: under 300 characters whole).
*/
export function packagedBaseRegimeSentence(
type: string, name: string, operation: 'save' | 'delete',
Expand All @@ -286,7 +299,43 @@ export function packagedBaseRegimeSentence(
const lock = row.regime === 'origin-gated'
? `${row.noun} '${name}' is code-defined and cannot be `
+ (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. '
: `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked `
: `Metadata item '${singular}/${name}' is provided by a managed package and is sealed `
+ (operation === 'delete' ? `against removal. ` : `against in-place edits. `);
return lock + rowPrescription(row, name);
}

/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */
const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md';

/**
* [ADR-0131 D6] THE refusal sentence for a write onto, or a removal of, an item a
* managed package ships, on a type with no environment overlay — every door that
* refuses one builds it here, and nowhere else: the metadata protocol's package
* doors (`refusePackagedBaseOverride` / `refusePackagedBaseRemoval`) and the
* repository's type door (`SysMetadataRepository.assertAllowed`), which answers
* the same condition one layer down for the writes that reach it without passing
* a package door (draft promotion, restore, revert).
*
* A type with a regime row speaks for its regime ({@link packagedBaseRegimeSentence}:
* the sanctioned path, never the hatch). Every other type reads the managed seal
* itself: the item is sealed, its type takes no environment overlay, the
* `OS_METADATA_WRITABLE` hatch does not open it, and the one remedy that exists —
* changing the definition where it is declared. The hatch is named to say it
* does not apply (see the module header for why it is named at all), and the
* registry flag that produced the verdict is named so the reader can tell this
* refusal from the regime-O overlay it is not.
*
* Kept under the REST door's 500-character client-message bound: the sentence
* outside the item's type and name is 321 / 275 characters, save / removal (measured).
*/
export function managedItemSealedSentence(type: string, name: string, operation: 'save' | 'delete'): string {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
const regime = packagedBaseRegimeSentence(singular, name, operation);
if (regime !== undefined) return regime;
return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed `
+ (operation === 'delete' ? 'against removal' : 'against in-place edits')
+ `: its type takes no environment overlay (allowOrgOverride=false), and OS_METADATA_WRITABLE does not `
+ `open a managed item. `
+ (operation === 'delete' ? '' : 'Edit the source artifact and redeploy. ')
+ `See ${MANAGED_SEAL_ADR}.`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -372,13 +372,28 @@ for (const { label, environmentId } of KERNELS) {
expect(rows.size).toBe(1);
});

it('[GUARD] the operator hatch opens the lock exactly as before (not this card\'s to move)', async () => {
it('[ADR-0131 D6] the operator hatch no longer opens the lock: a code-defined datasource is sealed with it open', async () => {
// Was `[GUARD] the operator hatch opens the lock exactly as before`:
// the save landed a row. Managed content is sealed now, so the hatch
// answers what the shut hatch answers — this door's own verdict and
// sentence — and nothing is written. A runtime datasource keeps its
// write (the control below).
process.env.OS_METADATA_WRITABLE = 'datasource';
ObjectStackProtocolImplementation.resetEnvWritableCache();
resetEnvWritableMetadataTypes();
const { protocol, rows } = session();
const saved = await protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') });
expect(saved).toMatchObject({ success: true });
const err: any = await protocol
.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') })
.then(() => null, (e: unknown) => e);
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
expect(String(err?.message)).toBe(
`Datasource '${CODE_DS}' is code-defined and cannot be edited at runtime: it is read-only. `
+ 'Edit the *.datasource.ts source that declares it and redeploy. '
+ 'See docs/adr/0062-external-datasource-runtime.md.',
);
expect(rows.size).toBe(0);
const runtime = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Hatch') });
expect(runtime).toMatchObject({ success: true });
expect(rows.size).toBe(1);
});
});
Expand Down
Loading
Loading