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
23 changes: 23 additions & 0 deletions .changeset/28563-permission-set-clone-lands-in-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/runtime': patch
---

feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package — by default the source package's `.custom`, never package-less (#28563)

Clause-②: yes (narrowing) — `POST /security/permission-sets/:name/clone` takes an optional `package` and its answer names the package the copy landed in (widening); a copy of a set shipped by a read-only package now lands in that package's paired `<X>.custom`, ensured through the pair's one creation path, so a source whose package the ensure door refuses (it declares no namespace, its version is not SemVer, or another package owns the pair's namespace) is refused 409/422 where it was copied package-less before, and a given target that is unknown, read-only or out of reach is refused.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, value or type changes: the door's HTTP body gains an optional key and its answer gains one, and the copy's own definition is the same whole-definition copy as before. A refused clone writes nothing and its refusal names the remedy, so there is nothing stored for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at the permission-set clone door (`@objectstack/runtime`).

- **Before.** Every copy was saved environment-wide with no package, whatever package shipped the set it was copied from. The body was `{ name, label }`.
- **Now (ADR-0146 D3/D6).** The body is `{ name, label, package? }` and the answer is `{ permissionSet, package, notice }`.
- `package` omitted, the set shipped by a read-only package X: the copy is saved in X's paired customization package `<X>.custom`, created through the ensure door (`ensureCustomizationPackage`) on the first copy and reused after. The stored row is bound to the pair; the copy's definition still names no package.
- `package` omitted, the set already in a writable package (a base you own, or `<X>.custom`): the copy is saved in that package.
- `package` omitted, the set authored in the environment: unchanged, environment-wide (`package: null`).
- `package` given: it must be installed and writable (else 422 `WRITABLE_PACKAGE_REQUIRED`), and its dependency closure must reach the source's package and every object the set grants on (else 409 `RESOURCE_CONFLICT`). The rule is the one the flow clone door uses.
- **Unchanged.** A new name is mandatory and a taken one is 409; `manage_metadata` and the walled catalog refusal answer first; the copy is assignable by its bare name through a position or a direct assignment.
- **Who sees it.** An administrator copying a packaged set from Setup: the copy now travels with the package's customizations. A copy of a set from a package the ensure door cannot pair is refused with the ensure door's own sentence.

**The remedy.** For a refused pair: give the base package a namespace (and a SemVer version), or name a writable target with `package`.
2 changes: 1 addition & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
| :--- | :--- |
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) with a new name. The clone door copies the whole definition from the security catalog. |
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `<X>.custom` (ADR-0146 D3/D6). |

A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable.

Expand Down
27 changes: 20 additions & 7 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -425,16 +425,29 @@ package and re-publish it (ADR-0086 two-doors).
### Cloning a permission set (ADR-0126 §7.1)

```text
POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "..." }
POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "...", "package"?: "..." }
```

The clone door copies a set's whole definition under a new machine name. It
reads the source from the security catalog, where every packaged and
environment-authored set lives. It saves the copy through the metadata layer
as a set authored in this system: no package, and no link back to the set
it was copied from, so changes to the source never reach the copy.

- **The body is `{ name, label }`, both required, and nothing else.** `name` is
as a **new** set beside the original, with no link back to it, so changes to
the source never reach the copy.

- **Where the copy lands (ADR-0146 D3/D6).** With no `package`, a copy of a
set shipped by a read-only package X is saved in X's paired customization
package `<X>.custom`, the package that holds everything you changed about X.
The pair is created on the first copy, by the same ensure door as
`POST /api/v1/packages/:id/ensure-customization` (its only creation path),
and reused after. A copy of a set already in a writable package is
saved in that package, and a copy of a set authored in this system stays
environment-wide, in no package. With `package`, the copy is saved there: it
must be an installed, writable package (else `422`
`WRITABLE_PACKAGE_REQUIRED`) whose declared dependencies reach the source's
package and every object the set grants on (else `409` `RESOURCE_CONFLICT`).
The response's `package` names where the copy went (`null` for
environment-wide), and the copy is assigned by its bare name like any set.
- **The body is `{ name, label, package? }`, `name` and `label` required, and nothing else.** `name` is
a new snake_case machine name: a name failing that rule answers `400`, and a
name any permission set already uses (the source's own included) answers `409`
`RESOURCE_CONFLICT`. An unknown source answers `404`.
Expand All @@ -444,8 +457,8 @@ it was copied from, so changes to the source never reach the copy.
(`packageId`, `protection` and the internal protection envelope),
and two privilege decisions — `adminScope` (delegated administration) and
`isDefault` (the `everyone` suggestion). Grant either deliberately on the copy
if it needs one. The response, `{ permissionSet, notice }`, says what was not
copied.
if it needs one. The response, `{ permissionSet, package, notice }`, says
where the copy went and what was not copied.
- **The copy is held by nobody** until you add it to a position or assign it.
- **Who may call it** is who may save a permission set through the metadata
API: a holder of `manage_metadata`. Anyone else is refused `403`
Expand Down
125 changes: 122 additions & 3 deletions packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@
// - the copy GRANTS: a holder of it resolves its object grants;
// - the source is read from the catalog, not from a `sys_permission_set` row:
// the source's rows are removed first, and the clone still lands;
// - a caller without `manage_metadata` is refused 403 and nothing is saved.
// - a caller without `manage_metadata` is refused 403 and nothing is saved;
// - [ADR-0146 D3/D6] "new similar": a copy of a PACKAGED set with no target
// lands in the source package's paired `<X>.custom`, created by N-13's
// ensure door on the first copy and reused on the next — the stored row
// bound to the pair, the item itself naming no package — and is granted
// through a position AND a direct assignment by its bare name; a given
// target is judged (writable and in closure lands, read-only / unknown /
// out-of-closure are refused with no row); an environment-authored source
// still copies environment-wide.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { resolveUserAuthzGrants, securityCatalogReaderOf } from '@objectstack/core';
Expand All @@ -34,6 +42,12 @@ const DEFAULT_COPY = 'my_member_default';
const MEMBER_EMAIL = 'clone.member@example.com';
const HOLDER_EMAIL = 'clone.holder@example.com';
const COPY_POSITION = 'clone_door_position';
/** The showcase's manifest id, and its paired customization package (ADR-0146 D3). */
const SHOWCASE = 'com.example.showcase';
const SHOWCASE_PAIR = `${SHOWCASE}.custom`;
/** A Studio-created writable package that declares the showcase, and one that declares nothing. */
const IN_CLOSURE = 'com.acme.clone-in-closure';
const OUT_OF_CLOSURE = 'com.acme.clone-out-of-closure';

interface Engine {
find(object: string, options?: unknown): Promise<Array<Record<string, unknown>>>;
Expand Down Expand Up @@ -82,17 +96,35 @@ describe('the permission-set clone door, on a booted showcase', () => {
// stage 8 retired the `sys_permission_set` row object.
expect(ql.getObject?.('sys_permission_set')).toBeFalsy();

// Anti-vacuity for the landing: the source is the showcase's, and no
// pair exists before the first copy.
expect(source?.packageId).toBe(SHOWCASE);
expect((await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`)).status).toBe(404);

const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' });
const text = await res.clone().text();
expect(res.status, text).toBe(200);
const data = JSON.parse(text).data as { permissionSet: Record<string, unknown>; notice: string };
const data = JSON.parse(text).data as { permissionSet: Record<string, unknown>; package: string | null; notice: string };
expect(data.permissionSet.name).toBe(DELEGATE_COPY);
expect(data.permissionSet).not.toHaveProperty('adminScope');
expect(data.permissionSet).not.toHaveProperty('packageId');
expect(data.notice).toContain('adminScope');
// [ADR-0146 D3/D6] The copy landed in the showcase's pair, which the
// ensure door created for it.
expect(data.package).toBe(SHOWCASE_PAIR);
expect(data.notice).toContain(`'${SHOWCASE_PAIR}'`);
const pair = await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`);
expect(pair.status, await pair.clone().text()).toBe(200);

// The stored row is bound to the pair (its `package_id`), and the item
// names no package of its own.
const rows = await ql.find('sys_metadata', { where: { type: 'permission', name: DELEGATE_COPY }, context: SYSTEM_CTX });
expect(rows.map((r) => r.package_id)).toEqual([SHOWCASE_PAIR]);
expect(JSON.parse(String(rows[0].metadata))).not.toHaveProperty('packageId');

const copy = await reader!.resolve('permission', DELEGATE_COPY);
expect(copy?.name, 'the copy does not resolve through the catalog').toBe(DELEGATE_COPY);
expect(copy?.packageId).toBeUndefined();
expect(copy?.packageId).toBe(SHOWCASE_PAIR);
expect(copy?.definition).not.toHaveProperty('adminScope');
expect(copy?.definition.label).toBe('My Field Ops Delegate');
// The facets came across whole.
Expand All @@ -106,9 +138,16 @@ describe('the permission-set clone door, on a booted showcase', () => {

const res = await clone(token, DEFAULT_SET, { name: DEFAULT_COPY, label: 'My Member Default' });
expect(res.status, await res.clone().text()).toBe(200);
// The second copy REUSES the pair the first created: one pair, the same id.
expect(((await res.json()) as any).data.package).toBe(SHOWCASE_PAIR);
const listed = ((await (await stack.apiAs(token, 'GET', '/packages')).json()) as any).data;
const pairs = (Array.isArray(listed) ? listed : listed?.packages ?? [])
.filter((p: any) => (p?.manifest?.id ?? p?.id) === SHOWCASE_PAIR);
expect(pairs).toHaveLength(1);

const copy = await reader.resolve('permission', DEFAULT_COPY);
expect(copy?.name).toBe(DEFAULT_COPY);
expect(copy?.packageId).toBe(SHOWCASE_PAIR);
expect(copy?.definition.isDefault === undefined || copy?.definition.isDefault === false).toBe(true);
expect(copy?.definition.objects).toEqual(source!.definition.objects);

Expand All @@ -131,6 +170,86 @@ describe('the permission-set clone door, on a booted showcase', () => {
expect(grants.permissions).toContain(DEFAULT_COPY);
});

it('a copy in the pair is granted through a direct assignment too, by its bare name', async () => {
const email = 'clone.direct@example.com';
await stack.signUp(email);
const [user] = await ql.find('sys_user', { where: { email }, limit: 1, context: SYSTEM_CTX });
expect(user?.id, `no sys_user row for ${email}`).toBeTruthy();
const tenantId = await sessionOrgOf(stack);
// Anti-vacuity: the user holds no copy before the assignment.
const before = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId });
expect(before.permissions).not.toContain(DELEGATE_COPY);
await ql.insert(
'sys_user_permission_set',
{ id: 'ups_clone_door_direct', user_id: String(user.id), permission_set: DELEGATE_COPY },
{ context: SYSTEM_CTX },
);
const after = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId });
expect(after.permissions).toContain(DELEGATE_COPY);
});

it('a given writable target inside the closure lands there; read-only, unknown and out-of-closure targets are refused with no row', async () => {
const reader = securityCatalogReaderOf(ql)!;
for (const [id, namespace, dependencies] of [
[IN_CLOSURE, 'clone_in', { [SHOWCASE]: '*' }],
[OUT_OF_CLOSURE, 'clone_out', {}],
] as const) {
const created = await stack.apiAs(token, 'POST', '/packages', {
manifest: { id, name: id, version: '0.1.0', type: 'app', namespace, dependencies },
});
expect(created.status, await created.clone().text()).toBe(201);
}

const landed = await clone(token, DEFAULT_SET, { name: 'in_closure_copy', label: 'In Closure', package: IN_CLOSURE });
expect(landed.status, await landed.clone().text()).toBe(200);
expect(((await landed.json()) as any).data.package).toBe(IN_CLOSURE);
expect((await reader.resolve('permission', 'in_closure_copy'))?.packageId).toBe(IN_CLOSURE);

const refused: Array<[string, string, number, string]> = [
['read_only_copy', SHOWCASE, 422, 'WRITABLE_PACKAGE_REQUIRED'],
['unknown_copy', 'com.acme.no_such_package', 422, 'WRITABLE_PACKAGE_REQUIRED'],
['out_closure_copy', OUT_OF_CLOSURE, 409, 'RESOURCE_CONFLICT'],
];
for (const [name, target, status, code] of refused) {
const res = await clone(token, DEFAULT_SET, { name, label: 'X', package: target });
const text = await res.clone().text();
expect(res.status, `${target}: ${text}`).toBe(status);
expect(((await res.json()) as any).error?.code, target).toBe(code);
expect(await reader.resolve('permission', name), target).toBeUndefined();
const rows = await ql.find('sys_metadata', { where: { type: 'permission', name }, context: SYSTEM_CTX });
expect(rows, target).toEqual([]);
}
});

it('a platform set (the lock-and-clone rule\'s commonest source) copies into its shipping package\'s pair', async () => {
const reader = securityCatalogReaderOf(ql)!;
const source = await reader.resolve('permission', 'member_default');
expect(source?.packageId, 'member_default is not packaged on this boot').toBeTruthy();

const res = await clone(token, 'member_default', { name: 'my_member_baseline', label: 'My Member Baseline' });
const text = await res.clone().text();
expect(res.status, text).toBe(200);
const landed = ((await res.json()) as any).data.package;
expect(landed).toBe(`${source!.packageId}.custom`);
expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBe(landed);
});

it('CONTROL — an environment-authored source still copies environment-wide, with no package', async () => {
const saved = await stack.apiAs(token, 'PUT', '/meta/permission/env_authored_source', {
name: 'env_authored_source', label: 'Env Authored', objects: { showcase_task: { allowRead: true } },
});
expect(saved.status, await saved.clone().text()).toBe(200);
const source = await securityCatalogReaderOf(ql)!.resolve('permission', 'env_authored_source');
expect(source?.packageId, 'the control source is packaged').toBeUndefined();

const res = await clone(token, 'env_authored_source', { name: 'env_authored_copy', label: 'Env Authored Copy' });
expect(res.status, await res.clone().text()).toBe(200);
expect(((await res.json()) as any).data.package).toBeNull();
expect((await securityCatalogReaderOf(ql)!.resolve('permission', 'env_authored_copy'))?.packageId).toBeUndefined();
const rows = await ql.find('sys_metadata', { where: { type: 'permission', name: 'env_authored_copy' }, context: SYSTEM_CTX });
expect(rows.map((r) => r.package_id ?? null)).toEqual([null]);
});

it('a taken name is 409, an unknown source 404, a bad name 400', async () => {
const taken = await clone(token, DEFAULT_SET, { name: DELEGATE_SET, label: 'X' });
expect(taken.status, await taken.clone().text()).toBe(409);
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@objectstack/driver-sql": "workspace:*",
"@objectstack/driver-sqlite-wasm": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/lint": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
Expand Down
Loading
Loading