Skip to main content

evault_core/service/
registry.rs

1//! [`RegistryService`] — the orchestration layer over the storage traits.
2//!
3//! `RegistryService` is the single entry point that the TUI and CLI use to
4//! create, update, link, and delete variables. It composes a
5//! [`MetadataStore`], a [`SecretStore`], an [`AuditSink`], a [`Clock`], and
6//! an [`IdGenerator`] through generic type parameters — so the service is
7//! testable with in-memory backends and deployable against `SQLCipher` + the
8//! OS keyring without any code change.
9//!
10//! The business rules enforced here are:
11//! - **One namespace per name**: creating a variable whose name already
12//!   exists fails with [`MetadataError::DuplicateName`].
13//! - **Two-tier value storage**: [`VarKind::Plain`] values go to the metadata
14//!   store, [`VarKind::Secret`] values go to the secret store. The same
15//!   service method (`update_value`) routes both cases.
16//! - **Idempotent delete**: deleting an absent variable is `Ok(())`.
17//! - **Audit-on-mutation**: every state-changing call emits an
18//!   [`AuditEntry`] timestamped by the injected [`Clock`].
19
20use crate::crypto::{ExposeSecret, SecretString};
21use crate::error::{CoreError, MetadataError};
22use crate::model::{
23    AuditAction, AuditEntry, AuditId, Group, Profile, Project, ProjectId, ProjectVar, Var,
24    VarFilter, VarId, VarKind,
25};
26use crate::traits::{
27    AuditSink, Clock, IdGenerator, MetadataStore, SecretStore, SystemClock, UuidV4IdGenerator,
28};
29
30/// Composed registry orchestration.
31///
32/// Generic over the five infrastructure traits so tests can substitute fast,
33/// deterministic backends; production binaries wire the `SQLCipher` /
34/// `OsKeyring` / `SystemClock` / `UuidV4IdGenerator` quartet.
35///
36/// See `evault-store-memory/tests/registry_service.rs` for a full example
37/// exercising the public API with the in-memory backends.
38pub struct RegistryService<M, S, A, C, I>
39where
40    M: MetadataStore,
41    S: SecretStore,
42    A: AuditSink,
43    C: Clock,
44    I: IdGenerator,
45{
46    metadata: M,
47    secrets: S,
48    audit: A,
49    clock: C,
50    id_gen: I,
51}
52
53impl<M, S, A> RegistryService<M, S, A, SystemClock, UuidV4IdGenerator>
54where
55    M: MetadataStore,
56    S: SecretStore,
57    A: AuditSink,
58{
59    /// Construct a registry with the default real-time clock and v4 UUID
60    /// generator.
61    ///
62    /// Convenience wrapper around [`Self::new`] for production wiring.
63    pub const fn with_defaults(metadata: M, secrets: S, audit: A) -> Self {
64        Self::new(metadata, secrets, audit, SystemClock, UuidV4IdGenerator)
65    }
66}
67
68impl<M, S, A, C, I> RegistryService<M, S, A, C, I>
69where
70    M: MetadataStore,
71    S: SecretStore,
72    A: AuditSink,
73    C: Clock,
74    I: IdGenerator,
75{
76    /// Construct a registry from its trait dependencies.
77    pub const fn new(metadata: M, secrets: S, audit: A, clock: C, id_gen: I) -> Self {
78        Self {
79            metadata,
80            secrets,
81            audit,
82            clock,
83            id_gen,
84        }
85    }
86
87    // -------------------------------------------------------------------
88    // Variables
89    // -------------------------------------------------------------------
90
91    /// Create a new variable, route its value to the correct storage tier,
92    /// and emit an audit entry.
93    ///
94    /// The name is validated, then uniqueness is checked. If both succeed,
95    /// the variable's id is generated through the injected
96    /// [`IdGenerator`] and its timestamps through the injected [`Clock`].
97    ///
98    /// # Atomicity (v1)
99    /// The implementation performs a best-effort compensation on
100    /// value-tier failure: if the metadata row was written and the value
101    /// write then fails, the metadata row is rolled back so the name is
102    /// not permanently reserved. A failure of the audit append after a
103    /// successful create is **not** compensated — the variable exists,
104    /// only the audit row is missing — and surfaces as
105    /// [`CoreError::Metadata`]. Full cross-tier atomic commit is on the
106    /// roadmap for the `SQLCipher` backend.
107    ///
108    /// # Errors
109    /// Returns [`CoreError::Metadata`] for validation, uniqueness, or
110    /// storage failures; [`CoreError::Secret`] if writing to the secret
111    /// store fails. Empty `value` is rejected with
112    /// [`MetadataError::Invalid`].
113    pub fn create_var(
114        &self,
115        name: &str,
116        group: Group,
117        kind: VarKind,
118        value: SecretString,
119    ) -> Result<VarId, CoreError> {
120        Var::validate_name(name)?;
121        if value.expose_secret().is_empty() {
122            return Err(MetadataError::Invalid("value is empty".into()).into());
123        }
124        if self.metadata.find_var_by_name(name)?.is_some() {
125            return Err(MetadataError::DuplicateName(name.to_owned()).into());
126        }
127
128        let id = VarId::from_uuid(self.id_gen.next());
129        let now = self.clock.now();
130        let length = value.expose_secret().len();
131        let var = Var::from_trusted_parts(
132            id,
133            name.to_owned(),
134            group,
135            kind,
136            Vec::new(),
137            length,
138            now,
139            now,
140        );
141        self.metadata.upsert_var(&var)?;
142
143        // Route the value to the correct tier. On failure, roll back the
144        // metadata row we just wrote so the name does not get permanently
145        // reserved on a half-created variable.
146        let value_write: Result<(), CoreError> = match kind {
147            VarKind::Plain => self
148                .metadata
149                .set_plain_value(id, value.expose_secret())
150                .map_err(Into::into),
151            VarKind::Secret => self.secrets.put(id, value).map_err(Into::into),
152        };
153        if let Err(e) = value_write {
154            // Best-effort rollback. The metadata.delete_var call also
155            // cascades plain values + links, which is harmless here.
156            let _ = self.metadata.delete_var(id);
157            return Err(e);
158        }
159
160        self.audit_var(id, AuditAction::Created)?;
161        Ok(id)
162    }
163
164    /// Update the value of an existing variable.
165    ///
166    /// Routes the new value to the same storage tier the variable was
167    /// created with. The metadata record's `length` is refreshed but the
168    /// `kind` is preserved.
169    ///
170    /// The value-tier write happens **before** the metadata length update
171    /// so that a partial failure cannot leave metadata advertising a
172    /// length that no value in storage actually has.
173    ///
174    /// # Atomicity (v1)
175    /// The kind on the metadata record is trusted to route the write. If
176    /// the metadata is corrupted with a kind that disagrees with the
177    /// actual stored value, this method will route to the corrupted
178    /// kind's tier and may leak the prior value to the opposite tier.
179    /// Subsequent `get_value` calls then surface
180    /// [`CoreError::TierMismatch`]. Hardening this path (probe-and-clear
181    /// the opposite tier defensively) is scheduled for the v1.1 `SQLCipher`
182    /// landing.
183    ///
184    /// A failure of the audit append after a successful value write is
185    /// reported as an error but the value is persisted; same v1 limitation
186    /// as documented on [`Self::create_var`].
187    ///
188    /// # Errors
189    /// Returns [`MetadataError::VarNotFound`] if the variable does not
190    /// exist, [`MetadataError::Invalid`] if the value is empty, or any
191    /// error from the underlying storage tier.
192    pub fn update_value(&self, id: VarId, value: SecretString) -> Result<(), CoreError> {
193        if value.expose_secret().is_empty() {
194            return Err(MetadataError::Invalid("value is empty".into()).into());
195        }
196        let Some(mut var) = self.metadata.get_var(id)? else {
197            return Err(MetadataError::VarNotFound(id).into());
198        };
199        let length = value.expose_secret().len();
200
201        // Write the value FIRST. If this fails, no metadata change has
202        // landed and the stored state still describes the prior value.
203        match var.kind() {
204            VarKind::Plain => self.metadata.set_plain_value(id, value.expose_secret())?,
205            VarKind::Secret => self.secrets.put(id, value)?,
206        }
207        // Now refresh the length and persist.
208        var.set_length(length);
209        self.metadata.upsert_var(&var)?;
210        self.audit_var(id, AuditAction::Updated)?;
211        Ok(())
212    }
213
214    /// Retrieve the value of a variable regardless of storage tier.
215    ///
216    /// If the metadata record claims one [`VarKind`] but the value is
217    /// found in the opposite tier, this method returns
218    /// [`CoreError::TierMismatch`] rather than silently treating the
219    /// situation as "value missing". This surfaces corruption that
220    /// bypassed the service layer's normal routing.
221    ///
222    /// # Errors
223    /// Returns any propagated storage error.
224    /// Returns [`CoreError::TierMismatch`] when the variable's metadata
225    /// kind disagrees with where the value actually lives (corruption
226    /// detection). Returns `Ok(None)` if the variable does not exist or
227    /// has no value in either tier.
228    pub fn get_value(&self, id: VarId) -> Result<Option<SecretString>, CoreError> {
229        let Some(var) = self.metadata.get_var(id)? else {
230            return Ok(None);
231        };
232        let kind = var.kind();
233        match kind {
234            VarKind::Plain => {
235                if let Some(plain) = self.metadata.get_plain_value(id)? {
236                    return Ok(Some(SecretString::from(plain)));
237                }
238                // Plain-tier miss: probe the opposite tier to surface
239                // corruption before returning the indistinguishable None.
240                if self.secrets.get(id)?.is_some() {
241                    return Err(CoreError::TierMismatch {
242                        id,
243                        expected: VarKind::Plain,
244                        found: VarKind::Secret,
245                    });
246                }
247                Ok(None)
248            }
249            VarKind::Secret => {
250                if let Some(value) = self.secrets.get(id)? {
251                    return Ok(Some(value));
252                }
253                if self.metadata.get_plain_value(id)?.is_some() {
254                    return Err(CoreError::TierMismatch {
255                        id,
256                        expected: VarKind::Secret,
257                        found: VarKind::Plain,
258                    });
259                }
260                Ok(None)
261            }
262        }
263    }
264
265    /// Fetch a variable by id.
266    ///
267    /// # Errors
268    /// Propagates storage failures.
269    pub fn get_var(&self, id: VarId) -> Result<Option<Var>, CoreError> {
270        Ok(self.metadata.get_var(id)?)
271    }
272
273    /// Fetch a variable by name.
274    ///
275    /// # Errors
276    /// Propagates storage failures.
277    pub fn find_var_by_name(&self, name: &str) -> Result<Option<Var>, CoreError> {
278        Ok(self.metadata.find_var_by_name(name)?)
279    }
280
281    /// List every variable matching the supplied filter.
282    ///
283    /// # Errors
284    /// Propagates storage failures.
285    pub fn list_vars(&self, filter: &VarFilter) -> Result<Vec<Var>, CoreError> {
286        Ok(self.metadata.list_vars(filter)?)
287    }
288
289    /// Delete a variable and every value stored for it across all tiers.
290    /// Idempotent: deleting an absent variable is a successful no-op.
291    ///
292    /// Order of operations: metadata first (cascading plain values + links),
293    /// then a defensive `secrets.delete` for *both* kinds. The metadata-first
294    /// order guarantees that a transient secret-tier failure cannot leave a
295    /// "zombie" variable that the user can see but never read; the
296    /// defensive secret delete on `Plain` kind closes a corruption-recovery
297    /// path. A secret-tier failure after metadata has been deleted is
298    /// returned to the caller but the variable is, from any reader's
299    /// perspective, gone.
300    ///
301    /// # Errors
302    /// Propagates storage failures.
303    pub fn delete_var(&self, id: VarId) -> Result<(), CoreError> {
304        if self.metadata.get_var(id)?.is_none() {
305            return Ok(());
306        }
307        // Metadata first: cascades plain values + links atomically (per
308        // backend contract) and removes the variable from every read path.
309        self.metadata.delete_var(id)?;
310        // Defensive: try the secret tier even when the kind was Plain. The
311        // call is idempotent per the trait contract.
312        self.secrets.delete(id)?;
313        self.audit_var(id, AuditAction::Deleted)?;
314        Ok(())
315    }
316
317    // -------------------------------------------------------------------
318    // Projects
319    // -------------------------------------------------------------------
320
321    /// Create a new project.
322    ///
323    /// # Errors
324    /// Propagates storage failures.
325    pub fn create_project(
326        &self,
327        name: impl Into<String>,
328        path: std::path::PathBuf,
329    ) -> Result<ProjectId, CoreError> {
330        let id = ProjectId::from_uuid(self.id_gen.next());
331        let project = Project::from_parts(id, name.into(), path);
332        self.metadata.upsert_project(&project)?;
333        self.audit_project(id, None, AuditAction::Created)?;
334        Ok(id)
335    }
336
337    /// Fetch a project by id.
338    ///
339    /// # Errors
340    /// Propagates storage failures.
341    pub fn get_project(&self, id: ProjectId) -> Result<Option<Project>, CoreError> {
342        Ok(self.metadata.get_project(id)?)
343    }
344
345    /// List every project, sorted by the backend's contract (usually by
346    /// name for deterministic output).
347    ///
348    /// # Errors
349    /// Propagates storage failures.
350    pub fn list_projects(&self) -> Result<Vec<Project>, CoreError> {
351        Ok(self.metadata.list_projects()?)
352    }
353
354    /// Delete a project and every linkage it owns.
355    ///
356    /// # Errors
357    /// Propagates storage failures.
358    pub fn delete_project(&self, id: ProjectId) -> Result<(), CoreError> {
359        if self.metadata.get_project(id)?.is_none() {
360            return Ok(());
361        }
362        self.metadata.delete_project(id)?;
363        self.audit_project(id, None, AuditAction::Deleted)?;
364        Ok(())
365    }
366
367    // -------------------------------------------------------------------
368    // Linkage
369    // -------------------------------------------------------------------
370
371    /// Link a variable to a project under the given profile, optionally
372    /// renaming it for the project's context.
373    ///
374    /// # Errors
375    /// Returns [`MetadataError::ProjectNotFound`] or
376    /// [`MetadataError::VarNotFound`] if either side is missing.
377    pub fn link_var(
378        &self,
379        project_id: ProjectId,
380        var_id: VarId,
381        profile: Profile,
382        alias: Option<String>,
383    ) -> Result<(), CoreError> {
384        let link = ProjectVar {
385            project_id,
386            var_id,
387            alias,
388            profile,
389        };
390        self.metadata.upsert_link(&link)?;
391        self.audit_project(project_id, Some(var_id), AuditAction::Linked)?;
392        Ok(())
393    }
394
395    /// Remove a linkage. Idempotent: removing an absent link is `Ok(())`.
396    ///
397    /// Emits an [`AuditAction::Unlinked`] entry **only when a linkage was
398    /// actually removed**. A call on an absent triple is a successful
399    /// no-op and is not audited (avoids audit-log pollution that would
400    /// degrade forensic value).
401    ///
402    /// # Atomicity (v1)
403    /// A failure of the audit append after a successful linkage removal
404    /// is surfaced as an error but the linkage is gone. Callers should
405    /// not retry, since a retry of `unlink_var` would return `Ok(())`
406    /// (the triple is now absent). Same v1 limitation as
407    /// [`Self::create_var`]; a dedicated `AuditWriteFailed` variant is
408    /// scheduled for v1.1.
409    ///
410    /// # Errors
411    /// Propagates storage failures.
412    pub fn unlink_var(
413        &self,
414        project_id: ProjectId,
415        var_id: VarId,
416        profile: &Profile,
417    ) -> Result<(), CoreError> {
418        let removed = self.metadata.delete_link(project_id, var_id, profile)?;
419        if removed {
420            self.audit_project(project_id, Some(var_id), AuditAction::Unlinked)?;
421        }
422        Ok(())
423    }
424
425    /// Every linkage owned by a project.
426    ///
427    /// # Errors
428    /// Propagates storage failures.
429    pub fn links_for_project(&self, project_id: ProjectId) -> Result<Vec<ProjectVar>, CoreError> {
430        Ok(self.metadata.list_links_for_project(project_id)?)
431    }
432
433    /// Every linkage that references a given variable.
434    ///
435    /// # Errors
436    /// Propagates storage failures.
437    pub fn links_for_var(&self, var_id: VarId) -> Result<Vec<ProjectVar>, CoreError> {
438        Ok(self.metadata.list_links_for_var(var_id)?)
439    }
440
441    // -------------------------------------------------------------------
442    // Audit
443    // -------------------------------------------------------------------
444
445    /// Return the newest `limit` audit entries.
446    ///
447    /// # Errors
448    /// Propagates storage failures.
449    pub fn recent_audit(&self, limit: usize) -> Result<Vec<AuditEntry>, CoreError> {
450        Ok(self.audit.list(limit)?)
451    }
452
453    // -------------------------------------------------------------------
454    // Internal helpers
455    // -------------------------------------------------------------------
456
457    fn audit_var(&self, var_id: VarId, action: AuditAction) -> Result<(), MetadataError> {
458        let entry = AuditEntry::from_parts(
459            AuditId::from_uuid(self.id_gen.next()),
460            action,
461            Some(var_id),
462            None,
463            None,
464            self.clock.now(),
465        );
466        self.audit.append(&entry)
467    }
468
469    fn audit_project(
470        &self,
471        project_id: ProjectId,
472        var_id: Option<VarId>,
473        action: AuditAction,
474    ) -> Result<(), MetadataError> {
475        let entry = AuditEntry::from_parts(
476            AuditId::from_uuid(self.id_gen.next()),
477            action,
478            var_id,
479            Some(project_id),
480            None,
481            self.clock.now(),
482        );
483        self.audit.append(&entry)
484    }
485}