Files
PiCloud/crates/manager-core/src/admin_user_repo.rs
MechaCat02 0c9f11558a feat(manager-core,picloud): accept email on admin create + patch
The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:52 +02:00

467 lines
16 KiB
Rust

//! CRUD over the `admin_users` table.
//!
//! Password hashes go in and come out as opaque strings — this module
//! never inspects or computes them; that's `auth.rs`'s job. The "must
//! keep at least one active admin" guard is implemented as a separate
//! count query the API layer composes around `set_active` / `delete`.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, InstanceRole};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum AdminUserRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(AdminUserId),
#[error("username already taken: {0}")]
DuplicateUsername(String),
#[error("email already taken: {0}")]
DuplicateEmail(String),
#[error("invalid instance_role stored in DB: {0}")]
InvalidInstanceRole(String),
}
/// Row returned to handlers and bootstrap. Never includes the password
/// hash by accident — that lives in `AdminUserCredentials` (separate
/// fetch from `get_credentials_by_username`).
#[derive(Debug, Clone)]
pub struct AdminUserRow {
pub id: AdminUserId,
pub username: String,
pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
/// Credentials fetched for the login path only. Splitting the hash off
/// from the public row makes it obvious in handler code which calls
/// touch a secret.
#[derive(Debug, Clone)]
pub struct AdminUserCredentials {
pub id: AdminUserId,
pub username: String,
pub password_hash: String,
pub is_active: bool,
pub instance_role: InstanceRole,
}
#[async_trait]
pub trait AdminUserRepository: Send + Sync {
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
async fn get_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError>;
async fn get_credentials_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
/// Create a new admin. `instance_role` defaults to `Owner` for the
/// env-var bootstrap path; admin-creates-admin flows pass an
/// explicit role. `email` is optional — pass `None` to leave the
/// column NULL.
async fn create(
&self,
username: &str,
password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn update_username(
&self,
id: AdminUserId,
username: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn update_password_hash(
&self,
id: AdminUserId,
password_hash: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
/// Set or clear the email address. `None` writes NULL to the column.
async fn update_email(
&self,
id: AdminUserId,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
/// callers enforce the last-owner guard (`count_other_active_owners`)
/// before invoking when role transitions away from `Owner`.
async fn update_instance_role(
&self,
id: AdminUserId,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn set_active(
&self,
id: AdminUserId,
is_active: bool,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>;
/// Count of `is_active = true` rows. Used at bootstrap to decide
/// whether to seed the first admin.
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError>;
/// Count of `is_active = true` rows excluding the given id. Used by
/// last-admin protection: "would deactivating / deleting this user
/// leave zero active admins?"
async fn count_active_excluding(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError>;
/// All active owners — used for the multi-owner startup warning.
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
/// Count of active owners excluding the given id. Used by the
/// last-owner guard when demoting / deactivating / deleting an
/// owner: "would this leave zero owners?"
async fn count_other_active_owners(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError>;
}
pub struct PostgresAdminUserRepository {
pool: PgPool,
}
impl PostgresAdminUserRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AdminUserRepository for PostgresAdminUserRepository {
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn get_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users WHERE username = $1",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn get_credentials_by_username(
&self,
username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminCredsRecord>(
"SELECT id, username, password_hash, is_active, instance_role \
FROM admin_users WHERE username = $1",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
let rows = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users ORDER BY username",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn create(
&self,
username: &str,
password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
VALUES ($1, $2, $3, $4) \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(username)
.bind(password_hash)
.bind(instance_role.as_str())
.bind(email)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => row.try_into(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
// username and email both have unique constraints; the
// create path can collide on either, so peek at the
// constraint name to surface the right error.
if e.constraint() == Some("admin_users_email_key") {
Err(AdminUserRepositoryError::DuplicateEmail(
email.unwrap_or("").to_string(),
))
} else {
Err(AdminUserRepositoryError::DuplicateUsername(
username.to_string(),
))
}
}
Err(e) => Err(e.into()),
}
}
async fn update_username(
&self,
id: AdminUserId,
username: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET username = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(username)
.fetch_optional(&self.pool)
.await;
match res {
Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
),
Err(e) => Err(e.into()),
}
}
async fn update_password_hash(
&self,
id: AdminUserId,
password_hash: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(password_hash)
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn update_email(
&self,
id: AdminUserId,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET email = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(email)
.fetch_optional(&self.pool)
.await;
match res {
Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
),
Err(e) => Err(e.into()),
}
}
async fn update_instance_role(
&self,
id: AdminUserId,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(instance_role.as_str())
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn set_active(
&self,
id: AdminUserId,
is_active: bool,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(is_active)
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
let res = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(AdminUserRepositoryError::NotFound(id));
}
Ok(())
}
async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
sqlx::query("UPDATE admin_users SET last_login_at = NOW() WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active")
.fetch_one(&self.pool)
.await?;
Ok(count)
}
async fn count_active_excluding(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active AND id <> $1")
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count)
}
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
let rows = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users \
WHERE is_active AND instance_role = 'owner' \
ORDER BY username",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn count_other_active_owners(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*)::BIGINT FROM admin_users \
WHERE is_active AND instance_role = 'owner' AND id <> $1",
)
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count)
}
}
#[derive(sqlx::FromRow)]
struct AdminUserRecord {
id: uuid::Uuid,
username: String,
is_active: bool,
instance_role: String,
email: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
}
impl TryFrom<AdminUserRecord> for AdminUserRow {
type Error = AdminUserRepositoryError;
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
username: r.username,
is_active: r.is_active,
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
)?,
email: r.email,
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
})
}
}
#[derive(sqlx::FromRow)]
struct AdminCredsRecord {
id: uuid::Uuid,
username: String,
password_hash: String,
is_active: bool,
instance_role: String,
}
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
type Error = AdminUserRepositoryError;
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
username: r.username,
password_hash: r.password_hash,
is_active: r.is_active,
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
)?,
})
}
}