use crate::protocol::{Message, WireParam};
use powdb_auth::{Permission, Role, UserStore};
use powdb_query::executor::{is_read_only_statement, Engine};
use powdb_query::parser;
use powdb_query::result::{QueryError, QueryResult};
use powdb_query::sql;
use powdb_storage::types::Value;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, BufWriter};
use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
pub type AuthRateLimiter = Arc<Mutex<HashMap<IpAddr, (u32, Instant)>>>;
pub type TxGate = Arc<Semaphore>;
pub fn new_tx_gate() -> TxGate {
Arc::new(Semaphore::new(1))
}
const MAX_QUERY_LENGTH: usize = 1024 * 1024;
const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_AUTH_FAILURES: u32 = 5;
const AUTH_FAILURE_WINDOW: Duration = Duration::from_secs(60);
pub fn new_rate_limiter() -> AuthRateLimiter {
Arc::new(Mutex::new(HashMap::new()))
}
fn is_rate_limited(limiter: &AuthRateLimiter, ip: IpAddr) -> bool {
let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
map.retain(|_, (_, ts)| now.duration_since(*ts) < AUTH_FAILURE_WINDOW);
if let Some((count, _)) = map.get(&ip) {
*count >= MAX_AUTH_FAILURES
} else {
false
}
}
fn record_auth_failure(limiter: &AuthRateLimiter, ip: IpAddr) {
let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let entry = map.entry(ip).or_insert((0, now));
if now.duration_since(entry.1) >= AUTH_FAILURE_WINDOW {
*entry = (1, now);
} else {
entry.0 += 1;
}
}
fn clear_auth_failures(limiter: &AuthRateLimiter, ip: IpAddr) {
let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
map.remove(&ip);
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use sha2::{Digest, Sha256};
let ha = Sha256::digest(a);
let hb = Sha256::digest(b);
let mut diff = 0u8;
for (x, y) in ha.iter().zip(hb.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub name: String,
pub role: String,
}
fn is_ddl_statement(stmt: &powdb_query::ast::Statement) -> bool {
use powdb_query::ast::Statement;
let inner = match stmt {
Statement::Explain(inner) => inner.as_ref(),
other => other,
};
matches!(
inner,
Statement::CreateType(_)
| Statement::AlterTable(_)
| Statement::DropTable(_)
| Statement::CreateView(_)
| Statement::DropView(_)
)
}
fn required_permission(stmt: &powdb_query::ast::Statement) -> Permission {
if is_read_only_statement(stmt) {
Permission::Read
} else if is_ddl_statement(stmt) {
Permission::Ddl
} else {
Permission::Write
}
}
fn check_statement_permitted(
principal: Option<&Principal>,
stmt: &powdb_query::ast::Statement,
) -> Result<(), QueryError> {
let Some(p) = principal else {
return Ok(());
};
if is_read_only_statement(stmt) {
return Ok(());
}
let needed = required_permission(stmt);
if Role::builtin(&p.role).is_some_and(|r| r.allows(needed)) {
return Ok(());
}
let kind = if needed == Permission::Ddl {
"schema-definition"
} else {
"write"
};
Err(QueryError::Execution(format!(
"permission denied: role '{}' cannot execute {kind} statements",
p.role
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthOutcome {
Authenticated { principal: Option<Principal> },
Rejected,
}
pub fn authenticate_connect(
users: &UserStore,
expected_password: Option<&str>,
username: Option<&str>,
password: Option<&str>,
) -> AuthOutcome {
if !users.is_empty() {
let Some(name) = username else {
return AuthOutcome::Rejected;
};
let Some(candidate) = password else {
return AuthOutcome::Rejected;
};
match users.authenticate(name, candidate) {
Some(user) => AuthOutcome::Authenticated {
principal: Some(Principal {
name: user.name.clone(),
role: user.role.clone(),
}),
},
None => AuthOutcome::Rejected,
}
} else {
match expected_password {
Some(expected) => {
if password.is_some_and(|p| constant_time_eq(p.as_bytes(), expected.as_bytes())) {
AuthOutcome::Authenticated { principal: None }
} else {
AuthOutcome::Rejected
}
}
None => AuthOutcome::Authenticated { principal: None },
}
}
}
const SAFE_ERROR_PREFIXES: &[&str] = &[
"table not found",
"column not found",
"parse error",
"type mismatch",
"unknown table",
"unknown column",
"unknown function",
"syntax error",
"expected",
"unexpected",
"missing",
"duplicate",
"invalid",
"cannot",
"no such",
"already exists",
"permission denied",
"row too large",
"unique constraint violation",
"sort input exceeds",
"join result exceeds",
"query exceeded memory budget",
];
fn sanitize_error(e: &str) -> String {
let lower = e.to_lowercase();
for prefix in SAFE_ERROR_PREFIXES {
if lower.starts_with(prefix) {
return e.to_string();
}
}
"query execution error".into()
}
async fn write_msg<W: AsyncWrite + Unpin>(writer: &mut BufWriter<W>, msg: &Message) -> bool {
let write_fut = async {
if msg.write_to(writer).await.is_err() {
return false;
}
writer.flush().await.is_ok()
};
tokio::time::timeout(WRITE_TIMEOUT, write_fut)
.await
.unwrap_or_default()
}
pub struct ConnOpts<'a> {
pub engine: Arc<RwLock<Engine>>,
pub tx_gate: TxGate,
pub expected_password: Option<Zeroizing<String>>,
pub users: Arc<UserStore>,
pub shutdown_rx: &'a mut watch::Receiver<bool>,
pub idle_timeout: Duration,
pub query_timeout: Duration,
pub rate_limiter: Option<&'a AuthRateLimiter>,
pub peer_addr: Option<std::net::SocketAddr>,
}
fn dispatch_query(
engine: &Arc<RwLock<Engine>>,
query: &str,
principal: Option<&Principal>,
) -> Result<QueryResult, QueryError> {
let stmt_result = parser::parse(query).map_err(|e| e.to_string());
if let Ok(stmt) = &stmt_result {
check_statement_permitted(principal, stmt)?;
}
let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
if can_try_read {
let res = {
let eng = engine
.read()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql_readonly(query)
};
match res {
Ok(r) => return Ok(r),
Err(QueryError::ReadonlyNeedsWrite) => {
}
Err(e) => return Err(e),
}
}
let mut eng = engine
.write()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql(query)
}
fn dispatch_sql_query(
engine: &Arc<RwLock<Engine>>,
query: &str,
principal: Option<&Principal>,
) -> Result<QueryResult, QueryError> {
let stmt_result = sql::parse_sql(query).map_err(|e| e.to_string());
if let Ok(stmt) = &stmt_result {
check_statement_permitted(principal, stmt)?;
}
let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
if can_try_read {
let res = {
let eng = engine
.read()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_sql_readonly(query)
};
match res {
Ok(r) => return Ok(r),
Err(QueryError::ReadonlyNeedsWrite) => {}
Err(e) => return Err(e),
}
}
let mut eng = engine
.write()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_sql(query)
}
fn wire_param_to_value(p: &WireParam) -> powdb_query::ast::ParamValue {
use powdb_query::ast::ParamValue;
match p {
WireParam::Null => ParamValue::Null,
WireParam::Int(v) => ParamValue::Int(*v),
WireParam::Float(v) => ParamValue::Float(*v),
WireParam::Bool(v) => ParamValue::Bool(*v),
WireParam::Str(s) => ParamValue::Str(s.clone()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TransactionControl {
Begin,
Commit,
Rollback,
}
fn transaction_control(stmt: &powdb_query::ast::Statement) -> Option<TransactionControl> {
use powdb_query::ast::Statement;
match stmt {
Statement::Begin => Some(TransactionControl::Begin),
Statement::Commit => Some(TransactionControl::Commit),
Statement::Rollback => Some(TransactionControl::Rollback),
_ => None,
}
}
fn classify_query_transaction_control(query: &str) -> Option<TransactionControl> {
parser::parse(query)
.ok()
.and_then(|stmt| transaction_control(&stmt))
}
fn classify_sql_transaction_control(query: &str) -> Option<TransactionControl> {
sql::parse_sql(query)
.ok()
.and_then(|stmt| transaction_control(&stmt))
}
fn classify_params_transaction_control(
query: &str,
params: &[WireParam],
) -> Option<TransactionControl> {
let bound: Vec<powdb_query::ast::ParamValue> = params.iter().map(wire_param_to_value).collect();
parser::parse_with_params(query, &bound)
.ok()
.and_then(|stmt| transaction_control(&stmt))
}
fn dispatch_query_with_params(
engine: &Arc<RwLock<Engine>>,
query: &str,
params: &[WireParam],
principal: Option<&Principal>,
) -> Result<QueryResult, QueryError> {
let bound: Vec<powdb_query::ast::ParamValue> = params.iter().map(wire_param_to_value).collect();
let stmt_result = parser::parse_with_params(query, &bound).map_err(|e| e.to_string());
if let Ok(stmt) = &stmt_result {
check_statement_permitted(principal, stmt)?;
}
let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
if can_try_read {
let res = {
let eng = engine
.read()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql_readonly_with_params(query, &bound)
};
match res {
Ok(r) => return Ok(r),
Err(QueryError::ReadonlyNeedsWrite) => {
}
Err(e) => return Err(e),
}
}
let mut eng = engine
.write()
.map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
eng.execute_powql_with_params(query, &bound)
}
async fn execute_wire_query(
engine: Arc<RwLock<Engine>>,
tx_gate: TxGate,
tx_permit: &mut Option<OwnedSemaphorePermit>,
query: String,
principal: Option<Principal>,
query_timeout: Duration,
) -> Message {
match classify_query_transaction_control(&query) {
Some(TransactionControl::Begin) => {
if tx_permit.is_some() {
return Message::Error {
message: sanitize_error("transaction already active"),
};
}
let permit = match tx_gate.acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
return Message::Error {
message: "query execution error".into(),
}
}
};
let response = run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_query(&engine, &query, principal.as_ref()),
)
.await;
if is_success_response(&response) {
*tx_permit = Some(permit);
}
response
}
Some(TransactionControl::Commit | TransactionControl::Rollback) => {
let response = run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_query(&engine, &query, principal.as_ref()),
)
.await;
if is_success_response(&response) {
tx_permit.take();
}
response
}
None if tx_permit.is_some() => {
run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_query(&engine, &query, principal.as_ref()),
)
.await
}
None => {
let permit = match tx_gate.acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
return Message::Error {
message: "query execution error".into(),
}
}
};
let response = run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_query(&engine, &query, principal.as_ref()),
)
.await;
drop(permit);
response
}
}
}
async fn execute_wire_query_sql(
engine: Arc<RwLock<Engine>>,
tx_gate: TxGate,
tx_permit: &mut Option<OwnedSemaphorePermit>,
query: String,
principal: Option<Principal>,
query_timeout: Duration,
) -> Message {
match classify_sql_transaction_control(&query) {
Some(TransactionControl::Begin) => {
if tx_permit.is_some() {
return Message::Error {
message: sanitize_error("transaction already active"),
};
}
let permit = match tx_gate.acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
return Message::Error {
message: "query execution error".into(),
}
}
};
let response = run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_sql_query(&engine, &query, principal.as_ref()),
)
.await;
if is_success_response(&response) {
*tx_permit = Some(permit);
}
response
}
Some(TransactionControl::Commit | TransactionControl::Rollback) => {
let response = run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_sql_query(&engine, &query, principal.as_ref()),
)
.await;
if is_success_response(&response) {
tx_permit.take();
}
response
}
None if tx_permit.is_some() => {
run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_sql_query(&engine, &query, principal.as_ref()),
)
.await
}
None => {
let permit = match tx_gate.acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
return Message::Error {
message: "query execution error".into(),
}
}
};
let response = run_blocking_query(
engine,
query,
principal,
query_timeout,
|engine, query, principal| dispatch_sql_query(&engine, &query, principal.as_ref()),
)
.await;
drop(permit);
response
}
}
}
async fn execute_wire_query_with_params(
engine: Arc<RwLock<Engine>>,
tx_gate: TxGate,
tx_permit: &mut Option<OwnedSemaphorePermit>,
query: String,
params: Vec<WireParam>,
principal: Option<Principal>,
query_timeout: Duration,
) -> Message {
match classify_params_transaction_control(&query, ¶ms) {
Some(TransactionControl::Begin) => {
if tx_permit.is_some() {
return Message::Error {
message: sanitize_error("transaction already active"),
};
}
let permit = match tx_gate.acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
return Message::Error {
message: "query execution error".into(),
}
}
};
let response = run_blocking_query(
engine,
(query, params),
principal,
query_timeout,
|engine, (query, params), principal| {
dispatch_query_with_params(&engine, &query, ¶ms, principal.as_ref())
},
)
.await;
if is_success_response(&response) {
*tx_permit = Some(permit);
}
response
}
Some(TransactionControl::Commit | TransactionControl::Rollback) => {
let response = run_blocking_query(
engine,
(query, params),
principal,
query_timeout,
|engine, (query, params), principal| {
dispatch_query_with_params(&engine, &query, ¶ms, principal.as_ref())
},
)
.await;
if is_success_response(&response) {
tx_permit.take();
}
response
}
None if tx_permit.is_some() => {
run_blocking_query(
engine,
(query, params),
principal,
query_timeout,
|engine, (query, params), principal| {
dispatch_query_with_params(&engine, &query, ¶ms, principal.as_ref())
},
)
.await
}
None => {
let permit = match tx_gate.acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
return Message::Error {
message: "query execution error".into(),
}
}
};
let response = run_blocking_query(
engine,
(query, params),
principal,
query_timeout,
|engine, (query, params), principal| {
dispatch_query_with_params(&engine, &query, ¶ms, principal.as_ref())
},
)
.await;
drop(permit);
response
}
}
}
async fn run_blocking_query<T, F>(
engine: Arc<RwLock<Engine>>,
input: T,
principal: Option<Principal>,
query_timeout: Duration,
f: F,
) -> Message
where
T: Send + 'static,
F: FnOnce(Arc<RwLock<Engine>>, T, Option<Principal>) -> Result<QueryResult, QueryError>
+ Send
+ 'static,
{
let handle = tokio::task::spawn_blocking(move || f(engine, input, principal));
let abort_handle = handle.abort_handle();
match tokio::time::timeout(query_timeout, handle).await {
Ok(Ok(Ok(result))) => query_result_to_message(result),
Ok(Ok(Err(e))) => Message::Error {
message: sanitize_error(&e.to_string()),
},
Ok(Err(e)) => Message::Error {
message: format!("internal error: {e}"),
},
Err(_) => {
abort_handle.abort();
Message::Error {
message: "query timeout exceeded".into(),
}
}
}
}
fn is_success_response(msg: &Message) -> bool {
matches!(
msg,
Message::ResultRows { .. }
| Message::ResultScalar { .. }
| Message::ResultOk { .. }
| Message::ResultMessage { .. }
)
}
fn rollback_open_transaction(engine: Arc<RwLock<Engine>>, principal: Option<Principal>) {
let _ = dispatch_query(&engine, "rollback", principal.as_ref());
}
pub async fn handle_connection<S>(stream: S, opts: ConnOpts<'_>)
where
S: AsyncRead + AsyncWrite + Unpin,
{
let ConnOpts {
engine,
tx_gate,
expected_password,
users,
shutdown_rx,
idle_timeout,
query_timeout,
rate_limiter,
peer_addr,
} = opts;
let peer = peer_addr
.map(|a| a.to_string())
.unwrap_or_else(|| "unknown".into());
let peer_ip = peer_addr.map(|a| a.ip());
let (reader, writer) = tokio::io::split(stream);
let mut reader = BufReader::new(reader);
let mut writer = BufWriter::new(writer);
let connect_msg = loop {
match tokio::time::timeout(idle_timeout, Message::read_from_preauth(&mut reader)).await {
Ok(Ok(Some(Message::Ping))) => {
debug!(peer = %peer, "pre-auth ping");
if !write_msg(&mut writer, &Message::Pong).await {
return;
}
continue;
}
Ok(Ok(Some(msg))) => break msg,
Ok(Ok(None)) => {
debug!(peer = %peer, "client closed before CONNECT");
return;
}
Ok(Err(e)) => {
error!(peer = %peer, error = %e, "error reading CONNECT");
return;
}
Err(_) => {
warn!(peer = %peer, "idle timeout waiting for CONNECT");
return;
}
}
};
let principal: Option<Principal>;
match connect_msg {
Message::Connect {
db_name,
password,
username,
} => {
if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
if is_rate_limited(limiter, ip) {
warn!(peer = %peer, "rate limited: too many auth failures");
let err = Message::Error {
message: "too many auth failures, try again later".into(),
};
write_msg(&mut writer, &err).await;
return;
}
}
let outcome = authenticate_connect(
&users,
expected_password.as_ref().map(|p| p.as_str()),
username.as_deref(),
password.as_ref().map(|p| p.as_str()),
);
match outcome {
AuthOutcome::Rejected => {
warn!(peer = %peer, db = %db_name, "auth rejected");
if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
record_auth_failure(limiter, ip);
}
let err = Message::Error {
message: "authentication failed".into(),
};
write_msg(&mut writer, &err).await;
return;
}
AuthOutcome::Authenticated {
principal: auth_principal,
} => {
if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
clear_auth_failures(limiter, ip);
}
match &auth_principal {
Some(p) => {
info!(peer = %peer, db = %db_name, user = %p.name, role = %p.role, "authenticated");
}
None => {
info!(peer = %peer, db = %db_name, "client connected");
}
}
principal = auth_principal;
}
}
let ok = Message::ConnectOk {
version: env!("CARGO_PKG_VERSION").into(),
};
if !write_msg(&mut writer, &ok).await {
return;
}
}
_ => {
warn!(peer = %peer, "first message was not CONNECT");
let err = Message::Error {
message: "expected CONNECT".into(),
};
write_msg(&mut writer, &err).await;
return;
}
}
let mut tx_permit: Option<OwnedSemaphorePermit> = None;
loop {
let msg = tokio::select! {
result = tokio::time::timeout(idle_timeout, Message::read_from(&mut reader)) => {
match result {
Ok(Ok(Some(msg))) => msg,
Ok(Ok(None)) => break,
Ok(Err(e)) => {
error!(peer = %peer, error = %e, "read error");
break;
}
Err(_) => {
info!(peer = %peer, "idle timeout, closing connection");
let err = Message::Error { message: "idle timeout".into() };
write_msg(&mut writer, &err).await;
break;
}
}
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!(peer = %peer, "server shutting down, closing connection");
let err = Message::Error { message: "server shutting down".into() };
write_msg(&mut writer, &err).await;
break;
}
continue;
}
};
let response = match msg {
Message::Ping => {
debug!(peer = %peer, "ping");
Message::Pong
}
Message::Query { query } => {
if query.len() > MAX_QUERY_LENGTH {
Message::Error {
message: format!(
"query too large: {} bytes (max {})",
query.len(),
MAX_QUERY_LENGTH
),
}
} else {
debug!(peer = %peer, query = %query, "received query");
let response = execute_wire_query(
engine.clone(),
tx_gate.clone(),
&mut tx_permit,
query.clone(),
principal.clone(),
query_timeout,
)
.await;
if matches!(&response, Message::Error { message } if message == "query timeout exceeded")
{
warn!(peer = %peer, query = %query, "query timeout exceeded");
if tx_permit.is_some() {
let engine = engine.clone();
let principal = principal.clone();
let _ = tokio::task::spawn_blocking(move || {
rollback_open_transaction(engine, principal)
})
.await;
}
tx_permit.take();
}
response
}
}
Message::QuerySql { query } => {
if query.len() > MAX_QUERY_LENGTH {
Message::Error {
message: format!(
"query too large: {} bytes (max {})",
query.len(),
MAX_QUERY_LENGTH
),
}
} else {
debug!(peer = %peer, query = %query, "received SQL query");
let response = execute_wire_query_sql(
engine.clone(),
tx_gate.clone(),
&mut tx_permit,
query.clone(),
principal.clone(),
query_timeout,
)
.await;
if matches!(&response, Message::Error { message } if message == "query timeout exceeded")
{
warn!(peer = %peer, query = %query, "SQL query timeout exceeded");
if tx_permit.is_some() {
let engine = engine.clone();
let principal = principal.clone();
let _ = tokio::task::spawn_blocking(move || {
rollback_open_transaction(engine, principal)
})
.await;
}
tx_permit.take();
}
response
}
}
Message::QueryWithParams { query, params } => {
if query.len() > MAX_QUERY_LENGTH {
Message::Error {
message: format!(
"query too large: {} bytes (max {})",
query.len(),
MAX_QUERY_LENGTH
),
}
} else {
debug!(peer = %peer, query = %query, n_params = params.len(), "received parameterized query");
let response = execute_wire_query_with_params(
engine.clone(),
tx_gate.clone(),
&mut tx_permit,
query.clone(),
params.clone(),
principal.clone(),
query_timeout,
)
.await;
if matches!(&response, Message::Error { message } if message == "query timeout exceeded")
{
warn!(peer = %peer, query = %query, "query timeout exceeded");
if tx_permit.is_some() {
let engine = engine.clone();
let principal = principal.clone();
let _ = tokio::task::spawn_blocking(move || {
rollback_open_transaction(engine, principal)
})
.await;
}
tx_permit.take();
}
response
}
}
Message::Disconnect => {
debug!(peer = %peer, "received DISCONNECT");
break;
}
_ => Message::Error {
message: "unexpected message type".into(),
},
};
if !write_msg(&mut writer, &response).await {
break;
}
}
if tx_permit.is_some() {
let engine = engine.clone();
let principal = principal.clone();
let _ =
tokio::task::spawn_blocking(move || rollback_open_transaction(engine, principal)).await;
}
tx_permit.take();
info!(peer = %peer, "client disconnected");
}
fn query_result_to_message(result: QueryResult) -> Message {
match result {
QueryResult::Rows { columns, rows } => {
let str_rows: Vec<Vec<String>> = rows
.iter()
.map(|row| row.iter().map(value_to_display).collect())
.collect();
Message::ResultRows {
columns,
rows: str_rows,
}
}
QueryResult::Scalar(val) => Message::ResultScalar {
value: value_to_display(&val),
},
QueryResult::Modified(n) => Message::ResultOk { affected: n },
QueryResult::Created(name) => Message::ResultMessage {
message: format!("type {name} created"),
},
QueryResult::Executed { message } => Message::ResultMessage { message },
}
}
fn value_to_display(v: &Value) -> String {
match v {
Value::Int(n) => n.to_string(),
Value::Float(n) => format!("{n}"),
Value::Bool(b) => b.to_string(),
Value::Str(s) => s.clone(),
Value::DateTime(t) => format!("{t}"),
Value::Uuid(u) => format!("{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
u[0], u[1], u[2], u[3], u[4], u[5], u[6], u[7],
u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15]),
Value::Bytes(b) => format!("<{} bytes>", b.len()),
Value::Empty => "null".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_serializes_as_null_bareword_on_wire() {
assert_eq!(value_to_display(&Value::Empty), "null");
}
#[test]
fn unique_violation_error_surfaces_to_remote_clients() {
assert_eq!(
sanitize_error("unique constraint violation on User.email"),
"unique constraint violation on User.email"
);
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
sanitize_error("some internal io panic detail"),
"query execution error"
);
}
#[test]
fn resource_limit_errors_surface_actionable_hints() {
for msg in [
"sort input exceeds row limit — add a LIMIT clause",
"join result exceeds row limit",
"query exceeded memory budget: requested 100 bytes, limit 50 bytes",
] {
assert_eq!(sanitize_error(msg), msg, "should pass through verbatim");
}
}
fn parsed(q: &str) -> powdb_query::ast::Statement {
parser::parse(q).unwrap()
}
fn principal(role: &str) -> Option<Principal> {
Some(Principal {
name: "u".into(),
role: role.into(),
})
}
#[test]
fn readonly_can_read_but_not_write() {
let p = principal("readonly");
assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
assert!(check_statement_permitted(p.as_ref(), &parsed("count(User)")).is_ok());
assert!(check_statement_permitted(p.as_ref(), &parsed("explain User")).is_ok());
for q in [
r#"insert User { name := "x" }"#,
"User filter .id = 1 update { age := 2 }",
"User filter .id = 1 delete",
"drop User",
"alter User add column c: str",
"type T { required id: int }",
"begin",
"commit",
"rollback",
] {
let err = check_statement_permitted(p.as_ref(), &parsed(q))
.expect_err(&format!("must deny: {q}"));
assert!(
err.to_string().contains("permission denied"),
"unexpected error for {q}: {err}"
);
}
}
#[test]
fn readwrite_and_admin_have_full_query_access() {
for role in ["readwrite", "admin"] {
let p = principal(role);
assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
assert!(check_statement_permitted(
p.as_ref(),
&parsed(r#"insert User { name := "x" }"#)
)
.is_ok());
assert!(check_statement_permitted(p.as_ref(), &parsed("drop User")).is_ok());
}
}
#[test]
fn unknown_role_fails_closed_for_writes() {
let p = principal("mystery");
assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
assert!(
check_statement_permitted(p.as_ref(), &parsed(r#"insert User { name := "x" }"#))
.is_err()
);
}
#[test]
fn no_principal_means_full_access() {
assert!(check_statement_permitted(None, &parsed("drop User")).is_ok());
assert!(check_statement_permitted(None, &parsed(r#"insert User { name := "x" }"#)).is_ok());
}
fn store_with_alice() -> UserStore {
let mut s = UserStore::new();
s.create_user("alice", "pw", "readwrite").unwrap();
s
}
#[test]
fn empty_store_no_password_is_open() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, None, None, None),
AuthOutcome::Authenticated { principal: None }
);
assert_eq!(
authenticate_connect(&s, None, Some("x"), Some("y")),
AuthOutcome::Authenticated { principal: None }
);
}
#[test]
fn empty_store_correct_shared_password_succeeds() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), None, Some("pw")),
AuthOutcome::Authenticated { principal: None }
);
}
#[test]
fn empty_store_wrong_shared_password_rejected() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), None, Some("bad")),
AuthOutcome::Rejected
);
}
#[test]
fn empty_store_missing_password_rejected_when_expected() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), None, None),
AuthOutcome::Rejected
);
}
#[test]
fn empty_store_ignores_username_for_shared_password() {
let s = UserStore::new();
assert_eq!(
authenticate_connect(&s, Some("pw"), Some("whoever"), Some("pw")),
AuthOutcome::Authenticated { principal: None }
);
}
#[test]
fn user_auth_success_binds_principal() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, Some("alice"), Some("pw")),
AuthOutcome::Authenticated {
principal: Some(Principal {
name: "alice".into(),
role: "readwrite".into(),
})
}
);
}
#[test]
fn user_auth_wrong_password_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, Some("alice"), Some("bad")),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_unknown_user_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, Some("mallory"), Some("pw")),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_missing_username_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, None, None, Some("pw")),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_missing_password_rejected() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, Some("pw"), Some("alice"), None),
AuthOutcome::Rejected
);
}
#[test]
fn user_auth_ignores_shared_password_when_users_present() {
let s = store_with_alice();
assert_eq!(
authenticate_connect(&s, Some("shared"), None, Some("shared")),
AuthOutcome::Rejected
);
}
}