Skip to main content

secrets_engine_github/
lib.rs

1//! GitHub App installation tokens — the one provider in this set where a lease
2//! means exactly what it says: credentials are short-lived by construction,
3//! narrowable to named repositories *and* a subset of permissions, and
4//! genuinely revocable before expiry.
5//!
6//! See `docs/delegation/github.md` for the mechanism and
7//! `docs/delegation/setup/github.md` for the operator walkthrough.
8
9use std::collections::BTreeMap;
10
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use secrets_core::engine::{
14    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
15    SecretsEngine, TtlDoc,
16};
17use secrets_core::lease::Lease;
18use secrets_core::mount::ConfigRoleStore;
19use secrets_core::storage::StorageBackend;
20use serde::{Deserialize, Serialize};
21use serde_json::json;
22use uuid::Uuid;
23
24const STORE: ConfigRoleStore = ConfigRoleStore::new("github/config/", "github/roles/");
25const MOUNT: &str = "github/creds/";
26const DEFAULT_API: &str = "https://api.github.com";
27
28/// GitHub caps an App JWT at 10 minutes. We ask for 9 to leave room for clock
29/// skew on their side as well as ours.
30const APP_JWT_TTL_SECONDS: i64 = 540;
31/// `iat` is backdated by a minute, the conventional guard against our clock
32/// running ahead of GitHub's — which GitHub rejects outright.
33const APP_JWT_BACKDATE_SECONDS: i64 = 60;
34/// Installation tokens always live one hour. GitHub does not let us choose.
35const INSTALLATION_TOKEN_TTL_SECONDS: i64 = 3600;
36
37/// The App's identity. `private_key_pem` is the only durable secret this
38/// engine needs, and it is never read back out (see `ConfigRoleStore`).
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct GithubConfig {
41    /// The App's client ID. The numeric App ID also works as a legacy fallback.
42    pub app_id: String,
43    /// RSA private key PEM generated for the App.
44    pub private_key_pem: String,
45    /// Override for GitHub Enterprise Server, e.g. `https://ghe.example.com/api/v3`.
46    #[serde(default = "default_api")]
47    pub base_url: String,
48}
49
50fn default_api() -> String {
51    DEFAULT_API.to_string()
52}
53
54/// What one consumer may mint. Both `repositories` and `permissions` narrow the
55/// token *down* from what the installation was granted — they can never widen
56/// it, so the App's own permissions remain the ceiling.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RoleConfig {
59    /// Which `github/config/{name}` document to authenticate with.
60    pub target: String,
61    pub installation_id: u64,
62    /// Repository names (not full `owner/repo` paths). Empty means every
63    /// repository the App is installed on — rarely what you want.
64    #[serde(default)]
65    pub repositories: Vec<String>,
66    /// e.g. `{"contents": "read", "pull_requests": "write"}`. Empty means the
67    /// installation's full permission set.
68    #[serde(default)]
69    pub permissions: BTreeMap<String, String>,
70}
71
72#[derive(Debug, Deserialize)]
73struct InstallationTokenResponse {
74    token: String,
75    expires_at: DateTime<Utc>,
76}
77
78#[derive(Debug, Serialize)]
79struct AppJwtClaims {
80    iat: i64,
81    exp: i64,
82    iss: String,
83}
84
85#[derive(Default)]
86pub struct GithubEngine {
87    http: reqwest::Client,
88}
89
90impl GithubEngine {
91    pub fn new() -> Self {
92        Self {
93            // GitHub rejects requests without a User-Agent.
94            http: reqwest::Client::builder()
95                .user_agent("secrets-server")
96                .build()
97                .unwrap_or_default(),
98        }
99    }
100
101    /// Builds the short-lived JWT that proves "I am this App". This is not a
102    /// repository credential — it only authenticates the installation-token
103    /// request below.
104    fn app_jwt(config: &GithubConfig, now: DateTime<Utc>) -> EngineResult<String> {
105        let claims = AppJwtClaims {
106            iat: now.timestamp() - APP_JWT_BACKDATE_SECONDS,
107            exp: now.timestamp() + APP_JWT_TTL_SECONDS,
108            iss: config.app_id.clone(),
109        };
110        let key = jsonwebtoken::EncodingKey::from_rsa_pem(config.private_key_pem.as_bytes())
111            .map_err(|e| {
112                EngineError::InvalidRequest(format!(
113                    "github/config private_key_pem is not a valid RSA PEM: {e}"
114                ))
115            })?;
116        jsonwebtoken::encode(
117            &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
118            &claims,
119            &key,
120        )
121        .map_err(|e| EngineError::Other(format!("failed to sign App JWT: {e}")))
122    }
123
124    fn scope_description(role: &RoleConfig) -> Vec<String> {
125        let mut scoped = Vec::new();
126        if role.repositories.is_empty() {
127            scoped.push("repos:ALL (every repository the App is installed on)".to_string());
128        } else {
129            scoped.extend(role.repositories.iter().map(|r| format!("repo:{r}")));
130        }
131        if role.permissions.is_empty() {
132            scoped.push("permissions:ALL (the installation's full grant)".to_string());
133        } else {
134            scoped.extend(role.permissions.iter().map(|(k, v)| format!("{k}:{v}")));
135        }
136        scoped
137    }
138}
139
140#[async_trait]
141impl SecretsEngine for GithubEngine {
142    fn doc(&self) -> EngineDoc {
143        EngineDoc {
144            provider: "GitHub".to_string(),
145            mechanism: "GitHub App installation access tokens, minted per request \
146                        and narrowed to named repositories and a permission subset"
147                .to_string(),
148            shape: CredentialShape::MintAndRevoke,
149            revocable: true,
150            revoke_effect: "DELETE /installation/token, authenticated with the leased \
151                            token itself — the credential stops working immediately. \
152                            GitHub is the only provider here where lease revocation \
153                            is a real guarantee rather than an advisory one."
154                .to_string(),
155            ttl: TtlDoc::fixed(
156                INSTALLATION_TOKEN_TTL_SECONDS,
157                "GitHub fixes installation tokens at one hour and offers no way to \
158                 shorten, lengthen or refresh them. Roles therefore carry no TTL \
159                 setting; mint again to get a fresh hour.",
160            ),
161            scoping: "per role: a list of repositories (at most 500) and a subset of \
162                      the App's permissions. Both only ever narrow what the \
163                      installation already has — the App's grant is the ceiling."
164                .to_string(),
165            root_credential: "the GitHub App's RSA private key PEM, at \
166                              github/config/{target}. It can mint tokens for every \
167                              repository the App is installed on, so install the App \
168                              narrowly."
169                .to_string(),
170            paths: vec![
171                PathDoc::new(
172                    "github/config/{target}",
173                    &["POST", "GET", "DELETE"],
174                    "sudo",
175                    "register the App id and private key. GET reports only whether \
176                     it is configured — the key is never returned.",
177                ),
178                PathDoc::new(
179                    "github/roles/{role}",
180                    &["POST", "GET", "DELETE"],
181                    "create / read / sudo",
182                    "define one consumer's installation, repositories and permissions",
183                ),
184                PathDoc::new(
185                    "github/creds/{role}",
186                    &["GET"],
187                    "read",
188                    "mint a one-hour installation token and open a lease",
189                ),
190                PathDoc::new(
191                    "github/help",
192                    &["GET"],
193                    "authenticated",
194                    "this document",
195                ),
196            ],
197            docs_url: Some("docs/delegation/github.md".to_string()),
198            caveats: vec![
199                "Token creation is rate-limited to roughly 2,000 per hour across the \
200                 whole App — not per installation. Under load, reuse a token for most \
201                 of its hour rather than minting per request."
202                    .to_string(),
203                "A token may name at most 500 repositories, and a wide permission set \
204                 crossed with a wide repository set can be rejected for 'complexity'."
205                    .to_string(),
206                "Treat the token as opaque. GitHub is rolling out a stateless \
207                 JWT-shaped installation token on some plans, so never parse it."
208                    .to_string(),
209                "Personal access tokens cannot be created by any API, so they are not \
210                 available here — store one in KV if you truly need it."
211                    .to_string(),
212            ],
213        }
214    }
215
216    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
217        STORE.handle_read::<RoleConfig>(storage, path).await
218    }
219
220    async fn write(
221        &self,
222        storage: &dyn StorageBackend,
223        path: &str,
224        data: serde_json::Value,
225    ) -> EngineResult<()> {
226        STORE.handle_write::<GithubConfig, RoleConfig>(storage, path, data).await
227    }
228
229    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
230        STORE.handle_delete(storage, path).await
231    }
232
233    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
234        STORE.handle_list(storage, prefix).await
235    }
236
237    async fn generate(
238        &self,
239        storage: &dyn StorageBackend,
240        role_name: &str,
241    ) -> EngineResult<GeneratedCredential> {
242        let role: RoleConfig = STORE.require_role(storage, role_name).await?;
243        let config: GithubConfig = STORE.require_config(storage, &role.target).await?;
244
245        let now = Utc::now();
246        let jwt = Self::app_jwt(&config, now)?;
247
248        let mut body = serde_json::Map::new();
249        if !role.repositories.is_empty() {
250            body.insert("repositories".to_string(), json!(role.repositories));
251        }
252        if !role.permissions.is_empty() {
253            body.insert("permissions".to_string(), json!(role.permissions));
254        }
255
256        let url = format!(
257            "{}/app/installations/{}/access_tokens",
258            config.base_url.trim_end_matches('/'),
259            role.installation_id
260        );
261        let response = self
262            .http
263            .post(&url)
264            .bearer_auth(&jwt)
265            .header("Accept", "application/vnd.github+json")
266            .header("X-GitHub-Api-Version", "2022-11-28")
267            .json(&serde_json::Value::Object(body))
268            .send()
269            .await
270            .map_err(|e| EngineError::Provider(format!("GitHub request failed: {e}")))?;
271
272        let status = response.status();
273        let text = response.text().await.unwrap_or_default();
274        if !status.is_success() {
275            return Err(EngineError::Provider(format!(
276                "GitHub returned {status} for {url}: {text}"
277            )));
278        }
279        let token: InstallationTokenResponse = serde_json::from_str(&text)
280            .map_err(|e| EngineError::Provider(format!("unexpected GitHub response: {e}")))?;
281
282        let lease = Lease {
283            id: Uuid::new_v4(),
284            // Set by the HTTP handler, which knows the requesting token.
285            token_id_hash: String::new(),
286            engine_mount: MOUNT.to_string(),
287            // Revocation authenticates with the token being revoked, so it has
288            // to be kept: an engine that discarded it could not revoke.
289            internal_data: json!({
290                "token": token.token,
291                "base_url": config.base_url,
292                "role": role_name,
293            }),
294            issued_at: now,
295            // GitHub's own expiry, not ours — a lease must never outlive the
296            // credential it governs.
297            expires_at: token.expires_at,
298        };
299
300        Ok(GeneratedCredential::new(
301            json!({
302                "token": token.token,
303                "expires_at": token.expires_at,
304                "git_clone_username": "x-access-token",
305            }),
306            lease,
307            Self::scope_description(&role),
308        ))
309    }
310
311    async fn revoke(&self, _storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
312        let token = lease.internal_data["token"]
313            .as_str()
314            .ok_or_else(|| EngineError::Other("lease missing 'token'".into()))?;
315        let base_url = lease.internal_data["base_url"]
316            .as_str()
317            .unwrap_or(DEFAULT_API)
318            .trim_end_matches('/');
319
320        let response = self
321            .http
322            .delete(format!("{base_url}/installation/token"))
323            .bearer_auth(token)
324            .header("Accept", "application/vnd.github+json")
325            .send()
326            .await
327            .map_err(|e| EngineError::Provider(format!("GitHub revoke failed: {e}")))?;
328
329        // An already-expired or already-revoked token is not an error — the
330        // reaper must be able to retry without tripping over its own success.
331        if response.status().is_success()
332            || response.status() == reqwest::StatusCode::UNAUTHORIZED
333            || response.status() == reqwest::StatusCode::NOT_FOUND
334        {
335            Ok(())
336        } else {
337            Err(EngineError::Provider(format!(
338                "GitHub returned {} when revoking the installation token",
339                response.status()
340            )))
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn role(repos: &[&str], perms: &[(&str, &str)]) -> RoleConfig {
350        RoleConfig {
351            target: "acme".to_string(),
352            installation_id: 1,
353            repositories: repos.iter().map(|r| r.to_string()).collect(),
354            permissions: perms
355                .iter()
356                .map(|(k, v)| (k.to_string(), v.to_string()))
357                .collect(),
358        }
359    }
360
361    #[test]
362    fn scope_description_lists_repos_and_permissions() {
363        let scoped = GithubEngine::scope_description(&role(
364            &["reports"],
365            &[("contents", "read"), ("pull_requests", "write")],
366        ));
367        assert!(scoped.contains(&"repo:reports".to_string()));
368        assert!(scoped.contains(&"contents:read".to_string()));
369        assert!(scoped.contains(&"pull_requests:write".to_string()));
370    }
371
372    /// An unscoped role is a footgun, so the `_doc` a consumer receives must
373    /// say so out loud rather than showing an empty list.
374    #[test]
375    fn scope_description_is_explicit_when_unscoped() {
376        let scoped = GithubEngine::scope_description(&role(&[], &[]));
377        assert!(scoped.iter().any(|s| s.contains("repos:ALL")));
378        assert!(scoped.iter().any(|s| s.contains("permissions:ALL")));
379    }
380
381    #[test]
382    fn app_jwt_respects_githubs_ten_minute_ceiling() {
383        // A real 2048-bit key is needed to exercise signing, so assert on the
384        // claim arithmetic, which is where the 10-minute rule gets broken.
385        let now = Utc::now();
386        let claims = AppJwtClaims {
387            iat: now.timestamp() - APP_JWT_BACKDATE_SECONDS,
388            exp: now.timestamp() + APP_JWT_TTL_SECONDS,
389            iss: "123".to_string(),
390        };
391        assert!(
392            claims.exp - claims.iat <= 600,
393            "App JWT lifetime must stay within GitHub's 10-minute limit"
394        );
395        assert!(claims.iat < now.timestamp(), "iat must be backdated for clock skew");
396    }
397
398    #[test]
399    fn rejects_a_private_key_that_is_not_a_pem() {
400        let config = GithubConfig {
401            app_id: "123".to_string(),
402            private_key_pem: "not a pem".to_string(),
403            base_url: default_api(),
404        };
405        let err = GithubEngine::app_jwt(&config, Utc::now()).unwrap_err();
406        assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
407    }
408
409    #[test]
410    fn doc_agrees_with_its_shape() {
411        let doc = GithubEngine::new().doc();
412        assert_eq!(doc.shape, CredentialShape::MintAndRevoke);
413        assert_eq!(doc.revocable, doc.shape.revocable());
414        assert!(doc.ttl.fixed);
415    }
416}