bootstrap rust services, auth, and compose stack
This commit is contained in:
93
apps/api/src/auth.rs
Normal file
93
apps/api/src/auth.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::AUTHORIZATION,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Scope {
|
||||
Admin,
|
||||
Read,
|
||||
}
|
||||
|
||||
pub async fn require_admin(
|
||||
State(state): State<AppState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
||||
let scope = authenticate(&state, token).await?;
|
||||
|
||||
if !matches!(scope, Scope::Admin) {
|
||||
return Err(ApiError::forbidden("admin scope required"));
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(scope);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
fn bearer_token(req: &Request) -> Option<&str> {
|
||||
req.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError> {
|
||||
if token == state.bootstrap_token.as_ref() {
|
||||
return Ok(Scope::Admin);
|
||||
}
|
||||
|
||||
let prefix = parse_prefix(token).ok_or_else(|| ApiError::unauthorized("invalid token format"))?;
|
||||
|
||||
let maybe_row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, scope
|
||||
FROM api_tokens
|
||||
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(prefix)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let row = maybe_row.ok_or_else(|| ApiError::unauthorized("invalid token"))?;
|
||||
|
||||
let token_hash: String = row.try_get("token_hash").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
let parsed_hash = PasswordHash::new(&token_hash).map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(token.as_bytes(), &parsed_hash)
|
||||
.map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
|
||||
let token_id: uuid::Uuid = row.try_get("id").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE id = $2")
|
||||
.bind(Utc::now())
|
||||
.bind(token_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
match scope.as_str() {
|
||||
"admin" => Ok(Scope::Admin),
|
||||
"read" => Ok(Scope::Read),
|
||||
_ => Err(ApiError::unauthorized("invalid token scope")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prefix(token: &str) -> Option<&str> {
|
||||
let mut parts = token.split('_');
|
||||
let namespace = parts.next()?;
|
||||
let prefix = parts.next()?;
|
||||
let secret = parts.next()?;
|
||||
if namespace != "stl" || secret.is_empty() || prefix.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
Some(prefix)
|
||||
}
|
||||
62
apps/api/src/error.rs
Normal file
62
apps/api/src/error.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody<'a> {
|
||||
error: &'a str,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(ErrorBody { error: &self.message })).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
Self::internal(format!("database error: {err}"))
|
||||
}
|
||||
}
|
||||
101
apps/api/src/libraries.rs
Normal file
101
apps/api/src/libraries.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::{extract::{Path as AxumPath, State}, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LibraryDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateLibraryInput {
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
}
|
||||
|
||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryDto>>, ApiError> {
|
||||
let rows = sqlx::query("SELECT id, name, root_path, enabled FROM libraries ORDER BY created_at DESC")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| LibraryDto {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
pub async fn create_library(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateLibraryInput>,
|
||||
) -> Result<Json<LibraryDto>, ApiError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("name is required"));
|
||||
}
|
||||
|
||||
let canonical = canonicalize_library_root(&input.root_path)?;
|
||||
let id = Uuid::new_v4();
|
||||
let root_path = canonical.to_string_lossy().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO libraries (id, name, root_path, enabled) VALUES ($1, $2, $3, TRUE)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.name.trim())
|
||||
.bind(&root_path)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(LibraryDto {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
root_path,
|
||||
enabled: true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_library(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("library not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
}
|
||||
|
||||
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
|
||||
let path = Path::new(root_path);
|
||||
if !path.is_absolute() {
|
||||
return Err(ApiError::bad_request("root_path must be absolute"));
|
||||
}
|
||||
|
||||
let canonical = std::fs::canonicalize(path)
|
||||
.map_err(|_| ApiError::bad_request("root_path does not exist or is inaccessible"))?;
|
||||
|
||||
if !canonical.is_dir() {
|
||||
return Err(ApiError::bad_request("root_path must point to a directory"));
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
58
apps/api/src/main.rs
Normal file
58
apps/api/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
mod auth;
|
||||
mod error;
|
||||
mod libraries;
|
||||
mod tokens;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{middleware, routing::{delete, get}, Router};
|
||||
use stripstream_core::config::ApiConfig;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
pool: sqlx::PgPool,
|
||||
bootstrap_token: Arc<str>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "api=info,axum=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = ApiConfig::from_env()?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
bootstrap_token: Arc::from(config.api_bootstrap_token),
|
||||
};
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
|
||||
.route("/libraries/:id", delete(libraries::delete_library))
|
||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth::require_admin));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.merge(protected)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "api listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
122
apps/api/src/tokens.rs
Normal file
122
apps/api/src/tokens.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTokenInput {
|
||||
pub name: String,
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreatedToken {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
pub token: String,
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TokenItem {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
pub prefix: String,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateTokenInput>,
|
||||
) -> Result<Json<CreatedToken>, ApiError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("name is required"));
|
||||
}
|
||||
|
||||
let scope = match input.scope.as_deref().unwrap_or("read") {
|
||||
"admin" => "admin",
|
||||
"read" => "read",
|
||||
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
||||
};
|
||||
|
||||
let mut random = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut random);
|
||||
let secret = URL_SAFE_NO_PAD.encode(random);
|
||||
let prefix: String = secret.chars().take(8).collect();
|
||||
let token = format!("stl_{prefix}_{secret}");
|
||||
|
||||
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
|
||||
let token_hash = Argon2::default()
|
||||
.hash_password(token.as_bytes(), &salt)
|
||||
.map_err(|_| ApiError::internal("failed to hash token"))?
|
||||
.to_string();
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.name.trim())
|
||||
.bind(&prefix)
|
||||
.bind(token_hash)
|
||||
.bind(scope)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreatedToken {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
scope: scope.to_string(),
|
||||
token,
|
||||
prefix,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenItem>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| TokenItem {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
scope: row.get("scope"),
|
||||
prefix: row.get("prefix"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("token not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||
}
|
||||
Reference in New Issue
Block a user