Skip to main content

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}