use chrono::{DateTime, Utc};
use glob::{Pattern, PatternError};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalDelegation {
pub id: Uuid,
pub from_user_id: Uuid,
pub to_user_id: Uuid,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub workflow_filter: Option<String>,
pub created_at: DateTime<Utc>,
}
impl ApprovalDelegation {
pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
self.valid_from <= now && now < self.valid_until
}
pub fn matches_workflow(&self, workflow_name: &str) -> bool {
match &self.workflow_filter {
None => true,
Some(filter) => Pattern::new(filter)
.map(|p| p.matches(workflow_name))
.unwrap_or(false),
}
}
pub fn covers(&self, workflow_name: &str, now: DateTime<Utc>) -> bool {
self.is_active_at(now) && self.matches_workflow(workflow_name)
}
}
#[derive(Debug, Clone)]
pub struct NewApprovalDelegation {
pub from_user_id: Uuid,
pub to_user_id: Uuid,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub workflow_filter: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct DelegationFilter {
pub from_user_id: Option<Uuid>,
pub to_user_id: Option<Uuid>,
pub involving_user_id: Option<Uuid>,
}
#[derive(Debug, Error)]
pub enum WorkflowFilterError {
#[error("workflow filter must not be empty")]
Empty,
#[error("invalid glob pattern: {0}")]
Invalid(#[from] PatternError),
}
pub fn validate_workflow_filter(pattern: &str) -> Result<(), WorkflowFilterError> {
if pattern.trim().is_empty() {
return Err(WorkflowFilterError::Empty);
}
Pattern::new(pattern)?;
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::TimeDelta;
use serde_json::{from_str, to_string};
use super::*;
fn delegation(valid_from: DateTime<Utc>, valid_until: DateTime<Utc>) -> ApprovalDelegation {
ApprovalDelegation {
id: Uuid::now_v7(),
from_user_id: Uuid::now_v7(),
to_user_id: Uuid::now_v7(),
valid_from,
valid_until,
workflow_filter: None,
created_at: valid_from,
}
}
#[test]
fn active_window_includes_its_start_and_excludes_its_end() {
let start = Utc::now();
let end = start + TimeDelta::hours(2);
let d = delegation(start, end);
assert!(d.is_active_at(start), "valid_from is inclusive");
assert!(d.is_active_at(start + TimeDelta::hours(1)));
assert!(!d.is_active_at(end), "valid_until is exclusive");
}
#[test]
fn a_delegation_is_inactive_before_and_after_its_window() {
let start = Utc::now();
let end = start + TimeDelta::hours(2);
let d = delegation(start, end);
assert!(!d.is_active_at(start - TimeDelta::seconds(1)));
assert!(!d.is_active_at(end + TimeDelta::seconds(1)));
}
#[test]
fn no_filter_matches_every_workflow() {
let now = Utc::now();
let d = delegation(now, now + TimeDelta::hours(1));
assert!(d.matches_workflow("deploy-prod"));
assert!(d.matches_workflow("cleanup"));
assert!(d.matches_workflow(""));
}
#[test]
fn glob_filter_matches_only_its_prefix() {
let now = Utc::now();
let d = ApprovalDelegation {
workflow_filter: Some("deploy-*".to_string()),
..delegation(now, now + TimeDelta::hours(1))
};
assert!(d.matches_workflow("deploy-prod"));
assert!(d.matches_workflow("deploy-"));
assert!(!d.matches_workflow("cleanup"));
assert!(!d.matches_workflow("redeploy-prod"));
}
#[test]
fn star_filter_matches_every_workflow() {
let now = Utc::now();
let d = ApprovalDelegation {
workflow_filter: Some("*".to_string()),
..delegation(now, now + TimeDelta::hours(1))
};
assert!(d.matches_workflow("deploy-prod"));
assert!(d.matches_workflow("cleanup"));
}
#[test]
fn an_invalid_pattern_matches_nothing() {
let now = Utc::now();
let d = ApprovalDelegation {
workflow_filter: Some("[unclosed".to_string()),
..delegation(now, now + TimeDelta::hours(1))
};
assert!(!d.matches_workflow("deploy-prod"));
assert!(!d.matches_workflow("[unclosed"));
}
#[test]
fn covers_combines_the_window_and_the_filter() {
let now = Utc::now();
let d = ApprovalDelegation {
workflow_filter: Some("deploy-*".to_string()),
..delegation(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
};
assert!(d.covers("deploy-prod", now));
assert!(!d.covers("cleanup", now), "workflow outside the filter");
assert!(
!d.covers("deploy-prod", now + TimeDelta::hours(2)),
"instant outside the window"
);
}
#[test]
fn approval_delegation_serde_roundtrip() {
let now = Utc::now();
let d = ApprovalDelegation {
workflow_filter: Some("deploy-*".to_string()),
..delegation(now, now + TimeDelta::days(1))
};
let json = to_string(&d).expect("serialize");
let back: ApprovalDelegation = from_str(&json).expect("deserialize");
assert_eq!(back.id, d.id);
assert_eq!(back.from_user_id, d.from_user_id);
assert_eq!(back.to_user_id, d.to_user_id);
assert_eq!(back.workflow_filter, d.workflow_filter);
assert_eq!(back.valid_until, d.valid_until);
}
#[test]
fn validate_workflow_filter_accepts_a_glob() {
assert!(validate_workflow_filter("deploy-*").is_ok());
assert!(validate_workflow_filter("*").is_ok());
assert!(validate_workflow_filter("deploy").is_ok());
}
#[test]
fn validate_workflow_filter_rejects_a_blank_pattern() {
assert!(matches!(
validate_workflow_filter(""),
Err(WorkflowFilterError::Empty)
));
assert!(matches!(
validate_workflow_filter(" "),
Err(WorkflowFilterError::Empty)
));
}
#[test]
fn validate_workflow_filter_rejects_a_broken_glob() {
assert!(matches!(
validate_workflow_filter("[unclosed"),
Err(WorkflowFilterError::Invalid(_))
));
}
#[test]
fn delegation_filter_defaults_to_no_constraint() {
let filter = DelegationFilter::default();
assert!(filter.from_user_id.is_none());
assert!(filter.to_user_id.is_none());
assert!(filter.involving_user_id.is_none());
}
}