Skip to main content

greentic_secrets_spec/
generation.rs

1//! Vocabulary for *managed* secrets — the declarative, pack-declared model the
2//! ecosystem uses to describe every secret a deployment needs, whether the
3//! operator supplies the value or the system generates it.
4//!
5//! This is types only. The generator (CSPRNG material) and the
6//! requirement→[`SecretSet`] discovery / provision pass live in
7//! `greentic-secrets-core` (they need an RNG and store access). Keeping the
8//! vocabulary here lets every repo agree on the shapes without pulling in the
9//! runtime engine.
10//!
11//! The generation model mirrors a pack's `secret-requirements.json`
12//! `generated` block exactly (`policy`/`length`/`encoding`/`scope`/
13//! `regenerate_if_present`), so a pack authored once mints identical material in
14//! start, setup, and the deployer rather than each rolling its own generator.
15
16use crate::requirements::SecretFormat;
17use crate::types::Scope;
18use crate::uri::SecretUri;
19#[cfg(feature = "serde")]
20use serde::{Deserialize, Serialize};
21
22/// The scope a generated secret is minted under, as declared by a pack.
23///
24/// `level` is `"tenant"`, `"team"`, etc.; a `tenant`-level secret is shared
25/// across all teams. `team` is the explicit team, where `Some("_")` denotes the
26/// team-less scope. The interpretation lives in [`generated_scope_team`].
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
29pub struct GeneratedSecretScope {
30    /// Scope level (`"tenant"`, `"team"`, …).
31    pub level: String,
32    /// Explicit team, or `None`. `Some("_")` is the team-less scope.
33    #[cfg_attr(
34        feature = "serde",
35        serde(default, skip_serializing_if = "Option::is_none")
36    )]
37    pub team: Option<String>,
38}
39
40/// How a system-generated secret's value is produced — a 1:1 model of a pack's
41/// `secret-requirements.json` `generated` block.
42///
43/// The fields are part of the contract: a consumer that regenerates or rotates a
44/// secret must produce a value of the same shape. The lib supports `policy =
45/// "random"` with `encoding` one of `raw_text` (random ASCII), `base64url`
46/// (URL-safe, no pad), or `hex` (lowercase). `length` is the character count for
47/// `raw_text` and the raw random-byte count for `base64url`/`hex`.
48#[derive(Clone, Debug, PartialEq, Eq)]
49#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
50pub struct GeneratedSecretRequirement {
51    /// Generation policy. Only `"random"` is supported today.
52    pub policy: String,
53    /// Character count (`raw_text`) or raw random-byte count (`base64url`/`hex`).
54    pub length: usize,
55    /// Value encoding: `raw_text` | `base64url` | `hex`.
56    pub encoding: String,
57    /// Scope the secret is minted under.
58    pub scope: GeneratedSecretScope,
59    /// Re-mint even when a value is already present.
60    pub regenerate_if_present: bool,
61}
62
63/// The team a generated secret is minted under, given its declared scope and a
64/// default team.
65///
66/// Mirrors the runtime rule: a `tenant`-level secret — or one that explicitly
67/// scopes to the `_` team — is team-less (returns `None`); otherwise the
68/// declared team wins, falling back to `default_team`. Sharing this keeps a
69/// generated secret's URI identical across the producer that mints it and the
70/// reader that resolves it.
71pub fn generated_scope_team<'a>(
72    generated: &'a GeneratedSecretRequirement,
73    default_team: Option<&'a str>,
74) -> Option<&'a str> {
75    if generated.scope.level.eq_ignore_ascii_case("tenant")
76        || generated.scope.team.as_deref() == Some("_")
77    {
78        return None;
79    }
80    generated.scope.team.as_deref().or(default_team)
81}
82
83/// A secret a pack declares it needs — the shared output type every consumer's
84/// pack reader parses into, so the deployer, start, and setup agree on the
85/// requirement model (including which secrets are system-generated).
86///
87/// `key` is expected to already be [`canonical_secret_name`](crate::canonical_secret_name)-
88/// normalized by the reader. `aliases` are alternate names a previously-seeded
89/// value may live under. `generated` carries the generation policy when the
90/// system mints the value, and is `None` for operator-supplied secrets.
91#[derive(Clone, Debug, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
93pub struct PackSecretRequirement {
94    /// Canonical secret name.
95    pub key: String,
96    /// Alternate names a previously-seeded value may live under.
97    #[cfg_attr(
98        feature = "serde",
99        serde(default, skip_serializing_if = "Vec::is_empty")
100    )]
101    pub aliases: Vec<String>,
102    /// Whether execution requires this secret.
103    pub required: bool,
104    /// Generation policy when the system mints this secret; `None` =
105    /// operator-supplied.
106    #[cfg_attr(
107        feature = "serde",
108        serde(default, skip_serializing_if = "Option::is_none")
109    )]
110    pub generated: Option<GeneratedSecretRequirement>,
111}
112
113impl PackSecretRequirement {
114    /// A required, operator-supplied requirement for `key`.
115    pub fn user_supplied(key: impl Into<String>) -> Self {
116        Self {
117            key: key.into(),
118            aliases: Vec::new(),
119            required: true,
120            generated: None,
121        }
122    }
123
124    /// A required, system-generated requirement for `key`.
125    pub fn generated(key: impl Into<String>, generated: GeneratedSecretRequirement) -> Self {
126        Self {
127            key: key.into(),
128            aliases: Vec::new(),
129            required: true,
130            generated: Some(generated),
131        }
132    }
133
134    /// True when the system mints this secret.
135    pub fn is_generated(&self) -> bool {
136        self.generated.is_some()
137    }
138}
139
140/// Where a [`ManagedSecret`]'s value comes from.
141#[derive(Clone, Debug, PartialEq, Eq)]
142#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
143#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
144pub enum SecretSource {
145    /// Provided by the operator/user (wizard, env var, paste).
146    UserSupplied,
147    /// Minted by the system using the given generation policy.
148    Generated(GeneratedSecretRequirement),
149}
150
151/// A single secret the system manages, identified by its canonical runtime store
152/// URI, tagged with how its value is obtained.
153#[derive(Clone, Debug, PartialEq, Eq)]
154#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
155pub struct ManagedSecret {
156    /// Canonical store URI (`secrets://...`); team segment already normalized.
157    pub uri: SecretUri,
158    /// Whether the secret is mandatory for execution.
159    pub required: bool,
160    /// How the value is obtained.
161    pub source: SecretSource,
162    /// Preferred content format when known.
163    #[cfg_attr(
164        feature = "serde",
165        serde(default, skip_serializing_if = "Option::is_none")
166    )]
167    pub format: Option<SecretFormat>,
168    /// Operator-facing description.
169    #[cfg_attr(
170        feature = "serde",
171        serde(default, skip_serializing_if = "Option::is_none")
172    )]
173    pub description: Option<String>,
174}
175
176impl ManagedSecret {
177    /// A required, operator-supplied secret.
178    pub fn user_supplied(uri: SecretUri) -> Self {
179        Self {
180            uri,
181            required: true,
182            source: SecretSource::UserSupplied,
183            format: None,
184            description: None,
185        }
186    }
187
188    /// A required, system-generated secret.
189    pub fn generated(uri: SecretUri, generated: GeneratedSecretRequirement) -> Self {
190        Self {
191            uri,
192            required: true,
193            source: SecretSource::Generated(generated),
194            format: None,
195            description: None,
196        }
197    }
198
199    /// True when the value is system-generated.
200    pub fn is_generated(&self) -> bool {
201        matches!(self.source, SecretSource::Generated(_))
202    }
203
204    /// The generation policy for a system-generated secret, or `None` if
205    /// user-supplied.
206    pub fn generated_requirement(&self) -> Option<&GeneratedSecretRequirement> {
207        match &self.source {
208            SecretSource::Generated(generated) => Some(generated),
209            SecretSource::UserSupplied => None,
210        }
211    }
212}
213
214/// The complete set of secrets a deployment scope needs — the single source of
215/// truth consumed by both the local runtime (start) and cloud promotion
216/// (deployer). It deliberately includes *generated* secrets so the deployer's
217/// cloud path can no longer miss them.
218#[derive(Clone, Debug, PartialEq, Eq)]
219#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
220pub struct SecretSet {
221    /// Scope all entries belong to.
222    pub scope: Scope,
223    /// The managed secrets, in declaration order.
224    pub secrets: Vec<ManagedSecret>,
225}
226
227impl SecretSet {
228    /// An empty set for the given scope.
229    pub fn new(scope: Scope) -> Self {
230        Self {
231            scope,
232            secrets: Vec::new(),
233        }
234    }
235
236    /// Append a managed secret.
237    pub fn push(&mut self, secret: ManagedSecret) {
238        self.secrets.push(secret);
239    }
240
241    /// Iterate the system-generated secrets.
242    pub fn generated(&self) -> impl Iterator<Item = &ManagedSecret> {
243        self.secrets.iter().filter(|secret| secret.is_generated())
244    }
245
246    /// Iterate the operator-supplied secrets.
247    pub fn user_supplied(&self) -> impl Iterator<Item = &ManagedSecret> {
248        self.secrets.iter().filter(|secret| !secret.is_generated())
249    }
250}
251
252#[cfg(all(test, feature = "serde"))]
253mod tests {
254    use super::*;
255
256    fn raw_text(length: usize, level: &str, team: Option<&str>) -> GeneratedSecretRequirement {
257        GeneratedSecretRequirement {
258            policy: "random".to_string(),
259            length,
260            encoding: "raw_text".to_string(),
261            scope: GeneratedSecretScope {
262                level: level.to_string(),
263                team: team.map(str::to_string),
264            },
265            regenerate_if_present: false,
266        }
267    }
268
269    #[test]
270    fn generated_requirement_serde_round_trips() {
271        let g = raw_text(20, "tenant", Some("_"));
272        let json = serde_json::to_string(&g).unwrap();
273        let back: GeneratedSecretRequirement = serde_json::from_str(&json).unwrap();
274        assert_eq!(back, g);
275    }
276
277    #[test]
278    fn generated_scope_team_collapses_tenant_and_underscore() {
279        // tenant-level → team-less regardless of the declared team.
280        assert_eq!(
281            generated_scope_team(&raw_text(20, "tenant", None), Some("legal")),
282            None
283        );
284        assert_eq!(
285            generated_scope_team(&raw_text(20, "tenant", Some("legal")), Some("ops")),
286            None
287        );
288        // explicit `_` team → team-less.
289        assert_eq!(
290            generated_scope_team(&raw_text(20, "team", Some("_")), Some("legal")),
291            None
292        );
293        // real team scope wins, else falls back to the default team.
294        assert_eq!(
295            generated_scope_team(&raw_text(20, "team", Some("legal")), Some("ops")),
296            Some("legal")
297        );
298        assert_eq!(
299            generated_scope_team(&raw_text(20, "team", None), Some("ops")),
300            Some("ops")
301        );
302    }
303
304    #[test]
305    fn managed_secret_partitions_by_source() {
306        let scope = Scope::new("dev", "demo", None).unwrap();
307        let mut set = SecretSet::new(scope);
308        set.push(ManagedSecret::user_supplied(
309            SecretUri::parse("secrets://dev/demo/_/messaging-slack/api_key").unwrap(),
310        ));
311        set.push(ManagedSecret::generated(
312            SecretUri::parse("secrets://dev/demo/_/messaging-telegram/webhook_secret").unwrap(),
313            raw_text(32, "tenant", Some("_")),
314        ));
315        assert_eq!(set.generated().count(), 1);
316        assert_eq!(set.user_supplied().count(), 1);
317    }
318}