Skip to content

Commit 8f0b9df

Browse files
os-zhuangclaude
andauthored
fix(cli,plugin-security): os meta resync to re-materialize default permission sets (#2705) (#2740)
* chore: bump objectui to 7a68d78f2a0c chore: release packages (#2304) objectui@7a68d78f2a0c3c1f99dafd39b75b4f117a24917b * fix(cli,plugin-security): os meta resync to re-materialize default permission sets (#2705) Default permission sets (admin_full_access / member_default / viewer_readonly …) were seeded INSERT-ONCE at boot: bootstrapPlatformAdmin skipped any existing row and never wrote the shipped declaration back. So editing a default set's source, recompiling, and restarting `os dev` WITHOUT `--fresh` left the runtime serving the OLD value — silently, because the runtime authz resolver hydrates permission sets from the sys_permission_set row (resolve-authz-context.ts), not from the in-memory dist. Every OTHER metadata seed (declared sets, positions, built-in roles, capabilities) already upserts on boot, leaving the platform-default path the lone insert-once holdout — a gap ADR-0090 widened by persisting more facets (system_permissions, delegated-admin admin_scope) onto the same row. Insert-once is deliberate for prod (it protects an admin's Setup edits and keeps the defaults env-authored), so this is NOT a blind upsert: - bootstrapPlatformAdmin gains a `resync` option. Default boot behavior is unchanged. Under resync an existing row is reconciled to the shipped dist only when the platform still owns it (managed_by absent or 'platform'); a row an admin took over ('user') or a package owns ('package') is left untouched. - New `os meta resync` command boots the runtime, reconciles the default permission-set rows to the compiled dist, and reports reconciled / preserved / newly-seeded — without touching business data and without a `--fresh` wipe. Confirmation-gated (--yes to skip; --json for scripting). Verified: plugin-security 240/240, cli 466/466, and end-to-end `os meta resync` against examples/app-todo — insert path (seeded 4 new), overwrite path (resynced 4), and clean process exit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2a71f48 commit 8f0b9df

5 files changed

Lines changed: 426 additions & 25 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
fix(cli,plugin-security): `os meta resync` to re-materialize default permission sets from dist (#2705)
7+
8+
The default permission sets (`admin_full_access` / `member_default` /
9+
`viewer_readonly` …) were seeded **insert-once** at boot: `bootstrapPlatformAdmin`
10+
skipped any row that already existed and never wrote the shipped declaration
11+
back. So editing a default set's source, recompiling, and restarting `os dev`
12+
**without** `--fresh` left the runtime serving the OLD value — silently, because
13+
the runtime authz resolver hydrates permission sets from the `sys_permission_set`
14+
row (`resolve-authz-context.ts`), not from the in-memory dist. A permission-gated
15+
surface (e.g. `setup.access`) would keep its stale behavior with no error, which
16+
repeatedly misled debugging. Every *other* metadata seed (declared permission
17+
sets, positions, built-in roles, capabilities) already upserts on boot, leaving
18+
the platform-default path the lone insert-once holdout — a gap ADR-0090 widened
19+
by persisting more facets (`system_permissions`, delegated-admin `admin_scope`)
20+
onto the same row.
21+
22+
The insert-once posture is deliberate for prod (it protects an admin's Setup
23+
edits and keeps the defaults env-authored — the exact posture
24+
`bootstrapDeclaredPermissions` relies on), so this is **not** switched to a blind
25+
upsert. Instead:
26+
27+
- `bootstrapPlatformAdmin` gains a `resync` option. Default boot behavior is
28+
unchanged (insert-once). Under `resync`, an existing row is reconciled to the
29+
shipped dist **only** when the platform still owns it (`managed_by` absent or
30+
`'platform'`); a row an admin took over (`managed_by:'user'`) or a package owns
31+
(`'package'`) is an intentional override and is left untouched.
32+
- New `os meta resync` command boots the runtime, reconciles the default
33+
permission-set rows to the compiled dist, and reports what was reconciled /
34+
preserved / newly seeded — **without touching business data** and without a
35+
`--fresh` wipe. Gated behind a confirmation prompt (`--yes` to skip; `--json`
36+
for scripting).
37+
38+
Prod boot is unaffected; the fix is entirely opt-in via the new command.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import { createInterface } from 'node:readline';
6+
import {
7+
printHeader,
8+
printSuccess,
9+
printWarning,
10+
printError,
11+
printInfo,
12+
printStep,
13+
createTimer,
14+
} from '../../utils/format.js';
15+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
16+
import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
17+
18+
async function confirm(question: string): Promise<boolean> {
19+
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
20+
const rl = createInterface({ input: process.stdin, output: process.stdout });
21+
try {
22+
const answer: string = await new Promise((resolve) => rl.question(question, resolve));
23+
return /^y(es)?$/i.test(answer.trim());
24+
} finally {
25+
rl.close();
26+
}
27+
}
28+
29+
function safeGetService(kernel: any, name: string): any {
30+
try {
31+
return kernel?.getService?.(name);
32+
} catch {
33+
return undefined;
34+
}
35+
}
36+
37+
/**
38+
* `os meta resync` — reconcile materialized metadata to the compiled `dist`
39+
* without a `--fresh` wipe (#2705).
40+
*
41+
* The default permission sets (admin_full_access / member_default /
42+
* viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never
43+
* clobbered, so an edit to a default set's source is served with its OLD value
44+
* until the DB is wiped — a silent dev-loop trap (a permission-gated action
45+
* "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared
46+
* permission sets, positions, built-in roles, capabilities) already upserts on
47+
* boot; this command closes the one remaining gap by force-reconciling the
48+
* default permission-set rows to the shipped declaration.
49+
*
50+
* Business data is never touched — only `sys_permission_set` definition rows.
51+
* A row an admin has taken over in Setup (`managed_by:'user'`) or a package
52+
* owns (`'package'`) is an intentional override and is left alone.
53+
*/
54+
export default class MetaResync extends Command {
55+
static override description =
56+
'Reconcile materialized metadata (default permission sets) to the compiled dist without a --fresh wipe (#2705)';
57+
58+
static override examples = [
59+
'$ os meta resync',
60+
'$ os meta resync --yes',
61+
'$ os meta resync --json',
62+
];
63+
64+
static override flags = {
65+
'database-url': Flags.string({
66+
description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)',
67+
env: 'OS_DATABASE_URL',
68+
}),
69+
yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }),
70+
json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }),
71+
};
72+
73+
async run(): Promise<void> {
74+
const { flags } = await this.parse(MetaResync);
75+
const timer = createTimer();
76+
let exitCode = 0;
77+
78+
if (!flags.json) {
79+
printHeader('Meta · resync');
80+
printStep('Booting runtime stack…');
81+
}
82+
83+
let stack;
84+
try {
85+
stack = await bootSchemaStack({ databaseUrl: flags['database-url'] });
86+
} catch (error: any) {
87+
if (flags.json) console.log(JSON.stringify({ error: error.message }));
88+
else printError(error.message || String(error));
89+
process.exit(1);
90+
}
91+
92+
try {
93+
const ql = safeGetService(stack.kernel, 'objectql');
94+
// Prefer the SecurityPlugin's resolved set when a full stack is booted (it
95+
// picks up an app's `defaultPermissionSets` override); fall back to the
96+
// platform defaults, which is what the standalone stack this command boots
97+
// exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds.
98+
const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets');
99+
const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets;
100+
101+
if (!ql) {
102+
if (flags.json) {
103+
console.log(JSON.stringify({ error: 'objectql_unavailable' }));
104+
return;
105+
}
106+
printError('ObjectQL service is not available — cannot resync.');
107+
return;
108+
}
109+
if (!Array.isArray(sets) || sets.length === 0) {
110+
if (flags.json) {
111+
console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }));
112+
return;
113+
}
114+
printWarning('No default permission sets are available to resync.');
115+
return;
116+
}
117+
118+
// Confirmation gate — resync overwrites the default permission-set
119+
// definitions from the compiled dist, so admin Setup edits to those sets
120+
// are replaced. Business data is never touched.
121+
if (!flags.yes) {
122+
if (flags.json || !process.stdin.isTTY) {
123+
if (flags.json) {
124+
console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }));
125+
return;
126+
}
127+
printWarning('Confirmation required. Re-run with --yes to resync.');
128+
return;
129+
}
130+
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
131+
printWarning('This overwrites the default permission-set definitions from the compiled dist.');
132+
console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.'));
133+
console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.'));
134+
const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `));
135+
if (!ok) {
136+
printInfo('Aborted — no changes made.');
137+
return;
138+
}
139+
}
140+
141+
const logger = flags.json
142+
? undefined
143+
: { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) };
144+
145+
const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger });
146+
const resynced = report.resynced ?? 0;
147+
const resyncSkipped = report.resyncSkipped ?? 0;
148+
// seeded = existing + newly inserted; existing (under resync) = resynced +
149+
// resyncSkipped, so the remainder is what this run freshly seeded.
150+
const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped);
151+
152+
if (flags.json) {
153+
console.log(JSON.stringify({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }, null, 2));
154+
return;
155+
}
156+
157+
console.log('');
158+
if (resynced > 0 || inserted > 0) {
159+
printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`);
160+
} else {
161+
printInfo('Nothing reconciled.');
162+
}
163+
if (resyncSkipped > 0) {
164+
printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned override).`);
165+
}
166+
console.log(chalk.dim(` ${timer.display()}`));
167+
console.log('');
168+
} catch (error: any) {
169+
exitCode = 1;
170+
if (flags.json) console.log(JSON.stringify({ error: error.message }));
171+
else printError(error.message || String(error));
172+
} finally {
173+
await stack.shutdown();
174+
// A one-shot command must exit even when the booted app stack left
175+
// keep-alive handles open (schedulers/watchers registered on kernel:ready
176+
// that `runtime.stop()` cannot fully drain). Matches the process.exit
177+
// posture the other one-shot CLI commands use.
178+
process.exit(exitCode);
179+
}
180+
}
181+
}

packages/cli/src/utils/schema-migrate.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export interface SchemaStack {
2929
driver: SqlDriverLike | null;
3030
dbLabel: string;
3131
managedTableCount: number;
32+
/** The booted kernel — `getService('objectql')` etc. for one-shot commands
33+
* beyond schema migration (e.g. `os meta resync`, #2705). */
34+
kernel: any;
3235
shutdown: () => Promise<void>;
3336
}
3437

@@ -95,6 +98,7 @@ export async function bootSchemaStack(opts: { databaseUrl?: string } = {}): Prom
9598
driver,
9699
dbLabel: describeDb(driver),
97100
managedTableCount,
101+
kernel,
98102
shutdown: async () => {
99103
try { await (runtime as any).stop?.(); } catch { /* ignore */ }
100104
try { await driver?.disconnect?.(); } catch { /* ignore */ }
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* bootstrapPlatformAdmin — permission-set materialization, focused on the
5+
* insert-once vs `resync` (#2705) split.
6+
*
7+
* The default boot path is insert-once: an existing default permission-set row
8+
* is env-authored config and is never clobbered on restart (so admin Setup
9+
* edits survive). That protection is CORRECT for prod but makes a dev source
10+
* edit silently stale until a `--fresh` wipe. `os meta resync` passes
11+
* `{ resync: true }` to reconcile platform-owned rows to the shipped dist
12+
* without touching business data — while still refusing to overwrite a row an
13+
* admin or a package has explicitly taken over.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
18+
19+
/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin-
20+
* promotion tables return empty, so promotion short-circuits and we assert the
21+
* seed/resync outcome (carried on every return) directly. */
22+
function makeQl(seedRows: any[] = []) {
23+
const rows: any[] = seedRows.map((r) => ({ ...r }));
24+
return {
25+
rows,
26+
async find(object: string, q: any) {
27+
if (object !== 'sys_permission_set') return [];
28+
const where = q?.where ?? {};
29+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
30+
},
31+
async insert(object: string, data: any) {
32+
if (object !== 'sys_permission_set') return null;
33+
rows.push({ ...data });
34+
return { id: data.id };
35+
},
36+
async update(object: string, data: any) {
37+
if (object !== 'sys_permission_set') return;
38+
const r = rows.find((x) => x.id === data.id);
39+
if (r) Object.assign(r, data);
40+
},
41+
};
42+
}
43+
44+
/** Shipped declaration: member_default now grants `setup.access`. */
45+
const memberDefault = (over: Record<string, any> = {}) =>
46+
({
47+
name: 'member_default',
48+
label: 'Member',
49+
objects: { crm_lead: { allowRead: true } },
50+
systemPermissions: ['setup.access'],
51+
...over,
52+
}) as any;
53+
54+
const row = (ql: ReturnType<typeof makeQl>) => ql.rows.find((x) => x.name === 'member_default');
55+
56+
describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => {
57+
it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => {
58+
const ql = makeQl([
59+
{ id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' },
60+
]);
61+
const r = await bootstrapPlatformAdmin(ql, [memberDefault()]);
62+
expect(r.resynced).toBe(0);
63+
// Same row, and the shipped `setup.access` did NOT land — this is the
64+
// #2705 boot behavior (protects admin edits; stale in the dev loop).
65+
expect(row(ql)!.id).toBe('ps_old');
66+
expect(row(ql)!.system_permissions).toBe('[]');
67+
});
68+
69+
it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => {
70+
const ql = makeQl([
71+
{ id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null },
72+
]);
73+
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
74+
expect(r.resynced).toBe(1);
75+
expect(r.resyncSkipped).toBe(0);
76+
// Updated in place (no new insert), and the declaration is now live.
77+
expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1);
78+
expect(row(ql)!.id).toBe('ps_old');
79+
expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']);
80+
expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } });
81+
});
82+
83+
it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => {
84+
const ql = makeQl([
85+
{ id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' },
86+
]);
87+
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
88+
expect(r.resynced).toBe(0);
89+
expect(r.resyncSkipped).toBe(1);
90+
expect(row(ql)!.system_permissions).toBe('["custom.perm"]');
91+
});
92+
93+
it('resync: leaves a package-owned row untouched', async () => {
94+
const ql = makeQl([
95+
{ id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' },
96+
]);
97+
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
98+
expect(r.resynced).toBe(0);
99+
expect(r.resyncSkipped).toBe(1);
100+
});
101+
102+
it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => {
103+
const ql = makeQl([]);
104+
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
105+
expect(r.resynced).toBe(0);
106+
expect(row(ql)).toBeTruthy();
107+
expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']);
108+
});
109+
});

0 commit comments

Comments
 (0)