use crate::access_control::{AccessLevel, RoleAccessMapping, RoleStatus};
use crate::shared::Metadata;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandApprovalPolicy {
pub approver_role: Option<String>,
pub deadline_seconds: Option<i64>,
pub allow_self_approval: bool,
}
impl CommandApprovalPolicy {
pub fn new(approver_role: Option<String>, deadline_seconds: Option<i64>) -> Self {
Self {
approver_role,
deadline_seconds,
allow_self_approval: false,
}
}
pub fn allowing_self_approval(mut self, allow: bool) -> Self {
self.allow_self_approval = allow;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommandProposalStatus {
Pending,
Approving,
Approved,
Rejected,
Expired,
Forgotten,
}
impl CommandProposalStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Approving => "approving",
Self::Approved => "approved",
Self::Rejected => "rejected",
Self::Expired => "expired",
Self::Forgotten => "forgotten",
}
}
pub fn from_db(value: &str) -> Option<Self> {
Some(match value {
"pending" => Self::Pending,
"approving" => Self::Approving,
"approved" => Self::Approved,
"rejected" => Self::Rejected,
"expired" => Self::Expired,
"forgotten" => Self::Forgotten,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandProposal {
pub id: String,
pub bounded_context: crate::event_store::BoundedContext,
pub command_type_name: String,
pub payload: String,
pub metadata: Metadata,
pub status: CommandProposalStatus,
pub idempotency_key: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub decided_at: Option<chrono::DateTime<chrono::Utc>>,
pub decided_by_role_id: Option<String>,
pub rejection_reason: Option<String>,
pub rejection_kind: Option<String>,
pub command_id: Option<String>,
}
impl CommandProposal {
pub fn is_expired_at(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
self.expires_at.is_some_and(|expires_at| now >= expires_at)
}
}
pub fn proposal_owner_scope_satisfied(
command_type: &crate::event_store::CommandType,
proposal: &CommandProposal,
scope: Option<&str>,
) -> bool {
if scope.is_none() || command_type.owner_tag_key.is_none() {
return true;
}
crate::event_store::tag_owner_scope_satisfied(
&crate::event_store::derive_plain_tags(&command_type.tag_mappings, &proposal.payload),
command_type.owner_tag_key.as_deref(),
scope,
)
}
pub fn check_approver(
policy: &CommandApprovalPolicy,
access_mapping: &RoleAccessMapping,
) -> crate::error::Result<()> {
if access_mapping.status != RoleStatus::Active {
return Err(crate::access_control::Error::GrantNotActive.into());
}
if access_mapping.level != AccessLevel::Admin {
return Err(crate::access_control::Error::InsufficientAccessLevel.into());
}
if let Some(required) = &policy.approver_role {
if &access_mapping.role.name != required {
return Err(Error::InsufficientApprovalRole {
required: required.clone(),
}
.into());
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProposalDecision {
Approve,
Reject,
}
pub fn check_self_approval(
policy: &CommandApprovalPolicy,
proposer_role: Option<&crate::access_control::Role>,
approver: &RoleAccessMapping,
) -> crate::error::Result<()> {
if policy.allow_self_approval {
return Ok(());
}
if proposer_role
.is_some_and(|proposer| proposer.external_subject == approver.role.external_subject)
{
return Err(Error::SelfApprovalNotAllowed.into());
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("no command proposal with id {id:?} exists in this bounded context")]
ProposalNotFound {
id: String,
},
#[error("this command proposal is {status}, not pending approval")]
ProposalNotPending {
status: &'static str,
},
#[error("this command proposal's approval deadline has passed")]
ProposalExpired,
#[error("this command type must be approved by a Role named {required:?}")]
InsufficientApprovalRole {
required: String,
},
#[error("an approval policy's deadline must be a positive number of seconds")]
InvalidApprovalPolicy,
#[error("the account that proposed this command may not approve it")]
SelfApprovalNotAllowed,
#[error(
"a subject this command proposal names was forgotten, so it can no longer be approved"
)]
ProposalSubjectForgotten,
}
impl Error {
pub fn code(&self) -> &'static str {
match self {
Error::ProposalNotFound { .. } => "command_proposal_not_found",
Error::ProposalNotPending { .. } => "command_proposal_not_pending",
Error::ProposalExpired => "command_proposal_expired",
Error::InsufficientApprovalRole { .. } => "insufficient_approval_role",
Error::InvalidApprovalPolicy => "invalid_approval_policy",
Error::SelfApprovalNotAllowed => "self_approval_not_allowed",
Error::ProposalSubjectForgotten => "command_proposal_subject_forgotten",
}
}
}
impl crate::error::SkiljRejection for Error {
fn code(&self) -> &str {
Error::code(self)
}
fn message(&self) -> String {
self.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::access_control::Role;
fn role(id: &str, subject: &str) -> Role {
Role {
id: id.to_string(),
external_subject: subject.to_string(),
name: "Admin".to_string(),
superadmin: false,
status: RoleStatus::Active,
created_at: chrono::DateTime::<chrono::Utc>::MIN_UTC,
revoked_at: None,
}
}
fn mapping(role: Role) -> RoleAccessMapping {
RoleAccessMapping {
role,
bounded_context: crate::event_store::BoundedContext {
name: "banking".to_string(),
status: crate::event_store::BoundedContextStatus::Active,
created_at: chrono::DateTime::<chrono::Utc>::MIN_UTC,
created_by: crate::bootstrap::ContextCreator::SystemCreator,
template: None,
},
level: AccessLevel::Admin,
can_read_sensitive: false,
scope: None,
capabilities: Default::default(),
delegable: Default::default(),
status: RoleStatus::Active,
created_at: chrono::DateTime::<chrono::Utc>::MIN_UTC,
revoked_at: None,
}
}
#[test]
fn self_is_the_subject_not_the_role_row() {
let policy = CommandApprovalPolicy::new(None, None);
let approver = mapping(role("role-new", "alice"));
let proposer = role("role-old", "alice");
assert!(check_self_approval(&policy, Some(&proposer), &approver).is_err());
let stranger = role("role-other", "bob");
assert!(check_self_approval(&policy, Some(&stranger), &approver).is_ok());
assert!(check_self_approval(&policy, None, &approver).is_ok());
let opt_in = CommandApprovalPolicy::new(None, None).allowing_self_approval(true);
assert!(check_self_approval(&opt_in, Some(&role("role-new", "alice")), &approver).is_ok());
}
}