hive-forge: stop embedding the forge token in clone URLs
This commit is contained in:
parent
aad5d3638f
commit
676f7715fd
6 changed files with 154 additions and 27 deletions
|
|
@ -144,7 +144,7 @@ lets a read-only user open a PR by pushing the current `HEAD` to the
|
||||||
magic ref `refs/for/<base>/<topic>`. Two verbs cover the workflow:
|
magic ref `refs/for/<base>/<topic>`. Two verbs cover the workflow:
|
||||||
|
|
||||||
```
|
```
|
||||||
hive-forge -r internal/knowledge clone # clone with token auto-injected
|
hive-forge -r internal/knowledge clone # clone, auth handled for you
|
||||||
cd knowledge
|
cd knowledge
|
||||||
# add / edit / delete any files, then commit normally
|
# add / edit / delete any files, then commit normally
|
||||||
git add -A && git commit -m "add foo runbook"
|
git add -A && git commit -m "add foo runbook"
|
||||||
|
|
@ -155,9 +155,13 @@ hive-forge -r internal/knowledge pr-create --agit \
|
||||||
```
|
```
|
||||||
|
|
||||||
`clone` derives the dest dir from the repo basename (override with a
|
`clone` derives the dest dir from the repo basename (override with a
|
||||||
positional arg); `--branch` / `--depth` are passed through. The token
|
positional arg); `--branch` / `--depth` are passed through. The clone
|
||||||
is injected into the clone's `origin` remote so `pr-create --agit`
|
URL and the `origin` remote it leaves behind carry no credentials —
|
||||||
(default remote `origin`) can push without re-auth.
|
`clone` instead configures `origin`'s `credential.helper` to invoke
|
||||||
|
`hive-forge credential-helper` (a hidden verb, not meant to be run by
|
||||||
|
hand), which git calls fresh on every fetch/push. That's what lets
|
||||||
|
`pr-create --agit` (default remote `origin`) push without re-auth,
|
||||||
|
without a durable token sitting in the checkout's `.git/config`.
|
||||||
|
|
||||||
`pr-create --agit` prints the PR URL. Re-running with the same
|
`pr-create --agit` prints the PR URL. Re-running with the same
|
||||||
`--topic` force-updates the existing open PR (the AGit ref is
|
`--topic` force-updates the existing open PR (the AGit ref is
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,12 @@ pub struct Client {
|
||||||
/// header the typed client sends.
|
/// header the typed client sends.
|
||||||
web: HttpClient,
|
web: HttpClient,
|
||||||
base: String,
|
base: String,
|
||||||
/// Per-agent forge token. Kept so verbs that shell out to `git`
|
/// The `-f/--forge` label this client resolved against, `None` for
|
||||||
/// (e.g. `clone`) can assemble an authenticated push URL without
|
/// the default internal forge. `clone` bakes this back into the
|
||||||
/// re-reading the token file.
|
/// `credential.helper` command it configures, so a later `git push`/
|
||||||
token: String,
|
/// `git fetch` in that checkout re-resolves the *same* forge account
|
||||||
|
/// rather than silently falling back to the internal one.
|
||||||
|
forge_label: Option<String>,
|
||||||
/// Default repo used when a verb doesn't carry an explicit
|
/// Default repo used when a verb doesn't carry an explicit
|
||||||
/// `[repo]` override.
|
/// `[repo]` override.
|
||||||
pub default_repo: String,
|
pub default_repo: String,
|
||||||
|
|
@ -98,7 +100,7 @@ impl Client {
|
||||||
api,
|
api,
|
||||||
web,
|
web,
|
||||||
base,
|
base,
|
||||||
token,
|
forge_label,
|
||||||
default_repo,
|
default_repo,
|
||||||
json_mode,
|
json_mode,
|
||||||
})
|
})
|
||||||
|
|
@ -111,21 +113,26 @@ impl Client {
|
||||||
&self.api
|
&self.api
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble an authenticated git URL for `repo` (e.g.
|
/// The *credential-free* git URL for `repo` — no user/token in the
|
||||||
/// `internal/knowledge`) by injecting the agent's forge user +
|
/// authority, so nothing durable lands in `.git/config` when this is
|
||||||
/// token into the base URL's authority: `http://<user>:<token>@host/<repo>.git`.
|
/// the URL `git clone` is given. Pairs with the `credential-helper`
|
||||||
/// The user comes from `HIVE_LABEL` (the agent's forge login),
|
/// verb, which `clone` configures as the repo's `credential.helper`
|
||||||
/// falling back to `oauth2` which Forgejo also accepts as the
|
/// so git asks for (and gets) the token fresh from its file on every
|
||||||
/// token-bearer username. Used by `clone` to clone/push.
|
/// fetch/push instead of it being embedded here. Replaces the old
|
||||||
|
/// `authed_git_url` (`http://<user>:<token>@host/<repo>.git`), which
|
||||||
|
/// left a durable token in every checkout's `.git/config` — a real
|
||||||
|
/// leak reported by atlas.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn authed_git_url(&self, repo: &str) -> String {
|
pub fn plain_git_url(&self, repo: &str) -> String {
|
||||||
let user = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "oauth2".to_owned());
|
format!("{}/{repo}.git", self.base)
|
||||||
// Split scheme from authority so credentials land in the right spot.
|
}
|
||||||
let (scheme, host) = self
|
|
||||||
.base
|
/// The `-f/--forge` label this client resolved against (`None` for
|
||||||
.split_once("://")
|
/// the internal forge). See the `forge_label` field doc for why
|
||||||
.unwrap_or(("http", self.base.as_str()));
|
/// `clone` needs this.
|
||||||
format!("{scheme}://{user}:{}@{host}/{repo}.git", self.token)
|
#[must_use]
|
||||||
|
pub fn forge_label(&self) -> Option<&str> {
|
||||||
|
self.forge_label.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the operator passed the global `--json` flag.
|
/// True when the operator passed the global `--json` flag.
|
||||||
|
|
@ -331,7 +338,7 @@ pub fn index(n: u64) -> Result<i64> {
|
||||||
/// `/` or `..`) is rejected up front with the same charset spelled out,
|
/// `/` or `..`) is rejected up front with the same charset spelled out,
|
||||||
/// rather than silently building a nonsense/traversing path and
|
/// rather than silently building a nonsense/traversing path and
|
||||||
/// surfacing a confusing file error later.
|
/// surfacing a confusing file error later.
|
||||||
fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
|
pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
|
||||||
let Some(label) = forge_label else {
|
let Some(label) = forge_label else {
|
||||||
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
|
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
|
||||||
let token = read_token().context("read forge-token")?;
|
let token = read_token().context("read forge-token")?;
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,13 @@ enum Verb {
|
||||||
/// Re-run CI without an empty commit. Pass one of `--pr <n>`,
|
/// Re-run CI without an empty commit. Pass one of `--pr <n>`,
|
||||||
/// `--run <n>`, or `--branch <name>`; `--workflow` defaults to `ci.yml`.
|
/// `--run <n>`, or `--branch <name>`; `--workflow` defaults to `ci.yml`.
|
||||||
CiRerun(verbs::ci_rerun::Args),
|
CiRerun(verbs::ci_rerun::Args),
|
||||||
|
/// Git credential-helper protocol (`get|store|erase`) — not a verb
|
||||||
|
/// you run by hand. `clone` configures each checkout's
|
||||||
|
/// `credential.helper` to invoke this, so git asks it for a token
|
||||||
|
/// fresh on every fetch/push instead of one being embedded in the
|
||||||
|
/// remote URL.
|
||||||
|
#[command(hide = true)]
|
||||||
|
CredentialHelper(verbs::credential_helper::Args),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper over [`run`] that owns how a failure reaches the operator.
|
/// Wrapper over [`run`] that owns how a failure reaches the operator.
|
||||||
|
|
@ -197,6 +204,14 @@ fn main() -> ExitCode {
|
||||||
|
|
||||||
fn run() -> Result<()> {
|
fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
let verb = cli.verb;
|
||||||
|
// `credential-helper` bypasses the normal client construction below:
|
||||||
|
// it needs no repo (unlike `Client::from_env`, which errors without
|
||||||
|
// one), and git may invoke it from a directory that isn't a resolved
|
||||||
|
// checkout yet (mid-`clone`, before the destination exists).
|
||||||
|
if let Verb::CredentialHelper(args) = verb {
|
||||||
|
return verbs::credential_helper::run(args, cli.forge.as_deref());
|
||||||
|
}
|
||||||
let client = client::Client::from_env(cli.repo, cli.json, cli.forge)
|
let client = client::Client::from_env(cli.repo, cli.json, cli.forge)
|
||||||
.context("initialize forge client")?;
|
.context("initialize forge client")?;
|
||||||
// Attach the resolved repo to every verb's error uniformly here,
|
// Attach the resolved repo to every verb's error uniformly here,
|
||||||
|
|
@ -211,7 +226,7 @@ fn run() -> Result<()> {
|
||||||
// line 1 column 0" into "repo typo-org/repo: EOF while parsing a
|
// line 1 column 0" into "repo typo-org/repo: EOF while parsing a
|
||||||
// value at line 1 column 0", which is diagnosable on sight.
|
// value at line 1 column 0", which is diagnosable on sight.
|
||||||
let repo = client.repo().to_owned();
|
let repo = client.repo().to_owned();
|
||||||
dispatch(&client, cli.verb).with_context(|| format!("repo {repo}"))
|
dispatch(&client, verb).with_context(|| format!("repo {repo}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch(client: &client::Client, verb: Verb) -> Result<()> {
|
fn dispatch(client: &client::Client, verb: Verb) -> Result<()> {
|
||||||
|
|
@ -253,5 +268,8 @@ fn dispatch(client: &client::Client, verb: Verb) -> Result<()> {
|
||||||
Verb::ArtifactGet(a) => verbs::artifact_get::run(client, a),
|
Verb::ArtifactGet(a) => verbs::artifact_get::run(client, a),
|
||||||
Verb::CiLog(a) => verbs::ci_log::run(client, a),
|
Verb::CiLog(a) => verbs::ci_log::run(client, a),
|
||||||
Verb::CiRerun(a) => verbs::ci_rerun::run(client, a),
|
Verb::CiRerun(a) => verbs::ci_rerun::run(client, a),
|
||||||
|
Verb::CredentialHelper(_) => {
|
||||||
|
unreachable!("handled in `run` before client construction")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@
|
||||||
//! `client::Client::from_env` for the full resolution chain). Pairs
|
//! `client::Client::from_env` for the full resolution chain). Pairs
|
||||||
//! with `pr-create --agit`: clone, edit + commit normally, then open a
|
//! with `pr-create --agit`: clone, edit + commit normally, then open a
|
||||||
//! PR via the `AGit` ref.
|
//! PR via the `AGit` ref.
|
||||||
|
//!
|
||||||
|
//! The clone URL itself carries no credentials, and the resulting
|
||||||
|
//! checkout's `.git/config` never gets one either: `-c
|
||||||
|
//! credential.helper=!hive-forge credential-helper` (baked into the
|
||||||
|
//! `git clone` invocation, which persists into the new repo's config)
|
||||||
|
//! makes git ask the `credential-helper` verb for a fresh token on every
|
||||||
|
//! subsequent fetch/push instead. Previously the token itself rode in
|
||||||
|
//! the clone URL and landed, durably, in every checkout's `.git/config` —
|
||||||
|
//! a real credential leaked to every clone on disk, reported by atlas.
|
||||||
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
|
@ -40,9 +49,17 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?,
|
.with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?,
|
||||||
};
|
};
|
||||||
let url = client.authed_git_url(repo);
|
let url = client.plain_git_url(repo);
|
||||||
|
let helper = match client.forge_label() {
|
||||||
|
Some(label) => format!("!hive-forge credential-helper -f {label}"),
|
||||||
|
None => "!hive-forge credential-helper".to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
let mut git_args = vec!["clone".to_owned()];
|
let mut git_args = vec![
|
||||||
|
"clone".to_owned(),
|
||||||
|
"-c".to_owned(),
|
||||||
|
format!("credential.helper={helper}"),
|
||||||
|
];
|
||||||
if let Some(depth) = args.depth {
|
if let Some(depth) = args.depth {
|
||||||
git_args.push(format!("--depth={depth}"));
|
git_args.push(format!("--depth={depth}"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
hive-forge/src/verbs/credential_helper.rs
Normal file
80
hive-forge/src/verbs/credential_helper.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
//! `credential-helper <get|store|erase>` — implements git's
|
||||||
|
//! credential-helper protocol (`gitcredentials(7)`) so a cloned repo's
|
||||||
|
//! `.git/config` only ever holds a *reference* to this command, never
|
||||||
|
//! the forge token itself. `clone` configures the new repo's
|
||||||
|
//! `credential.helper` to invoke this subcommand; git then calls it
|
||||||
|
//! fresh on every fetch/push, reading the token from its usual
|
||||||
|
//! on-disk location (the per-agent `forge-token` file, or the
|
||||||
|
//! `--forge <label>` sidecar) at auth time rather than the token
|
||||||
|
//! having been baked into the remote URL and left durably in
|
||||||
|
//! `.git/config`. A real leak of that shape, found and reported by
|
||||||
|
//! atlas, is what this verb exists to close.
|
||||||
|
//!
|
||||||
|
//! Deliberately bypasses the normal `Client::from_env` construction in
|
||||||
|
//! `main.rs` (see the special-case there): that path requires resolving
|
||||||
|
//! an active repo, which can fail depending on where git happens to run
|
||||||
|
//! this helper from (e.g. mid-`clone`, before the destination checkout
|
||||||
|
//! exists) — and a credential lookup has no need for a repo anyway.
|
||||||
|
//!
|
||||||
|
//! Only `get` does anything: reads the `key=value` lines git sends on
|
||||||
|
//! stdin, and if a `host=` line is present, refuses to answer unless it
|
||||||
|
//! matches the resolved forge's own host (defence in depth against this
|
||||||
|
//! helper ever being invoked outside the specific-URL scope `clone`
|
||||||
|
//! configures it under). `store`/`erase` drain stdin and no-op — there
|
||||||
|
//! is nothing durable to store or erase, the token always comes fresh
|
||||||
|
//! from its file.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::{Args as ClapArgs, ValueEnum};
|
||||||
|
|
||||||
|
use crate::client::resolve_credentials;
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
pub struct Args {
|
||||||
|
/// The git credential-protocol operation git invokes this with.
|
||||||
|
op: Op,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, ValueEnum)]
|
||||||
|
enum Op {
|
||||||
|
Get,
|
||||||
|
Store,
|
||||||
|
Erase,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if credential resolution fails (no token
|
||||||
|
/// provisioned for the active forge) or the `get` request's stdin
|
||||||
|
/// can't be read, or if a `host=` line in the request doesn't match
|
||||||
|
/// the resolved forge.
|
||||||
|
pub fn run(args: Args, forge_label: Option<&str>) -> Result<()> {
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin()
|
||||||
|
.read_to_string(&mut input)
|
||||||
|
.context("read credential request from stdin")?;
|
||||||
|
let Op::Get = args.op else {
|
||||||
|
// `store`/`erase`: protocol says read the request, do nothing.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (base, token) = resolve_credentials(forge_label)?;
|
||||||
|
if let Some(requested) = input.lines().find_map(|l| l.strip_prefix("host=")) {
|
||||||
|
let expected = url::Url::parse(&base)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(str::to_owned));
|
||||||
|
if expected.is_some_and(|e| e != requested.trim()) {
|
||||||
|
bail!(
|
||||||
|
"hive-forge credential-helper: refusing to hand the forge token to \
|
||||||
|
host {requested:?} (configured forge is {base:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "oauth2".to_owned());
|
||||||
|
println!("username={user}");
|
||||||
|
println!("password={token}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod comment;
|
||||||
pub mod comment_edit;
|
pub mod comment_edit;
|
||||||
pub mod comment_show;
|
pub mod comment_show;
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
|
pub mod credential_helper;
|
||||||
pub mod dependency;
|
pub mod dependency;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue