1use std::collections::BTreeSet;
7
8use nasaga_backend::{
9 SagaAttemptAuditRow, SagaConflictFactRow, SagaControlAuditRow, SagaManagementAuditRow,
10 SagaStepAttemptRow, SagaTransitionAuditRow,
11};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum SagaManagementPermission {
17 Pause,
19 Resume,
21 RetryCompensation,
23 RetryResolution,
25 ReadAudit,
27 ListInstances,
32 ManualClose,
37}
38
39impl SagaManagementPermission {
40 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Pause => "saga.pause",
48 Self::Resume => "saga.resume",
49 Self::RetryCompensation => "saga.retry_compensation",
50 Self::RetryResolution => "saga.retry_resolution",
51 Self::ReadAudit => "saga.audit.read",
52 Self::ListInstances => "saga.instance.list",
53 Self::ManualClose => "saga.manual_close",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum SagaManagementError {
62 InvalidContext,
64 PermissionDenied,
66 NotFound,
68 RateLimitExceeded,
70 PreconditionFailed,
72 OperationConflict,
74}
75
76impl SagaManagementError {
77 pub fn from_error(error: &anyhow::Error) -> Option<Self> {
83 error
84 .chain()
85 .find_map(|cause| cause.downcast_ref::<Self>().copied())
86 .or_else(|| {
87 error
88 .chain()
89 .any(|cause| {
90 cause
91 .downcast_ref::<nasaga_backend::SagaBackendError>()
92 .is_some_and(|error| {
93 error.kind() == nasaga_backend::SagaBackendErrorKind::Conflict
94 })
95 })
96 .then_some(Self::OperationConflict)
97 })
98 }
99}
100
101impl std::fmt::Display for SagaManagementError {
102 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 formatter.write_str(match self {
109 Self::InvalidContext => "Saga management context is invalid",
110 Self::PermissionDenied => "Saga management permission is denied",
111 Self::NotFound => "Saga management target was not found",
112 Self::RateLimitExceeded => "saga_tenant_action_rate_exceeded",
113 Self::PreconditionFailed => "Saga management precondition is not satisfied",
114 Self::OperationConflict => "Saga management operation has different audit facts",
115 })
116 }
117}
118
119impl std::error::Error for SagaManagementError {}
120
121#[derive(Debug, Clone)]
126pub struct SagaManagementContext {
127 actor: String,
128 reason: String,
129 permissions: BTreeSet<SagaManagementPermission>,
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
134pub struct SagaManagementExpectation {
135 expected_state_version: Option<u64>,
136 expected_control_version: Option<u64>,
137}
138
139impl SagaManagementExpectation {
140 pub fn new(expected_state_version: Option<u64>, expected_control_version: Option<u64>) -> Self {
146 Self {
147 expected_state_version,
148 expected_control_version,
149 }
150 }
151
152 pub(crate) fn verify(self, state_version: u64, control_version: u64) -> anyhow::Result<()> {
158 if self
159 .expected_state_version
160 .is_some_and(|expected| expected != state_version)
161 || self
162 .expected_control_version
163 .is_some_and(|expected| expected != control_version)
164 {
165 return Err(crate::SagaConcurrencyError::StaleSnapshot.into());
166 }
167 Ok(())
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct SagaAuditTrail {
176 pub attempts: Vec<SagaStepAttemptRow>,
178 pub transitions: Vec<SagaTransitionAuditRow>,
180 pub controls: Vec<SagaControlAuditRow>,
182 pub management_operations: Vec<SagaManagementAuditRow>,
184 pub conflicts: Vec<SagaConflictFactRow>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct SagaAuditPageCursor {
195 pub audit_seq: u64,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum SagaAuditRecord {
202 Attempt(SagaAttemptAuditRow),
204 Transition(SagaTransitionAuditRow),
206 Control(SagaControlAuditRow),
208 Management(SagaManagementAuditRow),
210 Conflict(SagaConflictFactRow),
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct SagaAuditPage {
217 pub records: Vec<SagaAuditRecord>,
219 pub next_cursor: Option<SagaAuditPageCursor>,
221}
222
223impl SagaManagementContext {
224 pub fn new(
233 actor: impl Into<String>,
234 reason: impl Into<String>,
235 permissions: impl IntoIterator<Item = SagaManagementPermission>,
236 ) -> anyhow::Result<Self> {
237 let actor = actor.into();
238 let reason = reason.into();
239 if !valid_audit_text(&actor, 128) || !valid_audit_text(&reason, 512) {
240 return Err(SagaManagementError::InvalidContext.into());
241 }
242 let permissions = permissions.into_iter().collect::<BTreeSet<_>>();
243 Ok(Self {
244 actor,
245 reason,
246 permissions,
247 })
248 }
249
250 pub fn actor(&self) -> &str {
256 &self.actor
257 }
258
259 pub fn reason(&self) -> &str {
265 &self.reason
266 }
267
268 pub fn require(&self, required: SagaManagementPermission) -> anyhow::Result<()> {
275 if self.permissions.contains(&required) {
276 Ok(())
277 } else {
278 Err(SagaManagementError::PermissionDenied.into())
279 }
280 }
281}
282
283fn valid_audit_text(value: &str, max_len: usize) -> bool {
291 !value.is_empty()
292 && value.len() <= max_len
293 && value.trim() == value
294 && !value.chars().any(char::is_control)
295}