fix: ci
This commit is contained in:
parent
ba74ea7a6f
commit
1b2d1e7471
16 changed files with 190 additions and 105 deletions
|
|
@ -41,8 +41,8 @@ jobs:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Inject bunfig.toml
|
- name: Inject Auth
|
||||||
run: printf '[install.scopes]\n"@nychthemeron" = { token = "${BUN_AUTH_TOKEN}", url = "https://git.mcpeakdev.com/api/packages/McPeakDev/npm/" }\n' > ./bunfig.toml
|
run: echo "//git.mcpeakdev.com/api/packages/mcpeakdev/npm/:_authToken=${BUN_AUTH_TOKEN}" > /root/.npmrc
|
||||||
env:
|
env:
|
||||||
BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }}
|
BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,15 @@ use axum::{
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{auth::{decode_jwt, resolve_api_key, Claims}, state::AppState};
|
use crate::{
|
||||||
|
auth::{decode_jwt, resolve_api_key, Claims},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
/// Resolves a Bearer token to Claims, trying JWT then API key.
|
/// Resolves a Bearer token to Claims, trying JWT then API key.
|
||||||
/// Inserts Claims into request extensions on success so downstream
|
/// Inserts Claims into request extensions on success so downstream
|
||||||
/// middleware can reuse them without an additional DB round-trip.
|
/// middleware can reuse them without an additional DB round-trip.
|
||||||
async fn authenticate(
|
async fn authenticate(token: &str, state: &AppState, req: &mut Request) -> Option<Claims> {
|
||||||
token: &str,
|
|
||||||
state: &AppState,
|
|
||||||
req: &mut Request,
|
|
||||||
) -> Option<Claims> {
|
|
||||||
if let Some(existing) = req.extensions().get::<Claims>().cloned() {
|
if let Some(existing) = req.extensions().get::<Claims>().cloned() {
|
||||||
return Some(existing);
|
return Some(existing);
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +41,11 @@ pub async fn blacklist_layer(
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
if state.blacklist_cache.is_blocked(&method, &path, caller_mask).await {
|
if state
|
||||||
|
.blacklist_cache
|
||||||
|
.is_blocked(&method, &path, caller_mask)
|
||||||
|
.await
|
||||||
|
{
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
|
|
@ -62,7 +65,9 @@ pub async fn require_auth(
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
authenticate(&token, &state, &mut req)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +77,9 @@ pub async fn require_super_admin(
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
let claims = authenticate(&token, &state, &mut req)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
|
if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +92,9 @@ pub async fn require_admin_query(
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
let claims = authenticate(&token, &state, &mut req)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
|
if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +107,9 @@ pub async fn require_admin_cache(
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
let claims = authenticate(&token, &state, &mut req)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
|
if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
src/cache/sweep.rs
vendored
8
src/cache/sweep.rs
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
use std::time::Duration;
|
|
||||||
use crate::state::QueryCache;
|
use crate::state::QueryCache;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
|
pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -7,9 +7,9 @@ pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interva
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(interval).await;
|
tokio::time::sleep(interval).await;
|
||||||
let now = crate::state::unix_now();
|
let now = crate::state::unix_now();
|
||||||
cache.map.retain(|_, entry| {
|
cache
|
||||||
now.saturating_sub(entry.last_accessed()) < idle_timeout_secs
|
.map
|
||||||
});
|
.retain(|_, entry| now.saturating_sub(entry.last_accessed()) < idle_timeout_secs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
src/main.rs
60
src/main.rs
|
|
@ -9,8 +9,17 @@ mod state;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{extract::DefaultBodyLimit, http::{header, HeaderValue, Method}, middleware, routing::{get, post}, Router};
|
use axum::{
|
||||||
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}};
|
extract::DefaultBodyLimit,
|
||||||
|
http::{header, HeaderValue, Method},
|
||||||
|
middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::{
|
||||||
|
cors::CorsLayer,
|
||||||
|
services::{ServeDir, ServeFile},
|
||||||
|
};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -19,11 +28,7 @@ use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
db::create_pool,
|
db::create_pool,
|
||||||
models::blacklist::BlacklistEntry,
|
models::blacklist::BlacklistEntry,
|
||||||
routes::{
|
routes::{admin::admin_router, auth::login, crud::handle_crud},
|
||||||
admin::admin_router,
|
|
||||||
auth::login,
|
|
||||||
crud::handle_crud,
|
|
||||||
},
|
|
||||||
state::{AppState, BlacklistCache, QueryCache},
|
state::{AppState, BlacklistCache, QueryCache},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -34,13 +39,16 @@ fn build_cors(origins: &[String]) -> CorsLayer {
|
||||||
if origins.iter().any(|o| o == "*") {
|
if origins.iter().any(|o| o == "*") {
|
||||||
return CorsLayer::permissive();
|
return CorsLayer::permissive();
|
||||||
}
|
}
|
||||||
let parsed: Vec<HeaderValue> = origins
|
let parsed: Vec<HeaderValue> = origins.iter().filter_map(|o| o.parse().ok()).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|o| o.parse().ok())
|
|
||||||
.collect();
|
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(parsed)
|
.allow_origin(parsed)
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
|
.allow_methods([
|
||||||
|
Method::GET,
|
||||||
|
Method::POST,
|
||||||
|
Method::PUT,
|
||||||
|
Method::DELETE,
|
||||||
|
Method::OPTIONS,
|
||||||
|
])
|
||||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,8 +57,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::EnvFilter::try_from_default_env()
|
.with(
|
||||||
.unwrap_or_else(|_| "mercury=info".into()))
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "mercury=info".into()),
|
||||||
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
|
@ -122,13 +132,22 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let crud_routes = Router::new()
|
let crud_routes = Router::new()
|
||||||
.route("/api/:table", get(handle_crud).post(handle_crud))
|
.route("/api/:table", get(handle_crud).post(handle_crud))
|
||||||
.route("/api/:table/", get(handle_crud).post(handle_crud))
|
.route("/api/:table/", get(handle_crud).post(handle_crud))
|
||||||
.route("/api/:table/:id", get(handle_crud).put(handle_crud).delete(handle_crud))
|
.route(
|
||||||
.route("/api/:table/:id/", get(handle_crud).put(handle_crud).delete(handle_crud))
|
"/api/:table/:id",
|
||||||
.layer(DefaultBodyLimit::max(1 * 1024 * 1024))
|
get(handle_crud).put(handle_crud).delete(handle_crud),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/:table/:id/",
|
||||||
|
get(handle_crud).put(handle_crud).delete(handle_crud),
|
||||||
|
)
|
||||||
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
// require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
|
// require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
|
||||||
// Order: blacklist check → auth check → handler.
|
// Order: blacklist check → auth check → handler.
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
blacklist_layer,
|
||||||
|
));
|
||||||
|
|
||||||
let cors_layer = build_cors(&config.cors_origins);
|
let cors_layer = build_cors(&config.cors_origins);
|
||||||
|
|
||||||
|
|
@ -136,7 +155,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.route("/auth/login", post(login))
|
.route("/auth/login", post(login))
|
||||||
.merge(crud_routes)
|
.merge(crud_routes)
|
||||||
.nest("/api/admin", admin_router(state.clone()))
|
.nest("/api/admin", admin_router(state.clone()))
|
||||||
.nest_service("/", ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")))
|
.nest_service(
|
||||||
|
"/",
|
||||||
|
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
|
||||||
|
)
|
||||||
.layer(cors_layer)
|
.layer(cors_layer)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ pub struct BlacklistEntry {
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateBlacklistEntry {
|
pub struct CreateBlacklistEntry {
|
||||||
pub pattern: String,
|
pub pattern: String,
|
||||||
|
|
@ -19,6 +20,7 @@ pub struct CreateBlacklistEntry {
|
||||||
pub reason: Option<String>,
|
pub reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateBlacklistEntry {
|
pub struct UpdateBlacklistEntry {
|
||||||
pub pattern: Option<String>,
|
pub pattern: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Permission {
|
pub struct Permission {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
|
@ -8,12 +9,14 @@ pub struct Permission {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreatePermission {
|
pub struct CreatePermission {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdatePermission {
|
pub struct UpdatePermission {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ pub struct StoredQuery {
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateQuery {
|
pub struct CreateQuery {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
|
|
@ -19,6 +20,7 @@ pub struct CreateQuery {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateQuery {
|
pub struct UpdateQuery {
|
||||||
pub sql_template: Option<String>,
|
pub sql_template: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub struct User {
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateUser {
|
pub struct CreateUser {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
@ -18,6 +19,7 @@ pub struct CreateUser {
|
||||||
pub permissions_mask: Option<String>,
|
pub permissions_mask: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateUser {
|
pub struct UpdateUser {
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ use crate::{
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn list_api_keys(
|
pub async fn list_api_keys(State(state): State<AppState>) -> Result<Json<Vec<ApiKey>>, StatusCode> {
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Vec<ApiKey>>, StatusCode> {
|
|
||||||
let keys = sqlx::query_as::<_, ApiKey>(
|
let keys = sqlx::query_as::<_, ApiKey>(
|
||||||
"SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at
|
"SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at
|
||||||
FROM api_keys ORDER BY created_at DESC",
|
FROM api_keys ORDER BY created_at DESC",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
use axum::extract::Extension;
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use axum::extract::Extension;
|
|
||||||
|
|
||||||
use crate::{auth::Claims, state::AppState};
|
use crate::{auth::Claims, state::AppState};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ pub fn admin_router(state: AppState) -> Router<AppState> {
|
||||||
));
|
));
|
||||||
|
|
||||||
let super_routes = Router::new()
|
let super_routes = Router::new()
|
||||||
.route("/tables", get(tables::list_tables).post(tables::create_table))
|
.route(
|
||||||
|
"/tables",
|
||||||
|
get(tables::list_tables).post(tables::create_table),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/tables/:name",
|
"/tables/:name",
|
||||||
get(tables::get_table_preview).delete(tables::drop_table),
|
get(tables::get_table_preview).delete(tables::drop_table),
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,10 @@ pub async fn execute_query(
|
||||||
q = q.bind(val.as_str());
|
q = q.bind(val.as_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = q
|
let rows = q.fetch_all(&state.pool).await.map_err(|e| {
|
||||||
.fetch_all(&state.pool)
|
tracing::error!("query execution error: {}", e);
|
||||||
.await
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
.map_err(|e| {
|
})?;
|
||||||
tracing::error!("query execution error: {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let json_rows: Vec<Value> = rows
|
let json_rows: Vec<Value> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -72,8 +69,16 @@ mod tests {
|
||||||
for (i, (name, _)) in params.iter().enumerate() {
|
for (i, (name, _)) in params.iter().enumerate() {
|
||||||
sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1));
|
sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1));
|
||||||
}
|
}
|
||||||
assert!(!sql.contains(":username"), "placeholder not replaced: {}", sql);
|
assert!(
|
||||||
assert!(!sql.contains(":user_id"), "placeholder not replaced: {}", sql);
|
!sql.contains(":username"),
|
||||||
|
"placeholder not replaced: {}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!sql.contains(":user_id"),
|
||||||
|
"placeholder not replaced: {}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
// username (len 8) comes first → $1; user_id (len 7) → $2
|
// username (len 8) comes first → $1; user_id (len 7) → $2
|
||||||
assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1");
|
assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,18 @@ use sqlx::Row;
|
||||||
use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState};
|
use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState};
|
||||||
|
|
||||||
const ALLOWED_TYPES: &[&str] = &[
|
const ALLOWED_TYPES: &[&str] = &[
|
||||||
"TEXT", "INTEGER", "BIGINT", "SMALLINT", "BOOLEAN", "NUMERIC",
|
"TEXT",
|
||||||
"FLOAT4", "FLOAT8", "UUID", "TIMESTAMPTZ", "DATE", "JSONB",
|
"INTEGER",
|
||||||
|
"BIGINT",
|
||||||
|
"SMALLINT",
|
||||||
|
"BOOLEAN",
|
||||||
|
"NUMERIC",
|
||||||
|
"FLOAT4",
|
||||||
|
"FLOAT8",
|
||||||
|
"UUID",
|
||||||
|
"TIMESTAMPTZ",
|
||||||
|
"DATE",
|
||||||
|
"JSONB",
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
|
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
|
||||||
|
|
@ -21,7 +31,6 @@ fn is_protected(name: &str) -> bool {
|
||||||
PROTECTED_TABLES.iter().any(|&t| t == lower)
|
PROTECTED_TABLES.iter().any(|&t| t == lower)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ColumnDef {
|
pub struct ColumnDef {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -179,13 +188,10 @@ pub async fn create_table(
|
||||||
|
|
||||||
let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", "));
|
let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", "));
|
||||||
|
|
||||||
sqlx::query(&sql)
|
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
|
||||||
.execute(&state.pool)
|
tracing::error!("create table error: {}", e);
|
||||||
.await
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
.map_err(|e| {
|
})?;
|
||||||
tracing::error!("create table error: {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(TableInfo {
|
Ok(Json(TableInfo {
|
||||||
table_name: body.name,
|
table_name: body.name,
|
||||||
|
|
@ -206,13 +212,10 @@ pub async fn drop_table(
|
||||||
}
|
}
|
||||||
|
|
||||||
let sql = format!("DROP TABLE IF EXISTS {}", name);
|
let sql = format!("DROP TABLE IF EXISTS {}", name);
|
||||||
sqlx::query(&sql)
|
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
|
||||||
.execute(&state.pool)
|
tracing::error!("drop table error: {}", e);
|
||||||
.await
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
.map_err(|e| {
|
})?;
|
||||||
tracing::error!("drop table error: {}", e);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "dropped": true })))
|
Ok(Json(serde_json::json!({ "dropped": true })))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{auth::encode_jwt, models::user::LoginRequest, state::AppState};
|
||||||
auth::encode_jwt,
|
|
||||||
models::user::LoginRequest,
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -28,8 +24,13 @@ pub async fn login(
|
||||||
}
|
}
|
||||||
|
|
||||||
let mask: u128 = user.permissions_mask.parse().unwrap_or(0);
|
let mask: u128 = user.permissions_mask.parse().unwrap_or(0);
|
||||||
let token = encode_jwt(&user.username, mask, &state.config.jwt_secret, state.config.jwt_expiry_secs)
|
let token = encode_jwt(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
&user.username,
|
||||||
|
mask,
|
||||||
|
&state.config.jwt_secret,
|
||||||
|
state.config.jwt_expiry_secs,
|
||||||
|
)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(json!({ "token": token })))
|
Ok(Json(json!({ "token": token })))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ use sqlx::Row;
|
||||||
use sqlx::TypeInfo;
|
use sqlx::TypeInfo;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{auth::Claims, models::blacklist::BlacklistEntry, state::{AppState, CacheEntry}};
|
use crate::{
|
||||||
|
auth::Claims,
|
||||||
|
models::blacklist::BlacklistEntry,
|
||||||
|
state::{AppState, CacheEntry},
|
||||||
|
};
|
||||||
|
|
||||||
/// Returns (sql, ordered_param_values, cache_key).
|
/// Returns (sql, ordered_param_values, cache_key).
|
||||||
/// body_cols: (col_name, typed_value) pairs from request body.
|
/// body_cols: (col_name, typed_value) pairs from request body.
|
||||||
|
|
@ -53,14 +57,22 @@ pub fn build_query(
|
||||||
let key = format!("GET:{}:", table);
|
let key = format!("GET:{}:", table);
|
||||||
Ok((sql, vec![], key))
|
Ok((sql, vec![], key))
|
||||||
} else {
|
} else {
|
||||||
let col_names: Vec<String> = sorted_filters.iter().map(|(c, _)| c.clone()).collect();
|
let col_names: Vec<String> =
|
||||||
|
sorted_filters.iter().map(|(c, _)| c.clone()).collect();
|
||||||
let where_clause: Vec<String> = col_names
|
let where_clause: Vec<String> = col_names
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, c)| format!("{} = ${}", c, i + 1))
|
.map(|(i, c)| format!("{} = ${}", c, i + 1))
|
||||||
.collect();
|
.collect();
|
||||||
let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND "));
|
let sql = format!(
|
||||||
let params: Vec<Value> = sorted_filters.iter().map(|(_, v)| Value::String(v.clone())).collect();
|
"SELECT * FROM {} WHERE {}",
|
||||||
|
table,
|
||||||
|
where_clause.join(" AND ")
|
||||||
|
);
|
||||||
|
let params: Vec<Value> = sorted_filters
|
||||||
|
.iter()
|
||||||
|
.map(|(_, v)| Value::String(v.clone()))
|
||||||
|
.collect();
|
||||||
let key = format!("GET:{}:{}", table, col_names.join(","));
|
let key = format!("GET:{}:{}", table, col_names.join(","));
|
||||||
Ok((sql, params, key))
|
Ok((sql, params, key))
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +148,7 @@ pub fn pg_row_to_json(row: PgRow) -> Value {
|
||||||
"FLOAT4" | "FLOAT8" => row
|
"FLOAT4" | "FLOAT8" => row
|
||||||
.try_get::<f64, _>(col.ordinal())
|
.try_get::<f64, _>(col.ordinal())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| serde_json::Number::from_f64(v))
|
.and_then(serde_json::Number::from_f64)
|
||||||
.map(Value::Number)
|
.map(Value::Number)
|
||||||
.unwrap_or(Value::Null),
|
.unwrap_or(Value::Null),
|
||||||
"BOOL" => row
|
"BOOL" => row
|
||||||
|
|
@ -207,10 +219,10 @@ pub async fn handle_crud(
|
||||||
|
|
||||||
// Enforce permission bits before doing any work.
|
// Enforce permission bits before doing any work.
|
||||||
let required_bit = match method_str.to_uppercase().as_str() {
|
let required_bit = match method_str.to_uppercase().as_str() {
|
||||||
"GET" => crate::auth::permissions::READ,
|
"GET" => crate::auth::permissions::READ,
|
||||||
"POST" | "PUT" => crate::auth::permissions::WRITE,
|
"POST" | "PUT" => crate::auth::permissions::WRITE,
|
||||||
"DELETE" => crate::auth::permissions::DELETE,
|
"DELETE" => crate::auth::permissions::DELETE,
|
||||||
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
||||||
};
|
};
|
||||||
if !claims.has_permission(required_bit) {
|
if !claims.has_permission(required_bit) {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
|
@ -281,16 +293,13 @@ pub async fn handle_crud(
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
"POST" | "PUT" => {
|
"POST" | "PUT" => {
|
||||||
let row = q
|
let row = q.fetch_one(&state.pool).await.map_err(|e| {
|
||||||
.fetch_one(&state.pool)
|
if e.to_string().contains("no rows") {
|
||||||
.await
|
StatusCode::NOT_FOUND
|
||||||
.map_err(|e| {
|
} else {
|
||||||
if e.to_string().contains("no rows") {
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
StatusCode::NOT_FOUND
|
}
|
||||||
} else {
|
})?;
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let mut v = pg_row_to_json(row);
|
let mut v = pg_row_to_json(row);
|
||||||
if table == "users" {
|
if table == "users" {
|
||||||
v = strip_password_hash(v);
|
v = strip_password_hash(v);
|
||||||
|
|
@ -341,8 +350,17 @@ mod tests {
|
||||||
("name".into(), Value::String("Alice".into())),
|
("name".into(), Value::String("Alice".into())),
|
||||||
];
|
];
|
||||||
let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
|
let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
|
||||||
assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *");
|
assert_eq!(
|
||||||
assert_eq!(params, vec![Value::String("a@b.com".into()), Value::String("Alice".into())]);
|
sql,
|
||||||
|
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
params,
|
||||||
|
vec![
|
||||||
|
Value::String("a@b.com".into()),
|
||||||
|
Value::String("Alice".into())
|
||||||
|
]
|
||||||
|
);
|
||||||
assert_eq!(key, "POST:users:email,name");
|
assert_eq!(key, "POST:users:email,name");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +369,10 @@ mod tests {
|
||||||
let cols = vec![("name".into(), Value::String("Bob".into()))];
|
let cols = vec![("name".into(), Value::String("Bob".into()))];
|
||||||
let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
|
let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
|
||||||
assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
|
assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
|
||||||
assert_eq!(params, vec![Value::String("Bob".into()), Value::String("7".into())]);
|
assert_eq!(
|
||||||
|
params,
|
||||||
|
vec![Value::String("Bob".into()), Value::String("7".into())]
|
||||||
|
);
|
||||||
assert_eq!(key, "PUT:users:name:by_id");
|
assert_eq!(key, "PUT:users:name:by_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,10 +386,19 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_select_with_filters() {
|
fn test_build_select_with_filters() {
|
||||||
let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())];
|
let filters = vec![
|
||||||
|
("status".into(), "active".into()),
|
||||||
|
("role".into(), "admin".into()),
|
||||||
|
];
|
||||||
let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
|
let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
|
||||||
assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
|
assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
|
||||||
assert_eq!(params, vec![Value::String("admin".into()), Value::String("active".into())]);
|
assert_eq!(
|
||||||
|
params,
|
||||||
|
vec![
|
||||||
|
Value::String("admin".into()),
|
||||||
|
Value::String("active".into())
|
||||||
|
]
|
||||||
|
);
|
||||||
assert_eq!(key, "GET:users:role,status");
|
assert_eq!(key, "GET:users:role,status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
19
src/state.rs
19
src/state.rs
|
|
@ -91,6 +91,7 @@ impl QueryCache {
|
||||||
self.map.insert(key, entry);
|
self.map.insert(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn remove(&self, key: &str) {
|
pub fn remove(&self, key: &str) {
|
||||||
self.map.remove(key);
|
self.map.remove(key);
|
||||||
}
|
}
|
||||||
|
|
@ -139,9 +140,11 @@ impl BlacklistCache {
|
||||||
.bypass_mask
|
.bypass_mask
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| s.parse::<u128>().ok());
|
.and_then(|s| s.parse::<u128>().ok());
|
||||||
Pattern::new(&e.pattern)
|
Pattern::new(&e.pattern).ok().map(|pattern| CompiledEntry {
|
||||||
.ok()
|
entry: e,
|
||||||
.map(|pattern| CompiledEntry { entry: e, pattern, bypass_mask })
|
pattern,
|
||||||
|
bypass_mask,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut guard = self.inner.write().await;
|
let mut guard = self.inner.write().await;
|
||||||
|
|
@ -163,16 +166,16 @@ impl BlacklistCache {
|
||||||
.entry
|
.entry
|
||||||
.method
|
.method
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|m| m.split(',').any(|part| part.trim().eq_ignore_ascii_case(method)))
|
.map(|m| {
|
||||||
|
m.split(',')
|
||||||
|
.any(|part| part.trim().eq_ignore_ascii_case(method))
|
||||||
|
})
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if !method_matches || !compiled.pattern.matches(path) {
|
if !method_matches || !compiled.pattern.matches(path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// If caller holds the bypass permission, they are not blocked.
|
// If caller holds the bypass permission, they are not blocked.
|
||||||
match compiled.bypass_mask {
|
!matches!(compiled.bypass_mask, Some(mask) if caller_mask & mask != 0)
|
||||||
Some(mask) if caller_mask & mask != 0 => false,
|
|
||||||
_ => true,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue