Skip to main content

secrets_provider_k8s/
lib.rs

1//! Kubernetes Secrets backend powered by the Kubernetes REST API.
2//!
3//! Each Greentic secret maps to a namespaced Kubernetes `Secret` resource.
4//! Every write creates a new resource whose name encodes the version number,
5//! preserving history. Deletions append a tombstone version. All operations
6//! execute via the standard Kubernetes HTTPS endpoints using a bearer token
7//! provided through environment variables.
8
9use anyhow::{Context, Result, bail};
10use base64::{Engine, engine::general_purpose::STANDARD};
11use greentic_secrets_core::http::{Http, HttpResponse};
12use greentic_secrets_spec::{
13    Envelope, KeyProvider, Scope, SecretListItem, SecretMeta, SecretRecord, SecretUri,
14    SecretVersion, SecretsBackend, SecretsError, SecretsResult, VersionedSecret,
15};
16use reqwest::{Certificate, Method, StatusCode};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value, json};
19use sha2::{Digest, Sha256};
20use std::collections::HashMap;
21use std::fs;
22use std::sync::Arc;
23use std::time::Duration;
24use url::form_urlencoded::byte_serialize;
25
26const DEFAULT_NAMESPACE_PREFIX: &str = "greentic";
27const DEFAULT_MAX_SECRET_SIZE: usize = 1_048_576; // 1 MiB
28const NAMESPACE_MAX_LEN: usize = 63;
29const SECRET_NAME_MAX_LEN: usize = 253;
30const LABEL_KEY: &str = "greentic.ai/key";
31const LABEL_VERSION: &str = "greentic.ai/version";
32const LABEL_ENV: &str = "greentic.ai/env";
33const LABEL_TENANT: &str = "greentic.ai/tenant";
34const LABEL_TEAM: &str = "greentic.ai/team";
35const LABEL_CATEGORY: &str = "greentic.ai/category";
36const LABEL_NAME: &str = "greentic.ai/name";
37const STATUS_LABEL: &str = "greentic.ai/status";
38
39/// Components returned to the broker wiring.
40pub struct BackendComponents {
41    pub backend: Box<dyn SecretsBackend>,
42    pub key_provider: Box<dyn KeyProvider>,
43}
44
45/// Construct the backend and key provider from environment configuration.
46pub async fn build_backend() -> Result<BackendComponents> {
47    let config = Arc::new(K8sProviderConfig::from_env()?);
48    let http = config.build_http_client()?;
49
50    let backend = K8sSecretsBackend::new(config.clone(), http.clone());
51    let key_provider = K8sKeyProvider::new(config);
52    Ok(BackendComponents {
53        backend: Box::new(backend),
54        key_provider: Box::new(key_provider),
55    })
56}
57
58#[derive(Clone)]
59struct K8sSecretsBackend {
60    config: Arc<K8sProviderConfig>,
61    http: Http,
62}
63
64impl K8sSecretsBackend {
65    fn new(config: Arc<K8sProviderConfig>, http: Http) -> Self {
66        Self { config, http }
67    }
68
69    fn request(
70        &self,
71        method: Method,
72        path: &str,
73        body: Option<Value>,
74    ) -> SecretsResult<HttpResponse> {
75        let url = format!(
76            "{}/{}",
77            self.config.api_server.trim_end_matches('/'),
78            path.trim_start_matches('/')
79        );
80
81        let mut builder = self.http.request(method, url);
82        builder = builder.bearer_auth(&self.config.bearer_token);
83        if let Some(payload) = body {
84            builder = builder.json(&payload);
85        }
86
87        builder
88            .send()
89            .map_err(|err| SecretsError::Storage(format!("kubernetes request failed: {err}")))
90    }
91
92    fn ensure_namespace(&self, namespace: &str) -> SecretsResult<()> {
93        let path = format!("/api/v1/namespaces/{namespace}");
94        let (status, body) = read_k8s_response(self.request(Method::GET, &path, None)?)?;
95        if status == StatusCode::OK {
96            return Ok(());
97        }
98
99        if status != StatusCode::NOT_FOUND {
100            return Err(SecretsError::Storage(format!(
101                "failed to inspect namespace: {status} {body}"
102            )));
103        }
104
105        let create = json!({
106            "apiVersion": "v1",
107            "kind": "Namespace",
108            "metadata": { "name": namespace },
109        });
110        let (status, body) =
111            read_k8s_response(self.request(Method::POST, "/api/v1/namespaces", Some(create))?)?;
112        if !status.is_success() {
113            return Err(SecretsError::Storage(format!(
114                "failed to create namespace: {status} {body}"
115            )));
116        }
117        Ok(())
118    }
119
120    fn put_secret(&self, namespace: &str, manifest: Value) -> SecretsResult<()> {
121        let path = format!("/api/v1/namespaces/{namespace}/secrets");
122        let (status, body) =
123            read_k8s_response(self.request(Method::POST, &path, Some(manifest))?)?;
124        if !status.is_success() {
125            return Err(SecretsError::Storage(format!(
126                "create secret failed: {status} {body}"
127            )));
128        }
129        Ok(())
130    }
131
132    fn list_versions(&self, namespace: &str, key: &str) -> SecretsResult<Vec<SecretSnapshot>> {
133        let mut snapshots = Vec::new();
134        let selector = format!("{LABEL_KEY}={key}");
135        let selector = percent_encode(&selector);
136        let mut continue_token: Option<String> = None;
137
138        loop {
139            let mut path = format!(
140                "/api/v1/namespaces/{namespace}/secrets?labelSelector={selector}&limit=100"
141            );
142            if let Some(token) = continue_token.as_ref() {
143                path.push_str("&continue=");
144                path.push_str(&percent_encode(token));
145            }
146
147            let (status, body) = read_k8s_response(self.request(Method::GET, &path, None)?)?;
148            if status == StatusCode::NOT_FOUND {
149                break;
150            }
151            if !status.is_success() {
152                return Err(SecretsError::Storage(format!(
153                    "list secrets failed: {status} {body}"
154                )));
155            }
156
157            let mut list: SecretList = serde_json::from_str(&body).map_err(|err| {
158                SecretsError::Storage(format!("failed to decode secret list: {err}; body={body}"))
159            })?;
160
161            for item in list.items.drain(..) {
162                if let Some(snapshot) = parse_secret(item)? {
163                    snapshots.push(snapshot);
164                }
165            }
166
167            if let Some(token) = list.metadata.and_then(|meta| meta.continue_token) {
168                continue_token = Some(token);
169                continue;
170            }
171            break;
172        }
173
174        snapshots.sort_by_key(|snapshot| snapshot.version);
175        Ok(snapshots)
176    }
177}
178
179fn read_k8s_response(response: HttpResponse) -> SecretsResult<(StatusCode, String)> {
180    let status = response.status();
181    let body = response.text().map_err(|err| {
182        SecretsError::Storage(format!("failed to read kubernetes response body: {err}"))
183    })?;
184    Ok((status, body))
185}
186
187impl SecretsBackend for K8sSecretsBackend {
188    fn put(&self, record: SecretRecord) -> SecretsResult<SecretVersion> {
189        if record.value.len() > self.config.max_secret_size {
190            return Err(SecretsError::Storage(format!(
191                "secret payload exceeds configured Kubernetes limit of {} bytes",
192                self.config.max_secret_size
193            )));
194        }
195
196        if self.config.use_sealed_secrets {
197            return Err(SecretsError::Storage(
198                "sealed secrets mode is not supported by the live Kubernetes backend".into(),
199            ));
200        }
201
202        let namespace = namespace_for_scope(&self.config, record.meta.uri.scope());
203        self.ensure_namespace(&namespace)?;
204
205        let key = canonical_storage_key(&record.meta.uri);
206        let versions = self.list_versions(&namespace, &key)?;
207        let next_version = versions
208            .last()
209            .map(|snapshot| snapshot.version)
210            .unwrap_or(0)
211            .saturating_add(1);
212
213        let name = secret_resource_name(&record.meta.uri, next_version);
214        let manifest = secret_manifest(
215            &record.meta.uri,
216            Some(&record),
217            &namespace,
218            &name,
219            &key,
220            next_version,
221            false,
222        )?;
223        self.put_secret(&namespace, manifest)?;
224
225        Ok(SecretVersion {
226            version: next_version,
227            deleted: false,
228        })
229    }
230
231    fn get(&self, uri: &SecretUri, version: Option<u64>) -> SecretsResult<Option<VersionedSecret>> {
232        let namespace = namespace_for_scope(&self.config, uri.scope());
233        let key = canonical_storage_key(uri);
234        let versions = self.list_versions(&namespace, &key)?;
235
236        if let Some(requested) = version {
237            for snapshot in versions {
238                if snapshot.version == requested && !snapshot.deleted {
239                    return snapshot.into_versioned();
240                }
241            }
242            return Ok(None);
243        }
244
245        for snapshot in versions.into_iter().rev() {
246            if snapshot.deleted {
247                continue;
248            }
249            return snapshot.into_versioned();
250        }
251
252        Ok(None)
253    }
254
255    fn list(
256        &self,
257        scope: &Scope,
258        category_prefix: Option<&str>,
259        name_prefix: Option<&str>,
260    ) -> SecretsResult<Vec<SecretListItem>> {
261        let namespace = namespace_for_scope(&self.config, scope);
262        let (status, body) = read_k8s_response(self.request(
263            Method::GET,
264            &format!("/api/v1/namespaces/{namespace}/secrets?limit=250"),
265            None,
266        )?)?;
267        if !status.is_success() {
268            return Err(SecretsError::Storage(format!(
269                "list secrets failed: {status} {body}"
270            )));
271        }
272
273        let mut list: SecretList = serde_json::from_str(&body).map_err(|err| {
274            SecretsError::Storage(format!("failed to decode secret list: {err}; body={body}"))
275        })?;
276
277        let mut items = Vec::new();
278        for item in list.items.drain(..) {
279            let Some(snapshot) = parse_secret(item)? else {
280                continue;
281            };
282            if snapshot.deleted {
283                continue;
284            }
285
286            if let Some(record) = snapshot.record.as_ref() {
287                let record_scope = record.meta.uri.scope();
288                if record_scope.env() != scope.env() || record_scope.tenant() != scope.tenant() {
289                    continue;
290                }
291                if let Some(team) = scope.team()
292                    && record_scope.team() != Some(team)
293                {
294                    continue;
295                }
296                if let Some(prefix) = category_prefix
297                    && !record.meta.uri.category().starts_with(prefix)
298                {
299                    continue;
300                }
301                if let Some(prefix) = name_prefix
302                    && !record.meta.uri.name().starts_with(prefix)
303                {
304                    continue;
305                }
306
307                let versioned = snapshot.into_versioned()?;
308                if let Some(versioned) = versioned
309                    && let Some(record) = versioned.record()
310                {
311                    items.push(SecretListItem::from_meta(
312                        &record.meta,
313                        Some(versioned.version.to_string()),
314                    ));
315                }
316            }
317        }
318
319        Ok(items)
320    }
321
322    fn delete(&self, uri: &SecretUri) -> SecretsResult<SecretVersion> {
323        let namespace = namespace_for_scope(&self.config, uri.scope());
324        let key = canonical_storage_key(uri);
325        let versions = self.list_versions(&namespace, &key)?;
326        if versions.is_empty() {
327            return Err(SecretsError::NotFound {
328                entity: uri.to_string(),
329            });
330        }
331
332        let next_version = versions
333            .last()
334            .map(|snapshot| snapshot.version)
335            .unwrap_or(0)
336            .saturating_add(1);
337
338        let name = secret_resource_name(uri, next_version);
339        let manifest = secret_manifest(uri, None, &namespace, &name, &key, next_version, true)?;
340        self.put_secret(&namespace, manifest)?;
341
342        Ok(SecretVersion {
343            version: next_version,
344            deleted: true,
345        })
346    }
347
348    fn versions(&self, uri: &SecretUri) -> SecretsResult<Vec<SecretVersion>> {
349        let namespace = namespace_for_scope(&self.config, uri.scope());
350        let key = canonical_storage_key(uri);
351        Ok(self
352            .list_versions(&namespace, &key)?
353            .into_iter()
354            .map(|snapshot| SecretVersion {
355                version: snapshot.version,
356                deleted: snapshot.deleted,
357            })
358            .collect())
359    }
360
361    fn exists(&self, uri: &SecretUri) -> SecretsResult<bool> {
362        Ok(self.get(uri, None)?.is_some())
363    }
364}
365
366#[derive(Clone)]
367struct K8sKeyProvider {
368    config: Arc<K8sProviderConfig>,
369}
370
371impl K8sKeyProvider {
372    fn new(config: Arc<K8sProviderConfig>) -> Self {
373        Self { config }
374    }
375
376    fn derive_key(&self, alias: &str) -> Vec<u8> {
377        let mut hasher = Sha256::new();
378        hasher.update(alias.as_bytes());
379        hasher.finalize()[..32].to_vec()
380    }
381}
382
383impl KeyProvider for K8sKeyProvider {
384    fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> SecretsResult<Vec<u8>> {
385        let alias = self
386            .config
387            .key_aliases
388            .resolve(scope.env(), scope.tenant())
389            .ok_or_else(|| SecretsError::Crypto("missing key material alias".into()))?;
390        let key = self.derive_key(alias);
391        Ok(xor_bytes(&key, dek))
392    }
393
394    fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> SecretsResult<Vec<u8>> {
395        let alias = self
396            .config
397            .key_aliases
398            .resolve(scope.env(), scope.tenant())
399            .ok_or_else(|| SecretsError::Crypto("missing key material alias".into()))?;
400        let key = self.derive_key(alias);
401        Ok(xor_bytes(&key, wrapped))
402    }
403}
404
405fn xor_bytes(key: &[u8], data: &[u8]) -> Vec<u8> {
406    data.iter()
407        .enumerate()
408        .map(|(idx, byte)| byte ^ key[idx % key.len()])
409        .collect()
410}
411
412#[derive(Clone, Debug)]
413struct K8sProviderConfig {
414    api_server: String,
415    bearer_token: String,
416    ca_bundle: Option<Vec<u8>>,
417    insecure_skip_tls: bool,
418    request_timeout: Duration,
419    namespace_prefix: String,
420    max_secret_size: usize,
421    key_aliases: AliasMap,
422    use_sealed_secrets: bool,
423}
424
425impl K8sProviderConfig {
426    fn from_env() -> Result<Self> {
427        let api_server = std::env::var("K8S_API_SERVER")
428            .context("set K8S_API_SERVER to the Kubernetes API server URL")?;
429
430        let bearer_token = match std::env::var("K8S_BEARER_TOKEN") {
431            Ok(value) => value,
432            Err(_) => {
433                let path = std::env::var("K8S_BEARER_TOKEN_FILE")
434                    .context("set K8S_BEARER_TOKEN or K8S_BEARER_TOKEN_FILE")?;
435                String::from_utf8(fs::read(path)?).context("token file is not valid UTF-8")?
436            }
437        };
438
439        let ca_bundle = std::env::var("K8S_CA_BUNDLE")
440            .ok()
441            .map(|path| fs::read(path).context("failed to read K8S_CA_BUNDLE"))
442            .transpose()?;
443
444        let insecure_skip_tls = std::env::var("K8S_INSECURE_SKIP_TLS")
445            .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
446            .unwrap_or(false);
447
448        let request_timeout = std::env::var("K8S_HTTP_TIMEOUT_SECS")
449            .ok()
450            .and_then(|value| value.parse::<u64>().ok())
451            .filter(|value| *value > 0)
452            .map(Duration::from_secs)
453            .unwrap_or_else(|| Duration::from_secs(15));
454
455        let namespace_prefix = std::env::var("K8S_NAMESPACE_PREFIX")
456            .unwrap_or_else(|_| DEFAULT_NAMESPACE_PREFIX.to_string());
457        let max_secret_size = std::env::var("K8S_SECRET_MAX_BYTES")
458            .ok()
459            .and_then(|value| value.parse::<usize>().ok())
460            .unwrap_or(DEFAULT_MAX_SECRET_SIZE);
461
462        Ok(Self {
463            api_server,
464            bearer_token: bearer_token.trim().to_string(),
465            ca_bundle,
466            insecure_skip_tls,
467            request_timeout,
468            namespace_prefix,
469            max_secret_size,
470            key_aliases: AliasMap::from_env("K8S_KEK_ALIAS")?,
471            use_sealed_secrets: cfg!(feature = "sealedsecrets"),
472        })
473    }
474
475    fn build_http_client(&self) -> Result<Http> {
476        let mut builder = reqwest::Client::builder().timeout(self.request_timeout);
477        if let Some(ca) = self.ca_bundle.as_ref() {
478            let cert = Certificate::from_pem(ca)
479                .or_else(|_| Certificate::from_der(ca))
480                .context("failed to parse K8S_CA_BUNDLE")?;
481            builder = builder.add_root_certificate(cert);
482        }
483        if self.insecure_skip_tls {
484            bail!("K8S_INSECURE_SKIP_TLS is not permitted");
485        }
486        Http::from_builder(builder).context("failed to build kubernetes HTTP client")
487    }
488}
489
490#[derive(Clone, Debug)]
491struct AliasMap {
492    default: Option<String>,
493    per_env: HashMap<String, String>,
494    per_tenant: HashMap<(String, String), String>,
495}
496
497impl AliasMap {
498    fn from_env(prefix: &str) -> Result<Self> {
499        let default = std::env::var(prefix).ok();
500        let mut per_env = HashMap::new();
501        let mut per_tenant = HashMap::new();
502        for (key, value) in std::env::vars() {
503            if !key.starts_with(prefix) || key == prefix {
504                continue;
505            }
506            let suffix = key.trim_start_matches(prefix).trim_matches('_');
507            if suffix.is_empty() {
508                continue;
509            }
510            let parts: Vec<&str> = suffix.split('_').collect();
511            match parts.as_slice() {
512                [env] => {
513                    per_env.insert(env.to_lowercase(), value.clone());
514                }
515                [env, tenant] => {
516                    per_tenant.insert((env.to_lowercase(), tenant.to_lowercase()), value.clone());
517                }
518                _ => {}
519            }
520        }
521        Ok(Self {
522            default,
523            per_env,
524            per_tenant,
525        })
526    }
527
528    fn resolve(&self, env: &str, tenant: &str) -> Option<&str> {
529        self.per_tenant
530            .get(&(env.to_lowercase(), tenant.to_lowercase()))
531            .or_else(|| self.per_env.get(&env.to_lowercase()))
532            .or(self.default.as_ref())
533            .map(String::as_str)
534    }
535}
536
537#[derive(Deserialize)]
538struct SecretList {
539    items: Vec<SecretItem>,
540    #[serde(default)]
541    metadata: Option<ListMeta>,
542}
543
544#[derive(Deserialize)]
545struct ListMeta {
546    #[serde(rename = "continue")]
547    #[serde(default)]
548    continue_token: Option<String>,
549}
550
551#[derive(Deserialize)]
552struct SecretItem {
553    metadata: ItemMeta,
554    #[serde(default)]
555    data: Option<HashMap<String, String>>,
556}
557
558#[derive(Deserialize)]
559struct ItemMeta {
560    #[serde(default)]
561    labels: HashMap<String, String>,
562}
563
564struct SecretSnapshot {
565    version: u64,
566    deleted: bool,
567    record: Option<StoredRecord>,
568}
569
570impl SecretSnapshot {
571    fn into_versioned(self) -> SecretsResult<Option<VersionedSecret>> {
572        if self.deleted {
573            return Ok(Some(VersionedSecret {
574                version: self.version,
575                deleted: true,
576                record: None,
577            }));
578        }
579
580        let record = self
581            .record
582            .ok_or_else(|| SecretsError::Storage("missing record".into()))?
583            .into_record()?;
584
585        Ok(Some(VersionedSecret {
586            version: self.version,
587            deleted: false,
588            record: Some(record),
589        }))
590    }
591}
592
593#[derive(Clone, Serialize, Deserialize)]
594struct StoredRecord {
595    meta: SecretMeta,
596    envelope: StoredEnvelope,
597    value: String,
598}
599
600impl StoredRecord {
601    fn from_record(record: &SecretRecord) -> SecretsResult<Self> {
602        Ok(Self {
603            meta: record.meta.clone(),
604            envelope: StoredEnvelope::from_envelope(&record.envelope),
605            value: STANDARD.encode(&record.value),
606        })
607    }
608
609    fn into_record(self) -> SecretsResult<SecretRecord> {
610        Ok(SecretRecord::new(
611            self.meta,
612            decode_bytes(&self.value)?,
613            self.envelope.into_envelope()?,
614        ))
615    }
616}
617
618#[derive(Clone, Serialize, Deserialize)]
619struct StoredEnvelope {
620    algorithm: String,
621    nonce: String,
622    hkdf_salt: String,
623    wrapped_dek: String,
624}
625
626impl StoredEnvelope {
627    fn from_envelope(envelope: &Envelope) -> Self {
628        Self {
629            algorithm: envelope.algorithm.to_string(),
630            nonce: STANDARD.encode(&envelope.nonce),
631            hkdf_salt: STANDARD.encode(&envelope.hkdf_salt),
632            wrapped_dek: STANDARD.encode(&envelope.wrapped_dek),
633        }
634    }
635
636    fn into_envelope(self) -> SecretsResult<Envelope> {
637        Ok(Envelope {
638            algorithm: self
639                .algorithm
640                .parse()
641                .map_err(|_| SecretsError::Storage("invalid algorithm".into()))?,
642            nonce: decode_bytes(&self.nonce)?,
643            hkdf_salt: decode_bytes(&self.hkdf_salt)?,
644            wrapped_dek: decode_bytes(&self.wrapped_dek)?,
645        })
646    }
647}
648
649fn parse_secret(item: SecretItem) -> SecretsResult<Option<SecretSnapshot>> {
650    let version = item
651        .metadata
652        .labels
653        .get(LABEL_VERSION)
654        .and_then(|value| value.parse::<u64>().ok())
655        .ok_or_else(|| SecretsError::Storage("secret missing version label".into()))?;
656
657    let deleted = item.metadata.labels.get(STATUS_LABEL).map(String::as_str) == Some("deleted");
658
659    let record = if let Some(data) = item.data.and_then(|mut map| map.remove("record")) {
660        let decoded = decode_bytes(&data)?;
661        let stored: StoredRecord = serde_json::from_slice(&decoded).map_err(|err| {
662            SecretsError::Storage(format!("failed to decode secret payload: {err}"))
663        })?;
664        Some(stored)
665    } else {
666        None
667    };
668
669    Ok(Some(SecretSnapshot {
670        version,
671        deleted,
672        record,
673    }))
674}
675
676fn secret_manifest(
677    uri: &SecretUri,
678    record: Option<&SecretRecord>,
679    namespace: &str,
680    name: &str,
681    key: &str,
682    version: u64,
683    deleted: bool,
684) -> SecretsResult<Value> {
685    let mut labels = Map::new();
686    labels.insert(LABEL_KEY.into(), Value::String(key.to_string()));
687    labels.insert(LABEL_VERSION.into(), Value::String(version.to_string()));
688    labels.insert(
689        LABEL_ENV.into(),
690        Value::String(uri.scope().env().to_string()),
691    );
692    labels.insert(
693        LABEL_TENANT.into(),
694        Value::String(uri.scope().tenant().to_string()),
695    );
696    if let Some(team) = uri.scope().team() {
697        labels.insert(LABEL_TEAM.into(), Value::String(team.to_string()));
698    }
699    labels.insert(
700        LABEL_CATEGORY.into(),
701        Value::String(uri.category().to_string()),
702    );
703    labels.insert(LABEL_NAME.into(), Value::String(uri.name().to_string()));
704    if deleted {
705        labels.insert(STATUS_LABEL.into(), Value::String("deleted".into()));
706    }
707
708    let mut metadata = Map::new();
709    metadata.insert("name".into(), Value::String(name.to_string()));
710    metadata.insert("namespace".into(), Value::String(namespace.to_string()));
711    metadata.insert("labels".into(), Value::Object(labels));
712
713    let mut resource = Map::new();
714    resource.insert("apiVersion".into(), Value::String("v1".into()));
715    resource.insert("kind".into(), Value::String("Secret".into()));
716    resource.insert("metadata".into(), Value::Object(metadata));
717    resource.insert("type".into(), Value::String("Opaque".into()));
718
719    if let Some(record) = record {
720        let stored = StoredRecord::from_record(record)?;
721        let payload = serde_json::to_vec(&stored)
722            .map_err(|err| SecretsError::Storage(format!("failed to encode payload: {err}")))?;
723        let mut data = Map::new();
724        data.insert("record".into(), Value::String(STANDARD.encode(payload)));
725        resource.insert("data".into(), Value::Object(data));
726    }
727
728    Ok(Value::Object(resource))
729}
730
731fn namespace_for_scope(config: &K8sProviderConfig, scope: &Scope) -> String {
732    let mut labels = Vec::new();
733    if !config.namespace_prefix.is_empty() {
734        labels.push(sanitize_label(&config.namespace_prefix));
735    }
736    labels.push(sanitize_label(scope.env()));
737    labels.push(sanitize_label(scope.tenant()));
738    join_labels(&labels, NAMESPACE_MAX_LEN)
739}
740
741fn secret_resource_name(uri: &SecretUri, version: u64) -> String {
742    let mut labels = Vec::new();
743    if let Some(team) = uri.scope().team() {
744        labels.push(sanitize_label(team));
745    }
746    labels.push(sanitize_label(uri.category()));
747    labels.push(sanitize_label(uri.name()));
748    labels.push(sanitize_label(&format!("v{version:04}")));
749    join_labels(&labels, SECRET_NAME_MAX_LEN)
750}
751
752fn sanitize_label(value: &str) -> String {
753    let mut label = String::new();
754    for ch in value.chars() {
755        match ch {
756            'a'..='z' | '0'..='9' => label.push(ch),
757            'A'..='Z' => label.push(ch.to_ascii_lowercase()),
758            '-' | '_' | '.' => {
759                if !label.ends_with('-') {
760                    label.push('-');
761                }
762            }
763            _ => {}
764        }
765    }
766    while label.starts_with('-') {
767        label.remove(0);
768    }
769    while label.ends_with('-') {
770        label.pop();
771    }
772    if label.is_empty() {
773        "default".into()
774    } else {
775        label
776    }
777}
778
779fn join_labels(labels: &[String], max_len: usize) -> String {
780    let mut result = String::new();
781    for label in labels {
782        if label.is_empty() {
783            continue;
784        }
785        if !result.is_empty() {
786            result.push('-');
787        }
788        result.push_str(label);
789    }
790    if result.is_empty() {
791        result.push_str("default");
792    }
793    if result.len() > max_len {
794        result.truncate(max_len);
795        while result.ends_with('-') {
796            result.pop();
797        }
798        if result.is_empty() {
799            result.push_str("default");
800        }
801    }
802    result
803}
804
805fn canonical_storage_key(uri: &SecretUri) -> String {
806    format!(
807        "{}/{}/{}/{}/{}",
808        uri.scope().env(),
809        uri.scope().tenant(),
810        uri.scope().team().unwrap_or("_"),
811        uri.category(),
812        uri.name()
813    )
814}
815
816fn decode_bytes(input: &str) -> SecretsResult<Vec<u8>> {
817    STANDARD
818        .decode(input.as_bytes())
819        .map_err(|err| SecretsError::Storage(err.to_string()))
820}
821
822fn percent_encode(value: &str) -> String {
823    byte_serialize(value.as_bytes()).collect()
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use greentic_secrets_spec::Scope;
830    use serial_test::serial;
831    use std::env;
832
833    fn set_env(key: &str, value: &str) {
834        unsafe { env::set_var(key, value) };
835    }
836
837    fn clear_env(key: &str) {
838        unsafe { env::remove_var(key) };
839    }
840
841    fn setup_env() {
842        set_env("K8S_API_SERVER", "http://127.0.0.1:9");
843        set_env("K8S_BEARER_TOKEN", "test-token");
844        set_env("K8S_NAMESPACE_PREFIX", "unit");
845        set_env("K8S_KEK_ALIAS", "default");
846        set_env("K8S_HTTP_TIMEOUT_SECS", "1");
847        clear_env("K8S_BEARER_TOKEN_FILE");
848        clear_env("K8S_CA_BUNDLE");
849    }
850
851    #[tokio::test(flavor = "multi_thread")]
852    #[serial]
853    async fn k8s_provider_ok_under_tokio() {
854        setup_env();
855        let BackendComponents { backend, .. } =
856            build_backend().await.expect("k8s backend builds from env");
857
858        let scope = Scope::new("dev", "tenant", None).expect("scope");
859        let result = backend.list(&scope, None, None);
860        assert!(
861            result.is_err(),
862            "list should attempt network and surface the failure without panicking"
863        );
864    }
865}