vgi_forge_github/manifest.rs
1//! Registering the community's GitHub App through the manifest flow (§5.7).
2//!
3//! The bridge builds a manifest; the admin's browser POSTs it to GitHub (a
4//! form whose `manifest` field is the JSON below) at the URL from
5//! [`registration_url`]; the admin approves; GitHub redirects to
6//! `redirect_url` with a one-time `code`; [`exchange_code`] trades it for
7//! the App's id, private key and webhook secret. Nobody copies a key by hand.
8//!
9//! The permission set is fixed here, not a parameter, so every community's
10//! App asks for the same reviewed set. The manifest travels through the
11//! admin's browser, where it could be altered, so [`exchange_code`] checks
12//! that the App GitHub actually registered holds nothing beyond these
13//! permissions and refuses the credentials otherwise.
14//!
15//! GitHub's manifest format cannot enable the OAuth device flow; the admin
16//! ticks "Enable Device Flow" on the App's settings page once, or account
17//! linking reports `device_flow_disabled`.
18
19use std::collections::BTreeMap;
20use std::fmt;
21
22use reqwest::Method;
23use serde::Deserialize;
24use serde_json::{Value, json};
25use url::Url;
26use vgi_forge::{ForgeError, Result};
27
28use crate::api::{Api, Auth};
29use crate::secret::Secret;
30
31/// The App's permissions: repository Administration (write), Contents
32/// (write, for the bootstrap commit and for pushing re-signed Dependabot
33/// commits, §9), Variables (write), Metadata (read);
34/// organisation Members (read) and Administration (write). No secrets,
35/// Actions logs, code scanning or packages.
36///
37/// Organisation Administration (`organization_administration`) is for one
38/// thing: the org ruleset that runs verify-trust as a **required workflow**
39/// from the bridge-managed `<org>/.vgi` repository at a pinned commit (§9).
40/// A `pull_request` workflow committed to the repository itself runs from
41/// the pull request's own files, so a writer could edit it to pass; the
42/// org ruleset takes what runs out of the pull request's reach. GitHub files
43/// every `/orgs/{org}/rulesets` endpoint under this permission, at write
44/// even for reads. It is always requested — the manifest is fixed per App,
45/// and organisations are the recommended topology — and it also lets the
46/// App edit other org settings, which is why the bind screen has to say
47/// why it is there. On a personal account it grants nothing. An owner who
48/// declines it gets the owner-review fallback (`missing_permissions` lists
49/// it).
50///
51/// Checks (`checks`, write), Pull requests (`pull_requests`, read) and Merge
52/// queues (`merge_queues`, read) are for one thing too: where there is no org
53/// required workflow, the **bridge posts the "Verify commit trust" check
54/// itself** (§9, "forged check runs"), and the repository ruleset requires
55/// that check from this App's own integration id. A workflow on another
56/// branch can post a check run under the GitHub Actions App — the reason a
57/// check pinned to Actions is forgeable by any writer — but nothing but
58/// this App's key can post one under this App. The two read permissions are
59/// what GitHub requires for the `pull_request` and `merge_group` events
60/// that tell the bridge when to check, and for reading a pull request's
61/// current base. None of them reaches code: checks only posts check runs,
62/// and the reads see pull request and queue metadata. Tokens for them are
63/// minted per pull request, for that one repository.
64pub const APP_PERMISSIONS: [(&str, &str); 9] = [
65 ("actions_variables", "write"),
66 ("administration", "write"),
67 ("checks", "write"),
68 ("contents", "write"),
69 ("members", "read"),
70 ("merge_queues", "read"),
71 ("metadata", "read"),
72 ("organization_administration", "write"),
73 ("pull_requests", "read"),
74];
75
76/// What the bridge-posted check needs on an installation: the permissions
77/// and the event subscriptions. An App registered before these were in the
78/// manifest has neither until its settings are changed and each
79/// installation's owner approves the change — until then the namespace
80/// keeps the in-repo Actions workflow.
81pub const CHECK_PERMISSIONS: [(&str, &str); 3] = [
82 ("checks", "write"),
83 ("merge_queues", "read"),
84 ("pull_requests", "read"),
85];
86
87/// The events the bridge-posted check is triggered by (see
88/// [`CHECK_PERMISSIONS`]).
89pub const CHECK_EVENTS: [&str; 2] = ["merge_group", "pull_request"];
90
91/// The events the Dependabot re-sign needs (§9, "Dependabot re-sign bot"):
92/// `push`, the signed record of who moved each `dependabot/*` branch, from
93/// which the bridge decides whether a branch is Dependabot's alone. GitHub
94/// delivers it under Contents, which the App already holds (write, for the
95/// bootstrap commit and the re-signed push). An App registered before `push`
96/// was in the manifest sees no pushes, so no branch is ever clean and nothing
97/// is re-signed until the owner subscribes it — failing safe.
98pub const RESIGN_EVENTS: [&str; 1] = ["push"];
99
100/// Webhook events for drift (§5.6), plus `organization` for members joining
101/// and leaving the org, `pull_request` / `merge_group` / `check_run` /
102/// `check_suite` so the bridge can post (and re-post, on a rerequest) the
103/// check where it runs it itself, and `push` for the Dependabot re-sign's
104/// provenance ledger ([`RESIGN_EVENTS`]). `installation` events are always
105/// delivered to an App and need no subscription. Sorted.
106pub const APP_EVENTS: [&str; 11] = [
107 "branch_protection_rule",
108 "check_run",
109 "check_suite",
110 "member",
111 "membership",
112 "merge_group",
113 "organization",
114 "pull_request",
115 "push",
116 "repository",
117 "repository_ruleset",
118];
119
120/// Inputs to the manifest.
121#[derive(Debug, Clone)]
122#[non_exhaustive]
123pub struct ManifestParams {
124 /// App name, unique on GitHub (e.g. `acme-vgi-bridge`).
125 pub name: String,
126 /// Homepage shown on the App's page.
127 pub url: String,
128 /// Where GitHub delivers webhooks (the bridge).
129 pub webhook_url: String,
130 /// Where GitHub sends the admin after registration, with `code`.
131 pub redirect_url: String,
132 /// OAuth callback URLs (device flow needs none, but GitHub wants one).
133 pub callback_urls: Vec<String>,
134 /// Where GitHub sends the admin after an install, with
135 /// `installation_id` and the bind `state` (§4.1 step 3).
136 pub setup_url: Option<String>,
137 /// Optional description.
138 pub description: Option<String>,
139}
140
141impl ManifestParams {
142 /// Parameters with one callback URL and no setup URL or description.
143 pub fn new(
144 name: impl Into<String>,
145 url: impl Into<String>,
146 webhook_url: impl Into<String>,
147 redirect_url: impl Into<String>,
148 ) -> Self {
149 let redirect_url = redirect_url.into();
150 ManifestParams {
151 name: name.into(),
152 url: url.into(),
153 webhook_url: webhook_url.into(),
154 callback_urls: vec![redirect_url.clone()],
155 redirect_url,
156 setup_url: None,
157 description: None,
158 }
159 }
160
161 /// Set the post-install setup URL.
162 pub fn with_setup_url(mut self, url: impl Into<String>) -> Self {
163 self.setup_url = Some(url.into());
164 self
165 }
166}
167
168/// The manifest JSON.
169pub fn app_manifest(p: &ManifestParams) -> Value {
170 let permissions: BTreeMap<_, _> = APP_PERMISSIONS.into_iter().collect();
171 let mut m = json!({
172 "name": p.name,
173 "url": p.url,
174 "hook_attributes": { "url": p.webhook_url, "active": true },
175 "redirect_url": p.redirect_url,
176 "callback_urls": p.callback_urls,
177 // Private: only the registering org or account can install it.
178 "public": false,
179 "default_permissions": permissions,
180 "default_events": APP_EVENTS,
181 "request_oauth_on_install": false,
182 });
183 if let Some(setup) = &p.setup_url {
184 m["setup_url"] = json!(setup);
185 // Bring the admin back through the bind callback on permission
186 // updates too, so an upgrade is seen rather than inferred.
187 m["setup_on_update"] = json!(true);
188 }
189 if let Some(d) = &p.description {
190 m["description"] = json!(d);
191 }
192 m
193}
194
195/// Where the admin's browser POSTs the manifest form: the org's settings
196/// when `org` is given (the App is then owned by the org), the admin's own
197/// otherwise. `state` comes back on the redirect; check it there.
198pub fn registration_url(web_base: &Url, org: Option<&str>, state: &str) -> Url {
199 let mut url = web_base.clone();
200 {
201 let mut path = url.path_segments_mut().expect("http(s) base");
202 path.pop_if_empty();
203 match org {
204 Some(org) => path.extend(["organizations", org, "settings", "apps", "new"]),
205 None => path.extend(["settings", "apps", "new"]),
206 };
207 }
208 url.query_pairs_mut().append_pair("state", state);
209 url
210}
211
212/// What registering the App produced. Holds the App private key and the
213/// webhook and client secrets: zeroized on drop, never `Debug`-printed.
214pub struct AppCredentials {
215 /// Numeric App id.
216 pub app_id: u64,
217 /// URL slug (for the install page).
218 pub slug: String,
219 /// OAuth client id (JWT issuer, device-flow client).
220 pub client_id: String,
221 /// OAuth client secret.
222 pub client_secret: Secret,
223 /// Webhook HMAC secret.
224 pub webhook_secret: Secret,
225 /// The App private key, PEM. Hand it to the sealed store and drop this.
226 pub pem: Secret,
227 /// The account that owns the App.
228 pub owner_login: Option<String>,
229}
230
231impl fmt::Debug for AppCredentials {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 f.debug_struct("AppCredentials")
234 .field("app_id", &self.app_id)
235 .field("slug", &self.slug)
236 .field("client_id", &self.client_id)
237 .field("client_secret", &"<redacted>")
238 .field("webhook_secret", &"<redacted>")
239 .field("pem", &"<redacted>")
240 .field("owner_login", &self.owner_login)
241 .finish()
242 }
243}
244
245#[derive(Deserialize)]
246struct Conversion {
247 id: u64,
248 slug: String,
249 client_id: String,
250 client_secret: String,
251 webhook_secret: Option<String>,
252 pem: String,
253 // Required, not defaulted: a response without these cannot be checked
254 // against what the admin approved, and must not pass as "no excess".
255 owner: Owner,
256 permissions: BTreeMap<String, String>,
257 events: Vec<String>,
258 /// Not part of GitHub's documented conversion response today; checked
259 /// when present so a public App is never accepted silently.
260 #[serde(default)]
261 public: Option<bool>,
262}
263
264/// Rank of a GitHub permission level; unknown levels rank highest so they
265/// count as excess.
266pub(crate) fn level_rank(level: &str) -> u8 {
267 match level {
268 "read" => 1,
269 "write" => 2,
270 _ => 3,
271 }
272}
273
274/// Permissions in `granted` that the reviewed set does not allow, as
275/// `name:level`.
276pub(crate) fn excess_permissions(granted: &BTreeMap<String, String>) -> Vec<String> {
277 granted
278 .iter()
279 .filter(|(name, level)| {
280 APP_PERMISSIONS
281 .iter()
282 .find(|(n, _)| n == name)
283 .is_none_or(|(_, allowed)| level_rank(level) > level_rank(allowed))
284 })
285 .map(|(name, level)| format!("{name}:{level}"))
286 .collect()
287}
288
289/// Whether an installation with `granted` permissions and `events`
290/// subscriptions can carry the bridge-posted check.
291pub fn check_ready(granted: &BTreeMap<String, String>, events: &[String]) -> bool {
292 CHECK_PERMISSIONS.iter().all(|(name, level)| {
293 granted
294 .get(*name)
295 .is_some_and(|have| level_rank(have) >= level_rank(level))
296 }) && CHECK_EVENTS.iter().all(|e| events.iter().any(|x| x == e))
297}
298
299/// Reviewed permissions that `granted` lacks or holds at a lower level, as
300/// `name:level`.
301pub(crate) fn missing_permissions(granted: &BTreeMap<String, String>) -> Vec<String> {
302 APP_PERMISSIONS
303 .iter()
304 .filter(|(name, level)| {
305 granted
306 .get(*name)
307 .is_none_or(|have| level_rank(have) < level_rank(level))
308 })
309 .map(|(name, level)| format!("{name}:{level}"))
310 .collect()
311}
312
313#[derive(Deserialize)]
314struct Owner {
315 login: String,
316}
317
318/// Exchange the redirect's `code` for the App's credentials
319/// (`POST /app-manifests/{code}/conversions`). The code is single-use and
320/// expires after an hour.
321///
322/// `expected_owner` is the org (or user) the admin set out to register the
323/// App under — the one passed to [`registration_url`]. An App registered
324/// anywhere else is refused: its key would act for the wrong account.
325pub async fn exchange_code(
326 api_base: &Url,
327 code: &str,
328 expected_owner: &str,
329) -> Result<AppCredentials> {
330 if code.is_empty() || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
331 return Err(ForgeError::Config(
332 "manifest code is not alphanumeric".into(),
333 ));
334 }
335 let api = Api::new(
336 api_base.clone(),
337 api_base.clone(),
338 std::time::Duration::from_secs(30),
339 )?;
340 let url = api.url(&["app-manifests", code, "conversions"]);
341 let c: Conversion = api
342 .json(Method::POST, url, Auth::None, None, "manifest conversion")
343 .await?;
344 // Move the secrets into zeroizing storage before anything can fail.
345 let creds = AppCredentials {
346 app_id: c.id,
347 slug: c.slug,
348 client_id: c.client_id,
349 client_secret: Secret::new(c.client_secret),
350 webhook_secret: Secret::new(c.webhook_secret.unwrap_or_default()),
351 pem: Secret::new(c.pem),
352 owner_login: Some(c.owner.login),
353 };
354 let refuse = |why: String| {
355 Err(ForgeError::Config(format!(
356 "{why}; delete App `{}` on GitHub and register again",
357 creds.slug
358 )))
359 };
360
361 let owner = creds.owner_login.as_deref().unwrap_or_default();
362 if !owner.eq_ignore_ascii_case(expected_owner) {
363 return refuse(format!(
364 "the App was registered under `{owner}`, not `{expected_owner}`"
365 ));
366 }
367 if c.public == Some(true) {
368 return refuse("the registered App is public; the manifest asks for a private one".into());
369 }
370 let mut events = c.events;
371 events.sort();
372 events.dedup();
373 if events != APP_EVENTS {
374 return refuse(format!(
375 "the registered App's events {events:?} differ from the reviewed manifest {APP_EVENTS:?}"
376 ));
377 }
378
379 // Fewer permissions than reviewed is harmless (the adapter reports what
380 // it cannot do); any permission beyond the set, or at a higher level, is
381 // not what the admin was shown.
382 let excess = excess_permissions(&c.permissions);
383 if !excess.is_empty() {
384 return refuse(format!(
385 "the registered App has permissions beyond the reviewed manifest ({})",
386 excess.join(", ")
387 ));
388 }
389 if creds.webhook_secret.expose().is_empty() {
390 return Err(ForgeError::Config(
391 "GitHub returned no webhook secret; webhooks could not be verified".into(),
392 ));
393 }
394 Ok(creds)
395}