Skip to main content

cloacina_workflow/
secret.rs

1/*
2 *  Copyright 2025-2026 Colliery Software
3 *
4 *  Licensed under the Apache License, Version 2.0 (the "License");
5 *  you may not use this file except in compliance with the License.
6 *  You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 *  Unless required by applicable law or agreed to in writing, software
11 *  distributed under the License is distributed on an "AS IS" BASIS,
12 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 *  See the License for the specific language governing permissions and
14 *  limitations under the License.
15 */
16
17//! Secret resolution side channel (CLOACI-I-0133 / T-0858, design D-1).
18//!
19//! A task/constructor reads a resolved secret through [`Context::secret`] — a
20//! dedicated accessor on the execution scope that is **structurally distinct**
21//! from the durable [`Context`](crate::Context) data. The resolved plaintext is
22//! *returned* to the task; it is never inserted into the context's serialized
23//! `data` map, so it can never land in `schedules.params`, the fires log, audit
24//! rows, or execution history (NFR-001).
25//!
26//! This module defines only the trait + error types that live in the authoring
27//! crate. The concrete backend (which decrypts against the tenant-scoped
28//! `SecretStore`) lives in the `cloacina` runtime crate as `SecretStoreResolver`
29//! and is threaded onto the `Context` by the executor at fire time.
30
31use async_trait::async_trait;
32use std::collections::BTreeMap;
33use thiserror::Error;
34
35/// Reserved `Context` data key holding the instance's `{"$secret": name}` binding
36/// map (CLOACI-I-0133 / T-0859, design D-4).
37///
38/// At fire time `merge_instance_params` recognizes a `{"$secret": "name"}` param
39/// value, keeps the **resolved value** out of the context entirely, and records
40/// only the non-sensitive `local_binding_name -> secret_name` alias here. The map
41/// carries NAMES ONLY (never values), so it is safe to serialize into the durable
42/// context; it survives the fire → persist → execute boundary and lets
43/// [`Context::secret`](crate::Context::secret) resolve a task's declared local
44/// binding name to the concrete secret the instance chose.
45pub const SECRET_REFS_KEY: &str = "__cloacina_secret_refs__";
46
47/// Error returned by a [`SecretResolver`] backend implementation.
48#[derive(Debug, Error)]
49pub enum SecretResolverError {
50    /// No secret of that name is visible to this tenant/scope.
51    #[error("secret not found: {0}")]
52    NotFound(String),
53
54    /// The name is not in this scope's granted secret allow-list
55    /// (CLOACI-I-0133 / T-0860, design D-3). Returned **before** any decryption —
56    /// the holder was never authorized to resolve this secret, regardless of
57    /// whether it exists. Distinct from [`NotFound`](Self::NotFound) so a denial
58    /// is not confusable with a missing secret in audit/logs.
59    #[error("secret not granted: {0}")]
60    NotGranted(String),
61
62    /// The backend failed to resolve (decrypt failure, DB error, misconfigured
63    /// KEK, …). The message is a redacted, non-plaintext description.
64    #[error("secret backend error: {0}")]
65    Backend(String),
66}
67
68/// Error surfaced to a task body by the [`Context`](crate::Context) secret
69/// accessor.
70#[derive(Debug, Error)]
71pub enum SecretAccessError {
72    /// No resolver was configured on this execution scope. On the embedded /
73    /// in-process path the host/runner wires one in; when it is absent,
74    /// `context.secret(...)` fails clearly instead of silently returning empty.
75    #[error("secrets backend not configured for this execution scope")]
76    NotConfigured,
77
78    /// The named secret does not exist (or is not visible to this tenant).
79    #[error("secret not found: {0}")]
80    NotFound(String),
81
82    /// The execution scope's grant does not include this secret name
83    /// (CLOACI-I-0133 / T-0860, D-3). The resolver denied it **before** any
84    /// decrypt; add the name to the constructor's `secrets` grant to allow it.
85    #[error("secret not granted: {0}")]
86    NotGranted(String),
87
88    /// The secret exists but has no field of that name.
89    #[error("secret '{secret}' has no field '{field}'")]
90    FieldNotFound { secret: String, field: String },
91
92    /// The backend failed to resolve the secret.
93    #[error("secret backend error: {0}")]
94    Backend(String),
95}
96
97/// A backend that resolves a named secret into its plaintext `{field: value}`
98/// map at fire time.
99///
100/// Implementations decrypt at the last possible moment and return the fields to
101/// the caller; they MUST NOT persist or log the plaintext. The runtime attaches
102/// a resolver to the [`Context`](crate::Context) via a non-serialized handle
103/// (see [`Context::set_secret_resolver`](crate::Context::set_secret_resolver)),
104/// which is what keeps resolution structurally separate from the durable
105/// context.
106#[async_trait]
107pub trait SecretResolver: Send + Sync {
108    /// Resolve `name` to its decrypted `{field: value}` map.
109    async fn resolve(&self, name: &str) -> Result<BTreeMap<String, String>, SecretResolverError>;
110}
111
112/// In-memory resolver over already-resolved secret values, keyed by concrete
113/// secret name (CLOACI-T-0895).
114///
115/// The packaged-task bridge uses this on the PLUGIN side: the host resolves
116/// every `{"$secret"}`-referenced secret through its real backend before the
117/// plugin call and ships the values across the boundary in the
118/// `TaskExecutionRequest`; the plugin shell rebuilds the execution scope with
119/// this resolver so `context.secret(...)` works identically inside the
120/// package. Values live only in this object for the duration of one task
121/// invocation — never serialized into the durable context (NFR-001).
122pub struct MapSecretResolver {
123    secrets: BTreeMap<String, BTreeMap<String, String>>,
124}
125
126impl MapSecretResolver {
127    /// Wrap a `{secret_name: {field: value}}` map.
128    pub fn new(secrets: BTreeMap<String, BTreeMap<String, String>>) -> Self {
129        Self { secrets }
130    }
131}
132
133// Values must never appear in logs; a manual Debug keeps names only.
134impl std::fmt::Debug for MapSecretResolver {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.debug_struct("MapSecretResolver")
137            .field("names", &self.secrets.keys().collect::<Vec<_>>())
138            .finish()
139    }
140}
141
142#[async_trait]
143impl SecretResolver for MapSecretResolver {
144    async fn resolve(&self, name: &str) -> Result<BTreeMap<String, String>, SecretResolverError> {
145        self.secrets
146            .get(name)
147            .cloned()
148            .ok_or_else(|| SecretResolverError::NotFound(name.to_string()))
149    }
150}