1use 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
28const APP_JWT_TTL_SECONDS: i64 = 540;
31const APP_JWT_BACKDATE_SECONDS: i64 = 60;
34const INSTALLATION_TOKEN_TTL_SECONDS: i64 = 3600;
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct GithubConfig {
41 pub app_id: String,
43 pub private_key_pem: String,
45 #[serde(default = "default_api")]
47 pub base_url: String,
48}
49
50fn default_api() -> String {
51 DEFAULT_API.to_string()
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RoleConfig {
59 pub target: String,
61 pub installation_id: u64,
62 #[serde(default)]
65 pub repositories: Vec<String>,
66 #[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 http: reqwest::Client::builder()
95 .user_agent("secrets-server")
96 .build()
97 .unwrap_or_default(),
98 }
99 }
100
101 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 token_id_hash: String::new(),
286 engine_mount: MOUNT.to_string(),
287 internal_data: json!({
290 "token": token.token,
291 "base_url": config.base_url,
292 "role": role_name,
293 }),
294 issued_at: now,
295 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 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 #[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 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}