forge: core token gets admin scope so PATCH /admin/users/* works (#646)

This commit is contained in:
damocles 2026-05-30 18:14:31 +02:00
commit 745d95d2f5

View file

@ -78,6 +78,16 @@ const SEEDED_ORGS: &[&str] = &[CONFIG_ORG];
const TOKEN_SCOPES: &str =
"read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
/// Scopes for the bootstrap `core` token used by hive-c0re itself.
/// Adds `read:admin,write:admin` on top of `TOKEN_SCOPES` so the host
/// daemon can drive `/api/v1/admin/*` endpoints (PATCH user email on
/// agent provision, future webhook + org admin work). Site-admin
/// membership alone isn't enough — the token's own scope gate runs
/// before the user-permission check, so `403 Forbidden` comes back
/// for any `/admin/users/*` call from a non-admin-scoped token
/// even if the bearer is an admin user (#646).
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/forge-token` from inside the container).
fn token_path(name: &str) -> PathBuf {
@ -257,8 +267,11 @@ async fn ensure_user_email(name: &str) {
/// Mint a fresh access token for `name` and persist it to
/// `<state>/forge-token` (0600). Token name is suffixed with a
/// monotonic clock so re-issuing doesn't collide with an existing
/// token of the same name in the DB.
async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> {
/// token of the same name in the DB. `scopes` is the scope string
/// passed to `forgejo admin user generate-access-token --scopes`;
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
/// bootstrap `core` user.
async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}",
@ -275,7 +288,7 @@ async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> {
"--token-name",
&token_name,
"--scopes",
TOKEN_SCOPES,
scopes,
])
.await?;
let token = extract_token(&stdout)
@ -299,7 +312,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
}
ensure_user_exists(name, false).await?;
ensure_user_email(name).await;
mint_and_persist_token(name, &token_path(name)).await
mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await
}
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
@ -377,7 +390,7 @@ async fn ensure_core_user_and_token() -> Result<String> {
}
}
ensure_user_exists("core", true).await?;
mint_and_persist_token("core", path).await?;
mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
Ok(raw.trim().to_owned())