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}