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>
153 lines
4.8 KiB
Rust
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())
|
|
}
|
|
}
|