Skip to content

Commit 32c7921

Browse files
hotlongCopilot
andcommitted
feat(security): zero-config first-user platform admin + fix RBAC drop
- Add bootstrapPlatformAdmin(): seeds default permission sets and promotes the first registered user to platform admin (cross-tenant admin_full_access link, organization_id NULL). Idempotent; runs on kernel:ready and after sys_user inserts. No env vars required. - Fix RestServer.resolveExecCtx silently dropping permissions in single-kernel mode by adding objectQLProvider fallback so RBAC link-table lookups work without a kernelManager. - Replace hardcoded permissions: ['member_default'] in raw fallback paths (hono-plugin /data/:object, rest-server multi-kernel path) with proper sys_member + sys_user_permission_set lookups, including cross-tenant rows (organization_id IS NULL). Verified: first@test.com auto-promoted platform admin sees all sys_user rows; second@test.com sees only own. All 200+33+38+rest tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cb3b6f1 commit 32c7921

6 files changed

Lines changed: 334 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Zero-config first-user platform admin bootstrap** (`@objectstack/plugin-security`) — On every server boot and after every `sys_user` insert, `bootstrapPlatformAdmin()` runs idempotently to (a) seed the three default permission sets (`admin_full_access` / `member_default` / `viewer_readonly`) into the `sys_permission_set` table and (b) — if no platform admin exists yet — promote the oldest registered user by inserting a `sys_user_permission_set` link with `organization_id = NULL` (= cross-tenant) targeting `admin_full_access`. The post-create middleware listens to both `create` and `insert` operations because better-auth's adapter calls `dataEngine.insert('sys_user', …)` directly. Result: `pnpm dev:crm` → sign up → first user is platform admin, no env vars or CLI flags required.
12+
13+
### Fixed
14+
- **REST single-kernel mode silently dropped RBAC permissions** (`@objectstack/rest`) — `RestServer.resolveExecCtx` previously fetched `objectql` only from a per-project kernel obtained via `kernelManager`. In single-project deployments (`pnpm dev:crm`, the default zero-config local mode) `kernelManager` is undefined, so `kernel` was `undefined` and the `sys_member` / `sys_user_permission_set` / `sys_permission_set` link-table lookups were silently skipped — every authenticated user landed on the `member_default` fallback regardless of explicit grants, *including the platform-admin promotion*. Added an `objectQLProvider` constructor argument to `RestServer` and wired it from `RestApiPlugin.start()` so single-kernel deployments resolve roles + permission sets correctly. Verified end-to-end: `first@test.com` (auto-promoted platform admin) sees all `sys_user` rows; `second@test.com` (regular) sees only their own row, and writes to managed identity tables remain 403.
15+
- **Hardcoded `permissions: ['member_default']` removed** from two raw-fallback HTTP entry points (`@objectstack/plugin-hono` `/data/:object` handlers and `@objectstack/rest` multi-kernel path). Both now mirror the canonical link-table lookup in `runtime/src/security/resolve-execution-context.ts`, including matching cross-tenant rows (`organization_id IS NULL`) so platform-admin grants apply regardless of the active organization.
16+
1017
### Fixed
1118
- **`@objectstack/driver-sql` tests failing in CI** — Added `vitest.config.ts` with resolve aliases for `@objectstack/spec/*` subpath exports (`/data`, `/contracts`, `/system`). Without these aliases, vitest could not resolve the source paths at test time, causing all 81 tests to fail with `ERR_MODULE_NOT_FOUND`.
1219
- **RLS fail-open across tenants** (`@objectstack/plugin-security`) — A logged-in user with no active organization (e.g. immediately after sign-up, before joining or creating one) was previously seeing every tenant's data on `account`, `sys_member`, `sys_organization`, etc. Multiple compounding bugs were responsible:

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,11 @@ export class HonoServerPlugin implements Plugin {
374374

375375
// Helper: resolve ExecutionContext from request headers (cookie session
376376
// or API key). Mirrors the runtime's resolveExecutionContext but
377-
// self-contained to avoid a cross-package dep.
377+
// self-contained to avoid a cross-package dep. We DO query the
378+
// `sys_user_permission_set` link tables because hardcoding a single
379+
// permission set name (e.g. `member_default`) would silently ignore
380+
// any explicit admin / role assignment — including the platform-admin
381+
// promotion seeded by `bootstrapPlatformAdmin`.
378382
const resolveCtx = async (c: any): Promise<any | undefined> => {
379383
try {
380384
const authService: any = ctx.getService('auth');
@@ -386,11 +390,65 @@ export class HonoServerPlugin implements Plugin {
386390
if (!api?.getSession) return undefined;
387391
const session = await api.getSession({ headers: c.req.raw.headers });
388392
if (!session?.user?.id) return undefined;
393+
const userId = session.user.id;
394+
const tenantId = session.session?.activeOrganizationId ?? undefined;
395+
const permissions: string[] = [];
396+
const roles: string[] = [];
397+
try {
398+
const ql = getObjectQL();
399+
const sysCtx = { context: { isSystem: true } };
400+
// Roles via sys_member (org-scoped if active org).
401+
const memberRows = await ql?.find?.(
402+
'sys_member',
403+
{
404+
where: tenantId
405+
? { user_id: userId, organization_id: tenantId }
406+
: { user_id: userId },
407+
limit: 50,
408+
...sysCtx,
409+
} as any,
410+
).catch(() => []);
411+
for (const m of (memberRows ?? []) as any[]) {
412+
if (typeof m.role === 'string') {
413+
for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) {
414+
if (!roles.includes(r)) roles.push(r);
415+
}
416+
}
417+
}
418+
// User-scoped permission sets — match BOTH (a) the active
419+
// org's link rows and (b) the cross-tenant rows
420+
// (organization_id IS NULL) so the platform-admin
421+
// promotion seeded by `bootstrapPlatformAdmin` applies
422+
// regardless of the user's active org.
423+
const upsRows = await ql?.find?.(
424+
'sys_user_permission_set',
425+
{ where: { user_id: userId }, limit: 100, ...sysCtx } as any,
426+
).catch(() => []);
427+
const psIds = new Set<string>();
428+
for (const r of (upsRows ?? []) as any[]) {
429+
const orgScope = r.organization_id ?? null;
430+
if (!orgScope || (tenantId && orgScope === tenantId)) {
431+
const pid = r.permission_set_id ?? r.permissionSetId;
432+
if (pid) psIds.add(pid);
433+
}
434+
}
435+
if (psIds.size > 0) {
436+
const psRows = await ql?.find?.(
437+
'sys_permission_set',
438+
{ where: { id: { $in: Array.from(psIds) } }, limit: 500, ...sysCtx } as any,
439+
).catch(() => []);
440+
for (const ps of (psRows ?? []) as any[]) {
441+
if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
442+
}
443+
}
444+
} catch {
445+
/* fall through with whatever we resolved so far */
446+
}
389447
return {
390-
userId: session.user.id,
391-
tenantId: session.session?.activeOrganizationId,
392-
roles: [],
393-
permissions: ['member_default'],
448+
userId,
449+
tenantId,
450+
roles,
451+
permissions,
394452
isSystem: false,
395453
};
396454
} catch {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* bootstrapPlatformAdmin — first-boot platform admin promotion.
5+
*
6+
* Two responsibilities, both idempotent and run on `kernel:ready`:
7+
*
8+
* 1. **Seed `sys_permission_set` rows** for each `defaultPermissionSets`
9+
* entry (admin_full_access / member_default / viewer_readonly). The
10+
* dashboard's CRUD on `sys_permission_set` needs persisted rows to
11+
* exist so admins can grant them to users by id; the in-memory
12+
* bootstrap list alone is invisible to the standard CRUD UI.
13+
*
14+
* 2. **Promote the first registered user to platform admin** by
15+
* inserting a `sys_user_permission_set` row that points at
16+
* `admin_full_access` with `organization_id = NULL` (= cross-tenant).
17+
* If a platform admin already exists, this is a no-op forever.
18+
*
19+
* Zero configuration: `pnpm dev:crm` → sign up → "I'm admin".
20+
*
21+
* The DB column shape (`object_permissions` JSON text) does not match
22+
* the spec shape (`objects` record). For now we only need stable rows
23+
* with the right `name` so `resolveExecutionContext` can translate the
24+
* link-table id back to the bootstrap permission set name; the actual
25+
* `objects`/`rowLevelSecurity` definitions are still served from the
26+
* in-memory `bootstrapPermissionSets` list inside SecurityPlugin.
27+
*/
28+
29+
import type { PermissionSet } from '@objectstack/spec/security';
30+
31+
interface BootstrapOptions {
32+
/** Logger from PluginContext. */
33+
logger?: {
34+
info: (message: string, meta?: Record<string, any>) => void;
35+
warn: (message: string, meta?: Record<string, any>) => void;
36+
};
37+
}
38+
39+
const SYSTEM_CTX = { isSystem: true };
40+
41+
async function tryFind(ql: any, object: string, where: any, limit = 100): Promise<any[]> {
42+
try {
43+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
44+
return Array.isArray(rows) ? rows : [];
45+
} catch {
46+
return [];
47+
}
48+
}
49+
50+
async function tryInsert(ql: any, object: string, data: any): Promise<any | null> {
51+
try {
52+
return await ql.insert(object, data, { context: SYSTEM_CTX });
53+
} catch {
54+
return null;
55+
}
56+
}
57+
58+
function genId(prefix: string): string {
59+
const rand = Math.random().toString(36).slice(2, 10);
60+
const ts = Date.now().toString(36);
61+
return `${prefix}_${ts}${rand}`;
62+
}
63+
64+
/**
65+
* Persist seed permission sets and promote the first registered user to
66+
* platform admin. Safe to call multiple times.
67+
*/
68+
export async function bootstrapPlatformAdmin(
69+
ql: any,
70+
bootstrapPermissionSets: PermissionSet[],
71+
options: BootstrapOptions = {},
72+
): Promise<{ seeded: number; adminPromoted: boolean; reason?: string }> {
73+
const logger = options.logger;
74+
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
75+
return { seeded: 0, adminPromoted: false, reason: 'objectql_unavailable' };
76+
}
77+
78+
// 1. Seed permission set rows (one row per name, idempotent).
79+
const seeded: Record<string, string> = {}; // name -> id
80+
for (const ps of bootstrapPermissionSets) {
81+
if (!ps.name) continue;
82+
const existing = await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1);
83+
if (existing.length > 0 && existing[0].id) {
84+
seeded[ps.name] = existing[0].id;
85+
continue;
86+
}
87+
const id = genId('ps');
88+
const created = await tryInsert(ql, 'sys_permission_set', {
89+
id,
90+
name: ps.name,
91+
label: ps.label ?? ps.name,
92+
description: ps.description ?? null,
93+
object_permissions: JSON.stringify(ps.objects ?? {}),
94+
field_permissions: JSON.stringify(ps.fields ?? {}),
95+
active: true,
96+
});
97+
if (created?.id) seeded[ps.name] = created.id;
98+
else if (created) seeded[ps.name] = id;
99+
}
100+
101+
const seededCount = Object.keys(seeded).length;
102+
103+
// 2. First-user platform admin promotion.
104+
const adminPsId = seeded['admin_full_access'];
105+
if (!adminPsId) {
106+
return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing' };
107+
}
108+
109+
// If a platform admin already exists, we're done.
110+
const existingAdminLinks = await tryFind(
111+
ql,
112+
'sys_user_permission_set',
113+
{ permission_set_id: adminPsId },
114+
5,
115+
);
116+
if (existingAdminLinks.some((r) => !r.organization_id)) {
117+
return { seeded: seededCount, adminPromoted: false, reason: 'already_have_admin' };
118+
}
119+
120+
// Promote the oldest user (= first registrant). If no users yet, the
121+
// sys_user post-create middleware will rerun this on first sign-up.
122+
const allUsers = await tryFind(ql, 'sys_user', {}, 50);
123+
if (allUsers.length === 0) {
124+
logger?.info?.('[security] no users yet — first sign-up will be promoted to platform admin');
125+
return { seeded: seededCount, adminPromoted: false, reason: 'no_users' };
126+
}
127+
const sorted = [...allUsers].sort((a, b) => {
128+
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
129+
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
130+
return ta - tb;
131+
});
132+
const target = sorted[0];
133+
134+
const inserted = await tryInsert(ql, 'sys_user_permission_set', {
135+
id: genId('ups'),
136+
user_id: target.id,
137+
permission_set_id: adminPsId,
138+
organization_id: null,
139+
granted_by: null,
140+
});
141+
if (!inserted) {
142+
logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`);
143+
return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed' };
144+
}
145+
logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
146+
return { seeded: seededCount, adminPromoted: true };
147+
}

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PermissionEvaluator } from './permission-evaluator.js';
66
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
77
import { FieldMasker } from './field-masker.js';
88
import { PermissionDeniedError } from './errors.js';
9+
import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
910
import {
1011
securityObjects,
1112
securityDefaultPermissionSets,
@@ -223,6 +224,46 @@ export class SecurityPlugin implements Plugin {
223224
});
224225

225226
ctx.logger.info('Security middleware registered on ObjectQL engine');
227+
228+
// Defer platform admin bootstrap until all plugins finish starting —
229+
// sys_user / sys_permission_set objects must be registered (by
230+
// plugin-auth and platform-objects respectively) before we can
231+
// insert seed rows. Falls back to immediate execution when the
232+
// kernel does not expose `hook` (test stubs).
233+
let bootstrapRanOnce = false;
234+
const runBootstrap = async () => {
235+
try {
236+
const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, {
237+
logger: ctx.logger,
238+
});
239+
bootstrapRanOnce = true;
240+
ctx.logger.info('[security] platform bootstrap complete', report);
241+
return report;
242+
} catch (e) {
243+
ctx.logger.warn('[security] platform bootstrap failed', { error: (e as Error).message });
244+
return undefined;
245+
}
246+
};
247+
if (typeof (ctx as any).hook === 'function') {
248+
(ctx as any).hook('kernel:ready', runBootstrap);
249+
} else {
250+
void runBootstrap();
251+
}
252+
253+
// Re-run bootstrap after a sys_user insert so the FIRST user that
254+
// signs up after boot is auto-promoted to platform admin without
255+
// requiring a server restart. The function itself is idempotent
256+
// and bails out as soon as any platform admin exists.
257+
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
258+
await next();
259+
if (
260+
opCtx?.object === 'sys_user' &&
261+
(opCtx?.operation === 'create' || opCtx?.operation === 'insert') &&
262+
bootstrapRanOnce
263+
) {
264+
await runBootstrap();
265+
}
266+
});
226267
}
227268

228269
async destroy(): Promise<void> {

packages/rest/src/rest-api-plugin.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
9898
} catch { return undefined; }
9999
};
100100

101+
// ObjectQL resolver — single-kernel fallback so resolveExecCtx
102+
// can run sys_member / sys_user_permission_set lookups when
103+
// there is no kernelManager wired (e.g. `pnpm dev:crm`).
104+
const objectQLProvider = async (_projectId?: string): Promise<any | undefined> => {
105+
try {
106+
return ctx.getService<any>('objectql');
107+
} catch { return undefined; }
108+
};
109+
101110
if (!server) {
102111
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
103112
return;
@@ -111,7 +120,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
111120
ctx.logger.info('Hydrating REST API from Protocol...');
112121

113122
try {
114-
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider);
123+
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
115124
restServer.registerRoutes();
116125

117126
ctx.logger.info('REST API successfully registered');

0 commit comments

Comments
 (0)