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