vgi-forge-github 0.16.0

GitHub adapter for VGI git namespaces: per-community GitHub App auth, repo creation and bootstrap, role projection, device-flow account linking and verified webhooks.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Registering the community's GitHub App through the manifest flow (§5.7).
//!
//! The bridge builds a manifest; the admin's browser POSTs it to GitHub (a
//! form whose `manifest` field is the JSON below) at the URL from
//! [`registration_url`]; the admin approves; GitHub redirects to
//! `redirect_url` with a one-time `code`; [`exchange_code`] trades it for
//! the App's id, private key and webhook secret. Nobody copies a key by hand.
//!
//! The permission set is fixed here, not a parameter, so every community's
//! App asks for the same reviewed set. The manifest travels through the
//! admin's browser, where it could be altered, so [`exchange_code`] checks
//! that the App GitHub actually registered holds nothing beyond these
//! permissions and refuses the credentials otherwise.
//!
//! GitHub's manifest format cannot enable the OAuth device flow; the admin
//! ticks "Enable Device Flow" on the App's settings page once, or account
//! linking reports `device_flow_disabled`.

use std::collections::BTreeMap;
use std::fmt;

use reqwest::Method;
use serde::Deserialize;
use serde_json::{Value, json};
use url::Url;
use vgi_forge::{ForgeError, Result};

use crate::api::{Api, Auth};
use crate::secret::Secret;

/// The App's permissions: repository Administration (write), Contents
/// (write, for the bootstrap commit and for pushing re-signed Dependabot
/// commits, §9), Variables (write), Metadata (read);
/// organisation Members (read) and Administration (write). No secrets,
/// Actions logs, code scanning or packages.
///
/// Organisation Administration (`organization_administration`) is for one
/// thing: the org ruleset that runs verify-trust as a **required workflow**
/// from the bridge-managed `<org>/.vgi` repository at a pinned commit (§9).
/// A `pull_request` workflow committed to the repository itself runs from
/// the pull request's own files, so a writer could edit it to pass; the
/// org ruleset takes what runs out of the pull request's reach. GitHub files
/// every `/orgs/{org}/rulesets` endpoint under this permission, at write
/// even for reads. It is always requested — the manifest is fixed per App,
/// and organisations are the recommended topology — and it also lets the
/// App edit other org settings, which is why the bind screen has to say
/// why it is there. On a personal account it grants nothing. An owner who
/// declines it gets the owner-review fallback (`missing_permissions` lists
/// it).
///
/// Checks (`checks`, write), Pull requests (`pull_requests`, read) and Merge
/// queues (`merge_queues`, read) are for one thing too: where there is no org
/// required workflow, the **bridge posts the "Verify commit trust" check
/// itself** (§9, "forged check runs"), and the repository ruleset requires
/// that check from this App's own integration id. A workflow on another
/// branch can post a check run under the GitHub Actions App — the reason a
/// check pinned to Actions is forgeable by any writer — but nothing but
/// this App's key can post one under this App. The two read permissions are
/// what GitHub requires for the `pull_request` and `merge_group` events
/// that tell the bridge when to check, and for reading a pull request's
/// current base. None of them reaches code: checks only posts check runs,
/// and the reads see pull request and queue metadata. Tokens for them are
/// minted per pull request, for that one repository.
///
/// Pull requests is requested at **write**, not read, for one more thing:
/// the **pull-request gate** (`git-ns/bridge/job` 0.5 `closePullRequest`).
/// When the community's pull-request policy does not allow a pull request's
/// author, the VTC has the bridge post the community's message on it and
/// close it. GitHub files a pull request's conversation comments under
/// Issues *or* Pull requests (write) and closing one under Pull requests
/// (write) alone, so `pull_requests: write` is the single least permission
/// that does both — Issues is not requested. It reaches no code either: it
/// cannot push, and merging needs Contents. Tokens for it are minted per
/// job, for the one repository. An installation that approved only `read`
/// keeps everything else (the bridge-posted check needs only
/// [`CHECK_PERMISSIONS`]); its `closePullRequest` jobs fail `forbidden`, and
/// the binding's `missing_permissions` lists `pull_requests:write` until the owner
/// approves the upgrade.
pub const APP_PERMISSIONS: [(&str, &str); 9] = [
    ("actions_variables", "write"),
    ("administration", "write"),
    ("checks", "write"),
    ("contents", "write"),
    ("members", "read"),
    ("merge_queues", "read"),
    ("metadata", "read"),
    ("organization_administration", "write"),
    ("pull_requests", "write"),
];

/// What the bridge-posted check needs on an installation: the permissions
/// and the event subscriptions. An App registered before these were in the
/// manifest has neither until its settings are changed and each
/// installation's owner approves the change — until then the namespace
/// keeps the in-repo Actions workflow.
pub const CHECK_PERMISSIONS: [(&str, &str); 3] = [
    ("checks", "write"),
    ("merge_queues", "read"),
    ("pull_requests", "read"),
];

/// The events the bridge-posted check is triggered by (see
/// [`CHECK_PERMISSIONS`]).
pub const CHECK_EVENTS: [&str; 2] = ["merge_group", "pull_request"];

/// The events the Dependabot re-sign needs (§9, "Dependabot re-sign bot"):
/// `push`, the signed record of who moved each `dependabot/*` branch, from
/// which the bridge decides whether a branch is Dependabot's alone. GitHub
/// delivers it under Contents, which the App already holds (write, for the
/// bootstrap commit and the re-signed push). An App registered before `push`
/// was in the manifest sees no pushes, so no branch is ever clean and nothing
/// is re-signed until the owner subscribes it — failing safe.
pub const RESIGN_EVENTS: [&str; 1] = ["push"];

/// Webhook events for drift (§5.6), plus `organization` for members joining
/// and leaving the org, `pull_request` / `merge_group` / `check_run` /
/// `check_suite` so the bridge can post (and re-post, on a rerequest) the
/// check where it runs it itself, and `push` for the Dependabot re-sign's
/// provenance ledger ([`RESIGN_EVENTS`]). `installation` events are always
/// delivered to an App and need no subscription. Sorted.
pub const APP_EVENTS: [&str; 11] = [
    "branch_protection_rule",
    "check_run",
    "check_suite",
    "member",
    "membership",
    "merge_group",
    "organization",
    "pull_request",
    "push",
    "repository",
    "repository_ruleset",
];

/// Inputs to the manifest.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ManifestParams {
    /// App name, unique on GitHub (e.g. `acme-vgi-bridge`).
    pub name: String,
    /// Homepage shown on the App's page.
    pub url: String,
    /// Where GitHub delivers webhooks (the bridge).
    pub webhook_url: String,
    /// Where GitHub sends the admin after registration, with `code`.
    pub redirect_url: String,
    /// OAuth callback URLs (device flow needs none, but GitHub wants one).
    pub callback_urls: Vec<String>,
    /// Where GitHub sends the admin after an install, with
    /// `installation_id` and the bind `state` (§4.1 step 3).
    pub setup_url: Option<String>,
    /// Optional description.
    pub description: Option<String>,
}

impl ManifestParams {
    /// Parameters with one callback URL and no setup URL or description.
    pub fn new(
        name: impl Into<String>,
        url: impl Into<String>,
        webhook_url: impl Into<String>,
        redirect_url: impl Into<String>,
    ) -> Self {
        let redirect_url = redirect_url.into();
        ManifestParams {
            name: name.into(),
            url: url.into(),
            webhook_url: webhook_url.into(),
            callback_urls: vec![redirect_url.clone()],
            redirect_url,
            setup_url: None,
            description: None,
        }
    }

    /// Set the post-install setup URL.
    pub fn with_setup_url(mut self, url: impl Into<String>) -> Self {
        self.setup_url = Some(url.into());
        self
    }
}

/// The manifest JSON.
pub fn app_manifest(p: &ManifestParams) -> Value {
    let permissions: BTreeMap<_, _> = APP_PERMISSIONS.into_iter().collect();
    let mut m = json!({
        "name": p.name,
        "url": p.url,
        "hook_attributes": { "url": p.webhook_url, "active": true },
        "redirect_url": p.redirect_url,
        "callback_urls": p.callback_urls,
        // Private: only the registering org or account can install it.
        "public": false,
        "default_permissions": permissions,
        "default_events": APP_EVENTS,
        "request_oauth_on_install": false,
    });
    if let Some(setup) = &p.setup_url {
        m["setup_url"] = json!(setup);
        // Bring the admin back through the bind callback on permission
        // updates too, so an upgrade is seen rather than inferred.
        m["setup_on_update"] = json!(true);
    }
    if let Some(d) = &p.description {
        m["description"] = json!(d);
    }
    m
}

/// Where the admin's browser POSTs the manifest form: the org's settings
/// when `org` is given (the App is then owned by the org), the admin's own
/// otherwise. `state` comes back on the redirect; check it there.
pub fn registration_url(web_base: &Url, org: Option<&str>, state: &str) -> Url {
    let mut url = web_base.clone();
    {
        let mut path = url.path_segments_mut().expect("http(s) base");
        path.pop_if_empty();
        match org {
            Some(org) => path.extend(["organizations", org, "settings", "apps", "new"]),
            None => path.extend(["settings", "apps", "new"]),
        };
    }
    url.query_pairs_mut().append_pair("state", state);
    url
}

/// What registering the App produced. Holds the App private key and the
/// webhook and client secrets: zeroized on drop, never `Debug`-printed.
pub struct AppCredentials {
    /// Numeric App id.
    pub app_id: u64,
    /// URL slug (for the install page).
    pub slug: String,
    /// OAuth client id (JWT issuer, device-flow client).
    pub client_id: String,
    /// OAuth client secret.
    pub client_secret: Secret,
    /// Webhook HMAC secret.
    pub webhook_secret: Secret,
    /// The App private key, PEM. Hand it to the sealed store and drop this.
    pub pem: Secret,
    /// The account that owns the App.
    pub owner_login: Option<String>,
}

impl fmt::Debug for AppCredentials {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("AppCredentials")
            .field("app_id", &self.app_id)
            .field("slug", &self.slug)
            .field("client_id", &self.client_id)
            .field("client_secret", &"<redacted>")
            .field("webhook_secret", &"<redacted>")
            .field("pem", &"<redacted>")
            .field("owner_login", &self.owner_login)
            .finish()
    }
}

#[derive(Deserialize)]
struct Conversion {
    id: u64,
    slug: String,
    client_id: String,
    client_secret: String,
    webhook_secret: Option<String>,
    pem: String,
    // Required, not defaulted: a response without these cannot be checked
    // against what the admin approved, and must not pass as "no excess".
    owner: Owner,
    permissions: BTreeMap<String, String>,
    events: Vec<String>,
    /// Not part of GitHub's documented conversion response today; checked
    /// when present so a public App is never accepted silently.
    #[serde(default)]
    public: Option<bool>,
}

/// Rank of a GitHub permission level; unknown levels rank highest so they
/// count as excess.
pub(crate) fn level_rank(level: &str) -> u8 {
    match level {
        "read" => 1,
        "write" => 2,
        _ => 3,
    }
}

/// Permissions in `granted` that the reviewed set does not allow, as
/// `name:level`.
pub(crate) fn excess_permissions(granted: &BTreeMap<String, String>) -> Vec<String> {
    granted
        .iter()
        .filter(|(name, level)| {
            APP_PERMISSIONS
                .iter()
                .find(|(n, _)| n == name)
                .is_none_or(|(_, allowed)| level_rank(level) > level_rank(allowed))
        })
        .map(|(name, level)| format!("{name}:{level}"))
        .collect()
}

/// Whether an installation with `granted` permissions and `events`
/// subscriptions can carry the bridge-posted check.
pub fn check_ready(granted: &BTreeMap<String, String>, events: &[String]) -> bool {
    CHECK_PERMISSIONS.iter().all(|(name, level)| {
        granted
            .get(*name)
            .is_some_and(|have| level_rank(have) >= level_rank(level))
    }) && CHECK_EVENTS.iter().all(|e| events.iter().any(|x| x == e))
}

/// Reviewed permissions that `granted` lacks or holds at a lower level, as
/// `name:level`.
pub(crate) fn missing_permissions(granted: &BTreeMap<String, String>) -> Vec<String> {
    APP_PERMISSIONS
        .iter()
        .filter(|(name, level)| {
            granted
                .get(*name)
                .is_none_or(|have| level_rank(have) < level_rank(level))
        })
        .map(|(name, level)| format!("{name}:{level}"))
        .collect()
}

#[derive(Deserialize)]
struct Owner {
    login: String,
}

/// Exchange the redirect's `code` for the App's credentials
/// (`POST /app-manifests/{code}/conversions`). The code is single-use and
/// expires after an hour.
///
/// `expected_owner` is the org (or user) the admin set out to register the
/// App under — the one passed to [`registration_url`]. An App registered
/// anywhere else is refused: its key would act for the wrong account.
pub async fn exchange_code(
    api_base: &Url,
    code: &str,
    expected_owner: &str,
) -> Result<AppCredentials> {
    if code.is_empty() || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
        return Err(ForgeError::Config(
            "manifest code is not alphanumeric".into(),
        ));
    }
    let api = Api::new(
        api_base.clone(),
        api_base.clone(),
        std::time::Duration::from_secs(30),
    )?;
    let url = api.url(&["app-manifests", code, "conversions"]);
    let c: Conversion = api
        .json(Method::POST, url, Auth::None, None, "manifest conversion")
        .await?;
    // Move the secrets into zeroizing storage before anything can fail.
    let creds = AppCredentials {
        app_id: c.id,
        slug: c.slug,
        client_id: c.client_id,
        client_secret: Secret::new(c.client_secret),
        webhook_secret: Secret::new(c.webhook_secret.unwrap_or_default()),
        pem: Secret::new(c.pem),
        owner_login: Some(c.owner.login),
    };
    let refuse = |why: String| {
        Err(ForgeError::Config(format!(
            "{why}; delete App `{}` on GitHub and register again",
            creds.slug
        )))
    };

    let owner = creds.owner_login.as_deref().unwrap_or_default();
    if !owner.eq_ignore_ascii_case(expected_owner) {
        return refuse(format!(
            "the App was registered under `{owner}`, not `{expected_owner}`"
        ));
    }
    if c.public == Some(true) {
        return refuse("the registered App is public; the manifest asks for a private one".into());
    }
    let mut events = c.events;
    events.sort();
    events.dedup();
    if events != APP_EVENTS {
        return refuse(format!(
            "the registered App's events {events:?} differ from the reviewed manifest {APP_EVENTS:?}"
        ));
    }

    // Fewer permissions than reviewed is harmless (the adapter reports what
    // it cannot do); any permission beyond the set, or at a higher level, is
    // not what the admin was shown.
    let excess = excess_permissions(&c.permissions);
    if !excess.is_empty() {
        return refuse(format!(
            "the registered App has permissions beyond the reviewed manifest ({})",
            excess.join(", ")
        ));
    }
    if creds.webhook_secret.expose().is_empty() {
        return Err(ForgeError::Config(
            "GitHub returned no webhook secret; webhooks could not be verified".into(),
        ));
    }
    Ok(creds)
}