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.
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}