forge: revalidate the core token against the live forge before trusting it
This commit is contained in:
parent
2a840c14a2
commit
3cac374c60
1 changed files with 120 additions and 4 deletions
|
|
@ -414,17 +414,82 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Outcome of probing whether the persisted core token still works
|
||||
/// against the *current* forge. Existence on disk is not validity: a
|
||||
/// token minted before a forge rebuild / re-provision is unknown to the
|
||||
/// new forge's DB and 401s on every call — which silently breaks the
|
||||
/// hive-ci runner-registration prefetch (it reads this same token to
|
||||
/// fetch a runner registration token).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CoreTokenCheck {
|
||||
/// Token authenticated successfully — keep using it.
|
||||
Valid,
|
||||
/// Forge explicitly rejected the token (401/403) — re-mint.
|
||||
Invalid,
|
||||
/// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a
|
||||
/// transient: keep the existing token and let a later ensure pass
|
||||
/// re-check once the forge is responsive. Re-minting here would both
|
||||
/// fail (mint needs the forge too) and churn tokens needlessly.
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
||||
/// Pure so the decision logic is unit-testable without a live forge.
|
||||
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
||||
if status.is_success() {
|
||||
CoreTokenCheck::Valid
|
||||
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||
CoreTokenCheck::Invalid
|
||||
} else {
|
||||
CoreTokenCheck::Indeterminate
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether `token` is still accepted by the current forge with a
|
||||
/// cheap authenticated `GET /api/v1/user` (covered by the core token's
|
||||
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
||||
/// interpreted.
|
||||
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/user");
|
||||
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
||||
Ok(status) => classify_core_token_status(status),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
error = %e,
|
||||
"forge: core-token probe could not reach forge; treating as indeterminate"
|
||||
);
|
||||
CoreTokenCheck::Indeterminate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the bootstrap `core` admin user + a token at
|
||||
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
||||
/// API calls (org creation now, meta-repo push later). Returns the
|
||||
/// token. Idempotent: skips creation when user exists, skips token
|
||||
/// when the file is present.
|
||||
/// API calls (org creation, meta-repo push, and the hive-ci
|
||||
/// runner-registration prefetch). Returns the token.
|
||||
///
|
||||
/// Idempotent, but validity-aware: when a token file is already present
|
||||
/// it is **probed against the current forge** before being trusted. A
|
||||
/// token persisted before a forge rebuild / re-provision is stale (the
|
||||
/// new forge DB doesn't know it) and would 401 every caller — so on a
|
||||
/// definitive rejection the token is re-minted. A merely-unreachable
|
||||
/// forge leaves the existing token in place (a later ensure pass
|
||||
/// re-checks) rather than churning tokens on a transient.
|
||||
async fn ensure_core_user_and_token() -> Result<String> {
|
||||
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
||||
if let Ok(existing) = std::fs::read_to_string(path) {
|
||||
let trimmed = existing.trim().to_owned();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed);
|
||||
match check_core_token(&trimmed).await {
|
||||
CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed),
|
||||
CoreTokenCheck::Invalid => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"forge: persisted core token rejected by forge (stale after rebuild?); \
|
||||
re-minting"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure_user_exists("core", true, None).await?;
|
||||
|
|
@ -868,3 +933,54 @@ pub async fn ensure_all() {
|
|||
sync_agent(name, core_token.as_deref()).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CoreTokenCheck, classify_core_token_status};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn success_statuses_are_valid() {
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::OK),
|
||||
CoreTokenCheck::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::NO_CONTENT),
|
||||
CoreTokenCheck::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_rejection_statuses_are_invalid() {
|
||||
// The whole point: a stale token (forge rebuilt out from under it)
|
||||
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::UNAUTHORIZED),
|
||||
CoreTokenCheck::Invalid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::FORBIDDEN),
|
||||
CoreTokenCheck::Invalid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_and_unexpected_statuses_are_indeterminate() {
|
||||
// Never re-mint on a transient — minting needs the forge too, and
|
||||
// churning tokens on a blip is worse than keeping the existing one.
|
||||
for s in [
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
StatusCode::NOT_FOUND,
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_core_token_status(s),
|
||||
CoreTokenCheck::Indeterminate,
|
||||
"status {s} should be indeterminate"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue