backbone_core/write_guard.rs
1//! The write guard: the rules a write must satisfy, asked on every path the
2//! generic service writes through.
3//!
4//! [`GenericCrudService`](crate::service::GenericCrudService) consults its
5//! guard on create, update (full and partial, single and bulk), soft delete,
6//! restore and hard delete, after the write's resulting row is known and
7//! before anything is persisted. A guard answers with violations to refuse and
8//! violations to only report: a rule that runs in shadow is logged and the
9//! write proceeds, so a newly compiled rule shows what it would refuse before
10//! it refuses anything. The schema generator compiles a module's declared rules
11//! into guards; without one, every write is allowed ([`AllowAll`]).
12
13use async_trait::async_trait;
14
15use crate::violation::Violation;
16
17/// What kind of write is being checked.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum WriteKind {
20 Create,
21 /// A full or partial update.
22 Update,
23 /// A soft delete.
24 Delete,
25 Restore,
26 /// Removal of a soft-deleted row for good.
27 HardDelete,
28}
29
30/// One write, as the guard sees it.
31#[derive(Debug)]
32#[non_exhaustive]
33pub struct WriteCtx<'a, E> {
34 pub kind: WriteKind,
35 /// The row as stored before the write; `None` on create.
36 pub before: Option<&'a E>,
37 /// The row the write would store; `None` on delete and hard delete.
38 pub after: Option<&'a E>,
39 /// Who is writing: the request's audit attribution, when the composing
40 /// service bound one; `None` for a job, a seed or an unscoped caller.
41 pub actor: Option<String>,
42 /// When the write happens, the `now()` a rule compares against.
43 pub at: chrono::DateTime<chrono::Utc>,
44 /// The state-machine verb this write performs (`post`, `approve`), when it runs inside
45 /// [`as_transition`]; `None` for a generic write, which must not move a state field.
46 pub transition: Option<String>,
47}
48
49impl<'a, E> WriteCtx<'a, E> {
50 /// The context of one write, attributed to the ambient request's actor
51 /// and timed now.
52 pub fn new(kind: WriteKind, before: Option<&'a E>, after: Option<&'a E>) -> Self {
53 Self {
54 kind,
55 before,
56 after,
57 actor: backbone_orm::audit_context::current_request_audit()
58 .map(|a| a.actor)
59 .filter(|a| !a.is_empty()),
60 at: chrono::Utc::now(),
61 transition: current_transition(),
62 }
63 }
64}
65
66tokio::task_local! {
67 static TRANSITION: String;
68}
69
70/// Run `f` as the state-machine verb `verb`: every write it makes is a transition, so a rule
71/// that keeps a state field verbs-only lets it through. A generated transition handler wraps
72/// its write in this; nothing else should.
73pub async fn as_transition<F: std::future::Future>(verb: &str, f: F) -> F::Output {
74 TRANSITION.scope(verb.to_string(), f).await
75}
76
77/// The verb the current write performs, inside [`as_transition`].
78pub fn current_transition() -> Option<String> {
79 TRANSITION.try_with(|v| v.clone()).ok()
80}
81
82/// A guard's answer.
83#[derive(Debug, Default, Clone, PartialEq)]
84pub struct GuardOutcome {
85 /// Broken rules that refuse the write.
86 pub refuse: Vec<Violation>,
87 /// Broken rules running in shadow: reported, not refused.
88 pub shadow: Vec<Violation>,
89}
90
91impl GuardOutcome {
92 pub fn allow() -> Self {
93 Self::default()
94 }
95
96 /// Fold another guard's answer into this one.
97 pub fn merge(mut self, other: GuardOutcome) -> Self {
98 self.refuse.extend(other.refuse);
99 self.shadow.extend(other.shadow);
100 self
101 }
102}
103
104/// The rules one entity's writes must satisfy.
105#[async_trait]
106pub trait WriteGuard<E>: Send + Sync {
107 async fn check(&self, ctx: &WriteCtx<'_, E>) -> GuardOutcome;
108}
109
110/// A guard that refuses nothing: the default for an entity whose schema
111/// declares no rules.
112#[derive(Debug, Default, Clone, Copy)]
113pub struct AllowAll;
114
115#[async_trait]
116impl<E: Sync> WriteGuard<E> for AllowAll {
117 async fn check(&self, _ctx: &WriteCtx<'_, E>) -> GuardOutcome {
118 GuardOutcome::allow()
119 }
120}
121
122#[cfg(test)]
123mod transition_tests {
124 use super::*;
125
126 #[tokio::test]
127 async fn a_write_knows_whether_a_verb_performs_it() {
128 let row = 1u8;
129 assert_eq!(WriteCtx::new(WriteKind::Update, Some(&row), Some(&row)).transition, None);
130 let inside = as_transition("post", async { WriteCtx::new(WriteKind::Update, Some(&row), Some(&row)).transition }).await;
131 assert_eq!(inside.as_deref(), Some("post"));
132 }
133}