Skip to main content

haematite_cli/serve/
drafts.rs

1//! Approved Lys drafts applied to the served store exactly once
2//! (ACCESS-001 R3, PERMISSIONS-ACROSS-THE-STACK D4).
3//!
4//! A held change was drafted with the exact frame its client sent. Once Lys
5//! approves it, the connector pulls it and lys-pass's `execute_once` hands it
6//! here. Its words are decoded as the transport decodes a frame, and must
7//! name exactly the resource and action the draft was approved for, so an
8//! approval never applies an act on anything else. The change is then
9//! applied through the service's own mutation path. That path is
10//! receipt-first: a request id already applied answers its stored receipt
11//! and writes nothing. So the mutation and its receipt are one commit, and
12//! a second run after a lost acknowledgment applies nothing again.
13
14use haematite::tree::Hash;
15use lys_pass::drafts::{ApprovedDraft, Execution, Executor};
16
17use crate::serve::authorize::subjects;
18use crate::serve::pass::target;
19use crate::serve::protocol::{Operation, Request};
20use crate::serve::service::Service;
21use crate::serve::storage::Failure;
22
23/// The served store's draft ledger, for the database `database`.
24pub struct Drafts<'a> {
25    service: &'a Service,
26    database: String,
27}
28
29impl<'a> Drafts<'a> {
30    /// Apply approved drafts to `service`, the served database `database`.
31    #[must_use]
32    pub fn new(service: &'a Service, database: &str) -> Self {
33        Self {
34            service,
35            database: database.to_owned(),
36        }
37    }
38}
39
40impl Executor for Drafts<'_> {
41    type Error = Failure;
42
43    /// The outcome is keyed by the branch and request id inside the draft's
44    /// words, which an id alone does not carry, so it is read where the
45    /// change is applied: receipt-first, before anything is written.
46    fn receipt(&self, _draft: &str) -> Result<Option<Execution>, Failure> {
47        Ok(None)
48    }
49
50    /// A draft whose words are not a change on exactly the approved
51    /// resource is refused on execution by its own name, so Lys records the
52    /// refusal and the page moves on. A failure of the store itself stays an
53    /// error: nothing is acknowledged, and the next pull asks again.
54    fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Failure> {
55        let operation = match self.approved_change(draft) {
56            Ok(operation) => operation,
57            Err(refusal) => {
58                return Ok(Execution::RefusedOnExecution {
59                    request_digest: draft.request_digest.clone(),
60                    refusal: refusal.code,
61                    reason: refusal.message,
62                });
63            }
64        };
65        let receipt = self
66            .service
67            .dispatch(operation)
68            .map_err(|failure| failure.failure().clone())?;
69        let bytes = serde_json::to_vec(&receipt).map_err(Failure::storage)?;
70        Ok(Execution::Executed {
71            request_digest: draft.request_digest.clone(),
72            receipt_digest: Hash::of(&bytes).to_string(),
73        })
74    }
75}
76
77impl Drafts<'_> {
78    /// The change in `draft`'s words, when they name exactly the resource and
79    /// action the draft was approved for; otherwise the refusal that says why.
80    fn approved_change(&self, draft: &ApprovedDraft) -> Result<Operation, Failure> {
81        let operation = serde_json::from_str::<Request>(&draft.words)
82            .map_err(|error| Failure::new("draft_words_invalid", error.to_string()))?
83            .operation;
84        if !matches!(operation, Operation::Mutate(_)) {
85            return Err(Failure::new(
86                "draft_not_a_change",
87                "only a change is applied from an approved draft",
88            ));
89        }
90        let differs = || {
91            Failure::new(
92                "draft_target_differs",
93                "the draft's words act on another resource than it was approved for",
94            )
95        };
96        let asked = subjects(&operation)?;
97        let [subject] = asked.as_slice() else {
98            return Err(differs());
99        };
100        let named = target(subject.route, &self.database, subject.name.as_deref())
101            .map_err(|error| Failure::new(error.name(), "the draft's resource is invalid"))?;
102        if named != draft.target {
103            return Err(differs());
104        }
105        Ok(operation)
106    }
107}
108
109#[cfg(test)]
110#[path = "drafts_tests.rs"]
111mod tests;