No durable mechanism to bootstrap the first admin user (admin pages Forbidden after first login) #28

Open
opened 2026-07-04 12:11:10 +00:00 by momsse · 0 comments
Owner

Migré depuis viziertronic/octant#71 — ouvert le 2026-06-23 par @momsse.

Summary

There is no durable way to bootstrap the first admin user. A freshly-authenticated user (first Discord/Google login) is created with zero permissions, and every /admin/* operation is gated by a permission policy (users:manage, roles:manage, groups:manage, permissions:manage, audit:read, security:audit). Granting those permissions itself requires roles:manage / permissions:manage — a chicken-and-egg: nobody can grant the first admin from inside the app.

Observed during a local smoke of apps/backoffice: after logging in, every admin list page returns ForbiddenError (the ResultHandler error path renders correctly). The dashboard, profile and session pages work because they are self-service (no admin permission required).

How permissions are resolved today

apps/api/src/auth/auth-middleware.layer.ts builds the Visitor from EffectivePermissionsQuery(userId), which reads (see packages/infrastructure/postgres-authorization/.../effective-permissions.query.adapter.ts):

read.role_permission_lookup           (role_id, permission)
  filtered by read.user_role_assignment_lookup (user_id, role_id)
  ∪ group paths (group_membership_lookup → group_role_lookup / group_permission_lookup)

Current (manual, unsatisfactory) workaround

To unblock local testing we manually seeded the read models:

INSERT INTO read.role_permission_lookup (role_id, permission) VALUES
  ('smoke-admin-role','users:manage'), ('smoke-admin-role','roles:manage'),
  ('smoke-admin-role','groups:manage'), ('smoke-admin-role','permissions:manage'),
  ('smoke-admin-role','audit:read'), ('smoke-admin-role','security:audit');
INSERT INTO read.user_role_assignment_lookup (user_id, role_id) VALUES
  ('<user-uuid>','smoke-admin-role');

This unblocks the policy middleware, but it is incomplete and inconsistent: it writes only the lookup read models, not the role/permission aggregates (event store) nor read.role_directory. As a result the checkUserPermissions admin query (which reads a richer path with sources) still reports "No effective permission" for the same user, even though the middleware grants access. Bypassing the write side leaves read paths out of sync.

What we should decide

A durable, write-side-correct bootstrap. Options to weigh:

  1. Seed script / migration that creates a real admin role aggregate (with all management permissions as real permission aggregates) via the domain commands, then assigns it — so every projection/read path is consistent.
  2. Env-configured bootstrap admin: e.g. BOOTSTRAP_ADMIN_EMAILS; on login (or on boot) the matching user is granted the admin role through the normal command path. Idempotent.
  3. First-user-is-admin convention (the first created user gets the admin role), guarded so it only fires when no admin exists.
  4. A privileged CLI (pnpm ... grant-admin <email>) issuing the real authorization commands.

Whatever we pick must go through the aggregates/commands (not raw read-model inserts) so the middleware query, checkUserPermissions, role_directory, and audit all stay consistent.

Context

  • Found while smoke-testing PR #65 (split/13-backoffice). Not a bug in that PR — a missing platform capability surfaced by it.
> _Migré depuis [viziertronic/octant#71](https://github.com/viziertronic/octant/issues/71) — ouvert le 2026-06-23 par @momsse._ ## Summary There is no durable way to bootstrap the **first admin user**. A freshly-authenticated user (first Discord/Google login) is created with **zero permissions**, and every `/admin/*` operation is gated by a permission policy (`users:manage`, `roles:manage`, `groups:manage`, `permissions:manage`, `audit:read`, `security:audit`). Granting those permissions itself requires `roles:manage` / `permissions:manage` — a chicken-and-egg: nobody can grant the first admin from inside the app. Observed during a local smoke of `apps/backoffice`: after logging in, **every admin list page returns `ForbiddenError`** (the `ResultHandler` error path renders correctly). The dashboard, profile and session pages work because they are self-service (no admin permission required). ## How permissions are resolved today `apps/api/src/auth/auth-middleware.layer.ts` builds the `Visitor` from `EffectivePermissionsQuery(userId)`, which reads (see `packages/infrastructure/postgres-authorization/.../effective-permissions.query.adapter.ts`): ``` read.role_permission_lookup (role_id, permission) filtered by read.user_role_assignment_lookup (user_id, role_id) ∪ group paths (group_membership_lookup → group_role_lookup / group_permission_lookup) ``` ## Current (manual, unsatisfactory) workaround To unblock local testing we manually seeded the read models: ```sql INSERT INTO read.role_permission_lookup (role_id, permission) VALUES ('smoke-admin-role','users:manage'), ('smoke-admin-role','roles:manage'), ('smoke-admin-role','groups:manage'), ('smoke-admin-role','permissions:manage'), ('smoke-admin-role','audit:read'), ('smoke-admin-role','security:audit'); INSERT INTO read.user_role_assignment_lookup (user_id, role_id) VALUES ('<user-uuid>','smoke-admin-role'); ``` This unblocks the policy middleware, **but it is incomplete and inconsistent**: it writes only the lookup read models, not the role/permission **aggregates** (event store) nor `read.role_directory`. As a result the `checkUserPermissions` admin query (which reads a richer path with sources) still reports **"No effective permission"** for the same user, even though the middleware grants access. Bypassing the write side leaves read paths out of sync. ## What we should decide A durable, write-side-correct bootstrap. Options to weigh: 1. **Seed script / migration** that creates a real `admin` role aggregate (with all management permissions as real permission aggregates) via the domain commands, then assigns it — so every projection/read path is consistent. 2. **Env-configured bootstrap admin**: e.g. `BOOTSTRAP_ADMIN_EMAILS`; on login (or on boot) the matching user is granted the admin role through the normal command path. Idempotent. 3. **First-user-is-admin** convention (the first created user gets the admin role), guarded so it only fires when no admin exists. 4. A privileged **CLI** (`pnpm ... grant-admin <email>`) issuing the real authorization commands. Whatever we pick must go through the **aggregates/commands** (not raw read-model inserts) so the middleware query, `checkUserPermissions`, `role_directory`, and audit all stay consistent. ## Context - Found while smoke-testing PR #65 (`split/13-backoffice`). Not a bug in that PR — a missing platform capability surfaced by it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
momsse/octant#28
No description provided.