lenso_secrets_env_plugin/
lib.rs1use std::{collections::BTreeMap, error::Error, fmt, rc::Rc};
4
5use lenso_capability_secrets::{
6 ResolveError, ResolveRequest, ResolveResponse, Secrets, SecretsEndpoint,
7 SecretsInvocationError, SecretsProvider,
8};
9use lenso_kernel::{
10 InvocationContext, NativeRequestEndpoint, NativeRequestFuture, PluginFuture, PluginLifecycle,
11 PrepareContext, RuntimeFailure,
12};
13use lenso_native_adapter::{NativePluginFactory, NativePluginFactoryContext, NativePluginInstance};
14
15pub const MAX_REFERENCE_LENGTH: usize = 256;
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum EnvSecretsConfigError {
21 Empty,
23 InvalidReference,
25 InvalidEnvironmentVariable,
27 DuplicateReference,
29}
30
31impl fmt::Display for EnvSecretsConfigError {
32 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::Empty => formatter.write_str("at least one secret reference is required"),
35 Self::InvalidReference => formatter.write_str("invalid logical secret reference"),
36 Self::InvalidEnvironmentVariable => {
37 formatter.write_str("invalid environment-variable name")
38 }
39 Self::DuplicateReference => {
40 formatter.write_str("logical secret reference is already configured")
41 }
42 }
43 }
44}
45
46impl Error for EnvSecretsConfigError {}
47
48#[derive(Clone, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct EnvSecretsConfig {
52 #[serde(rename = "references", deserialize_with = "deserialize_unique_sources")]
53 sources: BTreeMap<String, String>,
54}
55
56impl EnvSecretsConfig {
57 #[must_use]
59 pub const fn new() -> Self {
60 Self {
61 sources: BTreeMap::new(),
62 }
63 }
64
65 pub fn insert(
67 &mut self,
68 reference: impl Into<String>,
69 environment_variable: impl Into<String>,
70 ) -> Result<(), EnvSecretsConfigError> {
71 let reference = reference.into();
72 let environment_variable = environment_variable.into();
73 if !valid_reference(&reference) {
74 return Err(EnvSecretsConfigError::InvalidReference);
75 }
76 if !valid_environment_variable(&environment_variable) {
77 return Err(EnvSecretsConfigError::InvalidEnvironmentVariable);
78 }
79 if self.sources.contains_key(&reference) {
80 return Err(EnvSecretsConfigError::DuplicateReference);
81 }
82 self.sources.insert(reference, environment_variable);
83 Ok(())
84 }
85
86 pub fn with_reference(
88 mut self,
89 reference: impl Into<String>,
90 environment_variable: impl Into<String>,
91 ) -> Result<Self, EnvSecretsConfigError> {
92 self.insert(reference, environment_variable)?;
93 Ok(self)
94 }
95
96 #[must_use]
98 pub fn is_empty(&self) -> bool {
99 self.sources.is_empty()
100 }
101
102 #[must_use]
104 pub fn len(&self) -> usize {
105 self.sources.len()
106 }
107
108 fn source_for(&self, reference: &str) -> Option<&str> {
109 self.sources.get(reference).map(String::as_str)
110 }
111
112 fn validate(&self) -> Result<(), EnvSecretsConfigError> {
113 if self.sources.is_empty() {
114 return Err(EnvSecretsConfigError::Empty);
115 }
116 for (reference, environment_variable) in &self.sources {
117 if !valid_reference(reference) {
118 return Err(EnvSecretsConfigError::InvalidReference);
119 }
120 if !valid_environment_variable(environment_variable) {
121 return Err(EnvSecretsConfigError::InvalidEnvironmentVariable);
122 }
123 }
124 Ok(())
125 }
126}
127
128impl fmt::Debug for EnvSecretsConfig {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 formatter
131 .debug_struct("EnvSecretsConfig")
132 .field("references", &self.sources.keys().collect::<Vec<_>>())
133 .finish()
134 }
135}
136
137#[derive(Clone, Debug)]
139pub struct EnvSecretsFactory {
140 source: Rc<dyn SecretSource>,
141}
142
143impl EnvSecretsFactory {
144 #[must_use]
146 pub fn new() -> Self {
147 Self {
148 source: Rc::new(ProcessEnvironment),
149 }
150 }
151
152 #[cfg(test)]
153 fn with_source(source: Rc<dyn SecretSource>) -> Self {
154 Self { source }
155 }
156}
157
158impl Default for EnvSecretsFactory {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164impl NativePluginFactory for EnvSecretsFactory {
165 fn package_id(&self) -> &'static str {
166 PACKAGE_ID
167 }
168
169 fn package_version(&self) -> &'static str {
170 PACKAGE_VERSION
171 }
172
173 fn instantiate(
174 &self,
175 context: NativePluginFactoryContext<'_>,
176 ) -> Result<NativePluginInstance, RuntimeFailure> {
177 instantiate_with_source(context, self.source.clone())
178 }
179}
180
181#[lenso_native_adapter::plugin(
183 descriptor = r#"{"provided_capabilities":[{"capability_id":"lenso.secrets@1","descriptor_version":"1.0.0","operations":["resolve"],"operation_kinds":{},"default_admission":{"queue_capacity":2,"max_concurrency":1},"operation_admissions":{},"event_admission":null,"cross_lane_transfer":false}],"required_capabilities":[]}"#,
184 configuration_schema = "config.schema.json"
185)]
186fn instantiate(
187 context: NativePluginFactoryContext<'_>,
188) -> Result<NativePluginInstance, RuntimeFailure> {
189 instantiate_with_source(context, Rc::new(ProcessEnvironment))
190}
191
192fn instantiate_with_source(
193 context: NativePluginFactoryContext<'_>,
194 source: Rc<dyn SecretSource>,
195) -> Result<NativePluginInstance, RuntimeFailure> {
196 if context.entrypoint() != "default" {
197 return Err(RuntimeFailure::InvalidResolvedPlan {
198 detail: "unsupported Env Secrets Plugin entrypoint".to_owned(),
199 });
200 }
201 let config =
202 serde_json::from_str::<EnvSecretsConfig>(context.configuration()).map_err(|error| {
203 RuntimeFailure::InvalidResolvedPlan {
204 detail: format!("Env Secrets Plugin configuration is invalid: {error}"),
205 }
206 })?;
207 config
208 .validate()
209 .map_err(|error| RuntimeFailure::InvalidResolvedPlan {
210 detail: format!("Env Secrets Plugin configuration is invalid: {error}"),
211 })?;
212 let provider = EnvSecretsProvider::new(config, source);
213 let endpoint = Rc::new(SecretsEndpoint::new(provider.clone())) as Rc<dyn NativeRequestEndpoint>;
214 Ok(NativePluginInstance::with_lifecycle(
215 vec![endpoint],
216 EnvSecretsLifecycle { provider },
217 ))
218}
219
220#[derive(Clone)]
221struct EnvSecretsProvider {
222 config: Rc<EnvSecretsConfig>,
223 source: Rc<dyn SecretSource>,
224}
225
226impl EnvSecretsProvider {
227 fn new(config: EnvSecretsConfig, source: Rc<dyn SecretSource>) -> Self {
228 Self {
229 config: Rc::new(config),
230 source,
231 }
232 }
233
234 fn verify_sources(&self) -> Result<(), RuntimeFailure> {
235 for reference in self.config.sources.keys() {
236 self.read(reference)?;
237 }
238 Ok(())
239 }
240
241 fn read(&self, reference: &str) -> Result<String, RuntimeFailure> {
242 let source = self
243 .config
244 .source_for(reference)
245 .ok_or_else(|| RuntimeFailure::Internal {
246 detail: "Env Secrets attempted to read an unbound reference".to_owned(),
247 })?;
248 self.source
249 .read(source)
250 .map_err(|SourceUnavailable| RuntimeFailure::PluginFailure {
251 detail: format!("configured secret reference `{reference}` is unavailable"),
252 })
253 }
254}
255
256impl fmt::Debug for EnvSecretsProvider {
257 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
258 formatter
259 .debug_struct("EnvSecretsProvider")
260 .field("config", &self.config)
261 .finish_non_exhaustive()
262 }
263}
264
265impl SecretsProvider for EnvSecretsProvider {
266 fn resolve(
267 &self,
268 _context: InvocationContext,
269 request: ResolveRequest,
270 ) -> NativeRequestFuture<Secrets> {
271 let result = if !valid_reference(&request.reference) {
272 Err(SecretsInvocationError::Domain(
273 ResolveError::InvalidReference,
274 ))
275 } else if self.config.source_for(&request.reference).is_none() {
276 Err(SecretsInvocationError::Domain(
277 ResolveError::UnknownReference,
278 ))
279 } else {
280 self.read(&request.reference)
281 .map(|value| ResolveResponse { value })
282 .map_err(SecretsInvocationError::Runtime)
283 };
284 let result = match result {
285 Ok(response) => Ok(Ok(response)),
286 Err(SecretsInvocationError::Domain(error)) => Ok(Err(error)),
287 Err(SecretsInvocationError::Runtime(error)) => Err(error),
288 };
289 Box::pin(futures::future::ready(result))
290 }
291}
292
293#[derive(Debug)]
294struct EnvSecretsLifecycle {
295 provider: EnvSecretsProvider,
296}
297
298impl PluginLifecycle for EnvSecretsLifecycle {
299 fn prepare(&self, _context: PrepareContext) -> PluginFuture {
300 Box::pin(futures::future::ready(self.provider.verify_sources()))
301 }
302}
303
304trait SecretSource: fmt::Debug + 'static {
305 fn read(&self, environment_variable: &str) -> Result<String, SourceUnavailable>;
306}
307
308#[derive(Clone, Copy, Debug, Eq, PartialEq)]
309struct SourceUnavailable;
310
311#[derive(Debug)]
312struct ProcessEnvironment;
313
314impl SecretSource for ProcessEnvironment {
315 fn read(&self, environment_variable: &str) -> Result<String, SourceUnavailable> {
316 std::env::var(environment_variable).map_err(|_| SourceUnavailable)
317 }
318}
319
320fn deserialize_unique_sources<'de, D>(deserializer: D) -> Result<BTreeMap<String, String>, D::Error>
321where
322 D: serde::Deserializer<'de>,
323{
324 struct UniqueSources;
325
326 impl<'de> serde::de::Visitor<'de> for UniqueSources {
327 type Value = BTreeMap<String, String>;
328
329 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
330 formatter.write_str("a logical-reference to environment-variable map")
331 }
332
333 fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
334 where
335 A: serde::de::MapAccess<'de>,
336 {
337 let mut sources = BTreeMap::new();
338 while let Some((reference, source)) = access.next_entry::<String, String>()? {
339 if sources.insert(reference.clone(), source).is_some() {
340 return Err(serde::de::Error::custom(format!(
341 "duplicate logical secret reference `{reference}`"
342 )));
343 }
344 }
345 Ok(sources)
346 }
347 }
348
349 deserializer.deserialize_map(UniqueSources)
350}
351
352fn valid_reference(reference: &str) -> bool {
353 !reference.is_empty()
354 && reference.len() <= MAX_REFERENCE_LENGTH
355 && !reference.starts_with('/')
356 && !reference.ends_with('/')
357 && !reference.contains("//")
358 && reference
359 .split('/')
360 .all(|segment| segment != "." && segment != "..")
361 && reference
362 .bytes()
363 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
364}
365
366fn valid_environment_variable(name: &str) -> bool {
367 let mut bytes = name.bytes();
368 bytes
369 .next()
370 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
371 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
372}
373
374#[cfg(test)]
375mod tests;