Skip to main content

secrets_core/
mount.rs

1//! Shared plumbing for the `{mount}/config/{name}` + `{mount}/roles/{role}`
2//! convention that every dynamic engine follows.
3//!
4//! Without this, each engine reimplements the same prefix-stripping and
5//! JSON round-tripping. The one rule worth centralising is that **config is
6//! never readable**: it holds the provider root credential, so a read returns
7//! an existence probe instead of the stored document.
8
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use serde_json::json;
12
13use crate::engine::{EngineError, EngineResult};
14use crate::storage::{StorageBackend, StorageEntry};
15
16/// The storage prefixes an engine keeps its operator documents under.
17pub struct ConfigRoleStore {
18    pub config_prefix: &'static str,
19    pub role_prefix: &'static str,
20}
21
22impl ConfigRoleStore {
23    pub const fn new(config_prefix: &'static str, role_prefix: &'static str) -> Self {
24        Self {
25            config_prefix,
26            role_prefix,
27        }
28    }
29
30    pub async fn load_config<C: DeserializeOwned>(
31        &self,
32        storage: &dyn StorageBackend,
33        name: &str,
34    ) -> EngineResult<Option<C>> {
35        load(storage, self.config_prefix, name).await
36    }
37
38    pub async fn load_role<R: DeserializeOwned>(
39        &self,
40        storage: &dyn StorageBackend,
41        name: &str,
42    ) -> EngineResult<Option<R>> {
43        load(storage, self.role_prefix, name).await
44    }
45
46    /// Loads a role, or fails with the error an engine's `generate()` should
47    /// surface when the role was never defined.
48    pub async fn require_role<R: DeserializeOwned>(
49        &self,
50        storage: &dyn StorageBackend,
51        name: &str,
52    ) -> EngineResult<R> {
53        self.load_role(storage, name).await?.ok_or(EngineError::NotFound)
54    }
55
56    pub async fn require_config<C: DeserializeOwned>(
57        &self,
58        storage: &dyn StorageBackend,
59        name: &str,
60    ) -> EngineResult<C> {
61        self.load_config(storage, name).await?.ok_or_else(|| {
62            EngineError::InvalidRequest(format!("unknown config '{name}' — POST it first"))
63        })
64    }
65
66    /// `read` dispatch. Roles are returned in full; a config read deliberately
67    /// returns only whether it exists, because the document holds the root
68    /// credential and nothing above this layer should be able to read it back.
69    pub async fn handle_read<R: DeserializeOwned + Serialize>(
70        &self,
71        storage: &dyn StorageBackend,
72        path: &str,
73    ) -> EngineResult<serde_json::Value> {
74        if let Some(name) = path.strip_prefix("roles/") {
75            let role: R = self.require_role(storage, name).await?;
76            serde_json::to_value(role).map_err(|e| EngineError::Other(e.to_string()))
77        } else if let Some(name) = path.strip_prefix("config/") {
78            let exists = storage.get(&format!("{}{name}", self.config_prefix)).await?.is_some();
79            if exists {
80                Ok(json!({
81                    "configured": true,
82                    "note": "config is write-only — it holds this engine's root \
83                             credential and is never returned. POST to replace it.",
84                }))
85            } else {
86                Err(EngineError::NotFound)
87            }
88        } else {
89            Err(EngineError::InvalidRequest(
90                "expected config/{name} or roles/{name}".into(),
91            ))
92        }
93    }
94
95    pub async fn handle_write<C, R>(
96        &self,
97        storage: &dyn StorageBackend,
98        path: &str,
99        data: serde_json::Value,
100    ) -> EngineResult<()>
101    where
102        C: DeserializeOwned + Serialize,
103        R: DeserializeOwned + Serialize,
104    {
105        if let Some(name) = path.strip_prefix("config/") {
106            let config: C =
107                serde_json::from_value(data).map_err(|e| EngineError::InvalidRequest(e.to_string()))?;
108            save(storage, self.config_prefix, name, &config).await
109        } else if let Some(name) = path.strip_prefix("roles/") {
110            let role: R =
111                serde_json::from_value(data).map_err(|e| EngineError::InvalidRequest(e.to_string()))?;
112            save(storage, self.role_prefix, name, &role).await
113        } else {
114            Err(EngineError::InvalidRequest(
115                "expected config/{name} or roles/{name}".into(),
116            ))
117        }
118    }
119
120    pub async fn handle_delete(
121        &self,
122        storage: &dyn StorageBackend,
123        path: &str,
124    ) -> EngineResult<()> {
125        if let Some(name) = path.strip_prefix("config/") {
126            storage.delete(&format!("{}{name}", self.config_prefix)).await?;
127            Ok(())
128        } else if let Some(name) = path.strip_prefix("roles/") {
129            storage.delete(&format!("{}{name}", self.role_prefix)).await?;
130            Ok(())
131        } else {
132            Err(EngineError::InvalidRequest(
133                "expected config/{name} or roles/{name}".into(),
134            ))
135        }
136    }
137
138    pub async fn handle_list(
139        &self,
140        storage: &dyn StorageBackend,
141        prefix: &str,
142    ) -> EngineResult<Vec<String>> {
143        let (storage_prefix, rest) = if let Some(rest) = prefix.strip_prefix("roles/") {
144            (self.role_prefix, rest)
145        } else if let Some(rest) = prefix.strip_prefix("config/") {
146            (self.config_prefix, rest)
147        } else {
148            return Err(EngineError::InvalidRequest(
149                "expected config/ or roles/ prefix".into(),
150            ));
151        };
152        let keys = storage.list(&format!("{storage_prefix}{rest}")).await?;
153        Ok(keys
154            .into_iter()
155            .filter_map(|k| k.strip_prefix(storage_prefix).map(|s| s.to_string()))
156            .collect())
157    }
158}
159
160async fn load<T: DeserializeOwned>(
161    storage: &dyn StorageBackend,
162    prefix: &str,
163    name: &str,
164) -> EngineResult<Option<T>> {
165    let Some(entry) = storage.get(&format!("{prefix}{name}")).await? else {
166        return Ok(None);
167    };
168    Ok(Some(
169        serde_json::from_slice(&entry.value).map_err(|e| EngineError::Other(e.to_string()))?,
170    ))
171}
172
173async fn save<T: Serialize>(
174    storage: &dyn StorageBackend,
175    prefix: &str,
176    name: &str,
177    value: &T,
178) -> EngineResult<()> {
179    let bytes = serde_json::to_vec(value).map_err(|e| EngineError::Other(e.to_string()))?;
180    storage
181        .put(
182            &format!("{prefix}{name}"),
183            StorageEntry {
184                value: bytes,
185                expires_at: None,
186            },
187        )
188        .await?;
189    Ok(())
190}