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}