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:
@@ -22,3 +22,11 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
sqlx.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
argon2.workspace = true
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
33
crates/manager-core/migrations/0004_admin_auth.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Phase 3a admin auth — see blueprint §11.4.
|
||||
--
|
||||
-- Per-user platform-operator accounts (distinct from the v1.1+ `users`
|
||||
-- table, which is for script-end users). Every authenticated admin is a
|
||||
-- full admin in this cut; role/permission tables will be added later
|
||||
-- without touching this schema.
|
||||
--
|
||||
-- `admin_sessions.token_hash` stores SHA-256 of the raw token; the raw
|
||||
-- value only ever exists in the login response, the HttpOnly cookie, and
|
||||
-- bearer-token requests. Cascade on user delete kills the user's sessions
|
||||
-- automatically — which is also why deactivating a user can simply wipe
|
||||
-- their rows instead of marking each session expired.
|
||||
|
||||
CREATE TABLE admin_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE admin_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||
152
crates/manager-core/src/admin_session_repo.rs
Normal file
152
crates/manager-core/src/admin_session_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! 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())
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
320
crates/manager-core/src/admin_users_api.rs
Normal file
320
crates/manager-core/src/admin_users_api.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by
|
||||
//! `require_admin`; every authenticated admin can call all of these.
|
||||
//! Role/permission walls land later (see blueprint §11.4 — no
|
||||
//! privilege levels in this cut).
|
||||
//!
|
||||
//! "Last active admin" protection lives at the service layer (not just
|
||||
//! the DB) so it can produce a clean 422 with a human-readable message
|
||||
//! rather than a SQL constraint violation. Deactivating a user also
|
||||
//! wipes their sessions; deleting cascades through the FK.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||
use crate::auth::hash_password;
|
||||
|
||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||
/// password has a minimum length but no complexity rules (complexity
|
||||
/// rules push users to predictable patterns).
|
||||
const USERNAME_MIN: usize = 2;
|
||||
const USERNAME_MAX: usize = 32;
|
||||
const PASSWORD_MIN: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AdminsState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
}
|
||||
|
||||
pub fn admins_router(state: AdminsState) -> Router {
|
||||
Router::new()
|
||||
.route("/admins", get(list_admins).post(create_admin))
|
||||
.route(
|
||||
"/admins/{id}",
|
||||
get(get_admin).patch(patch_admin).delete(delete_admin),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<AdminUserRow> for AdminDto {
|
||||
fn from(r: AdminUserRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
created_at: r.created_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAdminRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct PatchAdminRequest {
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_admins(
|
||||
State(state): State<AdminsState>,
|
||||
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||
let rows = state.users.list().await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn get_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
state
|
||||
.users
|
||||
.get(id)
|
||||
.await?
|
||||
.map(AdminDto::from)
|
||||
.map(Json)
|
||||
.ok_or(AdminApiError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn create_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Json(input): Json<CreateAdminRequest>,
|
||||
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||
let username = input.username.trim();
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||
let row = state.users.create(username, &hash).await?;
|
||||
Ok((StatusCode::CREATED, Json(row.into())))
|
||||
}
|
||||
|
||||
async fn patch_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
Json(input): Json<PatchAdminRequest>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
// Verify the target exists upfront — keeps the error path uniform
|
||||
// for "rename a missing user" etc.
|
||||
let _ = state
|
||||
.users
|
||||
.get(id)
|
||||
.await?
|
||||
.ok_or(AdminApiError::NotFound(id))?;
|
||||
|
||||
let mut latest: Option<AdminUserRow> = None;
|
||||
|
||||
if let Some(raw_username) = input.username.as_deref() {
|
||||
let new_username = raw_username.trim();
|
||||
validate_username(new_username)?;
|
||||
latest = Some(state.users.update_username(id, new_username).await?);
|
||||
}
|
||||
|
||||
if let Some(new_password) = input.password.as_deref() {
|
||||
validate_password(new_password)?;
|
||||
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||
latest = Some(state.users.update_password_hash(id, &hash).await?);
|
||||
// Best practice: rotating your own password should still keep
|
||||
// your session alive, so we don't wipe sessions here. (If we
|
||||
// wanted "log everyone else out on password change", that'd be
|
||||
// a `delete_for_user` + re-issue current session. Out of scope
|
||||
// for the initial cut.)
|
||||
}
|
||||
|
||||
if let Some(new_active) = input.is_active {
|
||||
// Last-active-admin guard: only when transitioning to inactive.
|
||||
if !new_active {
|
||||
let remaining = state.users.count_active_excluding(id).await?;
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveAdmin);
|
||||
}
|
||||
}
|
||||
latest = Some(state.users.set_active(id, new_active).await?);
|
||||
// Deactivation invalidates all of the user's sessions. Cheap
|
||||
// and safer than waiting for sliding-window expiry.
|
||||
if !new_active {
|
||||
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = match latest {
|
||||
Some(r) => r,
|
||||
None => state
|
||||
.users
|
||||
.get(id)
|
||||
.await?
|
||||
.ok_or(AdminApiError::NotFound(id))?,
|
||||
};
|
||||
Ok(Json(row.into()))
|
||||
}
|
||||
|
||||
async fn delete_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<StatusCode, AdminApiError> {
|
||||
let target = state
|
||||
.users
|
||||
.get(id)
|
||||
.await?
|
||||
.ok_or(AdminApiError::NotFound(id))?;
|
||||
if target.is_active {
|
||||
let remaining = state.users.count_active_excluding(id).await?;
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveAdmin);
|
||||
}
|
||||
}
|
||||
state.users.delete(id).await?;
|
||||
// Sessions cascade via FK; no explicit delete needed.
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn validate_username(s: &str) -> Result<(), AdminApiError> {
|
||||
if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX {
|
||||
return Err(AdminApiError::InvalidUsername(format!(
|
||||
"username must be {USERNAME_MIN}-{USERNAME_MAX} characters"
|
||||
)));
|
||||
}
|
||||
if !s
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(AdminApiError::InvalidUsername(
|
||||
"username may contain only lowercase letters, digits, dot, underscore, and hyphen"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
||||
if s.chars().count() < PASSWORD_MIN {
|
||||
return Err(AdminApiError::InvalidPassword(format!(
|
||||
"password must be at least {PASSWORD_MIN} characters"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AdminApiError {
|
||||
#[error("admin user not found: {0}")]
|
||||
NotFound(AdminUserId),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidUsername(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPassword(String),
|
||||
|
||||
#[error("cannot leave the system with zero active admins")]
|
||||
LastActiveAdmin,
|
||||
|
||||
#[error("failed to hash password: {0}")]
|
||||
Hash(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] AdminUserRepositoryError),
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
|
||||
(StatusCode::CONFLICT, self.to_string())
|
||||
}
|
||||
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
Self::Repo(AdminUserRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "admin_users db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Hash(_) => {
|
||||
tracing::error!(error = %self, "password hashing failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_validation_accepts_valid() {
|
||||
for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] {
|
||||
assert!(validate_username(u).is_ok(), "should accept {u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_validation_rejects_invalid() {
|
||||
for u in ["", "a", "Alice", "user name", "user@domain", "user!"] {
|
||||
assert!(validate_username(u).is_err(), "should reject {u:?}");
|
||||
}
|
||||
let too_long = "x".repeat(33);
|
||||
assert!(validate_username(&too_long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_validation_enforces_min_length() {
|
||||
assert!(validate_password("1234567").is_err());
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok());
|
||||
}
|
||||
}
|
||||
132
crates/manager-core/src/auth.rs
Normal file
132
crates/manager-core/src/auth.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! Pure auth helpers: password hashing, session-token generation, and
|
||||
//! token-to-hash conversion. No DB, no HTTP — repos and middleware live
|
||||
//! in their own modules. Keeping this surface pure also keeps the unit
|
||||
//! tests fast (no Postgres needed).
|
||||
//!
|
||||
//! Hash algorithm is Argon2id with the OWASP default parameters
|
||||
//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes
|
||||
//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the
|
||||
//! sessions table.
|
||||
|
||||
use argon2::password_hash::rand_core::OsRng as ArgonRng;
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Returned when the supplied password hash string isn't a valid PHC
|
||||
/// Argon2id encoding. Only surfaces at bootstrap time when the operator
|
||||
/// passes `PICLOUD_ADMIN_PASSWORD_HASH`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("invalid Argon2id PHC hash")]
|
||||
pub struct InvalidPasswordHash;
|
||||
|
||||
/// Hash a raw password into an Argon2id PHC-formatted string suitable
|
||||
/// for `admin_users.password_hash`. The output already encodes the salt
|
||||
/// and parameters; nothing else needs to be persisted alongside it.
|
||||
pub fn hash_password(raw: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut ArgonRng);
|
||||
let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Constant-ish-time verify of a raw password against a PHC hash.
|
||||
/// Returns `false` for any error (including malformed stored hash) —
|
||||
/// callers should treat that case identically to "wrong password" so
|
||||
/// nothing leaks about why auth failed.
|
||||
#[must_use]
|
||||
pub fn verify_password(stored_hash: &str, raw: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(raw.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Validate that a string parses as a PHC Argon2id hash — used at
|
||||
/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH`
|
||||
/// rather than write garbage into the DB and discover it at first login.
|
||||
pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> {
|
||||
PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Newly minted session token: `raw` goes to the client (cookie + JSON
|
||||
/// response), `hash` is what gets stored. Raw is unrecoverable from hash
|
||||
/// even if the DB leaks.
|
||||
pub struct GeneratedToken {
|
||||
pub raw: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh session token (32 random bytes base64-url-encoded).
|
||||
/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure
|
||||
/// instead of returning, but that's a non-recoverable system condition.
|
||||
#[must_use]
|
||||
pub fn generate_session_token() -> GeneratedToken {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let raw = URL_SAFE_NO_PAD.encode(bytes);
|
||||
let hash = hash_token(&raw);
|
||||
GeneratedToken { raw, hash }
|
||||
}
|
||||
|
||||
/// SHA-256(raw) as lower-case hex. Stable lookup key for
|
||||
/// `admin_sessions.token_hash`.
|
||||
#[must_use]
|
||||
pub fn hash_token(raw: &str) -> String {
|
||||
let digest = Sha256::digest(raw.as_bytes());
|
||||
hex(&digest)
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_verify_roundtrip() {
|
||||
let h = hash_password("correct horse battery staple").unwrap();
|
||||
assert!(verify_password(&h, "correct horse battery staple"));
|
||||
assert!(!verify_password(&h, "wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_returns_false_on_malformed_hash() {
|
||||
assert!(!verify_password("not-a-phc-string", "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_password_hash_accepts_phc() {
|
||||
let h = hash_password("pw").unwrap();
|
||||
assert!(validate_password_hash(&h).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_password_hash_rejects_garbage() {
|
||||
assert!(validate_password_hash("not a hash").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_token_unique_and_hash_stable() {
|
||||
let a = generate_session_token();
|
||||
let b = generate_session_token();
|
||||
assert_ne!(a.raw, b.raw, "tokens must be unique");
|
||||
assert_ne!(a.hash, b.hash, "hashes must differ");
|
||||
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||
}
|
||||
}
|
||||
233
crates/manager-core/src/auth_api.rs
Normal file
233
crates/manager-core/src/auth_api.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! `/api/v1/admin/auth/*` — login, logout, who-am-I.
|
||||
//!
|
||||
//! Login mints an opaque session token, stores its SHA-256, sets the
|
||||
//! `picloud_session` HttpOnly cookie, and also returns the raw token in
|
||||
//! the JSON body for non-browser clients. The same token works as
|
||||
//! `Authorization: Bearer …` afterward; there is no separate "API
|
||||
//! token" concept yet.
|
||||
//!
|
||||
//! Logout deletes the session row regardless of whether the supplied
|
||||
//! token matched anything (idempotent). `me` returns the row that the
|
||||
//! middleware already attached to the request extensions.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Request, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
|
||||
pub fn auth_router(state: AuthState) -> Router {
|
||||
// /login + /logout are unguarded (login is how you get in; logout
|
||||
// is idempotent). /me is guarded — by definition it needs to know
|
||||
// who you are, so the middleware must run first.
|
||||
let guarded = Router::new()
|
||||
.route("/auth/me", get(me))
|
||||
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
||||
|
||||
Router::new()
|
||||
.route("/auth/login", post(login))
|
||||
.route("/auth/logout", post(logout))
|
||||
.merge(guarded)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub user: AdminUserDto,
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminUserDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> Response {
|
||||
// Always perform a verify, even on missing/inactive users, to flatten
|
||||
// timing and prevent username enumeration. The dummy hash is a real
|
||||
// Argon2id PHC string for "x" — the verify will simply fail.
|
||||
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
|
||||
|
||||
let creds = match state
|
||||
.users
|
||||
.get_credentials_by_username(&input.username)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users credentials lookup failed");
|
||||
return internal_error();
|
||||
}
|
||||
};
|
||||
|
||||
let (stored_hash, user_id, username, is_active) = match creds {
|
||||
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
||||
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
||||
};
|
||||
|
||||
let password_ok = verify_password(&stored_hash, &input.password);
|
||||
if !password_ok || user_id.is_none() || !is_active {
|
||||
return invalid_credentials();
|
||||
}
|
||||
let user_id = user_id.unwrap();
|
||||
|
||||
let token = generate_session_token();
|
||||
let expires_at = Utc::now()
|
||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||
|
||||
if let Err(err) = state
|
||||
.sessions
|
||||
.create(user_id, &token.hash, expires_at)
|
||||
.await
|
||||
{
|
||||
tracing::error!(?err, "admin_sessions insert failed");
|
||||
return internal_error();
|
||||
}
|
||||
if let Err(err) = state.users.touch_last_login(user_id).await {
|
||||
// Non-fatal — log and continue. Login itself succeeded.
|
||||
tracing::warn!(?err, "failed to touch admin last_login_at");
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| {
|
||||
// Cookie text is ASCII-clean by construction; this branch is
|
||||
// unreachable in practice but the type signature requires it.
|
||||
HeaderValue::from_static("")
|
||||
}),
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
user: AdminUserDto {
|
||||
id: user_id,
|
||||
username,
|
||||
},
|
||||
token: token.raw,
|
||||
expires_at,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||
// Pull token without requiring a valid session (logout is idempotent
|
||||
// and we still want to clear the cookie on the client side).
|
||||
let token = extract_token_for_logout(&req);
|
||||
if let Some(raw) = token {
|
||||
let hash = hash_token(&raw);
|
||||
if let Err(err) = state.sessions.delete(&hash).await {
|
||||
tracing::error!(?err, "admin_sessions delete failed");
|
||||
// Still clear the cookie below.
|
||||
}
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"),
|
||||
);
|
||||
(StatusCode::NO_CONTENT, headers).into_response()
|
||||
}
|
||||
|
||||
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||
Json(AdminUserDto {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn build_cookie(raw_token: &str, ttl: std::time::Duration) -> String {
|
||||
// Secure is on by default; flip to off for HTTP-only dev with
|
||||
// PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works
|
||||
// either way, so this is purely for browsers that prefer the cookie
|
||||
// path (e.g., direct API hits without the dashboard's auth.ts).
|
||||
let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| {
|
||||
!matches!(
|
||||
v.to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "no" | "off"
|
||||
)
|
||||
});
|
||||
let secure_attr = if secure { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}",
|
||||
ttl.as_secs()
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
|
||||
// Same precedence as the middleware — Authorization first, cookie
|
||||
// fallback. Duplicated here because logout has to read the request
|
||||
// before any middleware would run.
|
||||
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||
let trimmed = token.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
for chunk in s.split(';') {
|
||||
let chunk = chunk.trim();
|
||||
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||
if !rest.is_empty() {
|
||||
return Some(rest.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn invalid_credentials() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid credentials" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn internal_error() -> Response {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "internal error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
//! First-run admin seeding from env vars. Idempotent: if any admin
|
||||
//! already exists, this is a no-op (and a warning is logged when the
|
||||
//! env vars are also set, so the operator notices the inert state).
|
||||
//!
|
||||
//! On a fresh install, exactly one row is inserted from:
|
||||
//! - `PICLOUD_ADMIN_USERNAME` (required)
|
||||
//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR
|
||||
//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in)
|
||||
//!
|
||||
//! After that initial seed, the env vars become inert. This is
|
||||
//! deliberate: the env var is a one-time setup hatch, not a permanent
|
||||
//! override (which would let anyone with systemd/compose access change
|
||||
//! any admin's password without authentication). Recovery is the CLI
|
||||
//! subcommand `picloud admin reset-password <username>`.
|
||||
//!
|
||||
//! The env-var reading is factored into `BootstrapEnv::from_process`
|
||||
//! so the core logic stays pure (and testable) — the only side effect
|
||||
//! in `bootstrap_first_admin` is the DB write and a tracing log.
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::admin_user_repo::AdminUserRepository;
|
||||
use crate::auth::{hash_password, validate_password_hash};
|
||||
|
||||
pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME";
|
||||
pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD";
|
||||
pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||
|
||||
#[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")]
|
||||
MissingUsername,
|
||||
|
||||
#[error(
|
||||
"no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}"
|
||||
)]
|
||||
MissingPassword,
|
||||
|
||||
#[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")]
|
||||
InvalidHash,
|
||||
|
||||
#[error("failed to hash password: {0}")]
|
||||
HashFailure(String),
|
||||
}
|
||||
|
||||
/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap
|
||||
/// env vars. Read from the live process with `from_process`, or build
|
||||
/// directly in tests to keep them free of process-env races.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BootstrapEnv {
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl BootstrapEnv {
|
||||
/// Snapshot the bootstrap env vars from the current process.
|
||||
#[must_use]
|
||||
pub fn from_process() -> Self {
|
||||
Self {
|
||||
username: std::env::var(ENV_USERNAME).ok(),
|
||||
password: std::env::var(ENV_PASSWORD).ok(),
|
||||
password_hash: std::env::var(ENV_PASSWORD_HASH).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn any_set(&self) -> bool {
|
||||
self.username.is_some() || self.password.is_some() || self.password_hash.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the bootstrap. Reads env vars from the live process — the
|
||||
/// canonical wiring for the binary.
|
||||
pub async fn bootstrap_first_admin<R: AdminUserRepository + ?Sized>(
|
||||
repo: &R,
|
||||
) -> Result<(), BootstrapError> {
|
||||
bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await
|
||||
}
|
||||
|
||||
/// Run the bootstrap against an explicit env. Used by tests to keep
|
||||
/// the bootstrap logic independent of process state.
|
||||
pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||
repo: &R,
|
||||
env: BootstrapEnv,
|
||||
) -> Result<(), BootstrapError> {
|
||||
if repo.count_active().await? > 0 {
|
||||
if env.any_set() {
|
||||
warn!(
|
||||
"{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \
|
||||
already populated — env values ignored. Use \
|
||||
`picloud admin reset-password <user>` to change a password."
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let username = env.username.ok_or(BootstrapError::MissingUsername)?;
|
||||
|
||||
let password_hash = match (env.password_hash, env.password) {
|
||||
(Some(hash), maybe_raw) => {
|
||||
if maybe_raw.is_some() {
|
||||
warn!(
|
||||
"both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \
|
||||
using the pre-computed hash; raw password ignored."
|
||||
);
|
||||
}
|
||||
validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?;
|
||||
hash
|
||||
}
|
||||
(None, Some(raw)) => {
|
||||
hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))?
|
||||
}
|
||||
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||
};
|
||||
|
||||
repo.create(&username, &password_hash).await?;
|
||||
info!(username = %username, "bootstrapped initial admin user");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! These tests use an in-memory `AdminUserRepository` and the
|
||||
//! `bootstrap_first_admin_with` overload so they never touch
|
||||
//! process-global env vars. They can run in parallel safely.
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryRepo {
|
||||
rows: Mutex<Vec<AdminUserRow>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AdminUserRepository for InMemoryRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
_id: AdminUserId,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_username(
|
||||
&self,
|
||||
_u: &str,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_credentials_by_username(
|
||||
&self,
|
||||
_u: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
_password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = AdminUserRow {
|
||||
id: AdminUserId::new(),
|
||||
username: username.to_string(),
|
||||
is_active: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
};
|
||||
self.rows.lock().unwrap().push(row.clone());
|
||||
Ok(row)
|
||||
}
|
||||
async fn update_username(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_u: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update_password_hash(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_h: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn set_active(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_a: bool,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||
Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX))
|
||||
}
|
||||
async fn count_active_excluding(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_db_creates_admin_from_raw_password() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: Some("supersecret".into()),
|
||||
password_hash: None,
|
||||
};
|
||||
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_db_with_pre_hashed_password_succeeds() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let prehashed = hash_password("pw").unwrap();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
password_hash: Some(prehashed),
|
||||
};
|
||||
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populated_db_is_noop() {
|
||||
let repo = InMemoryRepo::default();
|
||||
repo.create("seeded", "x").await.unwrap();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: Some("supersecret".into()),
|
||||
password_hash: None,
|
||||
};
|
||||
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_username_fails() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: None,
|
||||
password: Some("supersecret".into()),
|
||||
password_hash: None,
|
||||
};
|
||||
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||
assert!(matches!(err, BootstrapError::MissingUsername));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_password_fails() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
password_hash: None,
|
||||
};
|
||||
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||
assert!(matches!(err, BootstrapError::MissingPassword));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_hash_fails() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
password_hash: Some("not a phc hash".into()),
|
||||
};
|
||||
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||
assert!(matches!(err, BootstrapError::InvalidHash));
|
||||
}
|
||||
}
|
||||
185
crates/manager-core/src/auth_middleware.rs
Normal file
185
crates/manager-core/src/auth_middleware.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
//! `require_admin` axum middleware: gates a router on a valid admin
|
||||
//! session. Accepts the token from either the `picloud_session` cookie
|
||||
//! or an `Authorization: Bearer …` header — same token system serves
|
||||
//! the dashboard and CLI/CI clients.
|
||||
//!
|
||||
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
||||
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
||||
//! returns 401 with a generic JSON body (no enumeration about whether
|
||||
//! the token was wrong vs. the user was deactivated).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::AdminUserRepository;
|
||||
use crate::auth::hash_token;
|
||||
|
||||
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||
|
||||
/// Shared state for auth: the two repos plus the configured sliding
|
||||
/// session TTL. Cheap to clone (`Arc` everywhere).
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
/// Request-extension type that authenticated handlers extract via
|
||||
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthedAdmin {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Middleware function. Wire with
|
||||
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
||||
pub async fn require_admin(
|
||||
State(state): State<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(token) = extract_token(&req) else {
|
||||
return unauthorized();
|
||||
};
|
||||
let token_hash = hash_token(&token);
|
||||
|
||||
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||
Ok(Some(lookup)) => lookup,
|
||||
Ok(None) => return unauthorized(),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_sessions lookup failed");
|
||||
return internal_error();
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the user. A deleted user is impossible here (FK cascade
|
||||
// wipes their sessions), but a deactivated user still needs to be
|
||||
// rejected — and so does the edge case of a session predating the
|
||||
// deactivate (we wipe their sessions on deactivate, but a race
|
||||
// could land a request in flight).
|
||||
let user = match state.users.get(lookup.user_id).await {
|
||||
Ok(Some(u)) if u.is_active => u,
|
||||
Ok(_) => return unauthorized(),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup failed");
|
||||
return internal_error();
|
||||
}
|
||||
};
|
||||
|
||||
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
||||
// surfaces as a request error rather than silent stale sessions.
|
||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||
tracing::error!(?err, "admin_sessions touch failed");
|
||||
return internal_error();
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(AuthedAdmin {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||
let trimmed = token.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(value) = req.headers().get(header::COOKIE) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
for chunk in s.split(';') {
|
||||
let chunk = chunk.trim();
|
||||
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
||||
if !rest.is_empty() {
|
||||
return Some(rest.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "authentication required" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn internal_error() -> Response {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "internal error" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
|
||||
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.header(name, value)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_bearer_token() {
|
||||
let r = req_with_header("authorization", "Bearer abc123");
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_bearer_with_no_token() {
|
||||
let r = req_with_header("authorization", "Bearer ");
|
||||
assert_eq!(extract_token(&r), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_cookie_token() {
|
||||
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_wins_over_cookie() {
|
||||
let r = Request::builder()
|
||||
.header("authorization", "Bearer header-token")
|
||||
.header("cookie", "picloud_session=cookie-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_neither_present() {
|
||||
let r = Request::builder().body(Body::empty()).unwrap();
|
||||
assert_eq!(extract_token(&r), None);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,14 @@
|
||||
//! the same DB for now; once we add caching and per-node ingress, the
|
||||
//! manager will publish change events.
|
||||
|
||||
pub mod admin_session_repo;
|
||||
pub mod admin_user_repo;
|
||||
pub mod admin_users_api;
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod log_sink;
|
||||
pub mod migrations;
|
||||
pub mod repo;
|
||||
@@ -13,7 +20,21 @@ pub mod route_repo;
|
||||
pub mod sandbox;
|
||||
pub mod scheduler;
|
||||
|
||||
pub use admin_session_repo::{
|
||||
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
|
||||
PostgresAdminSessionRepository,
|
||||
};
|
||||
pub use admin_user_repo::{
|
||||
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
|
||||
PostgresAdminUserRepository,
|
||||
};
|
||||
pub use admin_users_api::{admins_router, AdminsState};
|
||||
pub use api::{admin_router, AdminState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||
};
|
||||
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
pub use log_sink::PostgresExecutionLogSink;
|
||||
pub use repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
|
||||
Reference in New Issue
Block a user