Skip to content

Commit 9796e7c

Browse files
os-zhuangclaude
andauthored
feat(security): two-doors separation for permission sets (ADR-0086 P2) (#2573)
Splits who may change a permission set into two non-overlapping doors, enforced at the data layer instead of by convention. 块1 — package door (publish-time materialization): - metadata-protocol: generic publish-time materializer registry (registerPublishMaterializer); publishMetaItem projects a published body into its data-plane row and surfaces materializeApplied (best-effort, never thrown — same contract as seedApplied). publishPackageDrafts aggregates it across the batch so a refused set is surfaced, not swallowed. - promoteDraft now returns the draft's packageId so the materializer stamps the owning package. - plugin-security registers a `permission` materializer that upserts the published set into sys_permission_set with managed_by:'package' + package_id. The single-set upsert (upsertPackagePermissionSet) is shared with bootstrapDeclaredPermissions, so boot and publish apply identical own-row / foreign-package / env-authored rules. A publish that materializes nothing (no owning package / name owned elsewhere) reports success:false. 块2 — admin door (data-layer write gate): - The security middleware refuses any admin-door write to a sys_permission_set row with managed_by:'package', and refuses a payload (insert OR update, single object OR array) that forges managed_by:'package'. - Placed before the empty-principal fall-open and the CRUD check, so it is a real, unconditional boundary — it holds for a principal-less context and for a superuser with modifyAllRecords. A multi-row/filter write is denied only when a package-owned row actually falls within the write's own filter, so env-only bulk edits still succeed. System/boot writes carry isSystem and bypass the middleware, so the seeder and materializer are unaffected. Tested: unit (materialize semantics, gate incl. array-forge / update-to-forge / principal-less / precise-bulk) + a dogfood test booting the real showcase stack that publishes a package permission draft and drives the admin door against the seeded package row over REST. Claude-Session: https://claude.ai/code/session_014y5kiH3aPLWtRRRGcVrXcT Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9860de4 commit 9796e7c

8 files changed

Lines changed: 688 additions & 51 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
feat(security): two-doors separation for permission sets (ADR-0086 P2)
7+
8+
Splits who may change a permission set into two non-overlapping doors, enforced
9+
at the data layer instead of by convention:
10+
11+
**块1 — the package door (publish-time materialization).**
12+
`ObjectStackProtocolImplementation` gains a generic publish-time materializer
13+
registry (`registerPublishMaterializer(type, fn)`). When a draft of a registered
14+
type is published, its body is projected into a data-plane row and the result is
15+
surfaced on the publish response as `materializeApplied` (best-effort, never
16+
thrown — same contract as `seedApplied`). `promoteDraft` now returns the draft's
17+
`packageId` so the materializer can stamp the owning package. `plugin-security`
18+
registers a `permission` materializer that upserts the published set into
19+
`sys_permission_set` with `managed_by:'package'` + `package_id` — so a set
20+
authored through the studio package door (saved as a `permission` draft, then
21+
published) lands in the admin surface with the exact provenance the boot seeder
22+
already stamps, now on the runtime publish path too. The single-set upsert is
23+
shared with `bootstrapDeclaredPermissions` (`upsertPackagePermissionSet`), so
24+
both paths apply the same own-row / foreign-package / env-authored rules.
25+
26+
**块2 — the admin door (data-layer write gate).**
27+
The security middleware now refuses any admin-door write
28+
(`update`/`delete`/`transfer`/`restore`/`purge`) to a `sys_permission_set` row
29+
with `managed_by:'package'`, and refuses an `insert` that forges
30+
`managed_by:'package'`. The gate fails closed regardless of the caller's grants
31+
(a platform admin with `modifyAllRecords` is blocked just the same), so it is a
32+
real data-layer boundary rather than a UI hint. System/boot writes carry
33+
`isSystem` and bypass the whole middleware, so the boot seeder and the publish
34+
materializer are unaffected. Env-authored sets (`managed_by` `user`/`platform`
35+
or absent) stay freely editable through the admin door — the two doors never
36+
overwrite each other.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0086 P2 — "two doors" separation for permission sets, proven on the real
4+
// showcase stack (which declares `showcase_contributor` as a package set):
5+
//
6+
// 块1 — the PACKAGE door: a permission set authored as a `permission` metadata
7+
// draft under a package and then PUBLISHED is materialized into
8+
// `sys_permission_set` with `managed_by:'package'` + the owning
9+
// `package_id` (publish-time, not just at boot). A draft alone
10+
// materializes nothing — only publish makes it live.
11+
//
12+
// 块2 — the ADMIN door: the generic data-plane write path
13+
// (`PATCH /data/sys_permission_set/:id`) refuses to mutate a
14+
// package-managed row — even for the platform admin — so the two doors
15+
// never overwrite each other. An env-authored row stays freely editable.
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import showcaseStack from '@objectstack/example-showcase';
19+
import { bootStack, type VerifyStack } from '@objectstack/verify';
20+
21+
describe('two-doors permission separation (ADR-0086 P2)', () => {
22+
let stack: VerifyStack;
23+
let ql: any;
24+
let protocol: any;
25+
let adminToken: string;
26+
27+
beforeAll(async () => {
28+
stack = await bootStack(showcaseStack);
29+
adminToken = await stack.signIn();
30+
ql = await stack.kernel.getServiceAsync('objectql');
31+
protocol = await stack.kernel.getServiceAsync('protocol');
32+
}, 60_000);
33+
afterAll(async () => { await stack?.stop(); });
34+
35+
const findSet = async (name: string) =>
36+
(await ql.find('sys_permission_set', { where: { name } }, { context: { isSystem: true } }))?.[0];
37+
38+
// ── 块1 — package door: draft → publish → materialize ────────────────────
39+
it('块1: publishing a package permission draft materializes a package-managed row', async () => {
40+
// An unregistered authoring-workspace id is a WRITABLE base (isWritablePackage),
41+
// standing in for the package the studio package door edits.
42+
const PKG = 'com.example.twodoors_ws';
43+
const NAME = 'twodoors_pkgset';
44+
45+
await protocol.saveMetaItem({
46+
type: 'permission',
47+
name: NAME,
48+
mode: 'draft',
49+
packageId: PKG,
50+
item: {
51+
name: NAME,
52+
label: 'Two Doors Set',
53+
objects: { crm_lead: { allowRead: true, allowCreate: true } },
54+
},
55+
});
56+
57+
// Draft only — enforcement/admin-surface must NOT see it yet.
58+
expect(await findSet(NAME), 'a draft must not materialize a data row').toBeFalsy();
59+
60+
const pub = await protocol.publishMetaItem({ type: 'permission', name: NAME });
61+
expect(pub.materializeApplied, 'publish surfaces the materialize result').toBeTruthy();
62+
expect(pub.materializeApplied.success).toBe(true);
63+
64+
const row = await findSet(NAME);
65+
expect(row, 'published set is now a real record').toBeTruthy();
66+
expect(row.managed_by).toBe('package');
67+
expect(row.package_id).toBe(PKG);
68+
expect(JSON.parse(row.object_permissions || '{}')).toEqual({
69+
crm_lead: { allowRead: true, allowCreate: true },
70+
});
71+
});
72+
73+
// ── 块2 — admin door: write gate on package-managed rows ──────────────────
74+
it('块2: the admin data door CANNOT edit a package-managed set (403), even as platform admin', async () => {
75+
const contributor = await findSet('showcase_contributor');
76+
expect(contributor?.managed_by, 'showcase_contributor is package-owned').toBe('package');
77+
78+
const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${contributor.id}`, {
79+
label: 'hijacked-through-admin-door',
80+
});
81+
expect(res.status).toBe(403);
82+
83+
// And the row is untouched.
84+
const after = await findSet('showcase_contributor');
85+
expect(after.label).toBe(contributor.label);
86+
});
87+
88+
it('块2: the admin door CAN still edit an env-authored set (isolates the gate to package rows)', async () => {
89+
const memberDefault = await findSet('member_default');
90+
expect(memberDefault?.managed_by ?? null, 'member_default is env-owned').not.toBe('package');
91+
92+
const res = await stack.apiAs(adminToken, 'PATCH', `/data/sys_permission_set/${memberDefault.id}`, {
93+
description: 'edited through the admin door',
94+
});
95+
expect(res.status).toBeLessThan(300);
96+
97+
const after = await findSet('member_default');
98+
expect(after.description).toBe('edited through the admin door');
99+
});
100+
101+
it('块2: the admin door cannot forge package provenance on insert', async () => {
102+
const res = await stack.apiAs(adminToken, 'POST', '/data/sys_permission_set', {
103+
name: 'forged_pkg_set',
104+
label: 'Forged',
105+
managed_by: 'package',
106+
package_id: 'com.example.twodoors_ws',
107+
});
108+
expect(res.status).toBe(403);
109+
expect(await findSet('forged_pkg_set'), 'the forged row must not exist').toBeFalsy();
110+
});
111+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,30 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{
695695
return issues;
696696
}
697697

698+
/**
699+
* Result of projecting a published metadata body into its data-plane
700+
* representation. `success:false` with an `error` is the surfaced-not-thrown
701+
* failure contract — publishing the metadata itself always succeeds.
702+
*/
703+
export interface PublishMaterializeResult {
704+
success: boolean;
705+
inserted: number;
706+
updated: number;
707+
error?: string;
708+
}
709+
710+
/**
711+
* Publish-time materializer (ADR-0086 P2). Receives the just-published body
712+
* plus the draft's package binding and org scope. Registered per metadata type
713+
* via {@link ObjectStackProtocolImplementation.registerPublishMaterializer}.
714+
*/
715+
export type PublishMaterializer = (args: {
716+
body: unknown;
717+
packageId: string | null;
718+
organizationId: string | null;
719+
actor: string;
720+
}) => Promise<PublishMaterializeResult>;
721+
698722
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
699723
private engine: MetadataHostEngine;
700724
private getServicesRegistry?: () => Map<string, any>;
@@ -717,6 +741,19 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
717741
*/
718742
private overlayRepos = new Map<string, SysMetadataRepository>();
719743

744+
/**
745+
* Publish-time materializers keyed by singular metadata type (ADR-0086 P2).
746+
* When a draft of a registered type is published, its body is projected
747+
* into a data-plane representation the admin surface reads — e.g. a
748+
* `permission` set is upserted into `sys_permission_set` with
749+
* `managed_by:'package'`. Domain plugins own the projection (the generic
750+
* protocol layer must not know `sys_permission_set`'s field shape), so they
751+
* register here at init. Best-effort — a materializer failure is surfaced on
752+
* the publish response, never thrown (publishing metadata always succeeds
753+
* independently; the same contract as `seed` apply).
754+
*/
755+
private publishMaterializers = new Map<string, PublishMaterializer>();
756+
720757
constructor(
721758
engine: IDataEngine,
722759
getServicesRegistry?: () => Map<string, any>,
@@ -729,6 +766,18 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
729766
this.environmentId = environmentId;
730767
}
731768

769+
/**
770+
* Register a publish-time materializer for a metadata type (ADR-0086 P2).
771+
* Called by domain plugins at init (e.g. plugin-security registers the
772+
* `permission` → `sys_permission_set` projection). The singular type name is
773+
* used — `permissions` and `permission` both resolve here. One materializer
774+
* per type; a second registration replaces the first (idempotent re-init).
775+
*/
776+
registerPublishMaterializer(type: string, materializer: PublishMaterializer): void {
777+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
778+
this.publishMaterializers.set(singular, materializer);
779+
}
780+
732781
/**
733782
* Lazily obtain a SysMetadataRepository for the given organization.
734783
* Env-wide overlays (organizationId == null) share a singleton under
@@ -4042,6 +4091,13 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
40424091
error?: string;
40434092
errors?: unknown[];
40444093
};
4094+
/**
4095+
* Present when a publish-time materializer is registered for this type
4096+
* (ADR-0086 P2 — e.g. `permission` → `sys_permission_set`): the result
4097+
* of projecting the published body into its data-plane row. Best-effort,
4098+
* same contract as `seedApplied` — surfaced, never thrown.
4099+
*/
4100+
materializeApplied?: PublishMaterializeResult;
40454101
}> {
40464102
const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
40474103
if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType)
@@ -4098,6 +4154,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
40984154
seq: number;
40994155
message?: string;
41004156
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
4157+
materializeApplied?: PublishMaterializeResult;
41014158
} = {
41024159
success: true,
41034160
version: result.version,
@@ -4112,6 +4169,29 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
41124169
if (singularType === 'seed' && !request._skipSeedApply) {
41134170
response.seedApplied = await this.applySeedBodies([result.item.body], orgId);
41144171
}
4172+
// Publish-time materializer (ADR-0086 P2): project the published body
4173+
// into its data-plane row (e.g. `permission` → `sys_permission_set`
4174+
// with `managed_by:'package'`). Unlike seeds this needs no batch
4175+
// ordering — permission sets carry no cross-item references — so it
4176+
// runs on every publish path, package-draft batch included. The
4177+
// owning `package_id` rides on `result.packageId` (the draft's
4178+
// binding), so a package-door set materializes under the right owner.
4179+
const materializer = this.publishMaterializers.get(singularType);
4180+
if (materializer) {
4181+
try {
4182+
response.materializeApplied = await materializer({
4183+
body: result.item.body,
4184+
packageId: result.packageId,
4185+
organizationId: orgId,
4186+
actor: request.actor ?? 'system',
4187+
});
4188+
} catch (e: any) {
4189+
response.materializeApplied = {
4190+
success: false, inserted: 0, updated: 0,
4191+
error: e?.message ?? 'materialize failed',
4192+
};
4193+
}
4194+
}
41154195
return response;
41164196
} catch (err: any) {
41174197
if (err instanceof ConflictError) {
@@ -4243,6 +4323,20 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
42434323
failed: Array<{ type: string; name: string; error: string; code?: string }>;
42444324
/** Aggregate result of materializing every published `seed` (absent when no seeds). */
42454325
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[] };
4326+
/**
4327+
* ADR-0086 P2 — aggregate result of publish-time materializers across the
4328+
* batch (e.g. `permission` → `sys_permission_set`). Absent when no
4329+
* published item had a registered materializer. `failures` names each
4330+
* item whose projection did NOT land (e.g. a permission-set name owned by
4331+
* the env door or another package) so the caller surfaces it instead of
4332+
* reporting a clean publish over a set that never went live.
4333+
*/
4334+
materializeApplied?: {
4335+
success: boolean;
4336+
inserted: number;
4337+
updated: number;
4338+
failures: Array<{ type: string; name: string; error: string }>;
4339+
};
42464340
/**
42474341
* ADR-0038 L3 — post-publish runtime probe report (absent when nothing
42484342
* was publishable). One real read per published artifact: seeded
@@ -4295,6 +4389,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
42954389
}
42964390
}
42974391
const publishedSeqs: number[] = [];
4392+
// ADR-0086 P2 — accumulate each item's publish-time materialization so a
4393+
// batch package publish surfaces a permission set that failed to go live
4394+
// (owned by the env door / another package), not just a clean count.
4395+
const materialize = { any: false, inserted: 0, updated: 0, failures: [] as Array<{ type: string; name: string; error: string }> };
42984396

42994397
for (const d of ordered) {
43004398
try {
@@ -4316,6 +4414,17 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
43164414
});
43174415
published.push({ type: d.type, name: d.name, version: r.version });
43184416
if (typeof r.seq === 'number') publishedSeqs.push(r.seq);
4417+
if (r.materializeApplied) {
4418+
materialize.any = true;
4419+
materialize.inserted += r.materializeApplied.inserted;
4420+
materialize.updated += r.materializeApplied.updated;
4421+
if (!r.materializeApplied.success) {
4422+
materialize.failures.push({
4423+
type: d.type, name: d.name,
4424+
error: r.materializeApplied.error ?? 'materialize failed',
4425+
});
4426+
}
4427+
}
43194428
} catch (e: any) {
43204429
failed.push({
43214430
type: d.type,
@@ -4384,6 +4493,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
43844493
published,
43854494
failed,
43864495
...(seedApplied ? { seedApplied } : {}),
4496+
...(materialize.any
4497+
? { materializeApplied: {
4498+
success: materialize.failures.length === 0,
4499+
inserted: materialize.inserted,
4500+
updated: materialize.updated,
4501+
failures: materialize.failures,
4502+
} }
4503+
: {}),
43874504
...(probes ? { probes } : {}),
43884505
...(commit ? { commitId: commit.commitId } : {}),
43894506
};

packages/metadata-protocol/src/sys-metadata-repository.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ export class SysMetadataRepository implements MetadataRepository {
577577
async promoteDraft(
578578
ref: MetaRef,
579579
opts: { actor: string; source?: string; message?: string; intent?: MetadataWriteIntent },
580-
): Promise<{ version: string; seq: number; item: MetadataItem }> {
580+
): Promise<{ version: string; seq: number; item: MetadataItem; packageId: string | null }> {
581581
this.assertOpen();
582582
// Read the RAW draft row (not just the body) so the promotion can carry
583583
// the draft's package binding onto the active row. ADR-0048 keys overlay
@@ -629,7 +629,10 @@ export class SysMetadataRepository implements MetadataRepository {
629629
// best-effort: a concurrent publisher may have already drained
630630
// the draft; the active row's authoritative content is intact.
631631
}
632-
return result;
632+
// Surface the promoted draft's package binding so publish-time
633+
// materializers (ADR-0086 P2 — package-door permission sets) can stamp
634+
// the data-plane row with the owning `package_id`.
635+
return { ...result, packageId: draftPackageId };
633636
}
634637

635638
/**

0 commit comments

Comments
 (0)