mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
* fix: encrypt ai session backups with aes-256-gcm Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: cover ai session backups written with aes-256-gcm Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: name the release ai session readers ship in Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: hold ai session gcm writes until old servers are rolled out Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
352 lines
11 KiB
Rust
352 lines
11 KiB
Rust
use sqlx::{Acquire, PgConnection, PgExecutor, Pool, Postgres, Transaction};
|
|
|
|
use crate::audit::AuditAuthor;
|
|
|
|
pub type DB = Pool<Postgres>;
|
|
|
|
/// Workspace ID resolved by gateway middleware (stored in request extensions).
|
|
/// Used by auth to resolve workspace when the URL path doesn't contain one.
|
|
#[derive(Clone, Debug)]
|
|
pub struct GatewayWorkspaceId(pub String);
|
|
|
|
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
|
pub struct Authed {
|
|
pub email: String,
|
|
pub username: String,
|
|
pub is_admin: bool,
|
|
pub is_operator: bool,
|
|
pub groups: Vec<String>,
|
|
// (folder name, can write, is owner)
|
|
pub folders: Vec<(String, bool, bool)>,
|
|
pub scopes: Option<Vec<String>>,
|
|
pub token_prefix: Option<String>,
|
|
}
|
|
|
|
impl Authed {
|
|
pub fn to_authed_ref(&self) -> AuthedRef<'_> {
|
|
AuthedRef {
|
|
email: &self.email,
|
|
username: &self.username,
|
|
is_admin: &self.is_admin,
|
|
is_operator: &self.is_operator,
|
|
groups: &self.groups,
|
|
folders: &self.folders,
|
|
scopes: &self.scopes,
|
|
token_prefix: &self.token_prefix,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash)]
|
|
pub struct AuthedRef<'a> {
|
|
pub email: &'a str,
|
|
pub username: &'a str,
|
|
pub is_admin: &'a bool,
|
|
pub is_operator: &'a bool,
|
|
pub groups: &'a Vec<String>,
|
|
// (folder name, can write, is owner)
|
|
pub folders: &'a Vec<(String, bool, bool)>,
|
|
pub scopes: &'a Option<Vec<String>>,
|
|
pub token_prefix: &'a Option<String>,
|
|
}
|
|
|
|
impl Authable for AuthedRef<'_> {
|
|
fn email(&self) -> &str {
|
|
self.email
|
|
}
|
|
fn username(&self) -> &str {
|
|
self.username
|
|
}
|
|
fn is_admin(&self) -> bool {
|
|
*self.is_admin
|
|
}
|
|
fn is_operator(&self) -> bool {
|
|
*self.is_operator
|
|
}
|
|
fn groups(&self) -> &[String] {
|
|
self.groups
|
|
}
|
|
fn folders(&self) -> &[(String, bool, bool)] {
|
|
self.folders
|
|
}
|
|
fn scopes(&self) -> Option<&[String]> {
|
|
self.scopes.as_ref().map(|x| x.as_slice())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct UserDB {
|
|
db: DB,
|
|
}
|
|
|
|
pub trait Authable {
|
|
fn email(&self) -> &str;
|
|
fn username(&self) -> &str;
|
|
fn is_admin(&self) -> bool;
|
|
fn is_operator(&self) -> bool;
|
|
fn groups(&self) -> &[String];
|
|
fn folders(&self) -> &[(String, bool, bool)];
|
|
fn scopes(&self) -> Option<&[String]>;
|
|
}
|
|
|
|
impl Authable for Authed {
|
|
fn is_admin(&self) -> bool {
|
|
self.is_admin
|
|
}
|
|
|
|
fn is_operator(&self) -> bool {
|
|
self.is_operator
|
|
}
|
|
|
|
fn groups(&self) -> &[String] {
|
|
&self.groups
|
|
}
|
|
|
|
fn folders(&self) -> &[(String, bool, bool)] {
|
|
&self.folders
|
|
}
|
|
|
|
fn scopes(&self) -> Option<&[std::string::String]> {
|
|
self.scopes.as_ref().map(|x| x.as_slice())
|
|
}
|
|
|
|
fn email(&self) -> &str {
|
|
&self.email
|
|
}
|
|
|
|
fn username(&self) -> &str {
|
|
&self.username
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref PG_SCHEMA: Option<String> = std::env::var("PG_SCHEMA").ok();
|
|
}
|
|
|
|
pub struct UserDbWithAuthed<'c, T: Authable + Sync> {
|
|
pub authed: &'c T,
|
|
pub db: UserDB,
|
|
}
|
|
|
|
impl<'c, 'd, T: Authable + Sync> Acquire<'c> for &'c UserDbWithAuthed<'d, T> {
|
|
type Database = Postgres;
|
|
type Connection = Transaction<'c, Postgres>;
|
|
|
|
fn acquire(self) -> futures_core::future::BoxFuture<'c, Result<Self::Connection, sqlx::Error>> {
|
|
Box::pin(async move { self.db.clone().begin(self.authed).await })
|
|
}
|
|
|
|
fn begin(
|
|
self,
|
|
) -> futures_core::future::BoxFuture<'c, Result<Transaction<'c, Postgres>, sqlx::Error>> {
|
|
Box::pin(async move { self.db.clone().begin(self.authed).await })
|
|
}
|
|
}
|
|
|
|
pub enum DbWithOptAuthed<'a, T: Authable + Sync> {
|
|
UserDB { authed: &'a T, user_db: UserDB, db: DB },
|
|
DB { db: DB, audit_author: AuditAuthor },
|
|
}
|
|
impl<'a, T: Authable + Sync> DbWithOptAuthed<'a, T> {
|
|
pub fn from_authed(authed: &'a T, db: DB, user_db: Option<UserDB>) -> Self {
|
|
if let Some(user_db) = user_db {
|
|
Self::UserDB { authed, user_db, db }
|
|
} else {
|
|
Self::DB { db, audit_author: AuditAuthor::from(authed) }
|
|
}
|
|
}
|
|
pub fn db(&self) -> &DB {
|
|
match self {
|
|
DbWithOptAuthed::UserDB { db, .. } => db,
|
|
DbWithOptAuthed::DB { db, .. } => db,
|
|
}
|
|
}
|
|
|
|
pub fn authed(&self) -> Option<&T> {
|
|
match self {
|
|
DbWithOptAuthed::UserDB { authed, .. } => Some(authed),
|
|
DbWithOptAuthed::DB { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub fn audit_author(&self) -> Option<&AuditAuthor> {
|
|
match self {
|
|
DbWithOptAuthed::UserDB { .. } => None,
|
|
DbWithOptAuthed::DB { audit_author, .. } => Some(audit_author),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'c, 'd, T: Authable + Sync> Acquire<'c> for &'c DbWithOptAuthed<'d, T> {
|
|
type Database = Postgres;
|
|
type Connection = Transaction<'c, Postgres>;
|
|
|
|
fn acquire(self) -> futures_core::future::BoxFuture<'c, Result<Self::Connection, sqlx::Error>> {
|
|
Box::pin(async move {
|
|
match self {
|
|
DbWithOptAuthed::UserDB { authed, user_db, .. } => {
|
|
user_db.clone().begin(&**authed).await
|
|
}
|
|
DbWithOptAuthed::DB { db, .. } => db.clone().begin().await,
|
|
}
|
|
})
|
|
}
|
|
|
|
fn begin(
|
|
self,
|
|
) -> futures_core::future::BoxFuture<'c, Result<Transaction<'c, Postgres>, sqlx::Error>> {
|
|
Box::pin(async move {
|
|
match self {
|
|
DbWithOptAuthed::UserDB { authed, user_db, .. } => {
|
|
user_db.clone().begin(&**authed).await
|
|
}
|
|
DbWithOptAuthed::DB { db, .. } => db.clone().begin().await,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl UserDB {
|
|
pub fn new(db: DB) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
pub async fn begin<T>(self, authed: &T) -> Result<Transaction<'static, Postgres>, sqlx::Error>
|
|
where
|
|
T: Authable,
|
|
{
|
|
let (folders_write, folders_read): &(Vec<_>, Vec<_>) =
|
|
&authed.folders().into_iter().partition(|x| x.1);
|
|
|
|
let mut folders_read = folders_read.clone();
|
|
folders_read.extend(folders_write.clone());
|
|
|
|
// tracing::debug!(
|
|
// "Setting role to {} {:?} {:?} {:?} {:?}",
|
|
// user,
|
|
// authed.username(),
|
|
// authed.groups(),
|
|
// folders_read,
|
|
// folders_write
|
|
// );
|
|
|
|
let mut tx = self.db.begin().await?;
|
|
|
|
if let Some(schema) = PG_SCHEMA.as_ref() {
|
|
// SAFETY: `schema` is an operator-controlled environment variable (PG_SCHEMA), set at deploy time and never user-supplied.
|
|
sqlx::query(&format!("SET LOCAL search_path TO {}", schema))
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
sqlx::query!(
|
|
"SELECT set_session_context($1, $2, $3, $4, $5, $6)",
|
|
authed.is_admin(),
|
|
authed.username(),
|
|
authed.groups().join(","),
|
|
authed
|
|
.groups()
|
|
.iter()
|
|
.map(|x| format!("g/{}", x))
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
folders_read
|
|
.iter()
|
|
.map(|x| x.0.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
folders_write
|
|
.iter()
|
|
.map(|x| x.0.clone())
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
Ok(tx)
|
|
}
|
|
}
|
|
|
|
pub trait DbExecutor<'b>: Send + Sized + PgExecutor<'b> {
|
|
fn executor<'a>(&'a mut self) -> impl PgExecutor<'a>;
|
|
fn populate<'a>(&'a mut self) -> impl DbExecutor<'a>;
|
|
}
|
|
|
|
impl<'b> DbExecutor<'b> for &DB {
|
|
fn executor<'a>(&'a mut self) -> impl PgExecutor<'a> {
|
|
&**self
|
|
}
|
|
fn populate<'a>(&'a mut self) -> impl DbExecutor<'a> {
|
|
&**self
|
|
}
|
|
}
|
|
|
|
impl<'b> DbExecutor<'b> for &'b mut PgConnection {
|
|
fn executor<'a>(&'a mut self) -> impl PgExecutor<'a> {
|
|
&mut **self
|
|
}
|
|
fn populate<'a>(&'a mut self) -> impl DbExecutor<'a> {
|
|
&mut **self
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref PROCESS_START: std::time::Instant = std::time::Instant::now();
|
|
}
|
|
|
|
/// Milliseconds after [`PROCESS_START`] before which every pooled connection is retired;
|
|
/// 0 retires none.
|
|
static RETIRE_CONNECTIONS_OPENED_BEFORE_MS: std::sync::atomic::AtomicU64 =
|
|
std::sync::atomic::AtomicU64::new(0);
|
|
|
|
/// Postgres refuses a statement prepared before its result columns changed type (even a
|
|
/// varchar length) with this error, on every execution, for as long as the connection lives.
|
|
/// The routine is matched as well as the message, which `lc_messages` may translate.
|
|
pub fn is_stale_cached_plan(e: &sqlx::Error) -> bool {
|
|
let sqlx::Error::Database(d) = e else {
|
|
return false;
|
|
};
|
|
d.code().as_deref() == Some("0A000")
|
|
&& (d
|
|
.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()
|
|
.is_some_and(|pg| pg.routine() == Some("RevalidateCachedQuery"))
|
|
|| d.message()
|
|
.contains("cached plan must not change result type"))
|
|
}
|
|
|
|
/// Retire every pooled connection opened up to now, so a schema change that poisoned their
|
|
/// prepared statements costs one failed query instead of failing until they expire.
|
|
pub fn retire_connections_opened_before_now() {
|
|
let now = PROCESS_START.elapsed().as_millis() as u64 + 1;
|
|
RETIRE_CONNECTIONS_OPENED_BEFORE_MS.fetch_max(now, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
|
|
/// For the pool's `before_acquire`: whether a connection of this age must be closed.
|
|
pub fn connection_is_retired(age: std::time::Duration) -> bool {
|
|
let cutoff = RETIRE_CONNECTIONS_OPENED_BEFORE_MS.load(std::sync::atomic::Ordering::Relaxed);
|
|
cutoff != 0 && (PROCESS_START.elapsed().saturating_sub(age).as_millis() as u64) < cutoff
|
|
}
|
|
|
|
/// Servers report no version, so the worker version gate alone cannot see a server of the
|
|
/// previous release; its rollout is assumed done once this server has been up this long.
|
|
pub const PREVIOUS_RELEASE_ROLLOUT_UPTIME: std::time::Duration =
|
|
std::time::Duration::from_secs(30 * 60);
|
|
|
|
static PROCESS_UPTIME_OFFSET_SECS: std::sync::atomic::AtomicU64 =
|
|
std::sync::atomic::AtomicU64::new(0);
|
|
|
|
/// Time since this process first used its database pool.
|
|
pub fn process_uptime() -> std::time::Duration {
|
|
PROCESS_START.elapsed()
|
|
+ std::time::Duration::from_secs(
|
|
PROCESS_UPTIME_OFFSET_SECS.load(std::sync::atomic::Ordering::Relaxed),
|
|
)
|
|
}
|
|
|
|
/// For tests of what waits on [`process_uptime`]: adds `offset` to it.
|
|
#[doc(hidden)]
|
|
pub fn set_process_uptime_offset(offset: std::time::Duration) {
|
|
PROCESS_UPTIME_OFFSET_SECS.store(offset.as_secs(), std::sync::atomic::Ordering::Relaxed);
|
|
}
|