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:
37
Cargo.lock
generated
37
Cargo.lock
generated
@@ -46,6 +46,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
@@ -206,6 +218,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1233,6 +1254,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pear"
|
||||
version = "0.2.9"
|
||||
@@ -1337,15 +1369,20 @@ dependencies = [
|
||||
name = "picloud-manager-core"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -66,6 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
# Auth (admin users + sessions)
|
||||
argon2 = "0.5"
|
||||
rand = { version = "0.8", features = ["getrandom"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
||||
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
|
||||
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
};
|
||||
@@ -24,6 +27,38 @@ use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
||||
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
||||
|
||||
/// Bundles the auth-related dependencies that both `build_app` and the
|
||||
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
||||
pub struct AuthDeps {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
impl AuthDeps {
|
||||
/// Construct from a pool with the binary's standard defaults.
|
||||
#[must_use]
|
||||
pub fn from_pool(pool: PgPool) -> Self {
|
||||
Self {
|
||||
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
||||
ttl: read_session_ttl(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_session_ttl() -> Duration {
|
||||
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|h| *h > 0)
|
||||
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
|
||||
Duration::from_secs(hours * 3600)
|
||||
}
|
||||
|
||||
/// Compose the manager + orchestrator routes on top of a shared
|
||||
/// Postgres pool, returning an Axum router ready to be served.
|
||||
///
|
||||
@@ -31,7 +66,15 @@ use tower_http::trace::TraceLayer;
|
||||
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
||||
/// falls through to the user-route table — user scripts can bind to
|
||||
/// arbitrary paths (subject to the reserved-prefix list).
|
||||
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
||||
///
|
||||
/// `auth` carries the admin user/session repositories and the
|
||||
/// configured session TTL. The manager-side admin endpoints
|
||||
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
|
||||
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
|
||||
/// the `require_admin` middleware. The data plane
|
||||
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
let engine = Arc::new(Engine::new(Limits::default()));
|
||||
|
||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
@@ -68,9 +111,29 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
||||
routes: route_table,
|
||||
};
|
||||
|
||||
let auth_state = AuthState {
|
||||
users: auth.users.clone(),
|
||||
sessions: auth.sessions.clone(),
|
||||
ttl: auth.ttl,
|
||||
};
|
||||
let admins_state = AdminsState {
|
||||
users: auth.users,
|
||||
sessions: auth.sessions,
|
||||
};
|
||||
|
||||
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||
// you get in). /admin/auth/me applies the middleware internally so
|
||||
// the same Router::with_state machinery composes cleanly. Everything
|
||||
// else under /admin gets the require_admin layer.
|
||||
let guarded_admin = Router::new()
|
||||
.merge(admin_router(admin))
|
||||
.merge(route_admin_router(route_admin))
|
||||
.merge(admins_router(admins_state))
|
||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
||||
|
||||
let api_v1 = Router::new()
|
||||
.nest("/admin", admin_router(admin))
|
||||
.nest("/admin", route_admin_router(route_admin))
|
||||
.nest("/admin", auth_router(auth_state))
|
||||
.nest("/admin", guarded_admin)
|
||||
.merge(data_plane_router(data_plane.clone()));
|
||||
|
||||
Ok(Router::new()
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
|
||||
//! composition; this file is only the runtime shell (env config,
|
||||
//! logger, migrations, listener).
|
||||
//! logger, migrations, listener) plus the small `admin` CLI subcommand
|
||||
//! used for out-of-band password recovery.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use picloud::{build_app, init_db};
|
||||
use picloud_manager_core::migrations;
|
||||
use picloud::{build_app, init_db, AuthDeps};
|
||||
use picloud_manager_core::{
|
||||
auth::{hash_password, validate_password_hash},
|
||||
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
|
||||
};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
|
||||
// Subcommand dispatch — `picloud admin reset-password <username>`.
|
||||
// Kept handwritten to avoid pulling clap in just for one verb. Falls
|
||||
// through to the server when no subcommand is given.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.get(1).map(String::as_str) == Some("admin") {
|
||||
return run_admin_cli(&args[2..]).await;
|
||||
}
|
||||
|
||||
run_server().await
|
||||
}
|
||||
|
||||
async fn run_server() -> anyhow::Result<()> {
|
||||
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
|
||||
.unwrap_or_else(|_| "0.0.0.0:8080".into())
|
||||
.parse()?;
|
||||
@@ -22,7 +41,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
migrations::run(&pool).await?;
|
||||
tracing::info!("migrations applied");
|
||||
|
||||
let app = build_app(pool).await?;
|
||||
let auth = AuthDeps::from_pool(pool.clone());
|
||||
bootstrap_first_admin(&*auth.users).await?;
|
||||
|
||||
// Background session-prune sweep. Cheap; keeps the table from
|
||||
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||
// so a delayed sweep can't extend session lifetimes.
|
||||
spawn_session_pruner(auth.sessions.clone());
|
||||
|
||||
let app = build_app(pool, auth).await?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(%addr, "picloud all-in-one listening");
|
||||
@@ -33,6 +60,112 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
||||
// First tick fires immediately; skip it so we don't race startup.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match sessions.prune_expired().await {
|
||||
Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(?err, "admin session prune failed"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// `admin` subcommand
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("reset-password") => {
|
||||
let username = args.get(1).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"usage: picloud admin reset-password <username> [--password-hash <hash>]"
|
||||
)
|
||||
})?;
|
||||
// Optional inline hash via --password-hash <hash>; otherwise
|
||||
// read a raw password from stdin.
|
||||
let hash_arg = parse_flag(&args[2..], "--password-hash");
|
||||
cmd_reset_password(username, hash_arg).await
|
||||
}
|
||||
Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"usage: picloud admin reset-password <username>"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_flag(args: &[String], name: &str) -> Option<String> {
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == name {
|
||||
return it.next().cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn cmd_reset_password(username: &str, password_hash: Option<String>) -> anyhow::Result<()> {
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
|
||||
let pool = init_db(&database_url).await?;
|
||||
migrations::run(&pool).await?;
|
||||
|
||||
let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone());
|
||||
let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool);
|
||||
|
||||
let target = users
|
||||
.get_by_username(username)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?;
|
||||
|
||||
let hash = if let Some(h) = password_hash {
|
||||
validate_password_hash(&h)
|
||||
.map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?;
|
||||
h
|
||||
} else {
|
||||
let raw = prompt_password_from_stdin()?;
|
||||
hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
|
||||
};
|
||||
|
||||
users.update_password_hash(target.id, &hash).await?;
|
||||
// Recovery implies the operator already lost control of the account;
|
||||
// re-activate it (so a deactivated admin can also recover) and wipe
|
||||
// any pre-existing sessions in case the original holder is still
|
||||
// signed in elsewhere.
|
||||
if !target.is_active {
|
||||
users.set_active(target.id, true).await?;
|
||||
}
|
||||
let dropped = sessions.delete_for_user(target.id).await?;
|
||||
|
||||
println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_password_from_stdin() -> anyhow::Result<String> {
|
||||
eprint!("New password (will be read from stdin, no echo): ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
std::io::stdin()
|
||||
.lock()
|
||||
.read_line(&mut line)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
|
||||
let pw = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
if pw.is_empty() {
|
||||
return Err(anyhow::anyhow!("password must not be empty"));
|
||||
}
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Misc
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
|
||||
@@ -17,9 +17,35 @@ use axum_test::TestServer;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Build the all-in-one app over the test pool, seed a single admin
|
||||
/// directly through the repo (bypassing the env-var bootstrap path so
|
||||
/// tests don't contaminate the process environment), log in, and bake
|
||||
/// the bearer token into the TestServer as a default header so every
|
||||
/// request in the test passes the `require_admin` middleware.
|
||||
async fn server(pool: PgPool) -> TestServer {
|
||||
let app = picloud::build_app(pool).await.expect("build_app");
|
||||
TestServer::new(app).expect("TestServer should build")
|
||||
use picloud_manager_core::auth::hash_password;
|
||||
|
||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||
let hash = hash_password("test-pw").expect("hash");
|
||||
auth.users
|
||||
.create("test-admin", &hash)
|
||||
.await
|
||||
.expect("seed admin");
|
||||
|
||||
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||
let mut server = TestServer::new(app).expect("TestServer should build");
|
||||
|
||||
let resp = server
|
||||
.post("/api/v1/admin/auth/login")
|
||||
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
|
||||
.await;
|
||||
resp.assert_status_ok();
|
||||
let token = resp.json::<Value>()["token"]
|
||||
.as_str()
|
||||
.expect("login should return token")
|
||||
.to_string();
|
||||
server.add_header("authorization", format!("Bearer {token}"));
|
||||
server
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -705,7 +731,7 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
||||
let v: Value = r.json();
|
||||
assert!(v["public_base_url"].is_string());
|
||||
assert_eq!(v["api"], 1);
|
||||
assert_eq!(v["schema"], 3);
|
||||
assert_eq!(v["schema"], 4);
|
||||
assert_eq!(v["sdk"], "1.1");
|
||||
}
|
||||
|
||||
|
||||
@@ -50,3 +50,4 @@ macro_rules! id_type {
|
||||
id_type!(ScriptId);
|
||||
id_type!(ExecutionId);
|
||||
id_type!(RequestId);
|
||||
id_type!(AdminUserId);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub mod version;
|
||||
|
||||
pub use error::Error;
|
||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||
pub use ids::{ExecutionId, RequestId, ScriptId};
|
||||
pub use ids::{AdminUserId, ExecutionId, RequestId, ScriptId};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
pub use route::{HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
// the same Caddy upstream so the "Test invoke" panel can hit it
|
||||
// without any cross-origin gymnastics.
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
||||
|
||||
export interface ScriptSandbox {
|
||||
max_operations?: number;
|
||||
max_string_size?: number;
|
||||
@@ -134,12 +139,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
...((init?.headers as Record<string, string>) ?? {})
|
||||
};
|
||||
const tok = getToken();
|
||||
if (tok && !headers['authorization']) {
|
||||
headers['authorization'] = `Bearer ${tok}`;
|
||||
}
|
||||
const res = await fetch(path, { ...init, headers });
|
||||
const text = await res.text();
|
||||
const parsed: unknown = text ? safeJson(text) : null;
|
||||
if (res.status === 401) {
|
||||
// Token gone stale or never present. Drop any cached session
|
||||
// and bounce to login — unless we're already on it, in which
|
||||
// case throw and let the login form render the error.
|
||||
clearSession();
|
||||
if (browser && !window.location.pathname.endsWith('/login')) {
|
||||
void goto(`${base}/login`);
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(parsed && typeof parsed === 'object' && 'error' in parsed
|
||||
@@ -158,11 +177,76 @@ function safeJson(text: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminUserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAdminInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PatchAdminInput {
|
||||
username?: string;
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
user: AdminUser;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => fetch('/healthz').then((r) => r.text()),
|
||||
|
||||
version: () => adminRequest<VersionInfo>('/version'),
|
||||
|
||||
auth: {
|
||||
login: async (username: string, password: string): Promise<AdminUser> => {
|
||||
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setSession(r.user, r.token);
|
||||
return r.user;
|
||||
},
|
||||
logout: async (): Promise<void> => {
|
||||
try {
|
||||
await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
// Always clear locally — logout is idempotent server-side
|
||||
// and we don't want a network blip to strand the SPA in
|
||||
// a "logged out on server, still logged in client-side"
|
||||
// state.
|
||||
clearSession();
|
||||
}
|
||||
},
|
||||
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
||||
},
|
||||
|
||||
admins: {
|
||||
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
||||
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
||||
create: (input: CreateAdminInput) =>
|
||||
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (id: string, input: PatchAdminInput) =>
|
||||
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
routes: {
|
||||
listForScript: (scriptId: string) =>
|
||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||
|
||||
60
dashboard/src/lib/auth.ts
Normal file
60
dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Session state for the dashboard. Backed by a pair of Svelte stores
|
||||
// plus a tiny localStorage echo so a page reload doesn't sign you out.
|
||||
//
|
||||
// The bearer token doubles as the cookie value on the server side, so
|
||||
// in browsers that honor the Set-Cookie response the cookie path "just
|
||||
// works"; the token-in-localStorage path covers the rest (HTTP dev, API
|
||||
// clients impersonating the dashboard) by being injected into the
|
||||
// Authorization header in api.ts.
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'picloud.admin.token';
|
||||
|
||||
function readStoredToken(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredToken(value: string | null) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(TOKEN_KEY);
|
||||
else localStorage.setItem(TOKEN_KEY, value);
|
||||
} catch {
|
||||
// Non-fatal: localStorage can be disabled. The session will
|
||||
// just not survive page reloads, but the in-memory store still
|
||||
// works for the current SPA lifetime.
|
||||
}
|
||||
}
|
||||
|
||||
export const token = writable<string | null>(readStoredToken());
|
||||
export const currentUser = writable<AdminUser | null>(null);
|
||||
|
||||
token.subscribe((value) => writeStoredToken(value));
|
||||
|
||||
/** Snapshot of the current token without subscribing — used by the
|
||||
* fetch wrapper. Returns null when no admin is logged in. */
|
||||
export function getToken(): string | null {
|
||||
return get(token);
|
||||
}
|
||||
|
||||
export function setSession(user: AdminUser, raw_token: string) {
|
||||
currentUser.set(user);
|
||||
token.set(raw_token);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
currentUser.set(null);
|
||||
token.set(null);
|
||||
}
|
||||
@@ -1,6 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
import { currentUser, getToken } from '$lib/auth';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let booting = $state(true);
|
||||
const user = $derived($currentUser);
|
||||
|
||||
const isLoginRoute = $derived(page.url.pathname.endsWith('/login'));
|
||||
|
||||
onMount(async () => {
|
||||
// Hydrate the session: if there's a token, ask the server who we
|
||||
// are. On 401 the fetch wrapper already redirects to /login and
|
||||
// clears state; on success we land in the SPA fully signed in.
|
||||
const tok = getToken();
|
||||
if (!tok) {
|
||||
if (!isLoginRoute) {
|
||||
await goto(`${base}/login`);
|
||||
}
|
||||
booting = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await api.auth.me();
|
||||
currentUser.set(me);
|
||||
} catch {
|
||||
// adminRequest handles 401 redirects. For other errors fall
|
||||
// through — the page will surface its own error state.
|
||||
}
|
||||
booting = false;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await api.auth.logout();
|
||||
await goto(`${base}/login`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
@@ -8,10 +46,22 @@
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href={base + '/'}>Scripts</a>
|
||||
<a href={base + '/admins'}>Admins</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
{#if user}
|
||||
<div class="usermenu">
|
||||
<span class="username">{user.username}</span>
|
||||
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<main>
|
||||
{@render children?.()}
|
||||
{#if booting}
|
||||
<p class="boot">Loading…</p>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +95,11 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
@@ -55,6 +110,36 @@
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.usermenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
@@ -63,4 +148,8 @@
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.boot {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
687
dashboard/src/routes/admins/+page.svelte
Normal file
687
dashboard/src/routes/admins/+page.svelte
Normal file
@@ -0,0 +1,687 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
||||
import { currentUser } from '$lib/auth';
|
||||
|
||||
let admins = $state<AdminUserRecord[]>([]);
|
||||
let loadError = $state<string | null>(null);
|
||||
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||
|
||||
const me = $derived($currentUser);
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ username: '', password: '', confirm: '' });
|
||||
let createPending = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
let passwordTarget = $state<AdminUserRecord | null>(null);
|
||||
let passwordForm = $state({ password: '', confirm: '' });
|
||||
let passwordPending = $state(false);
|
||||
let passwordError = $state<string | null>(null);
|
||||
|
||||
let deleteTarget = $state<AdminUserRecord | null>(null);
|
||||
let deletePending = $state(false);
|
||||
|
||||
let actionsOpenFor = $state<string | null>(null);
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function refresh() {
|
||||
loadError = null;
|
||||
try {
|
||||
admins = await api.admins.list();
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
||||
}
|
||||
}
|
||||
|
||||
function flash(kind: 'error' | 'info', message: string) {
|
||||
banner = { kind, message };
|
||||
setTimeout(() => {
|
||||
if (banner?.message === message) banner = null;
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm = { username: '', password: '', confirm: '' };
|
||||
createError = null;
|
||||
createOpen = true;
|
||||
}
|
||||
|
||||
async function submitCreate(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
createError = null;
|
||||
if (createForm.password !== createForm.confirm) {
|
||||
createError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
createPending = true;
|
||||
try {
|
||||
await api.admins.create({
|
||||
username: createForm.username.trim(),
|
||||
password: createForm.password
|
||||
});
|
||||
createOpen = false;
|
||||
await refresh();
|
||||
flash('info', `Created admin "${createForm.username.trim()}".`);
|
||||
} catch (e) {
|
||||
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
||||
} finally {
|
||||
createPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPassword(row: AdminUserRecord) {
|
||||
passwordTarget = row;
|
||||
passwordForm = { password: '', confirm: '' };
|
||||
passwordError = null;
|
||||
actionsOpenFor = null;
|
||||
}
|
||||
|
||||
async function submitPassword(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!passwordTarget) return;
|
||||
passwordError = null;
|
||||
if (passwordForm.password !== passwordForm.confirm) {
|
||||
passwordError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
passwordPending = true;
|
||||
try {
|
||||
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
||||
const name = passwordTarget.username;
|
||||
passwordTarget = null;
|
||||
flash('info', `Password updated for "${name}".`);
|
||||
} catch (e) {
|
||||
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
||||
} finally {
|
||||
passwordPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(row: AdminUserRecord) {
|
||||
actionsOpenFor = null;
|
||||
try {
|
||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
||||
} catch (e) {
|
||||
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
||||
}
|
||||
}
|
||||
|
||||
function openDelete(row: AdminUserRecord) {
|
||||
deleteTarget = row;
|
||||
actionsOpenFor = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
deletePending = true;
|
||||
const target = deleteTarget;
|
||||
try {
|
||||
await api.admins.remove(target.id);
|
||||
deleteTarget = null;
|
||||
if (me && me.id === target.id) {
|
||||
// Just deleted ourselves — sign out and bounce.
|
||||
await api.auth.logout();
|
||||
await goto(`${base}/login`);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
flash('info', `Deleted "${target.username}".`);
|
||||
} catch (e) {
|
||||
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
||||
} finally {
|
||||
deletePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleActions(id: string) {
|
||||
actionsOpenFor = actionsOpenFor === id ? null : id;
|
||||
}
|
||||
|
||||
function relative(iso: string | null): string {
|
||||
if (!iso) return 'Never';
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const sec = Math.round((now - then) / 1000);
|
||||
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
if (day === 1) return 'Yesterday';
|
||||
if (day < 7) return `${day} days ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function absolute(iso: string | null): string {
|
||||
return iso ? new Date(iso).toISOString() : '';
|
||||
}
|
||||
|
||||
function shortDate(iso: string): string {
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="head">
|
||||
<h1>Admin Users</h1>
|
||||
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
||||
</header>
|
||||
|
||||
{#if banner}
|
||||
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadError}
|
||||
<div class="error">
|
||||
{loadError}
|
||||
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||
</div>
|
||||
{:else if admins.length === 0}
|
||||
<p class="empty">No admin users yet. Add one to get started.</p>
|
||||
{:else}
|
||||
<div class="table">
|
||||
<div class="row head-row">
|
||||
<div>Username</div>
|
||||
<div>Status</div>
|
||||
<div>Created</div>
|
||||
<div>Last login</div>
|
||||
<div class="actions-col"></div>
|
||||
</div>
|
||||
{#each admins as row (row.id)}
|
||||
<div class="row">
|
||||
<div class="username-cell">
|
||||
<span class="name">{row.username}</span>
|
||||
{#if me && me.id === row.id}
|
||||
<span class="you-tag">(you)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if row.is_active}
|
||||
<span class="status status-active">● Active</span>
|
||||
{:else}
|
||||
<span class="status status-inactive">○ Inactive</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>{shortDate(row.created_at)}</div>
|
||||
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
||||
<div class="actions-col">
|
||||
<button
|
||||
type="button"
|
||||
class="kebab"
|
||||
aria-label="Actions for {row.username}"
|
||||
onclick={() => toggleActions(row.id)}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{#if actionsOpenFor === row.id}
|
||||
<div class="menu">
|
||||
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
||||
<button type="button" onclick={() => toggleActive(row)}>
|
||||
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- New admin modal -->
|
||||
{#if createOpen}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) createOpen = false;
|
||||
}}
|
||||
>
|
||||
<form class="modal" onsubmit={submitCreate}>
|
||||
<div class="modal-head">
|
||||
<h2>New admin user</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="x"
|
||||
aria-label="Close"
|
||||
onclick={() => (createOpen = false)}>✕</button
|
||||
>
|
||||
</div>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
bind:value={createForm.username}
|
||||
required
|
||||
/>
|
||||
<small>Lowercase letters, digits, . _ -</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={createForm.password}
|
||||
required
|
||||
/>
|
||||
<small>Minimum 8 characters</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={createForm.confirm}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
||||
<button type="submit" class="primary" disabled={createPending}>
|
||||
{createPending ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Change password modal -->
|
||||
{#if passwordTarget}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) passwordTarget = null;
|
||||
}}
|
||||
>
|
||||
<form class="modal" onsubmit={submitPassword}>
|
||||
<div class="modal-head">
|
||||
<h2>Change password — {passwordTarget.username}</h2>
|
||||
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
<label>
|
||||
<span>New password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={passwordForm.password}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={passwordForm.confirm}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{#if passwordError}
|
||||
<div class="error">{passwordError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
||||
<button type="submit" class="primary" disabled={passwordPending}>
|
||||
{passwordPending ? 'Updating…' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
{#if deleteTarget}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) deleteTarget = null;
|
||||
}}
|
||||
>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2>Delete {deleteTarget.username}?</h2>
|
||||
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
{#if me && me.id === deleteTarget.id}
|
||||
<p>
|
||||
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
||||
and will not be able to sign back in with these credentials.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
||||
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
||||
{deletePending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.banner-error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
}
|
||||
.banner-info {
|
||||
background: #0c2a36;
|
||||
border: 1px solid #155e75;
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
border: 1px dashed #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
overflow: visible;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.head-row {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.username-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.you-tag {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.status-active {
|
||||
color: #34d399;
|
||||
}
|
||||
.status-inactive {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.kebab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.kebab:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 12rem;
|
||||
z-index: 10;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.menu button:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.menu button.danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.menu button.danger:hover {
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.retry {
|
||||
background: transparent;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
button.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #b91c1c;
|
||||
color: #fef2f2;
|
||||
border: none;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
button.danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #0b1220;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
min-width: 24rem;
|
||||
max-width: 28rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.x {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.modal label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.modal label small {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.modal input {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal input:focus {
|
||||
outline: none;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
172
dashboard/src/routes/login/+page.svelte
Normal file
172
dashboard/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { getToken } from '$lib/auth';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let pending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Already signed in? Skip the form.
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
await api.auth.me();
|
||||
await goto(`${base}/`);
|
||||
} catch {
|
||||
// stale token; let the form render
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
error = null;
|
||||
pending = true;
|
||||
try {
|
||||
await api.auth.login(username, password);
|
||||
await goto(`${base}/`);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Login failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-shell">
|
||||
<form class="card" onsubmit={submit}>
|
||||
<h1>PiCloud</h1>
|
||||
<p class="sub">Admin sign-in</p>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={username}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Signing in…' : 'Sign in →'}
|
||||
</button>
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Lost access? Run <code>picloud admin reset-password <username></code> on the host.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-shell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
min-width: 22rem;
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #38bdf8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
input {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #b91c1c;
|
||||
color: #fecaca;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #1e293b;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
@@ -732,9 +732,11 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## 11.4 Admin Auth (Phase 3a)
|
||||
## 11.4 Admin Auth (Phase 3a) — Shipped
|
||||
|
||||
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Today the surface is open — anyone reaching the bound port can create, edit, and delete scripts.
|
||||
**Status**: shipped. Implementation lives in `crates/manager-core/src/{auth,auth_*,admin_user_repo,admin_session_repo,admin_users_api}.rs`; migration `0004_admin_auth.sql`.
|
||||
|
||||
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Before this phase the surface was open — anyone reaching the bound port could create, edit, and delete scripts.
|
||||
|
||||
**Why per-user, not a shared secret**: shared admin passwords get shared between humans, leave no audit trail, and can't be revoked per-person. Per-user accounts solve all three. The initial cut deliberately stops there — no roles, no per-app permissions — because that scope is small enough to ship in a single phase without blocking Phase 3b. Roles + per-app permissions are queued for v1.3+.
|
||||
|
||||
@@ -746,26 +748,29 @@ We reserve the unqualified **`users`** table for the v1.1+ Rhai SDK feature (scr
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- Argon2id
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- Argon2id (PHC string)
|
||||
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, -- SHA-256 of the bearer token; raw token only exists in the login response + cookie
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
last_used_at TIMESTAMP DEFAULT NOW()
|
||||
token_hash TEXT PRIMARY KEY, -- SHA-256(hex) of the bearer token; raw token only exists in the login response + cookie
|
||||
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 idx_admin_sessions_user ON admin_sessions(user_id);
|
||||
CREATE INDEX idx_admin_sessions_expiry ON admin_sessions(expires_at);
|
||||
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||
```
|
||||
|
||||
`is_active` was added to the shipped cut so admins can be deactivated (login rejected, sessions wiped) without losing audit history; deletion still cascades sessions through the FK.
|
||||
|
||||
**Password hashing**: Argon2id with default OWASP parameters. This also resolves the v1.1+ open question about user-password hashing (§10) — the platform settles on Argon2id once, here.
|
||||
|
||||
### Bootstrap
|
||||
@@ -814,13 +819,17 @@ Companion endpoints:
|
||||
|
||||
```
|
||||
GET /api/v1/admin/admins — list
|
||||
POST /api/v1/admin/admins — create
|
||||
POST /api/v1/admin/admins — create ({ username, password })
|
||||
GET /api/v1/admin/admins/{id} — get
|
||||
PATCH /api/v1/admin/admins/{id} — update (username, password)
|
||||
DELETE /api/v1/admin/admins/{id} — delete (rejected if it would leave zero admins)
|
||||
PATCH /api/v1/admin/admins/{id} — update ({ username?, password?, is_active? })
|
||||
DELETE /api/v1/admin/admins/{id} — delete
|
||||
```
|
||||
|
||||
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet.
|
||||
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. The PATCH and DELETE handlers both refuse to leave the system with zero active admins (`422 Unprocessable Entity` with a clear message); PATCH that transitions `is_active` from true to false also wipes that user's sessions immediately.
|
||||
|
||||
Validation: username `^[a-z0-9._-]{2,32}$`, password minimum 8 characters (no complexity rules — follows NIST 800-63B guidance).
|
||||
|
||||
Dashboard surface: `/admin/login` (unauthed), `/admin/admins` (user list with add / change-password / deactivate / reactivate / delete actions per row). The top-bar shows the logged-in admin and a logout button. Token is held in a Svelte store with a localStorage echo so a page refresh doesn't sign you out; cookie-based auth works in parallel for non-SPA browser hits.
|
||||
|
||||
### Forward Compatibility
|
||||
|
||||
@@ -1031,7 +1040,7 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
||||
|
||||
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||
|
||||
**3a. Admin auth** — see section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||
|
||||
**3b. Multi-app scoping** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user