//! 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, pub created_at: DateTime, pub updated_at: DateTime, pub last_login_at: Option>, } /// 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, AdminUserRepositoryError>; async fn get_by_username( &self, username: &str, ) -> Result, AdminUserRepositoryError>; async fn get_credentials_by_username( &self, username: &str, ) -> Result, AdminUserRepositoryError>; async fn list(&self) -> Result, 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; async fn update_username( &self, id: AdminUserId, username: &str, ) -> Result; async fn update_password_hash( &self, id: AdminUserId, password_hash: &str, ) -> Result; /// Set or clear the email address. `None` writes NULL to the column. async fn update_email( &self, id: AdminUserId, email: Option<&str>, ) -> Result; /// 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; async fn set_active( &self, id: AdminUserId, is_active: bool, ) -> Result; 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; /// 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; /// All active owners — used for the multi-owner startup warning. async fn list_active_owners(&self) -> Result, 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; } 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, 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, 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, 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, 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, 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 { 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, created_at: DateTime, updated_at: DateTime, last_login_at: Option>, } impl TryFrom for AdminUserRow { type Error = AdminUserRepositoryError; fn try_from(r: AdminUserRecord) -> Result { 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 for AdminUserCredentials { type Error = AdminUserRepositoryError; fn try_from(r: AdminCredsRecord) -> Result { 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), )?, }) } }