Skip to main content

lenso_secrets_env_plugin/
lib.rs

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