secrets_core/engine.rs
1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5use crate::lease::Lease;
6use crate::storage::StorageBackend;
7
8#[derive(Debug, Error)]
9pub enum EngineError {
10 #[error("not found")]
11 NotFound,
12 #[error("operation not supported by this engine")]
13 Unsupported,
14 #[error("invalid request: {0}")]
15 InvalidRequest(String),
16 #[error("storage error: {0}")]
17 Storage(#[from] crate::storage::StorageError),
18 #[error("provider rejected the request: {0}")]
19 Provider(String),
20 #[error("engine error: {0}")]
21 Other(String),
22}
23
24pub type EngineResult<T> = Result<T, EngineError>;
25
26/// Which of the five delegation shapes an engine implements. The shape is the
27/// single most useful thing to know about a credential, because it says what a
28/// lease can actually promise — see `docs/delegation/README.md`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum CredentialShape {
32 /// A — minted on demand and destroyed on demand. A lease means what it says.
33 MintAndRevoke,
34 /// B — minted on demand, but the provider cannot un-mint it. TTL is the
35 /// only containment, and `revoke()` is advisory.
36 MintExpiryOnly,
37 /// C — the durable half stays here, only a short access token is handed out.
38 RefreshBroker,
39 /// D — nothing is mintable; this is encrypted custody plus rotation.
40 StaticCustody,
41 /// E — no credential exists anywhere; the consumer's own identity is trusted.
42 Federation,
43}
44
45impl CredentialShape {
46 /// Whether revoking the lease destroys **the credential the consumer
47 /// received**. That is the only question a consumer is actually asking, and
48 /// answering it narrowly keeps the `_doc` block honest.
49 ///
50 /// Only shape A can say yes. A refresh broker can kill the durable half —
51 /// which stops future issuance — but the access token already handed out
52 /// lives until it expires, so from the consumer's point of view its lease
53 /// is not revocable either.
54 pub fn revocable(self) -> bool {
55 matches!(self, Self::MintAndRevoke)
56 }
57
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::MintAndRevoke => "mint-and-revoke",
61 Self::MintExpiryOnly => "mint-expiry-only",
62 Self::RefreshBroker => "refresh-broker",
63 Self::StaticCustody => "static-custody",
64 Self::Federation => "federation",
65 }
66 }
67}
68
69/// The lifetime envelope a provider allows, so a caller can see why it got the
70/// TTL it got instead of guessing.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TtlDoc {
73 pub min_seconds: Option<i64>,
74 pub max_seconds: Option<i64>,
75 /// True when the provider dictates one lifetime and ignores our request.
76 pub fixed: bool,
77 pub note: String,
78}
79
80impl TtlDoc {
81 pub fn fixed(seconds: i64, note: impl Into<String>) -> Self {
82 Self {
83 min_seconds: Some(seconds),
84 max_seconds: Some(seconds),
85 fixed: true,
86 note: note.into(),
87 }
88 }
89
90 pub fn range(min: i64, max: i64, note: impl Into<String>) -> Self {
91 Self {
92 min_seconds: Some(min),
93 max_seconds: Some(max),
94 fixed: false,
95 note: note.into(),
96 }
97 }
98}
99
100/// One route an engine answers, described for the `help` endpoint.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct PathDoc {
103 pub path: String,
104 pub methods: Vec<String>,
105 pub capability: String,
106 pub description: String,
107}
108
109impl PathDoc {
110 pub fn new(
111 path: impl Into<String>,
112 methods: &[&str],
113 capability: &str,
114 description: impl Into<String>,
115 ) -> Self {
116 Self {
117 path: path.into(),
118 methods: methods.iter().map(|m| m.to_string()).collect(),
119 capability: capability.to_string(),
120 description: description.into(),
121 }
122 }
123}
124
125/// Everything an operator or a consumer needs to know about an engine without
126/// leaving the API: which provider, which mechanism, what a lease is worth,
127/// what `revoke()` really does, and what the server had to be trusted with.
128///
129/// Served at `GET /v1/{mount}/help`, and its operative fields are echoed in
130/// the `_doc` block of every credential response.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct EngineDoc {
133 pub provider: String,
134 /// The concrete provider API being used, e.g. "GitHub App installation tokens".
135 pub mechanism: String,
136 pub shape: CredentialShape,
137 /// Whether revoking a lease destroys the credential the consumer holds.
138 /// Must agree with `shape.revocable()` — the server asserts this, because
139 /// an engine that overstates it would mislead every caller.
140 pub revocable: bool,
141 /// What `revoke()` does *in reality* — including "nothing, the credential
142 /// keeps working until it expires", which is the truth for three of the
143 /// providers here and must not be dressed up.
144 pub revoke_effect: String,
145 pub ttl: TtlDoc,
146 pub scoping: String,
147 /// What long-lived secret the server must hold, or "none" under federation.
148 pub root_credential: String,
149 pub paths: Vec<PathDoc>,
150 pub docs_url: Option<String>,
151 /// Sharp edges worth knowing before depending on this engine.
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub caveats: Vec<String>,
154}
155
156/// The result of minting a credential: what the caller gets, the lease that
157/// governs it, and what it was narrowed to.
158#[derive(Debug)]
159pub struct GeneratedCredential {
160 pub data: serde_json::Value,
161 pub lease: Lease,
162 /// Human-readable scope of *this* credential — e.g. `["repo:reports",
163 /// "contents:read"]`. Echoed into the response `_doc` so a consumer can
164 /// see what it was granted rather than inferring it.
165 pub scoped_to: Vec<String>,
166 /// Overrides the engine's shape for this one credential. Needed because a
167 /// single engine can offer mechanisms with different guarantees — the AWS
168 /// engine mints both un-revocable STS sessions and revocable per-lease IAM
169 /// users — and a `_doc` block that averaged over them would be a lie.
170 pub shape: Option<CredentialShape>,
171 /// Overrides the engine's `revoke_effect` for this one credential.
172 pub revoke_effect: Option<String>,
173}
174
175impl GeneratedCredential {
176 /// The common case: this credential behaves exactly as the engine's own
177 /// `doc()` describes.
178 pub fn new(data: serde_json::Value, lease: Lease, scoped_to: Vec<String>) -> Self {
179 Self {
180 data,
181 lease,
182 scoped_to,
183 shape: None,
184 revoke_effect: None,
185 }
186 }
187
188 /// Declares that this credential's guarantees differ from the engine's
189 /// headline shape.
190 pub fn with_shape(mut self, shape: CredentialShape, revoke_effect: impl Into<String>) -> Self {
191 self.shape = Some(shape);
192 self.revoke_effect = Some(revoke_effect.into());
193 self
194 }
195}
196
197#[async_trait]
198pub trait SecretsEngine: Send + Sync {
199 async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value>;
200 async fn write(
201 &self,
202 storage: &dyn StorageBackend,
203 path: &str,
204 data: serde_json::Value,
205 ) -> EngineResult<()>;
206 async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()>;
207 async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>>;
208
209 /// Self-documentation. Every engine must answer this — an engine that
210 /// cannot say what its credentials are worth has no business minting them.
211 fn doc(&self) -> EngineDoc;
212
213 /// Dynamic-secret engines (e.g. Postgres) override these; static
214 /// engines (e.g. KV) inherit the default `Unsupported`.
215 async fn generate(
216 &self,
217 _storage: &dyn StorageBackend,
218 _role: &str,
219 ) -> EngineResult<GeneratedCredential> {
220 Err(EngineError::Unsupported)
221 }
222
223 async fn revoke(&self, _storage: &dyn StorageBackend, _lease: &Lease) -> EngineResult<()> {
224 Err(EngineError::Unsupported)
225 }
226}