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>
467 lines
16 KiB
Rust
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),
|
|
)?,
|
|
})
|
|
}
|
|
}
|