Files
PiCloud/crates/manager-core/src/admin_session_repo.rs
MechaCat02 6891496589 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>
2026-05-25 19:30:25 +02:00

153 lines
4.8 KiB
Rust

//! CRUD over the `admin_sessions` table.
//!
//! The token never appears in this module — only its SHA-256 hash. The
//! raw value lives in `auth::GeneratedToken` long enough to hit the
//! cookie and the JSON response, then is forgotten. Lookups also filter
//! expired rows at query time so a delayed prune sweep can never extend
//! a session's life.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::AdminUserId;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum AdminSessionRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// Result of a session lookup. Includes the user id (for auth context)
/// and the existing `expires_at` so the middleware can decide whether
/// the sliding window bump is worth a write.
#[derive(Debug, Clone)]
pub struct AdminSessionLookup {
pub user_id: AdminUserId,
pub expires_at: DateTime<Utc>,
}
#[async_trait]
pub trait AdminSessionRepository: Send + Sync {
async fn create(
&self,
user_id: AdminUserId,
token_hash: &str,
expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError>;
/// Look up a session by token hash. Returns `None` for missing or
/// already-expired rows (the query filters them).
async fn lookup(
&self,
token_hash: &str,
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError>;
/// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at`
/// to the supplied value.
async fn touch(
&self,
token_hash: &str,
new_expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError>;
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>;
/// Delete every session belonging to a user. Used when the user is
/// deactivated or has their password reset out-of-band — both
/// invalidate all current logins for that account.
async fn delete_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, AdminSessionRepositoryError>;
/// Sweep expired rows. The auth middleware filters expired rows on
/// lookup, so this is just bounded-growth hygiene, not correctness.
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError>;
}
pub struct PostgresAdminSessionRepository {
pool: PgPool,
}
impl PostgresAdminSessionRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AdminSessionRepository for PostgresAdminSessionRepository {
async fn create(
&self,
user_id: AdminUserId,
token_hash: &str,
expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError> {
sqlx::query(
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
VALUES ($1, $2, $3)",
)
.bind(token_hash)
.bind(user_id.into_inner())
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn lookup(
&self,
token_hash: &str,
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
"SELECT user_id, expires_at FROM admin_sessions \
WHERE token_hash = $1 AND expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(uid, exp)| AdminSessionLookup {
user_id: uid.into(),
expires_at: exp,
}))
}
async fn touch(
&self,
token_hash: &str,
new_expires_at: DateTime<Utc>,
) -> Result<(), AdminSessionRepositoryError> {
sqlx::query(
"UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \
WHERE token_hash = $1",
)
.bind(token_hash)
.bind(new_expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> {
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1")
.bind(token_hash)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, AdminSessionRepositoryError> {
let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1")
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
}