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}