use super::models::{NewUnifiedWsTicket, UnifiedWsTicket};
use super::DAL;
use crate::database::schema::unified::ws_tickets;
use crate::database::universal_types::{UniversalBool, UniversalTimestamp, UniversalUuid};
use crate::error::ValidationError;
use diesel::prelude::*;
#[derive(Debug, Clone)]
pub struct WsTicketAuth {
pub key_id: uuid::Uuid,
pub name: String,
pub permissions: String,
pub tenant_id: Option<String>,
pub is_admin: bool,
}
#[derive(Clone)]
pub struct WsTicketDAL<'a> {
dal: &'a DAL,
}
impl<'a> WsTicketDAL<'a> {
pub fn new(dal: &'a DAL) -> Self {
Self { dal }
}
pub async fn issue(
&self,
auth: WsTicketAuth,
ttl: std::time::Duration,
) -> Result<String, ValidationError> {
let ticket = uuid::Uuid::new_v4().to_string();
let now = UniversalTimestamp::now();
let expires_at = UniversalTimestamp(
now.0
+ chrono::Duration::from_std(ttl).unwrap_or_else(|_| chrono::Duration::seconds(60)),
);
let row = NewUnifiedWsTicket {
ticket: ticket.clone(),
key_id: UniversalUuid(auth.key_id),
key_name: auth.name,
permissions: auth.permissions,
tenant_id: auth.tenant_id,
is_admin: UniversalBool::new(auth.is_admin),
created_at: now,
expires_at,
redeemed_at: None,
};
crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(ws_tickets::table.filter(ws_tickets::expires_at.lt(now)))
.execute(conn)?;
diesel::insert_into(ws_tickets::table)
.values(&row)
.execute(conn)
})
.map_err(ValidationError::from)?;
Ok(ticket)
}
pub async fn redeem(&self, ticket: &str) -> Result<Option<WsTicketAuth>, ValidationError> {
let ticket = ticket.to_string();
let now = UniversalTimestamp::now();
let row: Option<UnifiedWsTicket> = crate::interact_on_backend!(self.dal, |conn| {
let affected = diesel::update(
ws_tickets::table
.filter(ws_tickets::ticket.eq(&ticket))
.filter(ws_tickets::redeemed_at.is_null())
.filter(ws_tickets::expires_at.gt(now)),
)
.set(ws_tickets::redeemed_at.eq(Some(now)))
.execute(conn)?;
if affected == 1 {
ws_tickets::table
.filter(ws_tickets::ticket.eq(&ticket))
.first::<UnifiedWsTicket>(conn)
.optional()
} else {
Ok(None)
}
})
.map_err(ValidationError::from)?;
Ok(row.map(|r| WsTicketAuth {
key_id: r.key_id.0,
name: r.key_name,
permissions: r.permissions,
tenant_id: r.tenant_id,
is_admin: r.is_admin.into(),
}))
}
pub async fn prune_expired(&self) -> Result<usize, ValidationError> {
let now = UniversalTimestamp::now();
crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(ws_tickets::table.filter(ws_tickets::expires_at.lt(now))).execute(conn)
})
.map_err(ValidationError::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
#[cfg(feature = "sqlite")]
fn shared_url() -> String {
format!(
"file:ws_tickets_test_{}?mode=memory&cache=shared",
uuid::Uuid::new_v4()
)
}
#[cfg(feature = "sqlite")]
async fn dal_for(url: &str) -> DAL {
let db = Database::new(url, "", 5);
db.run_migrations()
.await
.expect("migrations should succeed");
DAL::new(db)
}
#[cfg(feature = "sqlite")]
fn auth(name: &str) -> WsTicketAuth {
WsTicketAuth {
key_id: uuid::Uuid::new_v4(),
name: name.to_string(),
permissions: "read".to_string(),
tenant_id: Some("t1".to_string()),
is_admin: false,
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn ticket_issued_on_one_handle_redeems_on_another_once() {
let url = shared_url();
let dal_a = dal_for(&url).await;
let dal_b = DAL::new(Database::new(&url, "", 5));
let ticket = dal_a
.ws_tickets()
.issue(auth("cross-replica"), std::time::Duration::from_secs(60))
.await
.unwrap();
let redeemed = dal_b.ws_tickets().redeem(&ticket).await.unwrap();
let redeemed = redeemed.expect("ticket minted on A must redeem on B");
assert_eq!(redeemed.name, "cross-replica");
assert_eq!(redeemed.tenant_id.as_deref(), Some("t1"));
assert!(!redeemed.is_admin);
assert!(dal_a.ws_tickets().redeem(&ticket).await.unwrap().is_none());
assert!(dal_b.ws_tickets().redeem(&ticket).await.unwrap().is_none());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn unknown_ticket_is_rejected() {
let dal = dal_for(&shared_url()).await;
assert!(dal
.ws_tickets()
.redeem("not-a-real-ticket")
.await
.unwrap()
.is_none());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn expired_ticket_is_rejected_and_pruned() {
let dal = dal_for(&shared_url()).await;
let ticket = dal
.ws_tickets()
.issue(auth("expiring"), std::time::Duration::ZERO)
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert!(dal.ws_tickets().redeem(&ticket).await.unwrap().is_none());
assert_eq!(dal.ws_tickets().prune_expired().await.unwrap(), 1);
}
}