mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-15 12:55:56 +02:00
* Add Custom Roles collection permissions * Return NotFound for denied collection access * Grant Manage on imported collections to every Custom member
827 lines
32 KiB
Rust
827 lines
32 KiB
Rust
mod query_logger;
|
|
|
|
use std::{
|
|
future::Future,
|
|
sync::{
|
|
Arc, OnceLock,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
time::Duration,
|
|
};
|
|
|
|
use diesel::{
|
|
Connection, RunQueryDsl,
|
|
connection::SimpleConnection,
|
|
r2d2::{CustomizeConnection, Pool, PooledConnection},
|
|
};
|
|
use rocket::{
|
|
Request,
|
|
http::Status,
|
|
request::{FromRequest, Outcome},
|
|
};
|
|
use tokio::{
|
|
sync::{Mutex, OwnedSemaphorePermit, Semaphore},
|
|
time::timeout,
|
|
};
|
|
|
|
use crate::{
|
|
CONFIG,
|
|
error::{Error, MapResult},
|
|
};
|
|
|
|
// These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools
|
|
// A wrapper around spawn_blocking that propagates panics to the calling code.
|
|
pub async fn run_blocking<F, R>(job: F) -> R
|
|
where
|
|
F: FnOnce() -> R + Send + 'static,
|
|
R: Send + 'static,
|
|
{
|
|
match tokio::task::spawn_blocking(job).await {
|
|
Ok(ret) => ret,
|
|
Err(e) => match e.try_into_panic() {
|
|
Ok(panic) => std::panic::resume_unwind(panic),
|
|
Err(_) => unreachable!("spawn_blocking tasks are never cancelled"),
|
|
},
|
|
}
|
|
}
|
|
|
|
// This is used to generate the main DbConn and DbPool enums, which contain one variant for each database supported
|
|
#[derive(diesel::MultiConnection)]
|
|
pub enum DbConnInner {
|
|
#[cfg(mysql)]
|
|
Mysql(diesel::mysql::MysqlConnection),
|
|
#[cfg(postgresql)]
|
|
Postgresql(diesel::pg::PgConnection),
|
|
#[cfg(sqlite)]
|
|
Sqlite(diesel::sqlite::SqliteConnection),
|
|
}
|
|
|
|
/// Custom connection manager that implements manual connection establishment
|
|
pub struct DbConnManager {
|
|
database_url: String,
|
|
}
|
|
|
|
impl DbConnManager {
|
|
pub fn new(database_url: &str) -> Self {
|
|
Self {
|
|
database_url: database_url.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn establish_connection(&self) -> Result<DbConnInner, diesel::r2d2::Error> {
|
|
match DbConnType::from_url(&self.database_url) {
|
|
#[cfg(mysql)]
|
|
Ok(DbConnType::Mysql) => {
|
|
let conn = diesel::mysql::MysqlConnection::establish(&self.database_url)?;
|
|
Ok(DbConnInner::Mysql(conn))
|
|
}
|
|
#[cfg(postgresql)]
|
|
Ok(DbConnType::Postgresql) => {
|
|
let conn = diesel::pg::PgConnection::establish(&self.database_url)?;
|
|
Ok(DbConnInner::Postgresql(conn))
|
|
}
|
|
#[cfg(sqlite)]
|
|
Ok(DbConnType::Sqlite) => {
|
|
let conn = diesel::sqlite::SqliteConnection::establish(&self.database_url)?;
|
|
Ok(DbConnInner::Sqlite(conn))
|
|
}
|
|
|
|
Err(e) => Err(diesel::r2d2::Error::ConnectionError(diesel::ConnectionError::InvalidConnectionUrl(
|
|
format!("Unable to estabilsh a connection: {e:?}"),
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl diesel::r2d2::ManageConnection for DbConnManager {
|
|
type Connection = DbConnInner;
|
|
type Error = diesel::r2d2::Error;
|
|
|
|
fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
|
self.establish_connection()
|
|
}
|
|
|
|
fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
|
use diesel::r2d2::R2D2Connection;
|
|
conn.ping().map_err(diesel::r2d2::Error::QueryError)
|
|
}
|
|
|
|
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
|
|
use diesel::r2d2::R2D2Connection;
|
|
conn.is_broken()
|
|
}
|
|
}
|
|
|
|
#[derive(Eq, PartialEq)]
|
|
pub enum DbConnType {
|
|
#[cfg(mysql)]
|
|
Mysql,
|
|
#[cfg(postgresql)]
|
|
Postgresql,
|
|
#[cfg(sqlite)]
|
|
Sqlite,
|
|
}
|
|
|
|
pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
|
|
|
|
pub struct DbConn {
|
|
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
|
permit: Option<OwnedSemaphorePermit>,
|
|
transaction_open: AtomicBool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct DbConnOptions {
|
|
pub init_stmts: String,
|
|
}
|
|
|
|
impl CustomizeConnection<DbConnInner, diesel::r2d2::Error> for DbConnOptions {
|
|
fn on_acquire(&self, conn: &mut DbConnInner) -> Result<(), diesel::r2d2::Error> {
|
|
if !self.init_stmts.is_empty() {
|
|
conn.batch_execute(&self.init_stmts).map_err(diesel::r2d2::Error::QueryError)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct DbPool {
|
|
// This is an 'Option' so that we can drop the pool in a 'spawn_blocking'.
|
|
pool: Option<Pool<DbConnManager>>,
|
|
semaphore: Arc<Semaphore>,
|
|
}
|
|
|
|
impl Drop for DbConn {
|
|
fn drop(&mut self) {
|
|
let conn = Arc::clone(&self.conn);
|
|
let permit = self.permit.take();
|
|
let transaction_open = self.transaction_open.load(Ordering::Acquire);
|
|
|
|
// Since connection can't be on the stack in an async fn during an
|
|
// await, we have to spawn a new blocking-safe thread...
|
|
tokio::task::spawn_blocking(move || {
|
|
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
|
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
|
|
|
if let Some(mut conn) = conn.take() {
|
|
if transaction_open {
|
|
// A cancelled request must not return an open transaction to the pool.
|
|
if let Err(error) = conn.batch_execute("ROLLBACK") {
|
|
error!("Failed to roll back a cancelled database transaction: {error}");
|
|
}
|
|
}
|
|
drop(conn);
|
|
}
|
|
|
|
// Drop permit after the connection is dropped
|
|
drop(permit);
|
|
});
|
|
}
|
|
}
|
|
|
|
impl Drop for DbPool {
|
|
fn drop(&mut self) {
|
|
let pool = self.pool.take();
|
|
// Only use spawn_blocking if the Tokio runtime is still available
|
|
// Otherwise the pool will be dropped on the current thread
|
|
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
handle.spawn_blocking(move || drop(pool));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DbPool {
|
|
// For the given database URL, guess its type, run migrations, create pool, and return it
|
|
pub fn from_config() -> Result<Self, Error> {
|
|
let db_url = CONFIG.database_url();
|
|
let conn_type = DbConnType::from_url(&db_url)?;
|
|
|
|
// Only set the default instrumentation if the log level is specifically set to either warn, info or debug
|
|
if log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Warn)
|
|
|| log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Info)
|
|
|| log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Debug)
|
|
{
|
|
drop(diesel::connection::set_default_instrumentation(query_logger::simple_logger));
|
|
}
|
|
|
|
match conn_type {
|
|
#[cfg(mysql)]
|
|
DbConnType::Mysql => {
|
|
mysql_migrations::run_migrations(&db_url)?;
|
|
}
|
|
#[cfg(postgresql)]
|
|
DbConnType::Postgresql => {
|
|
postgresql_migrations::run_migrations(&db_url)?;
|
|
}
|
|
#[cfg(sqlite)]
|
|
DbConnType::Sqlite => {
|
|
sqlite_migrations::run_migrations(&db_url)?;
|
|
}
|
|
}
|
|
|
|
let max_conns = CONFIG.database_max_conns();
|
|
let manager = DbConnManager::new(&db_url);
|
|
let pool = Pool::builder()
|
|
.max_size(max_conns)
|
|
.min_idle(Some(CONFIG.database_min_conns()))
|
|
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
|
|
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
|
|
.connection_customizer(Box::new(DbConnOptions {
|
|
init_stmts: conn_type.get_init_stmts(),
|
|
}))
|
|
.build(manager)
|
|
.map_res("Failed to create pool")?;
|
|
|
|
// Set a global to determine the database more easily throughout the rest of the code
|
|
if ACTIVE_DB_TYPE.set(conn_type).is_err() {
|
|
error!("Tried to set the active database connection type more than once.");
|
|
}
|
|
|
|
Ok(DbPool {
|
|
pool: Some(pool),
|
|
semaphore: Arc::new(Semaphore::new(max_conns as usize)),
|
|
})
|
|
}
|
|
|
|
// Get a connection from the pool
|
|
pub async fn get(&self) -> Result<DbConn, Error> {
|
|
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
|
|
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
|
Ok(p) => p.expect("Semaphore should be open"),
|
|
Err(_) => {
|
|
err!("Timeout waiting for database connection");
|
|
}
|
|
};
|
|
|
|
let p = self.pool.as_ref().expect("DbPool.pool should always be Some()");
|
|
let pool = p.clone();
|
|
let c =
|
|
run_blocking(move || pool.get_timeout(duration)).await.map_res("Error retrieving connection from pool")?;
|
|
Ok(DbConn {
|
|
conn: Arc::new(Mutex::new(Some(c))),
|
|
permit: Some(permit),
|
|
transaction_open: AtomicBool::new(false),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl DbConnType {
|
|
pub fn from_url(url: &str) -> Result<Self, Error> {
|
|
// Mysql
|
|
if url.len() > 6 && &url[..6] == "mysql:" {
|
|
#[cfg(mysql)]
|
|
return Ok(DbConnType::Mysql);
|
|
|
|
#[cfg(not(mysql))]
|
|
err!("`DATABASE_URL` is a MySQL URL, but the 'mysql' feature is not enabled")
|
|
|
|
// Postgresql
|
|
} else if url.len() > 11 && (&url[..11] == "postgresql:" || &url[..9] == "postgres:") {
|
|
#[cfg(postgresql)]
|
|
return Ok(DbConnType::Postgresql);
|
|
|
|
#[cfg(not(postgresql))]
|
|
err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled")
|
|
|
|
// Sqlite (explicit)
|
|
} else if url.len() > 7 && &url[..7] == "sqlite:" {
|
|
#[cfg(sqlite)]
|
|
return Ok(DbConnType::Sqlite);
|
|
|
|
#[cfg(not(sqlite))]
|
|
err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled")
|
|
}
|
|
|
|
// No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist.
|
|
// This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database.
|
|
#[cfg(sqlite)]
|
|
{
|
|
if std::path::Path::new(url).exists() {
|
|
return Ok(DbConnType::Sqlite);
|
|
}
|
|
err!(format!(
|
|
"`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \
|
|
and no existing SQLite database was found at '{url}'. \
|
|
If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \
|
|
Otherwise, check your DATABASE_URL for typos or quoting issues."
|
|
))
|
|
}
|
|
|
|
#[cfg(not(sqlite))]
|
|
err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)")
|
|
}
|
|
|
|
pub fn get_init_stmts(&self) -> String {
|
|
let init_stmts = CONFIG.database_conn_init();
|
|
if init_stmts.is_empty() {
|
|
self.default_init_stmts()
|
|
} else {
|
|
init_stmts
|
|
}
|
|
}
|
|
|
|
pub fn default_init_stmts(&self) -> String {
|
|
match self {
|
|
#[cfg(mysql)]
|
|
Self::Mysql => String::new(),
|
|
#[cfg(postgresql)]
|
|
Self::Postgresql => String::new(),
|
|
#[cfg(sqlite)]
|
|
Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DbConn {
|
|
/// Run asynchronous model calls on this request's pooled connection in one transaction.
|
|
/// Send external notifications only after this returns successfully.
|
|
pub async fn transaction<F, Fut, T>(&self, operation: F) -> Result<T, Error>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: Future<Output = Result<T, Error>>,
|
|
{
|
|
self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?;
|
|
self.transaction_open.store(true, Ordering::Release);
|
|
let result = operation().await;
|
|
if result.is_ok() {
|
|
let committed = self.run(|conn| conn.batch_execute("COMMIT")).await;
|
|
let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok();
|
|
if closed {
|
|
self.transaction_open.store(false, Ordering::Release);
|
|
}
|
|
committed.map_res("Error committing database transaction")?;
|
|
} else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() {
|
|
self.transaction_open.store(false, Ordering::Release);
|
|
}
|
|
result
|
|
}
|
|
|
|
pub async fn run<F, R>(&self, f: F) -> R
|
|
where
|
|
F: FnOnce(&mut DbConnInner) -> R + Send,
|
|
R: Send + 'static,
|
|
{
|
|
let conn = Arc::clone(&self.conn);
|
|
let mut conn = conn.lock_owned().await;
|
|
let conn = conn.as_mut().expect("Internal invariant broken: self.conn is Some");
|
|
|
|
// Run blocking can't be used due to the 'static limitation, use block_in_place instead
|
|
tokio::task::block_in_place(move || f(conn))
|
|
}
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! db_run {
|
|
( $conn:ident: $body:block ) => {
|
|
$conn.run(move |$conn| $body).await
|
|
};
|
|
|
|
( $conn:ident: $( $($db:ident),+ $body:block )+ ) => {
|
|
$conn.run(move |$conn| {
|
|
match $conn {
|
|
$($(
|
|
#[cfg($db)]
|
|
pastey::paste!(&mut $crate::db::DbConnInner::[<$db:camel>](ref mut $conn)) => {
|
|
$body
|
|
},
|
|
)+)+}
|
|
}).await
|
|
};
|
|
}
|
|
|
|
// Write all ToSql<Text, DB> and FromSql<Text, DB> given a serializable/deserializable type.
|
|
#[macro_export]
|
|
macro_rules! impl_FromToSqlText {
|
|
($name:ty) => {
|
|
#[cfg(mysql)]
|
|
impl ToSql<Text, diesel::mysql::Mysql> for $name {
|
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result {
|
|
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[cfg(postgresql)]
|
|
impl ToSql<Text, diesel::pg::Pg> for $name {
|
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result {
|
|
serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[cfg(sqlite)]
|
|
impl ToSql<Text, diesel::sqlite::Sqlite> for $name {
|
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result {
|
|
serde_json::to_string(self).map_err(Into::into).map(|str| {
|
|
out.set_value(str);
|
|
diesel::serialize::IsNull::No
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<DB: diesel::backend::Backend> FromSql<Text, DB> for $name
|
|
where
|
|
String: FromSql<Text, DB>,
|
|
{
|
|
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
|
<String as FromSql<Text, DB>>::from_sql(bytes)
|
|
.and_then(|str| serde_json::from_str(&str).map_err(Into::into))
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
pub mod schema;
|
|
|
|
// Reexport the models, needs to be after the macros are defined so it can access them
|
|
pub mod models;
|
|
|
|
/// Creates a back-up of the sqlite database
|
|
/// MySQL/MariaDB and PostgreSQL are not supported.
|
|
#[cfg(sqlite)]
|
|
pub fn backup_sqlite() -> Result<String, Error> {
|
|
use diesel::Connection;
|
|
|
|
let db_url = CONFIG.database_url();
|
|
if DbConnType::from_url(&CONFIG.database_url()).is_ok_and(|t| t == DbConnType::Sqlite) {
|
|
// Strip the sqlite:// prefix if present to get the raw file path
|
|
let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url);
|
|
// Open a read-only connection for the backup
|
|
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{file_path}?mode=ro"))?;
|
|
|
|
let db_path = std::path::Path::new(file_path).parent().unwrap();
|
|
let backup_file = db_path
|
|
.join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S")))
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
|
|
diesel::sql_query("VACUUM INTO ?")
|
|
.bind::<diesel::sql_types::Text, _>(&backup_file)
|
|
.execute(&mut conn)
|
|
.map(|_| ())
|
|
.map_res("VACUUM INTO failed")?;
|
|
|
|
Ok(backup_file)
|
|
} else {
|
|
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
|
}
|
|
}
|
|
|
|
#[cfg(not(sqlite))]
|
|
pub fn backup_sqlite() -> Result<String, Error> {
|
|
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
|
}
|
|
|
|
/// Get the SQL Server version
|
|
pub async fn get_sql_server_version(conn: &DbConn) -> String {
|
|
db_run! { conn:
|
|
postgresql,mysql {
|
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("version();"))
|
|
.get_result::<String>(conn)
|
|
.unwrap_or_else(|_| "Unknown".to_owned())
|
|
}
|
|
sqlite {
|
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::Text>("sqlite_version();"))
|
|
.get_result::<String>(conn)
|
|
.unwrap_or_else(|_| "Unknown".to_owned())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Attempts to retrieve a single connection from the managed database pool. If
|
|
/// no pool is currently managed, fails with an `InternalServerError` status. If
|
|
/// no connections are available, fails with a `ServiceUnavailable` status.
|
|
#[rocket::async_trait]
|
|
impl<'r> FromRequest<'r> for DbConn {
|
|
type Error = ();
|
|
|
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
|
match request.rocket().state::<DbPool>() {
|
|
Some(p) => match p.get().await {
|
|
Ok(dbconn) => Outcome::Success(dbconn),
|
|
_ => Outcome::Error((Status::ServiceUnavailable, ())),
|
|
},
|
|
None => Outcome::Error((Status::InternalServerError, ())),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Embed the migrations from the migrations folder into the application
|
|
// This way, the program automatically migrates the database to the latest version
|
|
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
|
|
#[cfg(sqlite)]
|
|
mod sqlite_migrations {
|
|
use diesel::{Connection, RunQueryDsl};
|
|
use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
|
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite");
|
|
|
|
pub fn run_migrations(db_url: &str) -> Result<(), super::Error> {
|
|
// Establish a connection to the sqlite database (this will create a new one, if it does
|
|
// not exist, and exit if there is an error).
|
|
let mut connection = diesel::sqlite::SqliteConnection::establish(db_url)?;
|
|
|
|
// Run the migrations after successfully establishing a connection
|
|
// Disable Foreign Key Checks during migration
|
|
// Scoped to a connection.
|
|
diesel::sql_query("PRAGMA foreign_keys = OFF")
|
|
.execute(&mut connection)
|
|
.expect("Failed to disable Foreign Key Checks during migrations");
|
|
|
|
// Turn on WAL in SQLite
|
|
if crate::CONFIG.enable_db_wal() {
|
|
diesel::sql_query("PRAGMA journal_mode=wal").execute(&mut connection).expect("Failed to turn on WAL");
|
|
}
|
|
|
|
connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(mysql)]
|
|
mod mysql_migrations {
|
|
use diesel::{Connection, RunQueryDsl};
|
|
use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
|
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql");
|
|
|
|
pub fn run_migrations(db_url: &str) -> Result<(), super::Error> {
|
|
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
|
|
let mut connection = diesel::mysql::MysqlConnection::establish(db_url)?;
|
|
|
|
// Disable Foreign Key Checks during migration
|
|
// Scoped to a connection/session.
|
|
diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0")
|
|
.execute(&mut connection)
|
|
.expect("Failed to disable Foreign Key Checks during migrations");
|
|
|
|
connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(postgresql)]
|
|
mod postgresql_migrations {
|
|
use diesel::Connection;
|
|
use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
|
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql");
|
|
|
|
pub fn run_migrations(db_url: &str) -> Result<(), super::Error> {
|
|
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
|
|
let mut connection = diesel::pg::PgConnection::establish(db_url)?;
|
|
|
|
connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
|
#[cfg(all(test, sqlite))]
|
|
pub mod test_db {
|
|
use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS};
|
|
use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection};
|
|
use diesel_migrations::MigrationHarness;
|
|
use std::sync::{Arc, atomic::AtomicBool};
|
|
use tokio::sync::Mutex;
|
|
|
|
pub struct TestDb {
|
|
pool: Pool<DbConnManager>,
|
|
// Keep the named in-memory database alive across pooled connections.
|
|
_keeper: SqliteConnection,
|
|
}
|
|
|
|
impl TestDb {
|
|
fn new() -> Self {
|
|
let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid());
|
|
let mut keeper = SqliteConnection::establish(&url).unwrap();
|
|
keeper.run_pending_migrations(MIGRATIONS).unwrap();
|
|
let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap();
|
|
Self {
|
|
pool,
|
|
_keeper: keeper,
|
|
}
|
|
}
|
|
|
|
pub fn conn(&self) -> DbConn {
|
|
DbConn {
|
|
conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))),
|
|
permit: None,
|
|
transaction_open: AtomicBool::new(false),
|
|
}
|
|
}
|
|
}
|
|
|
|
// CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized.
|
|
pub fn run(test: impl AsyncFnOnce(&TestDb)) {
|
|
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
|
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
|
.env("DATABASE_URL", "sqlite://:memory:")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
|
"{}\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
return;
|
|
}
|
|
// DbConn::run uses block_in_place, requiring a multi-threaded runtime.
|
|
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
|
|
super::models::test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
|
|
test(&TestDb::new()).await;
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
|
use super::schema::users_organizations::dsl::*;
|
|
use diesel::{migration::MigrationSource, prelude::*};
|
|
let mut conn = SqliteConnection::establish(":memory:").unwrap();
|
|
for migration in conn.pending_migrations(MIGRATIONS).unwrap() {
|
|
if migration.name().version().to_string() == "20261010120000" {
|
|
break;
|
|
}
|
|
conn.run_migration(&migration).unwrap();
|
|
}
|
|
conn.batch_execute(
|
|
"PRAGMA foreign_keys=OFF;
|
|
INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b');
|
|
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES
|
|
('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1),
|
|
('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);",
|
|
)
|
|
.unwrap();
|
|
let rows = |conn: &mut SqliteConnection| {
|
|
users_organizations
|
|
.select((uuid, user_uuid, org_uuid, access_all, akey, status, atype))
|
|
.order(uuid)
|
|
.load::<(String, String, String, bool, String, i32, i32)>(conn)
|
|
.unwrap()
|
|
};
|
|
let before = rows(&mut conn);
|
|
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
|
assert_eq!(rows(&mut conn), before);
|
|
let grants = |conn: &mut SqliteConnection| {
|
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>(
|
|
"(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies
|
|
OR create_new_collections OR edit_any_collection OR delete_any_collection
|
|
OR access_event_logs OR access_import_export OR access_reports)",
|
|
))
|
|
.get_result::<i64>(conn)
|
|
.unwrap()
|
|
};
|
|
assert_eq!(grants(&mut conn), 0);
|
|
let migration = MigrationSource::<diesel::sqlite::Sqlite>::migrations(&MIGRATIONS)
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|m| m.name().version().to_string() == "20261010120000")
|
|
.unwrap();
|
|
for member_status in [1, 2] {
|
|
conn.batch_execute(&format!(
|
|
"UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'"
|
|
))
|
|
.unwrap();
|
|
let custom = rows(&mut conn);
|
|
assert!(conn.revert_migration(&*migration).is_err());
|
|
assert_eq!(rows(&mut conn), custom);
|
|
assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::<bool>(&mut conn).unwrap());
|
|
}
|
|
conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'")
|
|
.unwrap();
|
|
conn.revert_migration(&*migration).unwrap();
|
|
assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err());
|
|
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
|
assert_eq!(grants(&mut conn), 0);
|
|
assert_eq!(rows(&mut conn), before);
|
|
}
|
|
|
|
use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User};
|
|
|
|
pub async fn organization(conn: &DbConn) -> Organization {
|
|
let org = Organization::new("Org".into(), "billing@example.com", None, None);
|
|
org.save(conn).await.unwrap();
|
|
org
|
|
}
|
|
|
|
pub async fn member(
|
|
conn: &DbConn,
|
|
org: &OrganizationId,
|
|
role: MembershipType,
|
|
permissions: &[CustomPermission],
|
|
) -> (User, Membership) {
|
|
let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None);
|
|
user.password_hash = b"existing-account".to_vec();
|
|
user.save(conn).await.unwrap();
|
|
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
|
|
member.set_type(role);
|
|
member.status = 2;
|
|
member.set_custom_permissions(&permissions.iter().copied().collect());
|
|
member.save(conn).await.unwrap();
|
|
(user, member)
|
|
}
|
|
|
|
pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) {
|
|
let org = organization(conn).await;
|
|
let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await;
|
|
(org, user, member)
|
|
}
|
|
|
|
pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection {
|
|
let collection = Collection::new(org.clone(), "Collection".into(), None);
|
|
collection.save(conn).await.unwrap();
|
|
collection
|
|
}
|
|
|
|
#[test]
|
|
fn transactions_rollback_before_connection_reuse_and_commit_success() {
|
|
use crate::error::MapResult;
|
|
use diesel::RunQueryDsl;
|
|
run(async |db| {
|
|
let conn = db.conn();
|
|
conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap();
|
|
for (sql, expected) in [
|
|
("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64),
|
|
("INSERT INTO probe VALUES (1)", 1),
|
|
] {
|
|
let result: Result<(), super::Error> = conn
|
|
.transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed"))
|
|
.await;
|
|
assert_eq!(result.is_ok(), expected == 1);
|
|
let count: i64 = conn
|
|
.run(|c| {
|
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>("(SELECT COUNT(*) FROM probe)"))
|
|
.get_result(c)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(count, expected);
|
|
// The same live connection must accept another transaction, before DbConn::drop can help.
|
|
conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap();
|
|
}
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn collection_and_cipher_access_requires_active_membership_and_enabled_groups() {
|
|
use super::models::{Cipher, CipherAccessScope, CollectionCipher, CollectionUser, test_flag};
|
|
use crate::{
|
|
api::core::{CipherSyncData, CipherSyncType},
|
|
auth::{CollectionAction, custom_member_may},
|
|
};
|
|
run(async |db| {
|
|
let conn = db.conn();
|
|
let (org, user, mut member) = custom_member(&conn, CustomPermission::EditAnyCollection).await;
|
|
let collection = collection(&conn, &org.uuid).await;
|
|
let mut cipher = Cipher::new(2);
|
|
cipher.name = "Cipher".into();
|
|
cipher.organization_uuid = Some(org.uuid.clone());
|
|
cipher.save(&conn).await.unwrap();
|
|
CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await.unwrap();
|
|
for (enabled, status, allowed) in [(true, 2, true), (false, 2, false), (true, 1, false)] {
|
|
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
|
|
member.status = status;
|
|
member.save(&conn).await.unwrap();
|
|
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::User, &conn).await);
|
|
assert_eq!(
|
|
cipher.is_write_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await,
|
|
allowed
|
|
);
|
|
}
|
|
test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
|
|
member.status = 2;
|
|
member.save(&conn).await.unwrap();
|
|
let foreign = organization(&conn).await;
|
|
cipher.organization_uuid = Some(foreign.uuid);
|
|
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await);
|
|
cipher.organization_uuid = Some(org.uuid.clone());
|
|
member.set_custom_permissions(&std::collections::HashSet::new());
|
|
member.save(&conn).await.unwrap();
|
|
let sql=format!("INSERT INTO groups (uuid,organizations_uuid,name,access_all,creation_date,revision_date)
|
|
VALUES ('g','{}','Group',FALSE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP);
|
|
INSERT INTO groups_users (groups_uuid,users_organizations_uuid) VALUES ('g','{}');
|
|
INSERT INTO collections_groups (collections_uuid,groups_uuid,read_only,hide_passwords,manage) VALUES ('{}','g',FALSE,FALSE,TRUE);",
|
|
org.uuid,member.uuid,collection.uuid);
|
|
conn.run(move |c| c.batch_execute(&sql)).await.unwrap();
|
|
for status in [2, 1, -3, -126] {
|
|
member.status = status;
|
|
member.save(&conn).await.unwrap();
|
|
let allowed = status == 2 && crate::CONFIG.org_groups_enabled();
|
|
assert_eq!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await, allowed);
|
|
let sync = CipherSyncData::new(&user.uuid, CipherSyncType::User, &conn).await;
|
|
for cached in [None, Some(&sync)] {
|
|
assert_eq!(
|
|
cipher
|
|
.get_access_restrictions(&user.uuid, CipherAccessScope::User, cached, &conn)
|
|
.await
|
|
.is_some(),
|
|
allowed
|
|
);
|
|
}
|
|
}
|
|
member.status = 2;
|
|
member.save(&conn).await.unwrap();
|
|
CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap();
|
|
assert!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await);
|
|
});
|
|
}
|
|
}
|