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
38 changes: 38 additions & 0 deletions .changeset/meta-resync-default-permission-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/plugin-security": minor
"@objectstack/cli": minor
---

fix(cli,plugin-security): `os meta resync` to re-materialize default permission sets from dist (#2705)

The default permission sets (`admin_full_access` / `member_default` /
`viewer_readonly` …) were seeded **insert-once** at boot: `bootstrapPlatformAdmin`
skipped any row that already existed 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. A permission-gated
surface (e.g. `setup.access`) would keep its stale behavior with no error, which
repeatedly misled debugging. Every *other* metadata seed (declared permission
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.

The insert-once posture is deliberate for prod (it protects an admin's Setup
edits and keeps the defaults env-authored — the exact posture
`bootstrapDeclaredPermissions` relies on), so this is **not** switched to a blind
upsert. Instead:

- `bootstrapPlatformAdmin` gains a `resync` option. Default boot behavior is
unchanged (insert-once). 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 (`managed_by:'user'`) or a package owns
(`'package'`) is an intentional override and is left untouched.
- New `os meta resync` command boots the runtime, reconciles the default
permission-set rows to the compiled dist, and reports what was reconciled /
preserved / newly seeded — **without touching business data** and without a
`--fresh` wipe. Gated behind a confirmation prompt (`--yes` to skip; `--json`
for scripting).

Prod boot is unaffected; the fix is entirely opt-in via the new command.
181 changes: 181 additions & 0 deletions packages/cli/src/commands/meta/resync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { createInterface } from 'node:readline';
import {
printHeader,
printSuccess,
printWarning,
printError,
printInfo,
printStep,
createTimer,
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security';

async function confirm(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer: string = await new Promise((resolve) => rl.question(question, resolve));
return /^y(es)?$/i.test(answer.trim());
} finally {
rl.close();
}
}

function safeGetService(kernel: any, name: string): any {
try {
return kernel?.getService?.(name);
} catch {
return undefined;
}
}

/**
* `os meta resync` — reconcile materialized metadata to the compiled `dist`
* without a `--fresh` wipe (#2705).
*
* The default permission sets (admin_full_access / member_default /
* viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never
* clobbered, so an edit to a default set's source is served with its OLD value
* until the DB is wiped — a silent dev-loop trap (a permission-gated action
* "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared
* permission sets, positions, built-in roles, capabilities) already upserts on
* boot; this command closes the one remaining gap by force-reconciling the
* default permission-set rows to the shipped declaration.
*
* Business data is never touched — only `sys_permission_set` definition rows.
* A row an admin has taken over in Setup (`managed_by:'user'`) or a package
* owns (`'package'`) is an intentional override and is left alone.
*/
export default class MetaResync extends Command {
static override description =
'Reconcile materialized metadata (default permission sets) to the compiled dist without a --fresh wipe (#2705)';

static override examples = [
'$ os meta resync',
'$ os meta resync --yes',
'$ os meta resync --json',
];

static override flags = {
'database-url': Flags.string({
description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)',
env: 'OS_DATABASE_URL',
}),
yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }),
json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }),
};

async run(): Promise<void> {
const { flags } = await this.parse(MetaResync);
const timer = createTimer();
let exitCode = 0;

if (!flags.json) {
printHeader('Meta · resync');
printStep('Booting runtime stack…');
}

let stack;
try {
stack = await bootSchemaStack({ databaseUrl: flags['database-url'] });
} catch (error: any) {
if (flags.json) console.log(JSON.stringify({ error: error.message }));
else printError(error.message || String(error));
process.exit(1);
}

try {
const ql = safeGetService(stack.kernel, 'objectql');
// Prefer the SecurityPlugin's resolved set when a full stack is booted (it
// picks up an app's `defaultPermissionSets` override); fall back to the
// platform defaults, which is what the standalone stack this command boots
// exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds.
const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets');
const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets;

if (!ql) {
if (flags.json) {
console.log(JSON.stringify({ error: 'objectql_unavailable' }));
return;
}
printError('ObjectQL service is not available — cannot resync.');
return;
}
if (!Array.isArray(sets) || sets.length === 0) {
if (flags.json) {
console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }));
return;
}
printWarning('No default permission sets are available to resync.');
return;
}

// Confirmation gate — resync overwrites the default permission-set
// definitions from the compiled dist, so admin Setup edits to those sets
// are replaced. Business data is never touched.
if (!flags.yes) {
if (flags.json || !process.stdin.isTTY) {
if (flags.json) {
console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }));
return;
}
printWarning('Confirmation required. Re-run with --yes to resync.');
return;
}
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
printWarning('This overwrites the default permission-set definitions from the compiled dist.');
console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.'));
console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.'));
const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `));
if (!ok) {
printInfo('Aborted — no changes made.');
return;
}
}

const logger = flags.json
? undefined
: { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) };

const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger });
const resynced = report.resynced ?? 0;
const resyncSkipped = report.resyncSkipped ?? 0;
// seeded = existing + newly inserted; existing (under resync) = resynced +
// resyncSkipped, so the remainder is what this run freshly seeded.
const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped);

if (flags.json) {
console.log(JSON.stringify({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }, null, 2));
return;
}

console.log('');
if (resynced > 0 || inserted > 0) {
printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`);
} else {
printInfo('Nothing reconciled.');
}
if (resyncSkipped > 0) {
printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned override).`);
}
console.log(chalk.dim(` ${timer.display()}`));
console.log('');
} catch (error: any) {
exitCode = 1;
if (flags.json) console.log(JSON.stringify({ error: error.message }));
else printError(error.message || String(error));
} finally {
await stack.shutdown();
// A one-shot command must exit even when the booted app stack left
// keep-alive handles open (schedulers/watchers registered on kernel:ready
// that `runtime.stop()` cannot fully drain). Matches the process.exit
// posture the other one-shot CLI commands use.
process.exit(exitCode);
}
}
}
4 changes: 4 additions & 0 deletions packages/cli/src/utils/schema-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export interface SchemaStack {
driver: SqlDriverLike | null;
dbLabel: string;
managedTableCount: number;
/** The booted kernel — `getService('objectql')` etc. for one-shot commands
* beyond schema migration (e.g. `os meta resync`, #2705). */
kernel: any;
shutdown: () => Promise<void>;
}

Expand Down Expand Up @@ -95,6 +98,7 @@ export async function bootSchemaStack(opts: { databaseUrl?: string } = {}): Prom
driver,
dbLabel: describeDb(driver),
managedTableCount,
kernel,
shutdown: async () => {
try { await (runtime as any).stop?.(); } catch { /* ignore */ }
try { await driver?.disconnect?.(); } catch { /* ignore */ }
Expand Down
109 changes: 109 additions & 0 deletions packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* bootstrapPlatformAdmin — permission-set materialization, focused on the
* insert-once vs `resync` (#2705) split.
*
* The default boot path is insert-once: an existing default permission-set row
* is env-authored config and is never clobbered on restart (so admin Setup
* edits survive). That protection is CORRECT for prod but makes a dev source
* edit silently stale until a `--fresh` wipe. `os meta resync` passes
* `{ resync: true }` to reconcile platform-owned rows to the shipped dist
* without touching business data — while still refusing to overwrite a row an
* admin or a package has explicitly taken over.
*/

import { describe, it, expect } from 'vitest';
import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';

/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin-
* promotion tables return empty, so promotion short-circuits and we assert the
* seed/resync outcome (carried on every return) directly. */
function makeQl(seedRows: any[] = []) {
const rows: any[] = seedRows.map((r) => ({ ...r }));
return {
rows,
async find(object: string, q: any) {
if (object !== 'sys_permission_set') return [];
const where = q?.where ?? {};
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
},
async insert(object: string, data: any) {
if (object !== 'sys_permission_set') return null;
rows.push({ ...data });
return { id: data.id };
},
async update(object: string, data: any) {
if (object !== 'sys_permission_set') return;
const r = rows.find((x) => x.id === data.id);
if (r) Object.assign(r, data);
},
};
}

/** Shipped declaration: member_default now grants `setup.access`. */
const memberDefault = (over: Record<string, any> = {}) =>
({
name: 'member_default',
label: 'Member',
objects: { crm_lead: { allowRead: true } },
systemPermissions: ['setup.access'],
...over,
}) as any;

const row = (ql: ReturnType<typeof makeQl>) => ql.rows.find((x) => x.name === 'member_default');

describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => {
it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => {
const ql = makeQl([
{ id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' },
]);
const r = await bootstrapPlatformAdmin(ql, [memberDefault()]);
expect(r.resynced).toBe(0);
// Same row, and the shipped `setup.access` did NOT land — this is the
// #2705 boot behavior (protects admin edits; stale in the dev loop).
expect(row(ql)!.id).toBe('ps_old');
expect(row(ql)!.system_permissions).toBe('[]');
});

it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => {
const ql = makeQl([
{ id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null },
]);
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
expect(r.resynced).toBe(1);
expect(r.resyncSkipped).toBe(0);
// Updated in place (no new insert), and the declaration is now live.
expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1);
expect(row(ql)!.id).toBe('ps_old');
expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']);
expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } });
});

it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => {
const ql = makeQl([
{ id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' },
]);
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
expect(r.resynced).toBe(0);
expect(r.resyncSkipped).toBe(1);
expect(row(ql)!.system_permissions).toBe('["custom.perm"]');
});

it('resync: leaves a package-owned row untouched', async () => {
const ql = makeQl([
{ id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' },
]);
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
expect(r.resynced).toBe(0);
expect(r.resyncSkipped).toBe(1);
});

it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => {
const ql = makeQl([]);
const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true });
expect(r.resynced).toBe(0);
expect(row(ql)).toBeTruthy();
expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']);
});
});
Loading