# Bilko Auth Migration Runbook + Admin Guide

## Scope

This runbook covers: (1) how an operator bootstraps the first admin after legacy auth is retired, (2) how an admin creates and invites users, (3) how to assign and change roles, (4) the full user lifecycle via Entra, (5) migration notes from the WP1–WP4 branch stack. For architecture detail see [Bilko Authentication — Entra External ID (CIAM)](/books/bilko-balkan-accounting-saas/page/bilko-authentication-entra-external-id-ciam).

## 1. Bootstrapping the First Admin (Break-Glass)

After legacy `/auth/register` is retired (HTTP 410), there is no HTTP endpoint for creating the first user. Use the Gradle break-glass task:

```
# Set environment variables (never commit these):
export BOOTSTRAP_ADMIN_EMAIL="admin@yourorg.com"
export BOOTSTRAP_ADMIN_PASSWORD="<strong-temporary-password>"

# Run from the api project root:
cd apps/api
./gradlew :apps:api:bootStrapAdmin
```

What this does: calls `AuthService.register()` directly (bypasses HTTP routing), creates an organisation + owner user. No HTTP endpoint is exposed — zero backdoor surface. The temporary password should be rotated immediately via Entra SSPR after the admin first signs in.

Full runbook file: `apps/api/docs/runbooks/BREAK-GLASS-BOOTSTRAP.md` (on branch `feat/rbac-wp4-retire-legacy-auth`).

## 2. Creating / Inviting Users (Admin Flow)

User creation is now admin-gated. Self-serve registration is retired.

### Via API

```
POST /api/v1/admin/users
Authorization: Bearer <admin-or-owner-access-token>

{
  "email": "newuser@example.com",
  "fullName": "Full Name",
  "role": "viewer"        // viewer | accountant | admin | owner
}
```

Response: `HTTP 201 Created` — returns the new user object including their UUID. The user receives an invite; they sign in via Entra (JIT provisioning links their Entra identity on first sign-in).

### Permission required

`users:manage` — held by `admin` and `owner` roles.

### Via Web Admin UI

1. Sign in as admin or owner
2. Navigate to **Settings &gt; Users** (`/admin/users`)
3. Click **Invite User**
4. Enter email, full name, and select role
5. Submit — user receives invite email (Entra CIAM invitation flow)

Viewers and accountants see a redirect to the dashboard if they navigate to `/admin/users`.

## 3. Assigning / Changing Roles

### Via API

```
PUT /api/v1/users/:id/role
Authorization: Bearer <admin-or-owner-access-token>

{
  "role": "accountant"    // viewer | accountant | admin | owner
}
```

Constraints enforced:

- Caller must have `users:manage` permission (admin+)
- A user **cannot change their own role** (self-escalation blocked — HTTP 403)
- The `owner` role cannot be changed via this endpoint (owner is protected in `SettingsService.changeUserRole()`)
- Invalid role values return HTTP 400

### Via Web Admin UI

1. Navigate to **Settings &gt; Users**
2. Find the user row
3. Click the role dropdown (visible to admin/owner only)
4. Select the new role — saved immediately via `PUT /users/:id/role`

## 4. User Lifecycle

1. **Admin creates user** via `POST /admin/users` (role = viewer by default, or specified role)
2. **User receives Entra invite** (email from `bilkociam.onmicrosoft.com`)
3. **First sign-in**: user clicks Entra sign-in on Bilko web login → authenticates in Entra CIAM → Bilko backend calls `createSessionFromEntraIdToken()`:
- Looks up `entra_external_identities` by `oid` → not found (first login)
- Email-match lookup → finds pre-created user → inserts `entra_external_identities` row (JIT link) → audit event `entra_jit_link`
- Bilko session returned; user is logged in as viewer

5. **Admin promotes role** if needed via `PUT /users/:id/role`
6. **Subsequent logins**: Entra → backend finds `entra_external_identities` by `oid` → direct session, no email-match step
7. **Sign-out**: MSAL calls Entra logout endpoint → Entra session invalidated → Bilko session cookie cleared → next visit redirects to Entra login

## 5. What Changed — Migration Notes (WP1–WP4)

<table id="bkmrk-areabefore-%28pre-wp1%29"><thead><tr><th>Area</th><th>Before (pre-WP1)</th><th>After (WP1–WP4)</th></tr></thead><tbody><tr><td>Backend auth enforcement</td><td>`requireRole("admin")` inline in 51+ route handlers</td><td>`requirePermission("invoice:create")` via extension fn; 0 residual requireRole in routes</td></tr><tr><td>Permission data</td><td>No tables; hardcoded numeric hierarchy in RbacHelper</td><td>V67: `permissions` table (52 keys), `role_permissions` seed; V68: provisioning function</td></tr><tr><td>User provisioning</td><td>Self-serve `POST /auth/register`</td><td>Admin invite (`POST /admin/users`) + JIT Entra link on first sign-in; `UserProvisioningService`</td></tr><tr><td>Web login</td><td>Email/password form + "Sign in with Microsoft" coexisting</td><td>Entra-only CTA; no email/password form; register page shows "contact your admin"</td></tr><tr><td>Legacy endpoints</td><td>Active: /auth/login, /auth/register, /auth/forgot-password, /auth/reset-password</td><td>HTTP 410 Gone + ENDPOINT\_RETIRED body</td></tr><tr><td>Password reset</td><td>Email-based reset-password flow (V57 table)</td><td>Redirect to Entra SSPR portal (self-service via Microsoft account)</td></tr><tr><td>RBAC admin UI</td><td>No UI; role changes required direct DB query</td><td>Web: Settings &gt; Users page with role dropdown (admin/owner only)</td></tr></tbody></table>

## 6. Branch Stack (WP1–WP4 Stacked PRs)

<table id="bkmrk-wpbranchlatest-commi"><thead><tr><th>WP</th><th>Branch</th><th>Latest commit</th><th>Key files</th></tr></thead><tbody><tr><td>WP1 — RBAC catalog</td><td>`feat/rbac-wp1-permissions-catalog`</td><td>890168d (last route commit)</td><td>V67 migration, PermissionService, BilkoPrincipal, RbacHelper, 17 route files migrated</td></tr><tr><td>WP2 — Provisioning</td><td>`feat/rbac-wp2-user-provisioning`</td><td>a9fa67c</td><td>V68 migration, UserProvisioningService, UserManagementRoutes</td></tr><tr><td>WP3 — Web UI</td><td>`feat/rbac-wp3-web-entra-ui`</td><td>3c1c019</td><td>login/page.tsx (Entra CTA), register/page.tsx (retired), admin/users/page.tsx, lib/permissions.ts</td></tr><tr><td>WP4 — Retire legacy</td><td>`feat/rbac-wp4-retire-legacy-auth`</td><td>3ac1388</td><td>AuthRoutes.kt (5 x 410), api.ts (removed methods), auth-store.ts, 13 new web tests</td></tr></tbody></table>

These branches are stacked and NOT yet merged to main. Production cutover requires a consolidated merge PR after CEO/Securion sign-off.

## 7. Rollback Procedure

If the consolidated deploy to main needs to be rolled back:

1. **Feature flag path** (if `FEATURE_ENTRA_AUTH_ENABLED` env var is present): set to `false` to re-enable the password auth provider path (AuthProvider interface, D5 in MC #103075)
2. **Hard rollback**: revert to the pre-WP1 commit; Flyway handles down-migration if reversible V67/V68 down scripts were authored (check migration files)
3. **password\_hash**: column was made nullable in V66 but existing rows retain their hash values — password-based login can be re-enabled without data loss during the rollback window
4. **Password reset tokens table (V57)**: NOT dropped. Must not be dropped until the rollback window closes (minimum 30 days post-production cutover). See D8 plan in MC #103075
5. **Entra disable**: if Entra must be disabled urgently, also disable users in Bilko DB to enforce immediate revocation (7-day refresh window caveat — OC#4)

## 8. Database Schema Reference

<table id="bkmrk-tablekey-columnsnote"><thead><tr><th>Table</th><th>Key columns</th><th>Notes</th></tr></thead><tbody><tr><td>`users`</td><td>`id`, `organization_id`, `email`, `password_hash` (nullable), `role` (CHECK owner|admin|accountant|viewer), `two_factor_*`</td><td>V66: password\_hash nullable; V67: role CHECK added</td></tr><tr><td>`entra_external_identities`</td><td>`issuer`, `subject` (= oid), `user_id`, `last_login_at`</td><td>V64: created; UNIQUE(issuer,subject), UNIQUE(user\_id,issuer); RLS: V66</td></tr><tr><td>`permissions`</td><td>`permission_key` (PK, CHECK format)</td><td>V67: 52 keys; global catalog, no RLS, GRANT SELECT bilko\_app</td></tr><tr><td>`role_permissions`</td><td>`role`, `permission_key`</td><td>V67: exhaustive flat seed; V68: users:manage + users:invite added</td></tr></tbody></table>

## Evidence Files

- WP1 verification: `/tmp/evidence-103141/wp1-verification.md`, proveo-verdict.json
- WP2 verification: `/tmp/evidence-103142/wp2-verification.md`, proveo-wp2-verdict.json
- WP3 Proveo: `/tmp/evidence-103143/proveo-wp3-validation.md`
- WP4 verification: `/tmp/evidence-103144/wp4-verification.md`
- WP5 E2E: `/tmp/evidence-103145/wp5-e2e.md`, verification.json
- Phase 0 config: `/tmp/evidence-103076/phase0-config.md`