feat(admin): OIDC sign-in, role sync, deploy/delete UX, and docs

This commit lights up the missing pieces of the admin dashboard and the
OIDC flow that the desktop client already speaks. It bundles several
independent fixes that share enough touch points (oidc/callback,
admin/mod, database schema) that splitting was more churn than help.

OIDC — desktop client
- /api/login: when TOTP is enrolled, return type:"email_check"
  + tfa_type:"tfa_check" instead of type:"tfa_check". The Flutter
  client's switch only branches on access_token / email_check; the
  prior shape silently fell into "bad response from server".
- /api/login dispatcher: route the second leg to login_tfa_code when
  tfaCode + secret are both present, regardless of the declared type.
  The desktop client sends type:"email_code" for both email-code AND
  TOTP second legs and distinguishes by which field is set.
- /api/oidc/auth-query: drop the bogus extra {"body": "..."} envelope.
  The desktop client's http_request_sync already wraps every response
  in {status_code, headers, body}, and HbbHttpResponse::parse expects
  the auth payload at that level. Our extra envelope made the parser
  fail silently as DataTypeFormat and the poll loop spun until the
  180 s client timeout.
- UserPayload: add a required info: {} field; the Rust-side polling
  deserializer at src/hbbs_http/account.rs expects it (no
  #[serde(default)]). Without it the AuthBody parse failed on every
  poll, producing the same forever-pending symptom as above.
- Add an always-on info-level log line at the poll handler so this
  family of "client never advances" bugs is observable from hbbs.log.

OIDC — admin dashboard
- New unauthenticated entry points:
    GET /admin/oidc/providers      JSON list for login.html
    GET /admin/login/oidc/:provider 302 → IdP authorization endpoint
  The session is marked admin-flow via a sentinel ("__admin_ui__") in
  client_id_str / client_uuid so the existing /oidc/callback can tell
  it apart from a desktop device flow.
- /oidc/callback finishes admin sessions by setting the
  rd_admin_session cookie + 303 to /admin/. Non-admin users get a
  helpful error page instead of a session.
- Admin-flow callbacks SKIP device_claim() so the dashboard sign-in
  no longer inserts a phantom "__admin_ui__" device row in
  device_sysinfo, and the token's peer_id / peer_uuid columns stay
  blank instead of carrying the sentinel.
- admin_ui/login.html fetches the providers list on load and renders
  one button per enabled provider beneath the password form.

OIDC — role-based admin sync
- New per-provider config fields admin_role + roles_claim (in
  oidc.toml AND oidc_providers, via soft ALTER TABLE). When set, the
  callback evaluates the userinfo claim and forces users.is_admin
  accordingly on every login. Promotion AND demotion at the IdP
  propagate. Two claim shapes supported:
    - object key match  (Zitadel:
        urn:zitadel:iam:org:project:roles -> { "admin": {...} })
    - string-array contains (generic: roles -> ["admin","user"])
- user_upsert_oidc gains a desired_admin: Option<bool> arg so the same
  upsert path handles "leave admin alone" (desired_admin = None) and
  "force from IdP" (Some(bool)). Three unit tests cover both shapes
  plus the missing-claim case.

Admin dashboard — Address books
- Full CRUD for shared books from the dashboard:
  create, list shares, add/upgrade/remove a per-user share with
  read / read+write / full rules, delete the book.
- Personal books also get a Delete action — confirms with a stronger
  message that the user's desktop client will recreate an empty
  personal book on next sync if it's still signed in (deletion is
  effectively "reset to empty", not "permanently revoked"). Use in
  combination with user-delete to fully revoke.
- New DB methods: ab_create_shared, ab_delete (cascades peers/tags/
  peer_tags/shares), ab_get_owner_kind, ab_list_shares, ab_share_set
  (idempotent upsert), ab_share_remove.

Admin dashboard — Devices
- Delete action in the per-row menu. device_delete cascades through
  device_sysinfo, peer (rendezvous identity), heartbeat_commands and
  peer-scoped strategy_assignments. Audit logs, recordings, and AB
  entries that reference the peer are intentionally preserved
  (historical/manual data).

Admin dashboard — Deploy page
- New page that generates the unsigned CustomServer blob the desktop
  client accepts via `rustdesk --config <blob>` (see
  rustdesk/src/custom_server.rs:get_custom_server_from_config_string;
  the unsigned-JSON path is a real codepath, no Pro signing key
  needed). Form prefills the public key from id_ed25519.pub in CWD.
- Also emits the equivalent renamed-installer filename
  (rustdesk-host=...,key=... .exe). Strips api= from the filename
  when it equals the default http://<host>:21114 (Windows can't store
  : or / in filenames); warns when the API URL is non-default.

Login form fixes
- TOTP form field: rename serde wire field from tfa_code to tfaCode
  so the dashboard's HTMX form (input name="tfaCode") actually
  populates it. The previous mismatch silently dropped the code and
  the server kept asking for it.
- TOTP redirect guard: only redirect on empty 2xx body (real login).
  The TOTP-required path returns 2xx with an HTML prompt fragment
  that must NOT be redirected away from.

Docs
- New docs/CONFIGURATION.md covering all CLI flags, OIDC setup
  (generic + Zitadel walk-through), role-based admin sync, TOTP,
  strategies, address books, dashboard URL map, DB notes, and a
  pre-prod security checklist.

Schema
- soft ALTER TABLE oidc_providers ADD COLUMN admin_role / roles_claim
  (guarded by the duplicate-column-name swallower for SQLite < 3.35).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 01:05:52 +02:00
parent e183b386a1
commit 98b55e138e
17 changed files with 1560 additions and 62 deletions
+214 -6
View File
@@ -118,6 +118,15 @@ pub struct AbOverviewRow {
pub created_at: i64,
}
#[derive(Debug, Clone)]
pub struct AbShareDetailRow {
pub user_id: i64,
pub username: String,
/// 1=read, 2=read+write, 3=full (matches the desktop client's enum
/// in src/hbbs_http/account.rs and the §4.3 wire contract).
pub rule: i64,
}
#[derive(Debug, Clone)]
pub struct StrategyRow {
pub id: i64,
@@ -234,6 +243,15 @@ pub struct OidcProviderRow {
pub scopes: String,
pub redirect_url: String,
pub enabled: bool,
/// If `Some`, every successful sign-in via this provider sets the local
/// user's `is_admin` to whether the userinfo claim at `roles_claim`
/// contains this role name. `None` means "don't touch is_admin"
/// (existing behavior — admins are managed in the dashboard).
pub admin_role: Option<String>,
/// Userinfo claim that holds the user's roles. Defaults to `"roles"`
/// when `admin_role` is set but this is not. Zitadel's default format
/// lives at `"urn:zitadel:iam:org:project:roles"`.
pub roles_claim: Option<String>,
}
pub struct OidcSessionInsert<'a> {
@@ -497,6 +515,35 @@ impl Database {
Ok(res.rows_affected() > 0)
}
/// Drop a device by `peer_id`. Removes the dashboard-visible row
/// (`device_sysinfo`), the rendezvous-side identity (`peer` — so a
/// stale public key doesn't reject a fresh reinstall under the same
/// ID), and any pending peer-scoped operational state
/// (`heartbeat_commands`, peer-scoped `strategy_assignments`).
/// Audit rows, recordings, and address-book entries are intentionally
/// preserved — they're historical/manual data the operator may still
/// want even after the device is gone. Returns `true` iff a
/// `device_sysinfo` row actually existed.
pub async fn device_delete(&self, peer_id: &str) -> ResultType<bool> {
let _ = sqlx::query("DELETE FROM heartbeat_commands WHERE peer_id = ?")
.bind(peer_id)
.execute(self.pool.get().await?.deref_mut())
.await;
let _ = sqlx::query("DELETE FROM strategy_assignments WHERE peer_id = ?")
.bind(peer_id)
.execute(self.pool.get().await?.deref_mut())
.await;
let _ = sqlx::query("DELETE FROM peer WHERE id = ?")
.bind(peer_id)
.execute(self.pool.get().await?.deref_mut())
.await;
let res = sqlx::query("DELETE FROM device_sysinfo WHERE id = ?")
.bind(peer_id)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(res.rows_affected() > 0)
}
/// Devices listed for the dashboard. Returns each row of device_sysinfo
/// joined to its owner's username, sorted by recency.
pub async fn devices_list_all(
@@ -1036,6 +1083,132 @@ impl Database {
}
}
/// Create a new shared (kind=1) address book owned by `owner_user_id`.
/// The unique index on (owner_user_id, kind, name) means a duplicate
/// name from the same owner surfaces as a SQL error — caller should
/// translate to a friendly notice.
pub async fn ab_create_shared(
&self,
owner_user_id: i64,
name: &str,
) -> ResultType<String> {
let guid = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO address_books(guid, owner_user_id, name, kind) VALUES(?, ?, ?, 1)",
)
.bind(&guid)
.bind(owner_user_id)
.bind(name)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(guid)
}
/// Drop an address book and every dependent row. Used by the dashboard
/// to remove shared books; personal books are protected at the handler
/// layer (the desktop client owns those).
pub async fn ab_delete(&self, guid: &str) -> ResultType<bool> {
let _ = sqlx::query("DELETE FROM address_book_peer_tags WHERE ab_guid = ?")
.bind(guid)
.execute(self.pool.get().await?.deref_mut())
.await;
let _ = sqlx::query("DELETE FROM address_book_tags WHERE ab_guid = ?")
.bind(guid)
.execute(self.pool.get().await?.deref_mut())
.await;
let _ = sqlx::query("DELETE FROM address_book_peers WHERE ab_guid = ?")
.bind(guid)
.execute(self.pool.get().await?.deref_mut())
.await;
let _ = sqlx::query("DELETE FROM address_book_shares WHERE ab_guid = ?")
.bind(guid)
.execute(self.pool.get().await?.deref_mut())
.await;
let res = sqlx::query("DELETE FROM address_books WHERE guid = ?")
.bind(guid)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(res.rows_affected() > 0)
}
/// Returns (owner_user_id, kind) for a book, or None if unknown.
/// Handlers use this to check ownership and to refuse mutations on
/// personal (kind=0) books.
pub async fn ab_get_owner_kind(&self, guid: &str) -> ResultType<Option<(i64, i64)>> {
let row =
sqlx::query("SELECT owner_user_id, kind FROM address_books WHERE guid = ? LIMIT 1")
.bind(guid)
.fetch_optional(self.pool.get().await?.deref_mut())
.await?;
Ok(row.map(|r| {
let owner: i64 = r.try_get("owner_user_id").unwrap_or(0);
let kind: i64 = r.try_get("kind").unwrap_or(0);
(owner, kind)
}))
}
/// Per-user shares attached to a book (group shares aren't surfaced in
/// the dashboard yet — operators can use device-groups when they want
/// many users at once and assign by group via SQL; the common case is
/// per-user). Returns rows of (user_id, username, rule) for direct
/// `address_book_shares.user_id IS NOT NULL` rows.
pub async fn ab_list_shares(&self, guid: &str) -> ResultType<Vec<AbShareDetailRow>> {
let rows = sqlx::query(
"SELECT s.user_id, COALESCE(u.username,'') AS username, s.rule \
FROM address_book_shares s LEFT JOIN users u ON u.id = s.user_id \
WHERE s.ab_guid = ? AND s.user_id IS NOT NULL \
ORDER BY u.username",
)
.bind(guid)
.fetch_all(self.pool.get().await?.deref_mut())
.await?;
Ok(rows
.into_iter()
.map(|r| AbShareDetailRow {
user_id: r.try_get("user_id").unwrap_or(0),
username: r.try_get("username").unwrap_or_default(),
rule: r.try_get("rule").unwrap_or(1),
})
.collect())
}
/// Idempotent upsert of a per-user share. Replaces an existing rule
/// row for the same (ab, user) pair so admins can promote/demote
/// without having to remove first.
pub async fn ab_share_set(
&self,
guid: &str,
user_id: i64,
rule: i64,
) -> ResultType<()> {
let _ = sqlx::query(
"DELETE FROM address_book_shares WHERE ab_guid = ? AND user_id = ?",
)
.bind(guid)
.bind(user_id)
.execute(self.pool.get().await?.deref_mut())
.await;
sqlx::query(
"INSERT INTO address_book_shares(ab_guid, user_id, rule) VALUES(?, ?, ?)",
)
.bind(guid)
.bind(user_id)
.bind(rule)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(())
}
pub async fn ab_share_remove(&self, guid: &str, user_id: i64) -> ResultType<bool> {
let res =
sqlx::query("DELETE FROM address_book_shares WHERE ab_guid = ? AND user_id = ?")
.bind(guid)
.bind(user_id)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(res.rows_affected() > 0)
}
/// Look up the personal AB for a user, creating it if missing.
pub async fn ab_get_or_create_personal(&self, user_id: i64) -> ResultType<String> {
let row = sqlx::query("SELECT guid FROM address_books WHERE owner_user_id = ? AND kind = 0")
@@ -2221,8 +2394,9 @@ impl Database {
pub async fn oidc_provider_upsert(&self, p: &OidcProviderRow) -> ResultType<()> {
sqlx::query(
"INSERT INTO oidc_providers(name, display_name, icon_url, issuer_url, \
client_id, client_secret, scopes, redirect_url, enabled) \
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) \
client_id, client_secret, scopes, redirect_url, enabled, \
admin_role, roles_claim) \
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(name) DO UPDATE SET \
display_name = excluded.display_name, \
icon_url = excluded.icon_url, \
@@ -2231,7 +2405,9 @@ impl Database {
client_secret = excluded.client_secret, \
scopes = excluded.scopes, \
redirect_url = excluded.redirect_url, \
enabled = excluded.enabled",
enabled = excluded.enabled, \
admin_role = excluded.admin_role, \
roles_claim = excluded.roles_claim",
)
.bind(&p.name)
.bind(p.display_name.as_deref())
@@ -2242,6 +2418,8 @@ impl Database {
.bind(&p.scopes)
.bind(&p.redirect_url)
.bind(if p.enabled { 1 } else { 0 })
.bind(p.admin_role.as_deref())
.bind(p.roles_claim.as_deref())
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(())
@@ -2250,7 +2428,7 @@ impl Database {
pub async fn oidc_provider_get(&self, name: &str) -> ResultType<Option<OidcProviderRow>> {
let row = sqlx::query(
"SELECT name, display_name, icon_url, issuer_url, client_id, client_secret, \
scopes, redirect_url, enabled \
scopes, redirect_url, enabled, admin_role, roles_claim \
FROM oidc_providers WHERE name = ? AND enabled = 1",
)
.bind(name)
@@ -2262,7 +2440,7 @@ impl Database {
pub async fn oidc_provider_list_enabled(&self) -> ResultType<Vec<OidcProviderRow>> {
let rows = sqlx::query(
"SELECT name, display_name, icon_url, issuer_url, client_id, client_secret, \
scopes, redirect_url, enabled \
scopes, redirect_url, enabled, admin_role, roles_claim \
FROM oidc_providers WHERE enabled = 1 ORDER BY name",
)
.fetch_all(self.pool.get().await?.deref_mut())
@@ -2377,11 +2555,18 @@ impl Database {
/// Create or update a user from an OIDC identity. The local username is
/// either the email (preferred) or the sub if no email. Subsequent
/// logins re-use the same row via oidc_subject.
/// Find-or-create a local user from an OIDC sign-in. When
/// `desired_admin` is `Some(b)`, the user's `is_admin` flag is forced
/// to `b` on every login (used when role-based admin sync is configured
/// on the provider — both promotion and demotion at the IdP propagate).
/// `None` leaves `is_admin` untouched on existing rows and defaults to
/// `false` for new ones.
pub async fn user_upsert_oidc(
&self,
oidc_subject: &str,
email: Option<&str>,
display_name: Option<&str>,
desired_admin: Option<bool>,
) -> ResultType<UserRow> {
let username = email
.filter(|s| !s.is_empty())
@@ -2400,6 +2585,13 @@ impl Database {
.bind(existing.id)
.execute(self.pool.get().await?.deref_mut())
.await?;
if let Some(want) = desired_admin {
sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?")
.bind(if want { 1i64 } else { 0 })
.bind(existing.id)
.execute(self.pool.get().await?.deref_mut())
.await?;
}
return Ok(self
.user_find_by_id(existing.id)
.await?
@@ -2407,13 +2599,15 @@ impl Database {
}
// New user. Empty password_hash blocks password login until the
// operator (or the user) sets one.
let initial_admin: i64 = if matches!(desired_admin, Some(true)) { 1 } else { 0 };
sqlx::query(
"INSERT INTO users(username, password_hash, display_name, email, status, is_admin, oidc_subject) \
VALUES(?, '', ?, ?, 1, 0, ?)",
VALUES(?, '', ?, ?, 1, ?, ?)",
)
.bind(&username)
.bind(display_name.unwrap_or(""))
.bind(email.unwrap_or(""))
.bind(initial_admin)
.bind(oidc_subject)
.execute(self.pool.get().await?.deref_mut())
.await?;
@@ -2561,6 +2755,14 @@ fn row_to_oidc_provider(row: sqlx::sqlite::SqliteRow) -> OidcProviderRow {
.unwrap_or_else(|_| "openid email profile".to_string()),
redirect_url: row.try_get("redirect_url").unwrap_or_default(),
enabled: enabled != 0,
admin_role: row
.try_get::<Option<String>, _>("admin_role")
.ok()
.flatten(),
roles_claim: row
.try_get::<Option<String>, _>("roles_claim")
.ok()
.flatten(),
}
}
@@ -2703,6 +2905,12 @@ const M2_SOFT_ALTERS: &[&str] = &[
// OIDC `sub` claim, used to map an IdP identity to a local user across
// sessions. Nullable so password-only users keep working.
"ALTER TABLE users ADD COLUMN oidc_subject TEXT",
// Optional role-based admin sync. When `admin_role` is non-NULL the
// OIDC callback evaluates the userinfo claim at `roles_claim`
// (defaulting to "roles") and sets is_admin accordingly on every
// login — promotion AND demotion at the IdP propagate.
"ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT",
"ALTER TABLE oidc_providers ADD COLUMN roles_claim TEXT",
];
const M3_SCHEMA: &[&str] = &[