Skip to main content

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}