feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
322
crates/manager-core/src/admin_user_repo.rs
Normal file
322
crates/manager-core/src/admin_user_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! 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;
|
||||
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),
|
||||
}
|
||||
|
||||
/// 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 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,
|
||||
}
|
||||
|
||||
#[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>;
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &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>;
|
||||
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>;
|
||||
}
|
||||
|
||||
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, created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
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 \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
FROM admin_users ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"INSERT INTO admin_users (username, password_hash) \
|
||||
VALUES ($1, $2) \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => 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, created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.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, created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(password_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
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, created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(is_active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AdminUserRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
is_active: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<AdminUserRecord> for AdminUserRow {
|
||||
fn from(r: AdminUserRecord) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
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,
|
||||
}
|
||||
|
||||
impl From<AdminCredsRecord> for AdminUserCredentials {
|
||||
fn from(r: AdminCredsRecord) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
password_hash: r.password_hash,
|
||||
is_active: r.is_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user