diff --git a/ENV.md b/ENV.md index 4859f906..35595fd9 100644 --- a/ENV.md +++ b/ENV.md @@ -50,7 +50,7 @@ These variables are for local repo tooling and development workflows. They are n | Environment Variable | TOML Key | Type | Default | Description | |---------------------|----------|------|---------|-------------| | `SERVERBEE_OAUTH__BASE_URL` | `oauth.base_url` | string | `""` | Public base URL for constructing OAuth callback URLs (e.g. `https://monitor.example.com`) | -| `SERVERBEE_OAUTH__ALLOW_REGISTRATION` | `oauth.allow_registration` | bool | `false` | Auto-create user accounts on first OAuth login | +| `SERVERBEE_OAUTH__ALLOW_REGISTRATION` | `oauth.allow_registration` | bool | `false` | Auto-create member accounts on first OAuth login. ⚠️ Enable only when the OAuth provider itself is access-controlled (self-hosted OIDC / org-internal IdP): members can read **all** monitoring data (security events, public IPs), so enabling this with a public provider such as GitHub grants that access to every user of the provider | | `SERVERBEE_OAUTH__GITHUB__CLIENT_ID` | `oauth.github.client_id` | string | - | GitHub OAuth App client ID | | `SERVERBEE_OAUTH__GITHUB__CLIENT_SECRET` | `oauth.github.client_secret` | string | - | GitHub OAuth App client secret | | `SERVERBEE_OAUTH__GOOGLE__CLIENT_ID` | `oauth.google.client_id` | string | - | Google OAuth client ID | diff --git a/apps/docs/content/docs/en/admin.mdx b/apps/docs/content/docs/en/admin.mdx index 4449678a..4ee00c56 100644 --- a/apps/docs/content/docs/en/admin.mdx +++ b/apps/docs/content/docs/en/admin.mdx @@ -13,7 +13,11 @@ ServerBee supports multiple users across two roles: | Role | Permissions | |------|-------------| | **Admin** | Full management: users, server config, alert rules, notifications, audit logs, etc. | -| **Member** | Read-only: view Dashboard, server details, Ping results | +| **Member** | Read-only access to **all** servers' monitoring data — dashboards, server details, Ping results, security events, public IPs — plus self-service settings (own password, 2FA, API key list, mobile devices) | + + +Member visibility is fleet-wide; there is no per-server scoping. A member account is for trusted collaborators only. To show selected servers to outsiders (e.g., customers), use a [status page](/en/docs/status-page) instead. + ### Managing Users @@ -23,6 +27,16 @@ Go to Settings → Users: - **Edit role**: Change a user's role (Admin/Member) - **Delete user**: Remove a user account (cannot delete the last Admin) +### Recovering a Lost Admin Account + +There is no password-recovery flow (self-hosted deployments have no mail channel), and passwords are argon2 hashes that cannot be hand-edited into the database. If the only admin credential (password or 2FA device) is lost: + +1. Stop the server. +2. Run `sqlite3 /path/to/serverbee.db "DELETE FROM users;"`. +3. Start the server — with an empty users table it re-creates the `admin` user and prints a fresh one-time password banner in the startup log. + +This removes **all** user accounts, and their API keys stop working. Monitoring data, servers, and configuration are untouched. + ### API Endpoints | Endpoint | Method | Description | diff --git a/apps/docs/content/docs/en/security.mdx b/apps/docs/content/docs/en/security.mdx index 3df1ca90..d83c0996 100644 --- a/apps/docs/content/docs/en/security.mdx +++ b/apps/docs/content/docs/en/security.mdx @@ -68,6 +68,10 @@ client_secret = "your-github-client-secret" - Click **Unlink** to disconnect an OAuth account - If `allow_registration = false` (default), first-time OAuth logins do not create new users — an admin must create the user first + +Enable `allow_registration = true` only when the OAuth provider itself is access-controlled (self-hosted OIDC or an org-internal IdP). Auto-created accounts get the Member role, which can read **all** monitoring data — including security events and public IPs. With a public provider such as GitHub, enabling it grants that access to every user of the provider. + + ### Login Flow 1. Click an OAuth provider button on the login page (e.g., "Login with GitHub") diff --git a/apps/docs/content/docs/zh/admin.mdx b/apps/docs/content/docs/zh/admin.mdx index d7952528..f0b162e1 100644 --- a/apps/docs/content/docs/zh/admin.mdx +++ b/apps/docs/content/docs/zh/admin.mdx @@ -13,7 +13,11 @@ ServerBee 支持多用户,分为两种角色: | 角色 | 权限 | |------|------| | **Admin** | 完全管理权限:用户管理、服务器配置、告警规则、通知渠道、审计日志等 | -| **Member** | 只读权限:查看 Dashboard、服务器详情、Ping 结果 | +| **Member** | 对**所有**服务器监控数据的只读权限——Dashboard、服务器详情、Ping 结果、安全事件、公网 IP,以及自助设置(自己的密码、2FA、API Key 列表、移动设备) | + + +Member 的可见范围是全部服务器,不支持按服务器隔离。Member 账号仅适合完全信任的协作者。如需向外部人员(如客户)展示部分服务器,请改用[状态页](/zh/docs/status-page)。 + ### 管理用户 @@ -23,6 +27,16 @@ ServerBee 支持多用户,分为两种角色: - **编辑角色**:修改用户的角色(Admin/Member) - **删除用户**:删除用户账号(禁止删除最后一个 Admin) +### 找回丢失的管理员账号 + +系统没有"忘记密码"流程(自托管部署没有邮件通道),密码以 argon2 哈希存储,无法手工改库重置。如果唯一的管理员凭证(密码或 2FA 设备)丢失: + +1. 停止服务端。 +2. 执行 `sqlite3 /path/to/serverbee.db "DELETE FROM users;"`。 +3. 重新启动服务端——users 表为空时会重新创建 `admin` 用户,并在启动日志中打印一次性的新随机密码横幅。 + +此操作会删除**所有**用户账号,其 API Key 随之失效;监控数据、服务器与配置不受影响。 + ### API 端点 | 端点 | 方法 | 说明 | diff --git a/apps/docs/content/docs/zh/security.mdx b/apps/docs/content/docs/zh/security.mdx index 54ae1045..a745977b 100644 --- a/apps/docs/content/docs/zh/security.mdx +++ b/apps/docs/content/docs/zh/security.mdx @@ -68,6 +68,10 @@ client_secret = "your-github-client-secret" - 点击 **Unlink** 可以解除 OAuth 账号关联 - 如果 `allow_registration = false`(默认),OAuth 首次登录不会自动创建新用户,需要管理员先创建用户再关联 + +仅当 OAuth 提供商本身受控(自建 OIDC 或组织内部 IdP)时才应开启 `allow_registration = true`。自动创建的账号为 Member 角色,可读取**全部**监控数据(含安全事件与公网 IP)。若配合 GitHub 等公共提供商开启,等于向该提供商的所有用户开放这些数据。 + + ### 登录流程 1. 在登录页面点击 OAuth 提供商按钮(如 "Login with GitHub") diff --git a/apps/ios/ServerBee/Localizable.xcstrings b/apps/ios/ServerBee/Localizable.xcstrings index 7248bac5..45b3e45a 100644 --- a/apps/ios/ServerBee/Localizable.xcstrings +++ b/apps/ios/ServerBee/Localizable.xcstrings @@ -943,6 +943,23 @@ } } }, + "Admins have full control over the entire system, including terminals, file access, and user management.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Admins have full control over the entire system, including terminals, file access, and user management." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理员拥有整个系统的完全控制权,包括终端、文件访问和用户管理。" + } + } + } + }, "Advanced": { "extractionState": "manual", "localizations": { @@ -6993,6 +7010,23 @@ } } }, + "Members can view all servers' monitoring data — including security events and public IPs — with no write access.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Members can view all servers' monitoring data — including security events and public IPs — with no write access." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "成员可查看所有服务器的全部监控数据(含安全事件与公网 IP),但没有任何写入或操作权限。" + } + } + } + }, "Memory": { "extractionState": "manual", "localizations": { @@ -12450,6 +12484,23 @@ } } }, + "To show only selected servers to outsiders, use a status page instead.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To show only selected servers to outsiders, use a status page instead." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如需仅向外部人员展示部分服务器,请使用状态页。" + } + } + } + }, "Too many attempts. Please try again later.": { "extractionState": "manual", "localizations": { diff --git a/apps/ios/ServerBee/Views/Admin/UsersView.swift b/apps/ios/ServerBee/Views/Admin/UsersView.swift index ef259974..bf5a0e21 100644 --- a/apps/ios/ServerBee/Views/Admin/UsersView.swift +++ b/apps/ios/ServerBee/Views/Admin/UsersView.swift @@ -32,7 +32,12 @@ struct UsersView: View { Button { showCreate = true } label: { Image(systemName: "person.badge.plus") } } } - .task { await viewModel.load(apiClient: apiClient) } + .task { + #if DEBUG + if UITestSupport.autoPresent == "users-create" { showCreate = true } + #endif + await viewModel.load(apiClient: apiClient) + } .refreshable { await viewModel.load(apiClient: apiClient) } .sheet(isPresented: $showCreate) { CreateUserSheet(viewModel: viewModel) } .sheet(item: $editing) { user in @@ -76,6 +81,15 @@ private struct CreateUserSheet: View { !username.trimmingCharacters(in: .whitespaces).isEmpty && password.count >= 8 && !working } + private var roleHint: String { + if role == "admin" { + return String(localized: "Admins have full control over the entire system, including terminals, file access, and user management.") + } + let visibility = String(localized: "Members can view all servers' monitoring data — including security events and public IPs — with no write access.") + let pointer = String(localized: "To show only selected servers to outsiders, use a status page instead.") + return "\(visibility) \(pointer)" + } + var body: some View { NavigationStack { Form { @@ -85,12 +99,16 @@ private struct CreateUserSheet: View { .autocorrectionDisabled() SecureField(String(localized: "Password (min 8)"), text: $password) } - Section(String(localized: "Role")) { + Section { Picker(String(localized: "Role"), selection: $role) { Text(String(localized: "Member")).tag("member") Text(String(localized: "Admin")).tag("admin") } .pickerStyle(.segmented) + } header: { + Text("Role") + } footer: { + Text(roleHint) } if let error { Section { Label(error, systemImage: "exclamationmark.triangle.fill").foregroundStyle(Color.serverOffline) } diff --git a/apps/ios/ServerBee/Views/Settings/SettingsView.swift b/apps/ios/ServerBee/Views/Settings/SettingsView.swift index 870855a4..e93f43f6 100644 --- a/apps/ios/ServerBee/Views/Settings/SettingsView.swift +++ b/apps/ios/ServerBee/Views/Settings/SettingsView.swift @@ -17,7 +17,7 @@ struct SettingsView: View { /// DEBUG-only value-routed admin destinations, used by the launch hook to /// push a sub-screen without the cliclick harness scrolling the list. - enum AdminRoute: Hashable { case networkProbes, ipQuality, statusPage } + enum AdminRoute: Hashable { case networkProbes, ipQuality, statusPage, users } #endif var body: some View { @@ -40,6 +40,7 @@ struct SettingsView: View { case .networkProbes: NetworkProbeConfigView(isAdmin: isAdmin) case .ipQuality: IpQualityConfigView(isAdmin: isAdmin) case .statusPage: StatusPageConfigView(isAdmin: isAdmin) + case .users: UsersView() } } .task { @@ -47,6 +48,7 @@ struct SettingsView: View { case "network-probes": debugPath.append(AdminRoute.networkProbes) case "ip-quality": debugPath.append(AdminRoute.ipQuality) case "status-page": debugPath.append(AdminRoute.statusPage) + case "users": debugPath.append(AdminRoute.users) default: break } } diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 7bdbdb44..f7997fc8 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -59,6 +59,8 @@ "users.password_hint": "Password (min 6 chars)", "users.role_member": "Member", "users.role_admin": "Admin", + "users.role_hint_member": "Members can view monitoring data for all servers — including security events and public IPs — but have no write or control access. To show only selected servers to outsiders, use a status page instead.", + "users.role_hint_admin": "Admins have full control over the entire system, including terminals, file access, and user management.", "users.no_users": "No users found", "users.two_factor": "2FA", "users.role_label": "Role:", diff --git a/apps/web/src/locales/zh/settings.json b/apps/web/src/locales/zh/settings.json index 23bff1da..f4847889 100644 --- a/apps/web/src/locales/zh/settings.json +++ b/apps/web/src/locales/zh/settings.json @@ -59,6 +59,8 @@ "users.password_hint": "密码(最少 6 位)", "users.role_member": "普通成员", "users.role_admin": "管理员", + "users.role_hint_member": "普通成员可查看所有服务器的全部监控数据(含安全事件与公网 IP),但没有任何写入或操作权限。如需仅向外部人员展示部分服务器,请使用状态页。", + "users.role_hint_admin": "管理员拥有整个系统的完全控制权,包括终端、文件访问和用户管理。", "users.no_users": "暂无用户", "users.two_factor": "2FA", "users.role_label": "角色:", diff --git a/apps/web/src/routes/_authed/settings/users.tsx b/apps/web/src/routes/_authed/settings/users.tsx index 4de10f42..cc2a5a68 100644 --- a/apps/web/src/routes/_authed/settings/users.tsx +++ b/apps/web/src/routes/_authed/settings/users.tsx @@ -202,6 +202,9 @@ function UsersPage() { {t('users.role_admin')} +

+ {state.newRole === 'admin' ? t('users.role_hint_admin') : t('users.role_hint_member')} +

{createMutation.error &&

{createMutation.error.message}

} diff --git a/bun.lock b/bun.lock index 3ad36ea3..0b10f881 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "apps/web": { "name": "@serverbee/web", - "version": "1.0.0-alpha.10", + "version": "1.0.0-alpha.11", "dependencies": { "@base-ui/react": "^1.2.0", "@fontsource-variable/inter": "^5.2.8", diff --git a/crates/server/src/service/user.rs b/crates/server/src/service/user.rs index 5bd62ace..16e85e2d 100644 --- a/crates/server/src/service/user.rs +++ b/crates/server/src/service/user.rs @@ -92,13 +92,21 @@ impl UserService { } /// Update a user's role and optionally reset their password. + /// + /// Runs entirely in one transaction so the last-admin guard's count and + /// the mutation cannot interleave with a concurrent demotion/deletion — + /// two racing requests could otherwise both pass the count check and + /// leave the system with zero admins, a state `init_admin` cannot recover + /// (it only re-bootstraps an empty users table). pub async fn update_user( db: &DatabaseConnection, id: &str, input: UpdateUserInput, ) -> Result { + let txn = db.begin().await?; + let user = user::Entity::find_by_id(id) - .one(db) + .one(&txn) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; @@ -110,7 +118,7 @@ impl UserService { if user.role == "admin" && role != "admin" { let admin_count = user::Entity::find() .filter(user::Column::Role.eq("admin")) - .count(db) + .count(&txn) .await?; if admin_count <= 1 { return Err(AppError::BadRequest( @@ -133,35 +141,39 @@ impl UserService { } active.updated_at = Set(now); + let updated = active.update(&txn).await?; + // If an admin reset this user's password, revoke all their existing // sessions so a previously issued (possibly stolen) session cannot // outlive the reset. This includes the mobile auth path, whose refresh // secret lives in `mobile_session` (a separate table); an admin reset - // unconditionally drops all of the target user's mobile sessions. The - // update + revocation run in one transaction so the reset can't commit + // unconditionally drops all of the target user's mobile sessions. + // Sharing the surrounding transaction means the reset can't commit // while sessions stay live. - let updated = if password_reset { - let txn = db.begin().await?; - let updated = active.update(&txn).await?; + if password_reset { session::Entity::delete_many() .filter(session::Column::UserId.eq(id)) .exec(&txn) .await?; AuthService::revoke_user_mobile_sessions(&txn, id, None).await?; - txn.commit().await?; - updated - } else { - active.update(db).await? - }; + } + + txn.commit().await?; Ok(updated) } /// Delete a user along with their sessions and API keys. /// Refuses to delete the last admin. + /// + /// Runs entirely in one transaction: the last-admin guard's count cannot + /// interleave with a concurrent demotion/deletion (see `update_user`), + /// and the multi-table cleanup is atomic. pub async fn delete_user(db: &DatabaseConnection, id: &str) -> Result<(), AppError> { + let txn = db.begin().await?; + let user = user::Entity::find_by_id(id) - .one(db) + .one(&txn) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; @@ -169,7 +181,7 @@ impl UserService { if user.role == "admin" { let admin_count = user::Entity::find() .filter(user::Column::Role.eq("admin")) - .count(db) + .count(&txn) .await?; if admin_count <= 1 { return Err(AppError::BadRequest( @@ -181,23 +193,25 @@ impl UserService { // Clean up sessions session::Entity::delete_many() .filter(session::Column::UserId.eq(id)) - .exec(db) + .exec(&txn) .await?; // Clean up API keys api_key::Entity::delete_many() .filter(api_key::Column::UserId.eq(id)) - .exec(db) + .exec(&txn) .await?; // Clean up OAuth accounts oauth_account::Entity::delete_many() .filter(oauth_account::Column::UserId.eq(id)) - .exec(db) + .exec(&txn) .await?; // Delete the user - user::Entity::delete_by_id(id).exec(db).await?; + user::Entity::delete_by_id(id).exec(&txn).await?; + + txn.commit().await?; Ok(()) } diff --git a/docs/superpowers/specs/2026-07-07-user-system-design-review.md b/docs/superpowers/specs/2026-07-07-user-system-design-review.md new file mode 100644 index 00000000..bf04e293 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-user-system-design-review.md @@ -0,0 +1,105 @@ +# User System Design Review + +**Date:** 2026-07-07 +**Status:** Concluded — 6 decisions settled; follow-ups implemented alongside this document +**Scope:** The two-tier RBAC user system: `users` table, `admin`/`member` roles, `/settings/users` management UI, `require_admin` route gating, API keys, sessions. + +## Context + +ServerBee ships a deliberately simple user model: + +- `users.role` is a plain string, validated to `"admin" | "member"` (`crates/server/src/service/user.rs`). +- A default `admin` user is bootstrapped only when the users table is empty, with a random password and `must_change_password = true` (`AuthService::init_admin`). +- Authorization is enforced at the router layer: public routes → authenticated read routers → a write-router block wrapped in `require_admin` (`crates/server/src/router/api/mod.rs`). Terminal and Docker-log WebSockets check `role == "admin"` at upgrade time. +- The web UI fail-closes: every `/settings` route is admin-only except an explicit member allowlist (`MEMBER_SETTINGS_ROUTES` in `apps/web/src/routes/_authed.tsx`). + +This document records the design decisions confirmed during the review, one per section. + +## Decision 1 — Member is a trusted, fleet-wide, read-only observer + +**Question:** Should `member` get per-server / per-group visibility scoping, or stay global read-only? + +**Decision:** Keep the global two-tier model. `member` is positioned as a **trusted collaborator with full fleet visibility and zero control**. No per-server ACL will be built. + +**Rationale:** + +- ServerBee already has a three-tier disclosure spectrum; each audience maps to an existing tool: + + | Audience | Tool | Visibility | + |---|---|---| + | Strangers / customers | Status pages | Hand-picked servers, masked/aggregated status | + | Trusted collaborators | `member` account | All servers, all monitoring detail, no control | + | Operators | `admin` account | Everything | + +- "Show a customer their one server" is served by creating a status page for that server (already supports per-server selection and IP masking) — not by scoping member accounts. +- Per-server ACL would require filtering 30+ read endpoints, the browser WebSocket FullSync/Update fan-out, and every cross-server aggregate (insights, alert event streams). Any missed endpoint is a privilege leak. That complexity tax is not worth paying in a single-tenant, self-hosted product; true multi-tenant isolation should be solved by deploying separate instances. + +**Follow-ups (cheap, disclosure-only):** + +- [x] Create-user dialog: the `member` role option must state that members can see **all** servers' monitoring data (including security events and public IPs) with no write access. +- [x] User-management docs page: state the same boundary and point to status pages for external/partial exposure. + +## Decision 2 — No forced password change for admin-created users + +**Question:** `AuthService::create_user` hardcodes `must_change_password = false`, and the admin password-reset path (`PUT /api/users/{id}` with `password`) does not set it either — so the admin permanently knows the initial/reset password. Should both paths set `must_change_password = true` (reusing the existing onboarding machinery built for the bootstrap admin)? + +**Decision:** Keep as-is. No forced first-login password change for admin-created or admin-reset accounts. + +**Rationale (owner's call):** The admin is fully trusted and already holds maximum privilege over the whole system; knowing a member's password grants nothing the admin cannot already do. Forcing a change would add onboarding friction without a meaningful security gain in this trust model. The bootstrap admin keeps its forced change because its random password is printed to the startup log (a broader exposure channel). + +**Clarification recorded during this decision:** A bootstrap-created admin and a UI-created admin are identical after creation. There is no "super admin" concept — `role` is a plain string and every check is `role == "admin"`. Differences exist only at birth: the bootstrap admin has a fixed `admin` username, a random password shown once in a startup banner, `must_change_password = true`, and **no audit entry** (UI creation writes `user.create` to the audit log). The bootstrap admin can be deleted or demoted like any other admin as long as the count-based last-admin guard is satisfied. There is no config/env path that seeds the bootstrap admin password in production; `admin/admin123` exists only in the dev demo seed. + +## Decision 3 — Live WebSocket channels are not re-validated after role changes (known boundary) + +**Question:** Terminal (`ws/terminal.rs`) and Docker-log (`ws/docker_logs.rs`) WebSockets check `role == "admin"` only at upgrade time. A demoted or deleted admin's already-open PTY/log stream stays alive until the client disconnects, even though REST access is revoked immediately (role is re-read from the DB on every request, and deletion drops sessions). Should the WS loops periodically re-validate the session and role (e.g., every 60s) and close on failure? + +**Decision:** No — rejected as overdesign for this product's trust model. Recorded as a known boundary instead. + +**Rationale (owner's call):** Admin demotion/removal is a rare, deliberate act performed by another trusted admin in a single-operator/small-team deployment. The residual window (an already-open terminal surviving until disconnect) does not justify adding re-validation machinery to the WS loops. Operators who need immediate revocation can restart the server process, which drops all WS connections. + +## Decision 4 — OAuth auto-registration risk is handled by documentation, not an allowlist + +**Question:** With `SERVERBEE_OAUTH__ALLOW_REGISTRATION=true` (default `false`), any identity that can authenticate against the configured OAuth provider is auto-provisioned as a `member` on first login (`OAuthService::find_or_create_user`), with no domain/org/username allowlist and no approval step. Combined with Decision 1 (member = fleet-wide read visibility), enabling this against a public provider such as GitHub effectively publishes all read-only monitoring data — public IPs, process lists, security events — to every user of that provider. Should an allowlist be added? + +**Decision:** No allowlist. Documentation-only mitigation. + +**Rationale:** The flag is fail-closed by default, and the legitimate use case (a private/self-hosted IdP where the provider itself is the allowlist) doesn't need extra config. The danger is purely one of operator expectation, so it is fixed at the disclosure layer. + +**Follow-ups:** + +- [x] ENV.md `SERVERBEE_OAUTH__ALLOW_REGISTRATION` row and the OAuth docs page must warn: enable only when the OAuth provider itself is access-controlled (self-hosted OIDC / org-internal IdP). Enabling it with a public provider (e.g., GitHub) grants every user of that provider full read access to all monitoring data, including security events and public IPs. + +## Verified clean — no credential leakage to members + +Checked during the review, no action needed: `ServerResponse` deliberately excludes `token_hash`/`token_prefix` (exposes only a `has_token` bool), and `agent::read_router` exposes only `/agent/latest-version`. Members have no read path to agent tokens or enrollment secrets, so a member account cannot escalate to agent impersonation. + +## Decision 5 — Fix the last-admin guard race (wrap check + mutation in a transaction) + +**Question:** The last-admin guard in `UserService::update_user` (demote path) and `UserService::delete_user` is check-then-act: the `count(role = 'admin')` query and the subsequent write run outside a shared transaction. Two concurrent requests (e.g., two admins demoting each other) can both observe `admin_count = 2`, both pass, and leave the system with **zero admins**. Because `init_admin` re-bootstraps only when the users table is empty, an all-admins-demoted state is unrecoverable in-product — every `require_admin` route (including user management itself) returns 403 forever; the only way out is manual SQLite surgery. + +**Decision:** Fix. Wrap the guard count and the mutation in a single transaction in both paths (`update_user` role-demotion branch, `delete_user`). SQLite serializes write transactions, so the second concurrent request re-reads `admin_count = 1` inside its transaction and is correctly rejected. No new concepts or config; `delete_user`'s multi-table cleanup should have been transactional anyway. + +**Status:** Implemented — `update_user` and `delete_user` each run guard + mutation in one transaction. + +## Decision 6 — Admin lockout recovery is documented, not built + +**Question:** There is no forgot-password flow (reasonable — self-hosted, no mail channel), and passwords are argon2 hashes that cannot be hand-crafted via the sqlite CLI. If the only admin loses their password or 2FA device, the only real recovery is: stop the server → `sqlite3 serverbee.db "DELETE FROM users;"` → restart, which triggers `init_admin` re-bootstrap (new random password in the startup banner). This works but is written down nowhere. Should ServerBee ship a `reset-admin-password` CLI subcommand instead? + +**Decision:** No CLI. Document the wipe-and-rebootstrap recovery procedure in the docs site. + +**Rationale:** Anyone who can reach the DB file already has host-level access, so a CLI adds no new capability — only a new maintained entry point. The recovery recipe (with its side effects: all user accounts and API-key ownership are reset; monitoring data untouched) belongs in the troubleshooting docs. + +**Follow-ups:** + +- [x] Add a "Lost admin access" recovery section to the docs (Admin Guide, CN+EN). + +## Summary of outcomes + +| # | Topic | Outcome | +|---|---|---| +| 1 | Member scoping | Keep global read-only member; external exposure → status pages; add UI/docs disclosure | +| 2 | Forced password change for created users | Keep as-is (admin is fully trusted) | +| 3 | Live WS revalidation after role change | Known boundary, no code change | +| 4 | OAuth auto-registration blast radius | Docs warning only, no allowlist | +| 5 | Last-admin guard race | **Fix**: wrap count+mutation in one transaction (update_user demote path, delete_user) | +| 6 | Admin lockout recovery | Document wipe-and-rebootstrap procedure, no CLI |