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/// Maximum supported logical secret-reference length.
16pub const MAX_REFERENCE_LENGTH: usize = 256;
17
18/// Invalid immutable configuration supplied by the host author.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum EnvSecretsConfigError {
21    /// At least one required reference must be declared.
22    Empty,
23    /// A logical reference is not a canonical non-empty path.
24    InvalidReference,
25    /// An environment-variable name is not a portable process identifier.
26    InvalidEnvironmentVariable,
27    /// The same logical reference was configured more than once.
28    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/// Immutable logical-reference allowlist for one Plugin Instance.
49#[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    /// Creates an empty allowlist.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self {
61            sources: BTreeMap::new(),
62        }
63    }
64
65    /// Adds one required logical reference and its environment source.
66    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    /// Adds one required mapping through a consuming builder style.
87    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    /// Returns whether no references are configured.
97    #[must_use]
98    pub fn is_empty(&self) -> bool {
99        self.sources.is_empty()
100    }
101
102    /// Returns the number of explicitly configured references.
103    #[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/// Native Rust factory for one allowlisted environment-backed Provider.
138#[derive(Clone, Debug)]
139pub struct EnvSecretsFactory {
140    source: Rc<dyn SecretSource>,
141}
142
143impl EnvSecretsFactory {
144    /// Creates a Provider that reads the current process environment.
145    #[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/// Instantiates the ordinary process-environment-backed Plugin.
182#[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;