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
18 changes: 18 additions & 0 deletions .changeset/28990-permission-grant-unknown-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a runtime permission-set save refuses a grant on an object that does not exist

Clause-②: no (narrowing)
Metadata-protocol: narrowed

**BREAKING** for one shape only: a `permission` set whose `objects` map grants on an object this runtime does not know. It was stored and published (200), and the grant applied to nothing, because the permission evaluator keys a grant by the object's name: a short `lead` where the object is `crm_lead` silently granted nothing. `defineStack` already refuses the same grant at build; the metadata save doors now refuse it too.

- **Where.** `PUT /api/v1/meta/permission/NAME`, live and `?mode=draft`; the publish of a draft (`POST /meta/permission/NAME/publish`, `POST /packages/ID/publish-drafts`); and the restore doors (`POST /meta/permission/NAME/rollback`, a commit revert).
- **The refusal.** `422 INVALID_METADATA`, the save door's existing envelope, with one finding per unknown object at `objects.NAME`: "Permission 'SET' grants on object 'NAME' which is not defined in objects: …". Nothing is stored; a package publish aborts the batch.
- **What counts as known.** The `'*'` wildcard; an object the runtime registers, from any package, platform objects (`sys_*`, the RBAC link tables) included; an object stored in this environment or defined in code; on a draft save, any object draft; on a package publish, an object drafted in the same batch.
- **What is not judged.** A grant the set already holds: a re-save, `os migrate meta --stored` or a restore that keeps it is not refused, even if the object has gone since. Editing a shipped set is therefore never refused for a grant it ships with.
- **What to do.** Correct the object name to the object's full name, create the object first (or draft it in the same package), or remove the grant.

<!-- adr-0087: not-required (no-migration-prescription) the diff registers no retirement and prescribes no rewrite: the refused value granted nothing, so there is no stored shape to convert; a set that already holds such a grant is not refused on re-save -->
5 changes: 4 additions & 1 deletion packages/metadata-protocol/src/protocol-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface AuthoringGateHost {
runAuthoringGate: ObjectStackProtocolImplementation['runAuthoringGate'];
tenancyPostureInForce: ObjectStackProtocolImplementation['tenancyPostureInForce'];
unknownDatasourceSaveRefusal: ObjectStackProtocolImplementation['unknownDatasourceSaveRefusal'];
unknownGrantObjectSaveRefusal: ObjectStackProtocolImplementation['unknownGrantObjectSaveRefusal'];
seedExternalIdSaveRefusal: ObjectStackProtocolImplementation['seedExternalIdSaveRefusal'];
}

Expand Down Expand Up @@ -124,7 +125,7 @@ export function restoredBodyWriter(

/** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */
export async function refuseRestoredBodyTheSaveDoorRefuses(
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'seedExternalIdSaveRefusal' | 'unknownDatasourceSaveRefusal'>,
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'seedExternalIdSaveRefusal' | 'unknownDatasourceSaveRefusal' | 'unknownGrantObjectSaveRefusal'>,
type: string,
name: string,
body: unknown,
Expand All @@ -141,6 +142,8 @@ export async function refuseRestoredBodyTheSaveDoorRefuses(
if (bindingRefusal) throw bindingRefusal;
const datasourceRefusal = await this.unknownDatasourceSaveRefusal(type, body, name);
if (datasourceRefusal) throw datasourceRefusal;
const grantRefusal = await this.unknownGrantObjectSaveRefusal(type, body, name);
if (grantRefusal) throw grantRefusal;
const seedKeyRefusal = await this.seedExternalIdSaveRefusal(type, body, name);
if (seedKeyRefusal) throw seedKeyRefusal;
const repo = this.getOverlayRepo();
Expand Down
76 changes: 76 additions & 0 deletions packages/metadata-protocol/src/protocol-save-door-refusals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,82 @@ export async function unknownDatasourceSaveRefusal(
return err;
}

/**
* The body of `ObjectStackProtocolImplementation#unknownGrantObjectSaveRefusal`;
* the method's docblock there is its contract. [#28990] Written here beside
* {@link unknownDatasourceSaveRefusal}, whose shape it follows: the engine's
* registered objects, then the by-name read (`readMetaItem`), then the pending
* drafts, then the definition the set already holds.
*/
export async function unknownGrantObjectSaveRefusal(
this: Pick<SaveDoorRefusalHost, 'engine' | 'listDrafts' | 'readMetaItem'>,
singularType: string,
item: unknown,
name: string,
pending?: 'drafts' | ReadonlySet<string>,
writeFace?: MetadataWriteFace,
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
if (singularType !== 'permission') return undefined;
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
const grants = (item as { objects?: unknown }).objects;
if (!grants || typeof grants !== 'object' || Array.isArray(grants)) return undefined;
// A registry that cannot look an object up by name cannot say a name is
// unknown, so nothing is refused on it.
const registry = this.engine?.registry as { getObject?: unknown } | undefined;
const getObject = registry?.getObject;
if (typeof getObject !== 'function') return undefined;
// `'*'` is the wildcard grant the permission evaluator falls back to for
// every object with no entry of its own: it names no object.
let unknown = Object.keys(grants).filter((key) => {
if (key === '*') return false;
// The object's own name, exactly: the registry's legacy `pkg__name`
// lookup answers an object the evaluator never keys a grant by.
const registered = getObject.call(registry, key) as { name?: unknown } | undefined;
return registered?.name !== key;
});
if (unknown.length === 0) return undefined;
// An object the environment stores, or the code layer defines, that the
// registry has not (yet) registered: known here, and judged where it is used.
const declared = await Promise.all(unknown.map(async (key) =>
Boolean((await this.readMetaItem({ type: 'object', name: key }))?.item)));
unknown = unknown.filter((_, index) => !declared[index]);
if (unknown.length === 0) return undefined;
// An object still pending: any object draft, for a draft save; the batch's
// own object drafts, for a package publish.
if (pending instanceof Set) {
unknown = unknown.filter((key) => !pending.has(key));
} else if (pending === 'drafts') {
const drafted = new Set((await this.listDrafts({ type: 'object' })).drafts.map((d) => d.name));
unknown = unknown.filter((key) => !drafted.has(key));
}
if (unknown.length === 0) return undefined;
// A grant the set already holds is not this save's: a re-save, a
// stored-row migration or a restore that keeps it is judged at the save
// that introduced it, and an object that has gone since is not the
// re-saving author's to answer for. (A declaration boot never runs
// `onEnable`, so what a plugin registers there is absent from it.)
const current = (await this.readMetaItem({ type: 'permission', name }))?.item as { objects?: unknown } | undefined;
const held = current?.objects && typeof current.objects === 'object' && !Array.isArray(current.objects)
? new Set(Object.keys(current.objects))
: new Set<string>();
unknown = unknown.filter((key) => !held.has(key));
if (unknown.length === 0) return undefined;
const issues: MetadataIssueEntry[] = unknown.map((key) => ({
path: `objects.${key}`,
message: `Permission '${name}' grants on object '${key}' which is not defined in objects: no package registers `
+ 'an object of that name and none is saved here, so the grant applies to nothing. Correct the name to the '
+ `object's full name (e.g. 'crm_lead', not 'lead'), create the object first, or remove the grant.`,
code: 'custom',
}));
const err = new Error(
`${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`,
) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] };
err.code = 'INVALID_METADATA';
err.status = 422;
err.issues = issues;
return err;
}

/** The body of `ObjectStackProtocolImplementation#objectsBoundExternallyTo`; the method's docblock there is its contract. */
export async function objectsBoundExternallyTo(
this: Pick<SaveDoorRefusalHost, 'engine' | 'readActiveOverlayRows' | 'rethrowUnlessMetadataStoreUnprovisioned' | 'storedOverlayEntries'>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* family of shapes after its schema gate: a hook with no `body`, a second
* default record page of an object, an `external` object binding on a managed
* datasource (both halves of the pair), an object `datasource` that names no
* datasource (#26724), a keyed seed whose `externalId` names no field of its
* datasource (#26724), a permission-set grant on an object that does not
* exist (#28990), a keyed seed whose `externalId` names no field of its
* object (#28952), a `runAs: 'system'` flow an anonymous
* endpoint names, the shared runtime authoring rules, and the authoring gate a
* domain plugin registers. Measured on `origin/main` `5801a4f2c` before this
Expand Down Expand Up @@ -506,6 +507,28 @@ pinBothDoors({
},
});

pinBothDoors({
title: 'a permission set restored with a grant on an object that does not exist now (unknownGrantObjectSaveRefusal)',
type: 'permission',
name: 'qa_widget_editor',
code: 'INVALID_METADATA',
status: 422,
async stage(h) {
h.objects.set('qa_widget', { name: 'qa_widget' });
const set = (objects: Record<string, unknown>, label = 'Widget editor') => ({ name: 'qa_widget_editor', label, objects });
await h.protocol.saveMetaItem({ type: 'permission', name: 'qa_widget_editor', item: set({ qa_widget: { allowRead: true } }) });
await h.protocol.saveMetaItem({ type: 'permission', name: 'qa_widget_editor', item: set({}) });
await h.protocol.saveMetaItem({ type: 'permission', name: 'qa_widget_editor', item: set({}, 'Widget editor v3') });
// The object v1 granted on is gone, and the set holds no grant on it now.
h.objects.delete('qa_widget');
return { refused: 1, accepted: 2 };
},
expectDetail: (error) => {
expect(error.issues).toEqual([expect.objectContaining({ path: 'objects.qa_widget', code: 'custom' })]);
expect(error.issues[0].message.startsWith("Permission 'qa_widget_editor' grants on object 'qa_widget' which is not defined in objects")).toBe(true);
},
});

pinBothDoors({
title: 'a seed restored keyed on a field its object no longer has (seedExternalIdSaveRefusal)',
type: 'seed',
Expand Down
Loading
Loading