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
36 changes: 36 additions & 0 deletions .changeset/adr-0086-two-doors-permission-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/metadata-protocol": minor
"@objectstack/plugin-security": minor
---

feat(security): two-doors separation for permission sets (ADR-0086 P2)

Splits who may change a permission set into two non-overlapping doors, enforced
at the data layer instead of by convention:

**块1 — the package door (publish-time materialization).**
`ObjectStackProtocolImplementation` gains a generic publish-time materializer
registry (`registerPublishMaterializer(type, fn)`). When a draft of a registered
type is published, its body is projected into a data-plane row and the result is
surfaced on the publish response as `materializeApplied` (best-effort, never
thrown — same contract as `seedApplied`). `promoteDraft` now returns the draft's
`packageId` so the materializer can stamp the owning package. `plugin-security`
registers a `permission` materializer that upserts the published set into
`sys_permission_set` with `managed_by:'package'` + `package_id` — so a set
authored through the studio package door (saved as a `permission` draft, then
published) lands in the admin surface with the exact provenance the boot seeder
already stamps, now on the runtime publish path too. The single-set upsert is
shared with `bootstrapDeclaredPermissions` (`upsertPackagePermissionSet`), so
both paths apply the same own-row / foreign-package / env-authored rules.

**块2 — the admin door (data-layer write gate).**
The security middleware now refuses any admin-door write
(`update`/`delete`/`transfer`/`restore`/`purge`) to a `sys_permission_set` row
with `managed_by:'package'`, and refuses an `insert` that forges
`managed_by:'package'`. The gate fails closed regardless of the caller's grants
(a platform admin with `modifyAllRecords` is blocked just the same), so it is a
real data-layer boundary rather than a UI hint. System/boot writes carry
`isSystem` and bypass the whole middleware, so the boot seeder and the publish
materializer are unaffected. Env-authored sets (`managed_by` `user`/`platform`
or absent) stay freely editable through the admin door — the two doors never
overwrite each other.
111 changes: 111 additions & 0 deletions packages/dogfood/test/two-doors-permission.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// ADR-0086 P2 — "two doors" separation for permission sets, proven on the real
// showcase stack (which declares `showcase_contributor` as a package set):
//
// 块1 — the PACKAGE door: a permission set authored as a `permission` metadata
// draft under a package and then PUBLISHED is materialized into
// `sys_permission_set` with `managed_by:'package'` + the owning
// `package_id` (publish-time, not just at boot). A draft alone
// materializes nothing — only publish makes it live.
//
// 块2 — the ADMIN door: the generic data-plane write path
// (`PATCH /data/sys_permission_set/:id`) refuses to mutate a
// package-managed row — even for the platform admin — so the two doors
// never overwrite each other. An env-authored row stays freely editable.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';

describe('two-doors permission separation (ADR-0086 P2)', () => {
let stack: VerifyStack;
let ql: any;
let protocol: any;
let adminToken: string;

beforeAll(async () => {
stack = await bootStack(showcaseStack);
adminToken = await stack.signIn();
ql = await stack.kernel.getServiceAsync('objectql');
protocol = await stack.kernel.getServiceAsync('protocol');
}, 60_000);
afterAll(async () => { await stack?.stop(); });

const findSet = async (name: string) =>
(await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0];

// ── 块1 — package door: draft → publish → materialize ────────────────────
it('块1: publishing a package permission draft materializes a package-managed row', async () => {
// An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage),
// standing in for the package the studio package door edits.
const PKG = 'com.example.twodoors_ws';
const NAME = 'twodoors_pkgset';

await protocol.saveMetaItem({
type: 'permission',
name: NAME,
mode: 'draft',
packageId: PKG,
item: {
name: NAME,
label: 'Two Doors Set',
objects: { crm_lead: { allowRead: true, allowCreate: true } },
},
});

// Draft only — enforcement/admin-surface must NOT see it yet.
expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy();

const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME });
expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy();
expect(pub.materializeApplied.success).toBe(true);

const row = await findSet(NAME);
expect(row, 'published set is now a real record').toBeTruthy();
expect(row.managed_by).toBe('package');
expect(row.package_id).toBe(PKG);
expect(JSON.parse(row.object_permissions || '{}')).toEqual({
crm_lead: { allowRead: true, allowCreate: true },
});
});

// ── 块2 — admin door: write gate on package-managed rows ──────────────────
it('块2: the admin data door CANNOT edit a package-managed set (403), even as platform admin', async () => {
const contributor = await findSet('showcase_contributor');
expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package');

const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${contributor.id}`, {
label: 'hijacked-through-admin-door',
});
expect(res.status).toBe(403);

// And the row is untouched.
const after = await findSet('showcase_contributor');
expect(after.label).toBe(contributor.label);
});

it('块2: the admin door CAN still edit an env-authored set (isolates the gate to package rows)', async () => {
const memberDefault = await findSet('member_default');
expect(memberDefault?.managed_by ?? null, 'member_default is env-owned').not.toBe('package');

const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${memberDefault.id}`, {
description: 'edited through the admin door',
});
expect(res.status).toBeLessThan(300);

const after = await findSet('member_default');
expect(after.description).toBe('edited through the admin door');
});

it('块2: the admin door cannot forge package provenance on insert', async () => {
const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', {
name: 'forged_pkg_set',
label: 'Forged',
managed_by: 'package',
package_id: 'com.example.twodoors_ws',
});
expect(res.status).toBe(403);
expect(await findSet('forged_pkg_set'), 'the forged row must not exist').toBeFalsy();
});
});
117 changes: 117 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,30 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{
return issues;
}

/**
* Result of projecting a published metadata body into its data-plane
* representation. `success:false` with an `error` is the surfaced-not-thrown
* failure contract — publishing the metadata itself always succeeds.
*/
export interface PublishMaterializeResult {
success: boolean;
inserted: number;
updated: number;
error?: string;
}

/**
* Publish-time materializer (ADR-0086 P2). Receives the just-published body
* plus the draft's package binding and org scope. Registered per metadata type
* via {@link ObjectStackProtocolImplementation.registerPublishMaterializer}.
*/
export type PublishMaterializer = (args: {
body: unknown;
packageId: string | null;
organizationId: string | null;
actor: string;
}) => Promise<PublishMaterializeResult>;

export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
private engine: MetadataHostEngine;
private getServicesRegistry?: () => Map<string, any>;
Expand All @@ -717,6 +741,19 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
*/
private overlayRepos = new Map<string, SysMetadataRepository>();

/**
* Publish-time materializers keyed by singular metadata type (ADR-0086 P2).
* When a draft of a registered type is published, its body is projected
* into a data-plane representation the admin surface reads — e.g. a
* `permission` set is upserted into `sys_permission_set` with
* `managed_by:'package'`. Domain plugins own the projection (the generic
* protocol layer must not know `sys_permission_set`'s field shape), so they
* register here at init. Best-effort — a materializer failure is surfaced on
* the publish response, never thrown (publishing metadata always succeeds
* independently; the same contract as `seed` apply).
*/
private publishMaterializers = new Map<string, PublishMaterializer>();

constructor(
engine: IDataEngine,
getServicesRegistry?: () => Map<string, any>,
Expand All @@ -729,6 +766,18 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
this.environmentId = environmentId;
}

/**
* Register a publish-time materializer for a metadata type (ADR-0086 P2).
* Called by domain plugins at init (e.g. plugin-security registers the
* `permission` → `sys_permission_set` projection). The singular type name is
* used — `permissions` and `permission` both resolve here. One materializer
* per type; a second registration replaces the first (idempotent re-init).
*/
registerPublishMaterializer(type: string, materializer: PublishMaterializer): void {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
this.publishMaterializers.set(singular, materializer);
}

/**
* Lazily obtain a SysMetadataRepository for the given organization.
* Env-wide overlays (organizationId == null) share a singleton under
Expand Down Expand Up @@ -4042,6 +4091,13 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
error?: string;
errors?: unknown[];
};
/**
* Present when a publish-time materializer is registered for this type
* (ADR-0086 P2 — e.g. `permission` → `sys_permission_set`): the result
* of projecting the published body into its data-plane row. Best-effort,
* same contract as `seedApplied` — surfaced, never thrown.
*/
materializeApplied?: PublishMaterializeResult;
}> {
const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType)
Expand Down Expand Up @@ -4098,6 +4154,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
seq: number;
message?: string;
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
materializeApplied?: PublishMaterializeResult;
} = {
success: true,
version: result.version,
Expand All @@ -4112,6 +4169,29 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
if (singularType === 'seed' && !request._skipSeedApply) {
response.seedApplied = await this.applySeedBodies([result.item.body], orgId);
}
// Publish-time materializer (ADR-0086 P2): project the published body
// into its data-plane row (e.g. `permission` → `sys_permission_set`
// with `managed_by:'package'`). Unlike seeds this needs no batch
// ordering — permission sets carry no cross-item references — so it
// runs on every publish path, package-draft batch included. The
// owning `package_id` rides on `result.packageId` (the draft's
// binding), so a package-door set materializes under the right owner.
const materializer = this.publishMaterializers.get(singularType);
if (materializer) {
try {
response.materializeApplied = await materializer({
body: result.item.body,
packageId: result.packageId,
organizationId: orgId,
actor: request.actor ?? 'system',
});
} catch (e: any) {
response.materializeApplied = {
success: false, inserted: 0, updated: 0,
error: e?.message ?? 'materialize failed',
};
}
}
return response;
} catch (err: any) {
if (err instanceof ConflictError) {
Expand Down Expand Up @@ -4243,6 +4323,20 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
failed: Array<{ type: string; name: string; error: string; code?: string }>;
/** Aggregate result of materializing every published `seed` (absent when no seeds). */
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
/**
* ADR-0086 P2 — aggregate result of publish-time materializers across the
* batch (e.g. `permission` → `sys_permission_set`). Absent when no
* published item had a registered materializer. `failures` names each
* item whose projection did NOT land (e.g. a permission-set name owned by
* the env door or another package) so the caller surfaces it instead of
* reporting a clean publish over a set that never went live.
*/
materializeApplied?: {
success: boolean;
inserted: number;
updated: number;
failures: Array<{ type: string; name: string; error: string }>;
};
/**
* ADR-0038 L3 — post-publish runtime probe report (absent when nothing
* was publishable). One real read per published artifact: seeded
Expand Down Expand Up @@ -4295,6 +4389,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
}
}
const publishedSeqs: number[] = [];
// ADR-0086 P2 — accumulate each item's publish-time materialization so a
// batch package publish surfaces a permission set that failed to go live
// (owned by the env door / another package), not just a clean count.
const materialize = { any: false, inserted: 0, updated: 0, failures: [] as Array<{ type: string; name: string; error: string }> };

for (const d of ordered) {
try {
Expand All @@ -4316,6 +4414,17 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
});
published.push({ type: d.type, name: d.name, version: r.version });
if (typeof r.seq === 'number') publishedSeqs.push(r.seq);
if (r.materializeApplied) {
materialize.any = true;
materialize.inserted += r.materializeApplied.inserted;
materialize.updated += r.materializeApplied.updated;
if (!r.materializeApplied.success) {
materialize.failures.push({
type: d.type, name: d.name,
error: r.materializeApplied.error ?? 'materialize failed',
});
}
}
} catch (e: any) {
failed.push({
type: d.type,
Expand Down Expand Up @@ -4384,6 +4493,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
published,
failed,
...(seedApplied ? { seedApplied } : {}),
...(materialize.any
? { materializeApplied: {
success: materialize.failures.length === 0,
inserted: materialize.inserted,
updated: materialize.updated,
failures: materialize.failures,
} }
: {}),
...(probes ? { probes } : {}),
...(commit ? { commitId: commit.commitId } : {}),
};
Expand Down
7 changes: 5 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ export class SysMetadataRepository implements MetadataRepository {
async promoteDraft(
ref: MetaRef,
opts: { actor: string; source?: string; message?: string; intent?: MetadataWriteIntent },
): Promise<{ version: string; seq: number; item: MetadataItem }> {
): Promise<{ version: string; seq: number; item: MetadataItem; packageId: string | null }> {
this.assertOpen();
// Read the RAW draft row (not just the body) so the promotion can carry
// the draft's package binding onto the active row. ADR-0048 keys overlay
Expand Down Expand Up @@ -629,7 +629,10 @@ export class SysMetadataRepository implements MetadataRepository {
// best-effort: a concurrent publisher may have already drained
// the draft; the active row's authoritative content is intact.
}
return result;
// Surface the promoted draft's package binding so publish-time
// materializers (ADR-0086 P2 — package-door permission sets) can stamp
// the data-plane row with the owning `package_id`.
return { ...result, packageId: draftPackageId };
}

/**
Expand Down
Loading