Skip to content

Commit 08fbbb4

Browse files
committed
fix(security): skip usr_system in platform-admin promotion; seed admin@objectos.ai
bootstrapPlatformAdmin promoted the earliest-created sys_user, but #1392 (ensureSeedIdentity) provisions a non-loginable system identity (usr_system, role 'system') before the first human sign-up to own seeded rows. On any app shipping seed data it won the promotion, leaving the real admin at role:user — login worked but Setup/Studio (gated by setup.access / studio.access on admin_full_access) stayed invisible. - bootstrap-platform-admin.ts: exclude the system account (id === SystemUserId.SYSTEM || role === 'system') when selecting the first user; the "admin already exists" check ignores any admin_full_access grant held by usr_system, so a wrongly-promoted DB self-heals on reboot. - cli/dev.ts: --admin-email / --admin-password defaults change from admin@dev.local / admin12345 to fixed admin@objectos.ai / admin123. - Docs/skill: AGENTS.md, security.mdx, objectstack-platform SKILL.md, CHANGELOG.md + changeset updated to the new creds and the human-first promotion rule. Verified: pnpm dev → seeded admin get-session resolves role:admin and the cross-tenant admin_full_access grant belongs to the human admin.
1 parent e715166 commit 08fbbb4

7 files changed

Lines changed: 86 additions & 11 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
Fix: the first-boot platform-admin promotion no longer gets stolen by the
7+
`usr_system` seed identity, and the dev seed admin uses fixed, well-known
8+
credentials.
9+
10+
**`@objectstack/plugin-security``bootstrapPlatformAdmin` skips the system user**
11+
12+
`5e831dea3` (#1392) added `ensureSeedIdentity` to the runtime SeedLoader,
13+
which upserts a non-loginable system identity (`usr_system`, role `system`,
14+
`system@objectstack.local`) to own seeded records — created *before* the first
15+
human sign-up. Because `bootstrapPlatformAdmin` promoted the **earliest-created**
16+
`sys_user`, on any app that ships seed data `usr_system` won the promotion and
17+
the real admin login stayed at `role: user`. Login succeeded but Setup and
18+
Studio (gated by `setup.access` / `studio.access` on `admin_full_access`) were
19+
invisible — a silent, confusing regression.
20+
21+
`bootstrap-platform-admin.ts` now filters out the system account
22+
(`id === SystemUserId.SYSTEM || role === 'system'`) when picking the first user
23+
to promote, and the "an admin already exists" short-circuit ignores any
24+
`admin_full_access` grant held by `usr_system` — so a database where it was
25+
wrongly promoted self-heals on the next boot.
26+
27+
**`@objectstack/cli``os dev` seeds `admin@objectos.ai` / `admin123`**
28+
29+
The `--admin-email` / `--admin-password` defaults changed from
30+
`admin@dev.local` / `admin12345` to the fixed, well-known
31+
`admin@objectos.ai` / `admin123`, so tooling and docs never have to guess the
32+
seeded credentials. Override with `--admin-email` / `--admin-password`.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pnpm docs:dev # docs site
2323
| **Frontend debug** (UI in `../objectui` calls backend) | `PORT=3000 pnpm dev:crm` | Port **must** be 3000 (UI hard-wired); persistent state; leave running |
2424
| **Backend-only debug** | `pnpm dev:crm -- --fresh -p <random>` | Random high port; ephemeral tempdir; **you must kill it** when done |
2525

26-
`--fresh`: ephemeral tempdir (auto-deleted on exit) + `--seed-admin` (POSTs sign-up, prints creds — default `admin@dev.local` / `admin12345`, override via `--admin-email`/`--admin-password`).
26+
`--fresh`: ephemeral tempdir (auto-deleted on exit) + `--seed-admin` (POSTs sign-up, prints creds — default `admin@objectos.ai` / `admin123`, override via `--admin-email`/`--admin-password`). The seeded admin is auto-promoted to **platform admin** (the system seed identity `usr_system` is skipped), so Setup/Studio are reachable on first login.
2727

2828
Rules: never run two backends on port 3000; for backend tasks pick a random port and tear it down; always use `pnpm dev:crm` (flags after `--` are forwarded), not raw `pnpm --filter`.
2929

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Fixed — First-boot platform-admin promotion no longer stolen by the `usr_system` seed identity
11+
12+
`5e831dea3` (#1392) added `ensureSeedIdentity` to the runtime SeedLoader, which
13+
upserts a non-loginable system identity (`usr_system`, role `system`,
14+
`system@objectstack.local`) to own seeded records — created *before* the first
15+
human sign-up. Because `bootstrapPlatformAdmin` (`@objectstack/plugin-security`)
16+
promotes the **earliest-created** `sys_user`, on any app that ships seed data
17+
(e.g. the showcase / CRM examples) `usr_system` won the promotion and the real
18+
admin login stayed at `role: user`. Sign-in succeeded but **Setup and Studio**
19+
— gated by `setup.access` / `studio.access` on `admin_full_access` — were
20+
invisible, a silent and confusing regression.
21+
22+
`bootstrap-platform-admin.ts` now excludes the system account
23+
(`id === SystemUserId.SYSTEM || role === 'system'`) when picking the first user
24+
to promote, and the "an admin already exists" short-circuit ignores any
25+
`admin_full_access` grant held by `usr_system`, so a database where it was
26+
wrongly promoted self-heals on the next boot. Verified end-to-end: `pnpm dev`
27+
→ the seeded admin's `get-session` now resolves `role: admin` and the
28+
cross-tenant `admin_full_access` grant belongs to the human admin.
29+
30+
Alongside this, `os dev`'s seed-admin defaults changed from `admin@dev.local` /
31+
`admin12345` to the fixed, well-known `admin@objectos.ai` / `admin123`
32+
(`@objectstack/cli`), so tooling and docs never have to guess the credentials.
33+
1034
### Changed — Object metadata preview now mounts real `ObjectGrid`; Airtable widget removed (ADR-0014 addendum)
1135

1236
The first cut of ADR-0014 shipped a parallel `FieldsTable` component

content/docs/guides/security.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ than redefining the basics.
240240

241241
| Name | Scope | What it unlocks | Notes |
242242
|:--|:--|:--|:--|
243-
| `admin_full_access` | Platform (no RLS) | All objects, all system permissions, Studio + Setup, cross-org reads | Reserved for platform operators. Auto-granted to the bootstrap admin (`bootstrapPlatformAdmin`). |
243+
| `admin_full_access` | Platform (no RLS) | All objects, all system permissions, Studio + Setup, cross-org reads | Reserved for platform operators. Auto-granted to the bootstrap admin (`bootstrapPlatformAdmin`) — the **first registered human**. The non-loginable seed-data identity `usr_system` (role `system`, provisioned before the first sign-up to own seeded rows) is deliberately skipped, so the real admin wins the promotion. |
244244
| `organization_admin` | Per-org (`tenant_isolation` RLS, only active when [`plugin-org-scoping`](#objectstackplugin-org-scoping) is loaded) | Wildcard CRUD inside the org, `manage_org_users`, Setup app shell (only org-scoped entries visible), invite members, manage roles assignment | **Read-only** on `sys_role`, `sys_permission_set`, `sys_role_permission_set`, `sys_user_permission_set`, `sys_user_role` to prevent self-elevation. Does **not** see Studio. |
245245
| `member_default` | Per-org | Standard end-user CRUD on org records | Default profile for invited members. |
246246
| `viewer_readonly` | Per-org | Read access only | For auditors / read-only stakeholders. |

packages/cli/src/commands/dev.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,11 @@ export default class Dev extends Command {
6363
// so every run starts from a clean slate. Combine with `--seed-admin`
6464
// (default-on when --fresh) to also provision a logged-in admin
6565
// account, so backend debugging never blocks on first-run wizards.
66+
// The seeded admin uses FIXED, well-known credentials by default
67+
// (admin@objectos.ai / admin123) so tooling never has to guess them —
68+
// override with --admin-email / --admin-password when needed.
6669
fresh: Flags.boolean({
67-
description: 'Start with an ephemeral OS_HOME under the OS tempdir (clean DB, uploads, storage); auto-deletes on exit. Implies --seed-admin unless --no-seed-admin is given.',
70+
description: 'Start with an ephemeral OS_HOME under the OS tempdir (clean DB, uploads, storage); auto-deletes on exit. Implies --seed-admin (admin@objectos.ai / admin123) unless --no-seed-admin is given.',
6871
default: false,
6972
}),
7073
'seed-admin': Flags.boolean({
@@ -73,11 +76,11 @@ export default class Dev extends Command {
7376
}),
7477
'admin-email': Flags.string({
7578
description: 'Email for the seeded admin account.',
76-
default: 'admin@dev.local',
79+
default: 'admin@objectos.ai',
7780
}),
7881
'admin-password': Flags.string({
7982
description: 'Password for the seeded admin account (min 8 chars).',
80-
default: 'admin12345',
83+
default: 'admin123',
8184
}),
8285
};
8386

packages/plugins/plugin-security/src/bootstrap-platform-admin.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
*/
2121

2222
import type { PermissionSet } from '@objectstack/spec/security';
23+
import { SystemUserId } from '@objectstack/spec/system';
2324

2425
interface BootstrapOptions {
2526
/** Logger from PluginContext. */
@@ -117,18 +118,32 @@ export async function bootstrapPlatformAdmin(
117118
ql,
118119
'sys_user_permission_set',
119120
{ permission_set_id: adminPsId },
120-
5,
121+
50,
121122
);
122-
if (existingAdminLinks.some((r) => !r.organization_id)) {
123+
// A platform admin "already exists" only if a *human* holds the
124+
// cross-tenant grant. The seed-data owner `usr_system` (provisioned by
125+
// the SeedLoader, see runtime/app-plugin.ts `ensureSeedIdentity`) must
126+
// never count — otherwise a DB where it was wrongly promoted would block
127+
// every real admin forever. Ignoring it here makes the bootstrap
128+
// self-healing on restart.
129+
if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM)) {
123130
return { seeded: seededCount, adminPromoted: false, reason: 'already_have_admin' };
124131
}
125132

126133
const allUsers = await tryFind(ql, 'sys_user', {}, 50);
127-
if (allUsers.length === 0) {
128-
logger?.info?.('[security] no users yet — first sign-up will be promoted to platform admin');
134+
// Exclude the non-loginable system service account. It is created during
135+
// seed loading — *before* the first human sign-up — so without this filter
136+
// it is the earliest user and steals the platform-admin promotion, leaving
137+
// the real admin login without `setup.access` / `studio.access` (Setup and
138+
// Studio then stay invisible even though login succeeds).
139+
const humanUsers = allUsers.filter(
140+
(u) => u.id !== SystemUserId.SYSTEM && u.role !== 'system',
141+
);
142+
if (humanUsers.length === 0) {
143+
logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin');
129144
return { seeded: seededCount, adminPromoted: false, reason: 'no_users' };
130145
}
131-
const sorted = [...allUsers].sort((a, b) => {
146+
const sorted = [...humanUsers].sort((a, b) => {
132147
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
133148
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
134149
return ta - tb;

skills/objectstack-platform/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@ be re-run when commands are added.
10601060
| Command | What it does |
10611061
|:--------|:-------------|
10621062
| `os init` | Scaffold a new project (alternative to `npx create-objectstack`) |
1063-
| `os dev` | Start the dev server with hot metadata reload |
1063+
| `os dev` | Start the dev server with hot metadata reload. `--fresh` = ephemeral clean DB + auto `--seed-admin`, which POSTs a sign-up after boot (default `admin@objectos.ai` / `admin123`; override with `--admin-email` / `--admin-password`). The seeded human is auto-promoted to **platform admin**, so Setup/Studio work on first login. |
10641064
| `os studio` | Launch Studio UI against the local stack |
10651065
| `os validate` | Validate `objectstack.config.ts` (Zod + cross-reference checks) |
10661066
| `os lint` | Style/convention lint on metadata files |
@@ -1183,6 +1183,7 @@ describe('account hooks', () => {
11831183
| Adapter 404s on auto-generated routes | `enable.apiEnabled: false` on the object, or missing `os build` |
11841184
| LiteKernel test passes, ObjectKernel boot fails | Test missed a plugin dependency — list with `os info` |
11851185
| Hot reload misses new objects | Barrel `src/objects/index.ts` not re-exporting — check the file |
1186+
| Login works but **Setup / Studio missing** | The logged-in user isn't a platform admin. Setup/Studio are gated by `setup.access` / `studio.access` on `admin_full_access`, auto-granted only to the first registered **human** (`bootstrapPlatformAdmin`). The `usr_system` seed identity is skipped, so it can't steal the grant. Either sign up first (`--seed-admin`/`--fresh` does this) or check `sys_user_permission_set` for a cross-tenant (`organization_id = NULL`) `admin_full_access` link on your user. Don't edit nav code first. |
11861187

11871188
---
11881189

0 commit comments

Comments
 (0)