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> {
84 error
85 .chain()
86 .find_map(|cause| cause.downcast_ref::<Self>().copied())
87 .or_else(|| {
88 error
89 .chain()
90 .any(|cause| {
91 cause
92 .downcast_ref::<nasaga_backend::SagaBackendError>()
93 .is_some_and(|error| {
94 error.kind() == nasaga_backend::SagaBackendErrorKind::Conflict
95 })
96 })
97 .then_some(Self::OperationConflict)
98 })
99 }
100}
101
102impl std::fmt::Display for SagaManagementError {
103 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 formatter.write_str(match self {
111 Self::InvalidContext => "Saga management context is invalid",
112 Self::PermissionDenied => "Saga management permission is denied",
113 Self::NotFound => "Saga management target was not found",
114 Self::RateLimitExceeded => "saga_tenant_action_rate_exceeded",
115 Self::PreconditionFailed => "Saga management precondition is not satisfied",
116 Self::OperationConflict => "Saga management operation has different audit facts",
117 })
118 }
119}
120
121impl std::error::Error for SagaManagementError {}
122
123#[derive(Debug, Clone)]
128pub struct SagaManagementContext {
129 actor: String,
130 reason: String,
131 permissions: BTreeSet<SagaManagementPermission>,
132}
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
136pub struct SagaManagementExpectation {
137 expected_state_version: Option<u64>,
138 expected_control_version: Option<u64>,
139}
140
141impl SagaManagementExpectation {
142 pub fn new(expected_state_version: Option<u64>, expected_control_version: Option<u64>) -> Self {
150 Self {
151 expected_state_version,
152 expected_control_version,
153 }
154 }
155
156 pub(crate) fn verify(self, state_version: u64, control_version: u64) -> anyhow::Result<()> {
164 if self
165 .expected_state_version
166 .is_some_and(|expected| expected != state_version)
167 || self
168 .expected_control_version
169 .is_some_and(|expected| expected != control_version)
170 {
171 return Err(crate::SagaConcurrencyError::StaleSnapshot.into());
172 }
173 Ok(())
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct SagaAuditTrail {
182 pub attempts: Vec<SagaStepAttemptRow>,
184 pub transitions: Vec<SagaTransitionAuditRow>,
186 pub controls: Vec<SagaControlAuditRow>,
188 pub management_operations: Vec<SagaManagementAuditRow>,
190 pub conflicts: Vec<SagaConflictFactRow>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct SagaAuditPageCursor {
201 pub audit_seq: u64,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum SagaAuditRecord {
208 Attempt(SagaAttemptAuditRow),
210 Transition(SagaTransitionAuditRow),
212 Control(SagaControlAuditRow),
214 Management(SagaManagementAuditRow),
216 Conflict(SagaConflictFactRow),
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct SagaAuditPage {
223 pub records: Vec<SagaAuditRecord>,
225 pub next_cursor: Option<SagaAuditPageCursor>,
227}
228
229impl SagaManagementContext {
230 pub fn new(
239 actor: impl Into<String>,
240 reason: impl Into<String>,
241 permissions: impl IntoIterator<Item = SagaManagementPermission>,
242 ) -> anyhow::Result<Self> {
243 let actor = actor.into();
244 let reason = reason.into();
245 if !valid_audit_text(&actor, 128) || !valid_audit_text(&reason, 512) {
246 return Err(SagaManagementError::InvalidContext.into());
247 }
248 let permissions = permissions.into_iter().collect::<BTreeSet<_>>();
249 Ok(Self {
250 actor,
251 reason,
252 permissions,
253 })
254 }
255
256 pub fn actor(&self) -> &str {
262 &self.actor
263 }
264
265 pub fn reason(&self) -> &str {
271 &self.reason
272 }
273
274 pub fn require(&self, required: SagaManagementPermission) -> anyhow::Result<()> {
281 if self.permissions.contains(&required) {
282 Ok(())
283 } else {
284 Err(SagaManagementError::PermissionDenied.into())
285 }
286 }
287}
288
289fn valid_audit_text(value: &str, max_len: usize) -> bool {
297 !value.is_empty()
298 && value.len() <= max_len
299 && value.trim() == value
300 && !value.chars().any(char::is_control)
301}