diff --git a/nix/checks.nix b/nix/checks.nix index c5238637..706fba9e 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -156,24 +156,7 @@ in # per-container toplevels stay fully cached. cargo-test = craneLib.cargoTest { src = cleanSrc; - inherit cargoArtifacts; - # `swarm-controller`'s forge tests build a real `forgejo_api::Forgejo` - # (hence a real `reqwest::Client`) against a loopback stub server, never - # the real forge — but `reqwest`/`rustls` still resolves roots through - # `rustls-native-certs` at *client-build* time, unconditionally, even - # though every request that client ever sends is plain `http://` to - # `127.0.0.1`. The nix build sandbox has no system CA store, so that - # resolution finds zero certs and `ClientBuilder::build()` itself fails - # with "No CA certificates were loaded from the system" — the tests - # never get as far as making a request. Same root cause the runtime - # deployment already works around (see `nix/host-modules/swarm- - # controller.nix`'s `caTrust` comment) and the same fix: give - # `rustls-native-certs` something to find via `SSL_CERT_FILE`. The - # bundle's actual contents don't matter here — nothing in these tests - # ever presents or verifies a real certificate — only that the store is - # non-empty. - nativeBuildInputs = nativeBuildInputs ++ [ pkgs.cacert ]; - SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + inherit cargoArtifacts nativeBuildInputs; pname = "hyperhive-workspace"; version = "0.1.0"; cargoTestExtraArgs = "--workspace"; diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index bb4af221..cdfb0540 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -20,9 +20,9 @@ use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption, - CreateUserOption, EditUserOption, IssueListIssuesQuery, IssueListIssuesQueryState, - IssueListIssuesQueryType, RepoGetContentsQuery, RepoListPullRequestsQuery, - RepoListPullRequestsQueryState, RepoSearchQuery, StateType, + CreateUserOption, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, + RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, + RepoSearchQuery, StateType, }; use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; use futures_util::{StreamExt as _, TryStreamExt as _}; @@ -361,8 +361,8 @@ impl Client { .await } - /// Ensure `agent` exists as a Forgejo user account that cannot create - /// repos — the whole job of the `CreateForgeUser` node, and the fix for the "user does not + /// Ensure `agent` exists as a Forgejo user account — the whole job of + /// the `CreateForgeUser` node, and the fix for the "user does not /// exist" failure `AddRepoMember` hit before this node existed: adding /// a nonexistent user as a collaborator is a Forgejo validation error, /// not an idempotent no-op, so something has to create the account @@ -373,8 +373,12 @@ impl Client { /// `forgejo admin` CLI, which assumes co-location with the forge host. /// This daemon has no such assumption — like every other call in this /// file, it only ever talks to the forge over HTTP — so this goes through - /// `admin_create_user` instead. The password is a throwaway: 32 random - /// bytes, generated once, never persisted or read again (see + /// `admin_create_user` instead. + /// + /// The password itself is a throwaway: 32 random bytes, generated once, + /// never persisted anywhere, and never needed again (unlike + /// `hive-c0re`'s CLI path, which can ask forgejo to `--random-password` + /// on its own, the HTTP admin API requires a real value up front — see /// [`crate::webhook::generate_hex_secret`], reused here for the same /// reason a webhook secret and this password are both "32 random bytes /// nothing reads back"). @@ -382,79 +386,19 @@ impl Client { /// Idempotent, same as [`Self::ensure_org_repo`]: 409 folds to success /// unconditionally, 422 only after a follow-up `user_get` confirms the /// user (see [`folds_into_success`] — not every 422 means it exists). - /// - /// Then locks the account out of creating repos - /// ([`Self::disable_repo_creation`]), whether it was just created or - /// already existed. Nothing else does that for a swarm-created agent, so - /// a failure fails the node. Does not align the account's email the way - /// `hive-c0re`'s `ensure_user_email` does — cosmetic, not a guard. + /// Deliberately does not align the account's email or disable its own + /// repo-creation rights the way `hive-c0re`'s per-hive provisioning does + /// (`ensure_user_email`, `ensure_repo_creation_disabled`) — this account + /// only exists so `AddRepoMember` has something to add, and the owning + /// hive's own provisioning pass self-heals both once the agent spawns. pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> { - self.create_agent_user(agent).await?; - self.disable_repo_creation(agent).await - } - - /// Set `max_repo_creation = 0` on `agent`'s account. Agents create repos - /// through the hive, never with their own token — a write-scoped token - /// can otherwise create and own a repo and self-merge in it, bypassing - /// the operator-only-merge policy. Existing repos are untouched; push, - /// PRs and clone keep working. - /// - /// The PATCH itself is unconditional rather than verify-then-patch: - /// Forgejo's API never returns `max_repo_creation` (it is a field of - /// `EditUserOption` only, not of `User`), so there is no current value - /// to compare against, and setting 0 on an account already at 0 is a - /// no-op. But `create_agent_user`'s idempotent fold only proves *some* - /// account named `agent` exists — a 409 folds with no read at all, and - /// a 422's follow-up `user_get` only confirms the username, not that - /// swarm-controller created it — so before that PATCH this reads the - /// account once and checks the one thing that *is* comparable: the - /// email. A match against either [`agent_email`] (what - /// `create_agent_user` itself sets) or [`legacy_agent_email`] (what - /// `hive-c0re::forge::users::ensure_user_exists` used before its own - /// `ensure_user_email` alignment pass runs) counts as this agent's - /// account — the legacy value has to be accepted too, not just the - /// aligned one, because `ensure_user_email` only runs on the forge-host - /// hive and only once it has a core token and has ticked at least once, - /// so a real, existing legacy agent can still carry the old email when - /// this node reads it mid-rollout. A mismatch against both means the - /// name collided with an account this node didn't create, so it fails - /// the node rather than sending the PATCH (or silently skipping) to an - /// account nobody verified is agent-controlled. - async fn disable_repo_creation(&self, agent: &str) -> Result<()> { - let user = self - .api - .user_get(agent) - .await - .with_context(|| format!("read forge user {agent} before locking it down"))?; - let expected = agent_email(agent); - let legacy = legacy_agent_email(agent); - if !matches!(user.email.as_deref(), Some(e) if e == expected || e == legacy) { - anyhow::bail!( - "forge account `{agent}` exists but is not this agent's (email {:?}, expected \ - `{expected}` or the legacy `{legacy}`); refusing to lock it down", - user.email - ); - } - self.api - .admin_edit_user(agent, repo_creation_lockdown(agent)) - .await - .with_context(|| { - format!("disable repo creation for forge user {agent} (max_repo_creation = 0)") - })?; - tracing::info!(%agent, "swarm forge: disabled repo creation (max_repo_creation = 0)"); - Ok(()) - } - - /// Create `agent`'s account. Idempotent: an existing user (409/422) is - /// folded into success, same as [`Self::ensure_org_repo`]. - async fn create_agent_user(&self, agent: &str) -> Result<()> { let password = crate::webhook::generate_hex_secret() .context("generating a throwaway password for the agent's forge account")?; let res = self .api .admin_create_user(CreateUserOption { created_at: None, - email: agent_email(agent), + email: format!("{agent}@hyperhive.local"), full_name: None, login_name: None, must_change_password: Some(false), @@ -1140,63 +1084,6 @@ fn initial_flake_nix() -> &'static str { "{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n" } -/// Canonical email for an agent's Forgejo account, set by -/// [`Client::create_agent_user`] at creation and checked by -/// [`Client::disable_repo_creation`] before it sends the account's PATCH. Same -/// format string as `hive-c0re::forge::users::agent_email` — not called -/// across the crate boundary (see this module's doc comment), but both -/// creators (and `hive-c0re`'s `ensure_user_email`, which aligns older -/// accounts to it) converge on this exact value, so it doubles as the -/// marker that distinguishes an agent's own account from an unrelated one -/// that happens to share its username. -fn agent_email(agent: &str) -> String { - format!("{agent}@hyperhive.local") -} - -/// The pre-alignment email `hive-c0re::forge::users::ensure_user_exists` -/// gave an agent account before that crate's own `ensure_user_email` -/// existed — see that function's doc comment ("Existing agents were -/// created with `{name}@hive.local`; this corrects that"). Accepted by -/// [`Client::disable_repo_creation`]'s guard alongside [`agent_email`] -/// because `ensure_user_email`'s alignment PATCH only runs on the -/// forge-host hive, and only after it has a core token and has ticked at -/// least once, so a real legacy agent can still carry this value when this -/// node reads it. -fn legacy_agent_email(agent: &str) -> String { - format!("{agent}@hive.local") -} - -/// The `admin_edit_user` body that sets `max_repo_creation = 0` on `agent` -/// and nothing else. Same shape as `hive-c0re::forge::users`' -/// `sparse_edit_user_option`: `login_name` + `source_id = 0` (local auth, -/// which is what [`Client::create_agent_user`] creates) ride along because -/// Forgejo resets `use_custom_avatar` on an admin edit that omits -/// `login_name`; every other field is left unset so nothing else is touched. -fn repo_creation_lockdown(agent: &str) -> EditUserOption { - EditUserOption { - active: None, - admin: None, - allow_create_organization: None, - allow_git_hook: None, - allow_import_local: None, - description: None, - email: None, - full_name: None, - hide_email: None, - location: None, - login_name: Some(agent.to_owned()), - max_repo_creation: Some(0), - must_change_password: None, - password: None, - prohibit_login: None, - pronouns: None, - restricted: None, - source_id: Some(0), - visibility: None, - website: None, - } -} - /// Whether a create-style call failed because the object already /// exists. Forgejo signals this as HTTP 409 (conflict) or 422 /// (validation) — match both defensively, same as @@ -1441,212 +1328,4 @@ mod tests { "new logic: correctly refuses to fold a 422 with no confirmed existence" ); } - - /// What [`stub_forge`] saw: method, path and JSON body of each request. - type Seen = std::sync::Arc>>; - - /// A stand-in forge on a loopback port — never the real one — answering - /// the admin user create (`POST`) with `create`, the pre-lockdown - /// `user_get` (`GET`) with `get`, and the admin user edit (`PATCH`) with - /// `edit` (status, JSON body each), and recording every request. - async fn stub_forge( - create: (u16, &'static str), - get: (u16, &'static str), - edit: (u16, &'static str), - ) -> (Client, Seen) { - use axum::http::{Method, StatusCode, Uri, header::CONTENT_TYPE}; - - let seen = Seen::default(); - let recorded = seen.clone(); - let app = axum::Router::new().fallback( - move |method: Method, uri: Uri, body: axum::body::Bytes| { - let recorded = recorded.clone(); - async move { - let json = serde_json::from_slice(&body).unwrap_or_default(); - recorded.lock().unwrap().push(( - method.to_string(), - uri.path().to_owned(), - json, - )); - let (status, body) = match method { - Method::POST => create, - Method::GET => get, - Method::PATCH => edit, - _ => (404, r#"{"message":"not stubbed"}"#), - }; - let status = StatusCode::from_u16(status).unwrap(); - (status, [(CONTENT_TYPE, "application/json")], body) - } - }, - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await }); - let api = Forgejo::new(Auth::Token("stub-token"), url).unwrap(); - (Client { api }, seen) - } - - /// The `PATCH` [`Client::ensure_agent_user`] sent, if any. - fn lockdown_patch(seen: &Seen, agent: &str) -> Option { - let path = format!("/api/v1/admin/users/{agent}"); - seen.lock() - .unwrap() - .iter() - .find(|(method, p, _)| method == "PATCH" && *p == path) - .map(|(_, _, body)| body.clone()) - } - - /// The email [`stub_forge`]'s `user_get` stub should answer with for an - /// account this agent actually owns — same value - /// [`Client::create_agent_user`] itself sets. - fn own_account_get(agent: &str) -> (u16, &'static str) { - // Leaked rather than borrowed: `stub_forge`'s `get` param needs - // `&'static str` (it moves into a `'static` axum handler), and this - // helper builds the body per-agent instead of hardcoding "alice". - ( - 200, - Box::leak(format!(r#"{{"email":"{agent}@hyperhive.local"}}"#).into_boxed_str()), - ) - } - - /// A freshly created agent account is locked out of repo creation by - /// this node itself — not left for a hive-level pass to catch later — - /// and the edit sets nothing beyond `max_repo_creation` plus the - /// `login_name` / `source_id` pair Forgejo needs to leave the rest alone. - #[tokio::test] - async fn a_created_agent_user_gets_repo_creation_disabled() { - let (client, seen) = stub_forge((201, "{}"), own_account_get("alice"), (200, "{}")).await; - - client.ensure_agent_user("alice").await.unwrap(); - - let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); - assert_eq!(body["max_repo_creation"], 0); - let set: Vec<&str> = body - .as_object() - .unwrap() - .iter() - .filter(|(_, v)| !v.is_null()) - .map(|(k, _)| k.as_str()) - .collect(); - assert_eq!(set, ["login_name", "max_repo_creation", "source_id"]); - assert_eq!(body["login_name"], "alice"); - assert_eq!(body["source_id"], 0); - } - - /// An account that already existed is locked down too: the node is - /// re-run, and an account created before this lockdown existed must - /// not keep repo creation just because the create step folded to - /// success. 409 rather than 422 so the case doesn't hinge on how a 422 - /// is disambiguated. - #[tokio::test] - async fn an_existing_agent_user_still_gets_repo_creation_disabled() { - let (client, seen) = stub_forge( - (409, r#"{"message":"user already exists"}"#), - own_account_get("alice"), - (200, "{}"), - ) - .await; - - client.ensure_agent_user("alice").await.unwrap(); - - let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); - assert_eq!(body["max_repo_creation"], 0); - } - - /// A refused lockdown fails the node with Forgejo's own message, rather - /// than warning and reporting the account as provisioned — that is - /// `hive-c0re`'s `ensure_repo_creation_disabled` shape, which leaves an - /// agent able to create and self-merge with nothing marked failed. - #[tokio::test] - async fn a_refused_lockdown_fails_the_node() { - let (client, _seen) = stub_forge( - (201, "{}"), - own_account_get("alice"), - (403, r#"{"message":"token does not have required scope"}"#), - ) - .await; - - let err = client.ensure_agent_user("alice").await.unwrap_err(); - - let msg = format!("{err:#}"); - assert!(msg.contains("max_repo_creation"), "{msg}"); - assert!(msg.contains("token does not have required scope"), "{msg}"); - } - - /// The guard this PR adds: a name collision with a pre-existing - /// non-agent account (or any account whose email isn't the - /// `{agent}@hyperhive.local` marker) must fail the node rather than - /// PATCH `max_repo_creation`/`source_id` onto an account nobody - /// verified this node created. 409 create-fold, same as - /// `an_existing_agent_user_still_gets_repo_creation_disabled` — the - /// fold itself is untouched by this PR; the fix lives in the read right - /// before the PATCH. - #[tokio::test] - async fn a_name_collision_with_a_non_agent_account_fails_without_patching() { - let (client, seen) = stub_forge( - (409, r#"{"message":"user already exists"}"#), - (200, r#"{"email":"someone@example.com"}"#), - (200, "{}"), - ) - .await; - - let err = client.ensure_agent_user("alice").await.unwrap_err(); - - let msg = format!("{err:#}"); - assert!(msg.contains("alice"), "{msg}"); - assert!( - msg.contains("not this agent's"), - "must name the collision, got: {msg}" - ); - assert!( - lockdown_patch(&seen, "alice").is_none(), - "a collision must never reach the PATCH" - ); - } - - /// A legacy account `hive-c0re::forge::users::ensure_user_exists` (or - /// its `ensure_user_email` alignment pass) created carries the same - /// `{agent}@hyperhive.local` marker, plus the `login_name`/`source_id` - /// fields its own `sparse_edit_user_option` sets — this node must still - /// lock such an account down, not just ones it created itself. - #[tokio::test] - async fn a_legacy_hive_created_agent_account_still_gets_locked_down() { - let (client, seen) = stub_forge( - (409, r#"{"message":"user already exists"}"#), - ( - 200, - r#"{"id":42,"login":"alice","login_name":"alice","email":"alice@hyperhive.local","source_id":0}"#, - ), - (200, "{}"), - ) - .await; - - client.ensure_agent_user("alice").await.unwrap(); - - let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); - assert_eq!(body["max_repo_creation"], 0); - } - - /// argus's round-2 🟡: `hive-c0re`'s `ensure_user_email` alignment pass - /// (the thing that rewrites a legacy `{name}@hive.local` account to - /// `{name}@hyperhive.local`) only runs on the forge-host hive, and only - /// once it has a core token and has ticked — so a real, pre-existing - /// agent can still be sitting on the pre-alignment `@hive.local` email - /// when this node reads it. The guard must accept that value too, not - /// just the aligned one, or a real agent would fail with "not this - /// agent's" during the rollout window. - #[tokio::test] - async fn a_pre_alignment_legacy_email_still_gets_locked_down() { - let (client, seen) = stub_forge( - (409, r#"{"message":"user already exists"}"#), - (200, r#"{"email":"alice@hive.local"}"#), - (200, "{}"), - ) - .await; - - client.ensure_agent_user("alice").await.unwrap(); - - let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); - assert_eq!(body["max_repo_creation"], 0); - } }