diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index a8ff9976..a4987bf8 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -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 /// `/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 { } } 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())