Compare commits
25 changed files with 270 additions and 715 deletions
10
README.md
10
README.md
|
|
@ -114,18 +114,16 @@ doesn't go through the broker (built alongside `hive-c0re` when the host
|
|||
module is enabled):
|
||||
|
||||
```sh
|
||||
sudo hivectl forge create-user mara # provisions a forge user
|
||||
sudo hivectl forge create-user mara --password 'hunter2' # … with a fixed password
|
||||
sudo hivectl matrix create-user mara # provisions a matrix user
|
||||
sudo hivectl matrix create-user mara --password-stdin # … reading one line from stdin
|
||||
```
|
||||
|
||||
For a name that's a managed agent, `hivectl` persists the resulting token
|
||||
to that agent's state dir, the same as the boot sweep does. For a
|
||||
non-agent name (for example the operator's own matrix account), it prints the
|
||||
token to stdout and writes nothing.
|
||||
|
||||
A human's first SSO login to the forge makes their forge account, not
|
||||
`hivectl`; `swarmctl forge make-admin <name>` on the swarm-controller's
|
||||
host makes it a site admin.
|
||||
non-agent name (e.g. the operator's own forge/matrix account), it prints
|
||||
the token to stdout and writes nothing.
|
||||
|
||||
## Build / deploy
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,8 @@ about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for
|
|||
the pass. A hive without a swarm secret store has no path to a forge token
|
||||
for ruth at all.
|
||||
|
||||
Swarm SSO creates the human operator's own forge account: the forge
|
||||
makes it on their first login through authelia, and `swarmctl forge
|
||||
make-admin <you>` then makes it a site admin — see _Swarm SSO_ below.
|
||||
Swarm SSO creates the human operator's own forge account instead of
|
||||
a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`).
|
||||
|
||||
### 2 · Gateway (HTTP Basic auth)
|
||||
|
||||
|
|
@ -196,22 +195,6 @@ If an account already exists without it, `user add` refuses rather
|
|||
than amends — adding the group afterwards is `swarmctl user update mara
|
||||
--add-group admins`.
|
||||
|
||||
Then sign in to the forge once through authelia, with that account. That
|
||||
first login creates your forge account, under the same username. Make it
|
||||
a site admin:
|
||||
|
||||
```bash
|
||||
# On the swarm-controller's host. Fails until that first login has happened.
|
||||
swarmctl forge make-admin mara
|
||||
```
|
||||
|
||||
⚠️ **Keep `--email` too.** The forge won't create an account without an
|
||||
email: a subject that has none gets the forge's link-account page and no
|
||||
account. `swarmctl user update mara --email …` fixes it.
|
||||
|
||||
If the forge already has a local account with your username, the first
|
||||
SSO login asks for that account's forge password once, to link the two.
|
||||
|
||||
Detail, including what the password is and why this stays manual:
|
||||
[`swarm/sso.md`](../swarm/sso.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ each agent on relevant activity.
|
|||
|
||||
## Token scopes
|
||||
|
||||
Two scope sets:
|
||||
Two scope sets live in `hive-c0re::forge`:
|
||||
|
||||
**`AGENT_TOKEN_SCOPES`** (swarm-controller's `forge::agent_token`, pinned
|
||||
by a test). swarm-controller mints every agent token with it:
|
||||
**`TOKEN_SCOPES`** (per-agent tokens, and `hivectl forge create-user`
|
||||
accounts). swarm-controller mints agent tokens with a byte-identical copy,
|
||||
`forge::agent_token::AGENT_TOKEN_SCOPES`, pinned by a test:
|
||||
|
||||
| Scope | Why |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -31,9 +32,9 @@ by a test). swarm-controller mints every agent token with it:
|
|||
| `read:notification` | Poll `GET /notifications` for unread events. |
|
||||
| `write:notification` | Mark notifications read via `PATCH /notifications/threads/{id}`. |
|
||||
|
||||
**`CORE_TOKEN_SCOPES`** (`hive-c0re::forge`, for its own `core` user):
|
||||
everything in `AGENT_TOKEN_SCOPES` plus `read:admin` and `write:admin`.
|
||||
Site-admin membership alone isn't sufficient — Forgejo's token scope gate runs
|
||||
**`CORE_TOKEN_SCOPES`** (hive-c0re's own `core` user): everything in
|
||||
`TOKEN_SCOPES` plus `read:admin` and `write:admin`. Site-admin
|
||||
membership alone isn't sufficient — Forgejo's token scope gate runs
|
||||
before the user-permission check, so `/api/v1/admin/*` returns
|
||||
`403 Forbidden` for any token without the admin scope bits, even when
|
||||
the bearer is a site admin.
|
||||
|
|
|
|||
|
|
@ -170,16 +170,7 @@ result isn't.
|
|||
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
||||
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
||||
|
||||
Three consequences worth stating plainly:
|
||||
|
||||
- **A person's first forge login creates their forge account**, named
|
||||
after authelia's `preferred_username` (`[oauth2_client]` in the forge
|
||||
module), provided the subject has an email. It starts as an ordinary user; `swarmctl forge make-admin
|
||||
<name>` makes it a site admin. A name that already has a local forge
|
||||
account doesn't get it handed over: forgejo asks for that account's
|
||||
own password first (`ACCOUNT_LINKING = login`). Agents, `core` and
|
||||
`swarm-controller` all have local accounts, and `swarmctl user add`
|
||||
refuses none of those names.
|
||||
Two consequences worth stating plainly:
|
||||
|
||||
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
||||
at startup, and its own sandboxing hides most paths from it. It gets the
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
|
||||
* [`hivectl`↴](#hivectl)
|
||||
* [`hivectl forge`↴](#hivectl-forge)
|
||||
* [`hivectl forge create-user`↴](#hivectl-forge-create-user)
|
||||
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||
* [`hivectl matrix`↴](#hivectl-matrix)
|
||||
* [`hivectl matrix create-user`↴](#hivectl-matrix-create-user)
|
||||
|
|
@ -62,13 +63,13 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
|
||||
## `hivectl`
|
||||
|
||||
Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that don't go through the broker — manual user provisioning on the bundled matrix container, plus future recovery / debugging verbs.
|
||||
Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that don't go through the broker — manual user provisioning on the bundled forge + matrix containers, plus future recovery / debugging verbs.
|
||||
|
||||
**Usage:** `hivectl [OPTIONS] <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `forge` — Reconcile an agent's config between this hive and the forge
|
||||
* `forge` — Forgejo user provisioning
|
||||
* `matrix` — matrix-tuwunel user provisioning
|
||||
* `github` — GitHub account provisioning
|
||||
* `gateway` — Gateway htpasswd user management
|
||||
|
|
@ -94,18 +95,38 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
|
|||
|
||||
## `hivectl forge`
|
||||
|
||||
Reconcile an agent's config between this hive and the forge.
|
||||
Forgejo user provisioning.
|
||||
|
||||
A human's first SSO login to the forge makes their forge account.
|
||||
Manual entry point to the same idempotent provisioning c0re runs at boot — for recovery, ad-hoc reprovisioning, or fixing one agent without bouncing the daemon.
|
||||
|
||||
**Usage:** `hivectl forge <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh a non-agent Forgejo account + token for `<name>`
|
||||
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
|
||||
|
||||
|
||||
|
||||
## `hivectl forge create-user`
|
||||
|
||||
Create or refresh a non-agent Forgejo account + token for `<name>`.
|
||||
|
||||
Prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). Refused for an existing agent: swarm-controller mints an agent's token (`swarmctl agent mint-forge-token <agent>`).
|
||||
|
||||
**Usage:** `hivectl forge create-user [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Forgejo username of a human/other account — `mara`, `damocles`, etc
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--password <PASSWORD>` — Set the account password to this string instead of a random throwaway. Use this for operator accounts that need to log into the forge web UI. Mutually exclusive with `--password-stdin`. WARNING: the password is visible in shell history + process listings; prefer `--password-stdin` for anything sensitive
|
||||
* `--password-stdin` — Read the password from stdin (single line, trailing newline stripped) instead of an inline flag. Mutually exclusive with `--password`
|
||||
|
||||
|
||||
|
||||
## `hivectl forge reconcile-config`
|
||||
|
||||
Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Available via the `hive-c0re` package in the host NixOS config.
|
|||
|
||||
Unlike the `hive-c0re` daemon subcommands (which go through the broker),
|
||||
`hivectl` covers direct host-side administration: manual provisioning of
|
||||
matrix accounts, gateway htpasswd management, container
|
||||
forge + matrix accounts, gateway htpasswd management, container
|
||||
lifecycle shortcuts, and interactive agent shell access.
|
||||
|
||||
This page is the curated guide. For the exhaustive flag-by-flag
|
||||
|
|
@ -24,18 +24,30 @@ markdown-docs > docs/tools/hivectl-cli.md`.
|
|||
|
||||
## Forge
|
||||
|
||||
Reconciles an agent's config between this hive and the forge. `hivectl`
|
||||
makes no forge accounts: a human's first SSO login to the forge makes
|
||||
theirs, and `swarmctl forge make-admin <name>` makes it a site admin.
|
||||
swarm-controller makes an agent's, and `swarmctl agent mint-forge-token
|
||||
<agent>` checks and, if needed, re-mints its token.
|
||||
Manual entry to the same idempotent provisioning flow `hive-c0re` runs
|
||||
at boot. Useful for recovery, ad-hoc re-provisioning, or fixing a single
|
||||
agent without bouncing the daemon.
|
||||
|
||||
```bash
|
||||
hivectl forge create-user iris # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris`
|
||||
hivectl forge create-user mara # create forge account for a human user; prints token to stdout
|
||||
hivectl forge create-user mara --password hunter2 # set a web-login password
|
||||
hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting)
|
||||
|
||||
hivectl forge reconcile-config iris # show local-applied <-> forge config divergence, then prompt
|
||||
hivectl forge reconcile-config iris --from forge # reset local applied checkout to forge main (effective next deploy)
|
||||
hivectl forge reconcile-config iris --verbose # include the full diff, not just --stat
|
||||
```
|
||||
|
||||
- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
|
||||
`create-user` refuses. swarm-controller mints an agent's token into
|
||||
the swarm secret store; `swarmctl agent mint-forge-token <agent>` checks
|
||||
and, if needed, re-mints it.
|
||||
- For **non-agents** (humans): creates the account and prints the token to
|
||||
stdout, creating no state dir. Re-running after account already exists
|
||||
re-mints the token and prints it again — safe for password resets.
|
||||
- Without `--password` / `--password-stdin` `create-user` uses a random
|
||||
throwaway password (fine for agents — they auth by token).
|
||||
- `reconcile-config <agent>` shows the divergence between the agent's local
|
||||
applied config checkout and its forge `agent-configs/<agent>` `main`, then
|
||||
reconciles. `--from forge` resets the local checkout to forge `main` (takes
|
||||
|
|
@ -89,7 +101,7 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
|
|||
|
||||
Write an operator-supplied GitHub personal access token (PAT) into an
|
||||
agent's token file so its `gh` wrapper + git credential helper can act as
|
||||
the bot account. Unlike matrix there is no account creation — the PAT
|
||||
the bot account. Unlike forge/matrix there is no account creation — the PAT
|
||||
is for an existing GitHub account. A CLI alternative to the dashboard
|
||||
credentials tab; the [GitHub integration](../integrations/github.md) is on by default
|
||||
(`services.hyperhive.agent.github.enable`), so no per-agent config is needed.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ This document contains the help content for the `swarmctl` command-line program.
|
|||
* [`swarmctl user add`↴](#swarmctl-user-add)
|
||||
* [`swarmctl user update`↴](#swarmctl-user-update)
|
||||
* [`swarmctl user list`↴](#swarmctl-user-list)
|
||||
* [`swarmctl forge`↴](#swarmctl-forge)
|
||||
* [`swarmctl forge make-admin`↴](#swarmctl-forge-make-admin)
|
||||
* [`swarmctl completions`↴](#swarmctl-completions)
|
||||
|
||||
## `swarmctl`
|
||||
|
|
@ -27,7 +25,6 @@ swarm-level operator CLI
|
|||
|
||||
* `agent` — Manage agents across the swarm
|
||||
* `user` — Manage subjects in the swarm's SSO provider
|
||||
* `forge` — Manage human accounts on the swarm's forge
|
||||
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
|
||||
|
||||
###### **Options:**
|
||||
|
|
@ -194,38 +191,6 @@ Read-only: it never writes the file. Shows every subject in it, including agent
|
|||
|
||||
|
||||
|
||||
## `swarmctl forge`
|
||||
|
||||
Manage human accounts on the swarm's forge
|
||||
|
||||
**Usage:** `swarmctl forge <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `make-admin` — Make an existing forge user a site admin
|
||||
|
||||
|
||||
|
||||
## `swarmctl forge make-admin`
|
||||
|
||||
Make an existing forge user a site admin.
|
||||
|
||||
The forge creates a human's account on their first SSO login, and this fails until that login has happened. Running it on a site admin changes nothing. Refused for an agent.
|
||||
|
||||
**Usage:** `swarmctl forge make-admin [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Forge username, the same as the user's SSO username
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--controller-socket <PATH>` — swarm-controller's unix socket.
|
||||
|
||||
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
||||
|
||||
|
||||
|
||||
## `swarmctl completions`
|
||||
|
||||
Generate a shell completion script for `swarmctl` and print it to stdout.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub use repos::{
|
|||
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main,
|
||||
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
};
|
||||
pub use users::core_token;
|
||||
pub use users::{core_token, provision_user_token};
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption};
|
|||
use forgejo_api::{ApiErrorKind, ForgejoError};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::{CONFIG_ORG, api, forge_admin};
|
||||
use super::{CONFIG_ORG, api, forge_admin, is_present};
|
||||
|
||||
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
||||
// Where the host-side `core` admin token lives. Used by hive-c0re itself
|
||||
|
|
@ -58,12 +58,18 @@ fn config_org_avatar_png_path() -> std::path::PathBuf {
|
|||
this process was started outside that unit",
|
||||
))
|
||||
}
|
||||
/// Per-agent token scopes (broad-but-not-admin) for tokens hive-c0re
|
||||
/// mints itself on the **internal** forge. See `docs/integrations/forge.md::Token
|
||||
/// scopes` for the per-scope rationale. Not `pub(super)` — external
|
||||
/// forges (`dashboard/extra_forges.rs`) take an operator-pasted token
|
||||
/// verbatim, so their scope is whatever the operator's remote account
|
||||
/// happened to grant; we never mint there and don't need to know it.
|
||||
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
|
||||
/// top of the agent scopes (swarm-controller's `AGENT_TOKEN_SCOPES`)
|
||||
/// so the host daemon can drive `/api/v1/admin/*`. Site-admin
|
||||
/// membership alone isn't enough: the token's own scope gate runs
|
||||
/// before the user-permission check.
|
||||
/// top of `TOKEN_SCOPES` so the host daemon can drive
|
||||
/// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the
|
||||
/// token's own scope gate runs before the user-permission check.
|
||||
/// See `docs/integrations/forge.md::Token scopes`.
|
||||
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";
|
||||
|
||||
|
|
@ -131,20 +137,18 @@ fn is_forbidden(e: &ForgejoError) -> bool {
|
|||
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||
/// returns a "user already exists" error which we treat as success.
|
||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
||||
/// `core` user that drives the API. The password is random and never
|
||||
/// read: the account authenticates by token.
|
||||
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
|
||||
/// `core` user that drives the API. `password` picks the initial
|
||||
/// account password: `None` uses `--random-password` (the existing
|
||||
/// agent provisioning shape — the password is never read, agents auth
|
||||
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
|
||||
/// in `hivectl` can set a real password for matrix-style web-UI login.
|
||||
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
|
||||
let email = agent_email(name);
|
||||
let mut args = vec![
|
||||
"user",
|
||||
"create",
|
||||
"--username",
|
||||
name,
|
||||
"--email",
|
||||
&email,
|
||||
"--random-password",
|
||||
"--must-change-password=false",
|
||||
];
|
||||
let mut args = vec!["user", "create", "--username", name, "--email", &email];
|
||||
match password {
|
||||
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
|
||||
None => args.extend(["--random-password", "--must-change-password=false"]),
|
||||
}
|
||||
if admin {
|
||||
args.push("--admin");
|
||||
}
|
||||
|
|
@ -170,6 +174,30 @@ async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Set the forgejo password for an existing user. Used by the operator
|
||||
/// path in `hivectl forge create-user --password` so re-running on an
|
||||
/// already-created account still updates the password (covers the
|
||||
/// "I forgot the password I set last week" case + the "argus retried
|
||||
/// the verb to verify the fix" case — `forgejo admin user create`
|
||||
/// silently skips a password change once the account exists). Idempotent
|
||||
/// from the operator's point of view: same password input → same final
|
||||
/// account state.
|
||||
async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
||||
let args = [
|
||||
"user",
|
||||
"change-password",
|
||||
"--username",
|
||||
name,
|
||||
"--password",
|
||||
password,
|
||||
];
|
||||
forge_admin(&args)
|
||||
.await
|
||||
.with_context(|| format!("forgejo admin user change-password {name}"))?;
|
||||
tracing::info!(%name, "forge: changed user password");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotently align the Forgejo account email to `agent_email(name)`.
|
||||
/// Existing agents were created with `{name}@hive.local`; this corrects
|
||||
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
|
||||
|
|
@ -285,7 +313,8 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
|||
/// a monotonic clock so re-issuing doesn't collide with an existing
|
||||
/// token of the same name in the DB. `scopes` is the scope string
|
||||
/// passed to `forgejo admin user generate-access-token --scopes`;
|
||||
/// the bootstrap `core` user, its one caller, passes `CORE_TOKEN_SCOPES`.
|
||||
/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and
|
||||
/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user.
|
||||
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
||||
let token_name = format!(
|
||||
"{TOKEN_NAME_PREFIX}-{}",
|
||||
|
|
@ -337,6 +366,37 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Provision a forgejo user for `name` and return the freshly-minted
|
||||
/// token, which is **not** persisted to disk — the caller is responsible
|
||||
/// for storing it. Used by `hivectl forge create-user` for human
|
||||
/// (non-agent) accounts. Agent tokens are minted by swarm-controller
|
||||
/// (`swarm-controller/src/forge/agent_token.rs`), not here.
|
||||
///
|
||||
/// `password` picks the account password. `None` keeps the existing
|
||||
/// random-throwaway shape (caller doesn't need web UI access — token
|
||||
/// alone is enough). `Some(pw)` sets `pw` as the password, including
|
||||
/// running `forgejo admin user change-password` if the account already
|
||||
/// exists, so the operator can log into the forge web UI afterwards.
|
||||
/// Idempotent: re-running with the same `Some(pw)` lands on the same
|
||||
/// final state.
|
||||
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
|
||||
if !is_present().await {
|
||||
anyhow::bail!(
|
||||
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
||||
);
|
||||
}
|
||||
ensure_user_exists(name, false, password).await?;
|
||||
if let Some(pw) = password {
|
||||
// `user create` silently no-ops on an existing account, so
|
||||
// we run change-password unconditionally when the caller
|
||||
// asked for a specific password — keeps the verb idempotent
|
||||
// for "set or reset" use.
|
||||
change_user_password(name, pw).await?;
|
||||
}
|
||||
ensure_user_email(name).await;
|
||||
mint_token(name, TOKEN_SCOPES).await
|
||||
}
|
||||
|
||||
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
||||
/// remember it so subsequent startups don't re-upload. Best-effort
|
||||
/// — any non-2xx is logged at the caller; the project runs fine
|
||||
|
|
@ -495,7 +555,7 @@ pub(super) async fn ensure_core_user_and_token() -> Result<String> {
|
|||
}
|
||||
}
|
||||
}
|
||||
ensure_user_exists("core", true).await?;
|
||||
ensure_user_exists("core", true, None).await?;
|
||||
mint_and_persist_core_token(path).await?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
||||
|
|
|
|||
|
|
@ -868,9 +868,9 @@ async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()>
|
|||
/// can pass [`random_password`] to keep the existing throwaway
|
||||
/// behaviour.
|
||||
///
|
||||
/// **Not idempotent**: the matrix `/register` endpoint returns
|
||||
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
|
||||
/// appservice-authorised or not.
|
||||
/// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the
|
||||
/// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a
|
||||
/// second call for the same localpart, appservice-authorised or not.
|
||||
/// Callers re-running this for a known-existing matrix user should expect
|
||||
/// a hard error from this fn and route to a password-reset path instead.
|
||||
pub async fn provision_user_token(
|
||||
|
|
|
|||
|
|
@ -233,6 +233,9 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
HostRequest::MatrixInvite { user, room } => {
|
||||
handle_matrix_invite(user, room.as_deref()).await?
|
||||
}
|
||||
HostRequest::ForgeCreateUser { name, password } => {
|
||||
handle_forge_create_user(name, password.as_deref()).await?
|
||||
}
|
||||
HostRequest::ReconcileConfigStatus { agent, verbose } => {
|
||||
crate::forge::reconcile_config_status(agent.as_str(), *verbose).await?
|
||||
}
|
||||
|
|
@ -588,6 +591,43 @@ async fn handle_matrix_create_user(
|
|||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
||||
async fn handle_forge_create_user(
|
||||
name: &hive_types::Ident,
|
||||
password: Option<&str>,
|
||||
) -> Result<HostResponse> {
|
||||
if !crate::forge::is_present().await {
|
||||
anyhow::bail!(
|
||||
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
||||
);
|
||||
}
|
||||
if agent_exists(name)? {
|
||||
// An agent's forge user and token are swarm-controller's: it mints
|
||||
// the token into the swarm secret store and the agent fetches it
|
||||
// from there. A hive-minted token would be one the swarm neither
|
||||
// tracks nor rotates.
|
||||
anyhow::bail!(
|
||||
"forge create-user: '{name}' is an agent; its forge token is minted by \
|
||||
swarm-controller — run `swarmctl agent mint-forge-token {name}` on the \
|
||||
controller host"
|
||||
);
|
||||
}
|
||||
let token = crate::forge::provision_user_token(name.as_str(), password)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
let mut out = vec![
|
||||
format!("forge: provisioned user '{name}' (not an agent — token not persisted)"),
|
||||
format!("token: {token}"),
|
||||
];
|
||||
if password.is_some() {
|
||||
out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
|
||||
} else {
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
||||
async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
|
||||
crate::priv_client::write_agent_github_token(agent, token)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -315,6 +315,17 @@ pub enum HostRequest {
|
|||
#[serde(default)]
|
||||
room: Option<String>,
|
||||
},
|
||||
/// Create or refresh a forge account + API token for `name`. Daemon-side
|
||||
/// equivalent of `hivectl forge create-user`: for a non-agent
|
||||
/// (operator/human) it mints a user and returns the token in
|
||||
/// [`HostResponse::messages`]. An existing agent is refused: its token is
|
||||
/// swarm-controller's to mint. `password` is resolved client-side
|
||||
/// (inline flag or stdin) and only meaningful for non-agent accounts.
|
||||
ForgeCreateUser {
|
||||
name: Ident,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
},
|
||||
/// Report the divergence between agent `agent`'s local applied config
|
||||
/// checkout and its forge `agent-configs/<agent>` `main`. The daemon
|
||||
/// fetches forge `main` read-only and returns a human-readable report
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use std::path::PathBuf;
|
|||
long_about = "\
|
||||
Sibling to the `hive-c0re` daemon binary. Covers host-side admin \
|
||||
operations that don't go through the broker — manual user \
|
||||
provisioning on the bundled matrix container, plus future \
|
||||
provisioning on the bundled forge + matrix containers, plus future \
|
||||
recovery / debugging verbs.\
|
||||
"
|
||||
)]
|
||||
|
|
@ -28,10 +28,11 @@ pub struct Cli {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Cmd {
|
||||
/// Reconcile an agent's config between this hive and the forge.
|
||||
/// Forgejo user provisioning.
|
||||
///
|
||||
/// A human's first SSO login to the forge makes their forge
|
||||
/// account.
|
||||
/// Manual entry point to the same idempotent provisioning c0re runs at
|
||||
/// boot — for recovery, ad-hoc reprovisioning, or fixing one agent
|
||||
/// without bouncing the daemon.
|
||||
Forge {
|
||||
#[command(subcommand)]
|
||||
cmd: ForgeCmd,
|
||||
|
|
@ -247,6 +248,30 @@ impl ScopeArgs {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ForgeCmd {
|
||||
/// Create or refresh a non-agent Forgejo account + token for `<name>`.
|
||||
///
|
||||
/// Prints the token to stdout. Set a password to enable forge web-UI
|
||||
/// login (otherwise it uses a random throwaway). Refused for an
|
||||
/// existing agent: swarm-controller mints an agent's token
|
||||
/// (`swarmctl agent mint-forge-token <agent>`).
|
||||
CreateUser {
|
||||
/// Forgejo username of a human/other account — `mara`,
|
||||
/// `damocles`, etc.
|
||||
name: String,
|
||||
/// Set the account password to this string instead of a random
|
||||
/// throwaway. Use this for operator accounts that need to log
|
||||
/// into the forge web UI. Mutually exclusive with
|
||||
/// `--password-stdin`. WARNING: the password is visible in
|
||||
/// shell history + process listings; prefer `--password-stdin`
|
||||
/// for anything sensitive.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Read the password from stdin (single line, trailing newline
|
||||
/// stripped) instead of an inline flag. Mutually exclusive with
|
||||
/// `--password`.
|
||||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Show + reconcile the divergence between an agent's local applied
|
||||
/// config checkout and its forge `agent-configs/<agent>` main.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! `hivectl forge reconcile-config <agent>` — show and reconcile an
|
||||
//! agent's config divergence via the daemon.
|
||||
//! `hivectl forge create-user <name>` — provision a Forgejo account via the
|
||||
//! daemon (which owns the forge admin token);
|
||||
//! hivectl just resolves the password client-side and relays the request.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
|
@ -8,7 +9,29 @@ use anyhow::Result;
|
|||
use hive_host_sock::{HostRequest, ReconcileDirection};
|
||||
|
||||
use crate::cli::ReconcileFrom;
|
||||
use crate::util::daemon_request;
|
||||
use crate::util::{daemon_request, resolve_password};
|
||||
|
||||
pub(crate) async fn forge_create_user(
|
||||
socket: &Path,
|
||||
name: &str,
|
||||
password: Option<&str>,
|
||||
password_stdin: bool,
|
||||
) -> Result<()> {
|
||||
// Resolve the password client-side (inline flag or stdin read); the
|
||||
// daemon never touches this process's stdin. The is-present check, the
|
||||
// agent-vs-operator branch, and token persistence now live in the
|
||||
// daemon handler.
|
||||
let password = resolve_password(password, password_stdin)?;
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::ForgeCreateUser {
|
||||
name: crate::util::parse_ident(name)?,
|
||||
password,
|
||||
},
|
||||
"forge",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `hivectl forge reconcile-config <agent> [--from <forge|local>] [--verbose]`.
|
||||
/// Always shows the divergence first (daemon computes it read-only), then
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ mod github;
|
|||
mod watch;
|
||||
use github::github_set_token;
|
||||
mod forge;
|
||||
use forge::forge_reconcile_config;
|
||||
use forge::{forge_create_user, forge_reconcile_config};
|
||||
mod agents;
|
||||
use agents::{agents_list, run_agent};
|
||||
mod power;
|
||||
|
|
@ -66,6 +66,11 @@ async fn main() -> Result<()> {
|
|||
let socket = cli.socket;
|
||||
match cli.cmd {
|
||||
Cmd::Forge { cmd } => match cmd {
|
||||
ForgeCmd::CreateUser {
|
||||
name,
|
||||
password,
|
||||
password_stdin,
|
||||
} => forge_create_user(&socket, &name, password.as_deref(), password_stdin).await,
|
||||
ForgeCmd::ReconcileConfig {
|
||||
agent,
|
||||
from,
|
||||
|
|
|
|||
|
|
@ -775,33 +775,13 @@ in
|
|||
# the custom theme CSS baked straight into the nix store.
|
||||
STATIC_ROOT_PATH = staticRootWithTheme;
|
||||
};
|
||||
# Local sign-up off. An agent's account is made by
|
||||
# swarm-controller over the admin API, a human's by their
|
||||
# first authelia login (`oauth2_client` below), which this
|
||||
# does not block: forgejo's auto-registration checks only
|
||||
# `ALLOW_ONLY_INTERNAL_REGISTRATION`
|
||||
# (`routers/web/auth/oauth.go`, 16.0.5).
|
||||
# Registration off — operator seeds agent users via
|
||||
# `nixos-container run hive-forge -- forgejo admin
|
||||
# user create …`.
|
||||
service = {
|
||||
DISABLE_REGISTRATION = true;
|
||||
REQUIRE_SIGNIN_VIEW = false;
|
||||
};
|
||||
# A first login through the authelia source creates the
|
||||
# account, named after authelia's `preferred_username` — the
|
||||
# authelia username, not the opaque `sub`.
|
||||
#
|
||||
# ⚠️ `ACCOUNT_LINKING = login`, never `auto`. A login whose
|
||||
# name is already taken does not create an account; `auto`
|
||||
# would then hand the SSO user the existing one. Agents,
|
||||
# `core` and `swarm-controller` all have local accounts here,
|
||||
# the last two site admins, and `swarmctl user add` refuses
|
||||
# none of those names. `login` asks for the existing
|
||||
# account's own password instead, which an agent's or a
|
||||
# service account's never leaves the forge.
|
||||
oauth2_client = {
|
||||
ENABLE_AUTO_REGISTRATION = true;
|
||||
USERNAME = "preferred_username";
|
||||
ACCOUNT_LINKING = "login";
|
||||
};
|
||||
repository = {
|
||||
DEFAULT_BRANCH = "main";
|
||||
DEFAULT_PRIVATE = "private";
|
||||
|
|
|
|||
|
|
@ -70,8 +70,6 @@ let
|
|||
tokenFile = m: m.services.hyperhive.deploy.swarm-controller.forgeTokenFile;
|
||||
forgePath = "/var/lib/hyperhive-forge/swarm-controller.token";
|
||||
|
||||
forgeSettings = forgeHere.containers.hive-forge.config.services.forgejo.settings;
|
||||
|
||||
cases = [
|
||||
{
|
||||
# The absence the whole option exists for: a second forge in a swarm
|
||||
|
|
@ -141,30 +139,6 @@ let
|
|||
&& tokenFile forgeHere == forgePath
|
||||
&& tokenFile allLocal == forgePath;
|
||||
}
|
||||
{
|
||||
# The only thing that makes a human's forge account: nothing else
|
||||
# creates one.
|
||||
name = "a first authelia login creates the forge account, named by preferred_username";
|
||||
ok =
|
||||
let
|
||||
o = forgeSettings.oauth2_client;
|
||||
in
|
||||
o.ENABLE_AUTO_REGISTRATION == true && o.USERNAME == "preferred_username";
|
||||
}
|
||||
{
|
||||
# `auto` would give an SSO user whatever local account carries their
|
||||
# name — an agent's, or `core`'s.
|
||||
name = "an SSO login adopts an existing forge account only with that account's password";
|
||||
ok = forgeSettings.oauth2_client.ACCOUNT_LINKING == "login";
|
||||
}
|
||||
{
|
||||
# Both halves: local sign-up stays off, and nothing turns on the one
|
||||
# setting forgejo's auto-registration does check.
|
||||
name = "local sign-up stays off without blocking the SSO registration";
|
||||
ok =
|
||||
forgeSettings.service.DISABLE_REGISTRATION == true
|
||||
&& !(forgeSettings.service.ALLOW_ONLY_INTERNAL_REGISTRATION or false);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "forge-placement" cases
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ use utoipa::ToSchema;
|
|||
use crate::webhook::DeliveryKind;
|
||||
|
||||
pub mod agent_token;
|
||||
pub mod site_admin;
|
||||
|
||||
/// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
|
||||
/// and `GET /api/agents/{name}/config-pr` serves it.
|
||||
|
|
@ -1660,78 +1659,4 @@ mod tests {
|
|||
let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent");
|
||||
assert_eq!(body["max_repo_creation"], 0);
|
||||
}
|
||||
|
||||
/// Every `PATCH` body the stub saw.
|
||||
fn patches(seen: &Seen) -> Vec<serde_json::Value> {
|
||||
seen.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(method, _, _)| method == "PATCH")
|
||||
.map(|(_, _, body)| body.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Only `admin` goes out: a `source_id` would reset an SSO-made
|
||||
/// account's login type, and the other fields would overwrite the user's
|
||||
/// own settings.
|
||||
#[tokio::test]
|
||||
async fn make_site_admin_sets_admin_and_nothing_else() {
|
||||
let (client, seen) = stub_forge(
|
||||
(404, "{}"),
|
||||
(200, r#"{"login":"mara","is_admin":false}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let plan = client.make_site_admin("mara").await.unwrap();
|
||||
|
||||
assert_eq!(plan, site_admin::Plan::Promote);
|
||||
let bodies = patches(&seen);
|
||||
assert_eq!(bodies.len(), 1, "{bodies:?}");
|
||||
let set: Vec<(&String, &serde_json::Value)> = bodies[0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.collect();
|
||||
assert_eq!(set, [(&"admin".to_owned(), &serde_json::Value::Bool(true))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn make_site_admin_leaves_an_admin_alone() {
|
||||
let (client, seen) = stub_forge(
|
||||
(404, "{}"),
|
||||
(200, r#"{"login":"mara","is_admin":true}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let plan = client.make_site_admin("mara").await.unwrap();
|
||||
|
||||
assert_eq!(plan, site_admin::Plan::AlreadyAdmin);
|
||||
assert!(patches(&seen).is_empty());
|
||||
}
|
||||
|
||||
/// A user who has not logged in yet is reported, not created: nothing is
|
||||
/// sent but the read.
|
||||
#[tokio::test]
|
||||
async fn make_site_admin_does_not_create_a_missing_user() {
|
||||
let (client, seen) = stub_forge(
|
||||
(201, "{}"),
|
||||
(404, r#"{"message":"user does not exist"}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let plan = client.make_site_admin("mara").await.unwrap();
|
||||
|
||||
assert_eq!(plan, site_admin::Plan::NoSuchUser);
|
||||
let methods: Vec<String> = seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(m, _, _)| m.clone())
|
||||
.collect();
|
||||
assert_eq!(methods, ["GET"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@ use super::Client;
|
|||
/// of its re-mints, and a later sweep of those must never match this one.
|
||||
pub const AGENT_TOKEN_NAME: &str = "swarm-agent";
|
||||
|
||||
/// The scopes an agent token carries. Byte-identical to the `TOKEN_SCOPES`
|
||||
/// `hive-c0re` minted with, so the move from hive to swarm changes nothing an
|
||||
/// agent can do. Narrowing it is a separate decision. A test here pins the
|
||||
/// literal.
|
||||
/// The scopes an agent token carries. Byte-identical to `hive-c0re`'s
|
||||
/// `forge::users::TOKEN_SCOPES`, so the move from hive to swarm changes
|
||||
/// nothing an agent can do. Narrowing it is a separate decision.
|
||||
///
|
||||
/// ⚠️ Duplicated across the crate boundary until the last `hive-c0re` caller
|
||||
/// of that constant goes. A test here pins the literal.
|
||||
pub const AGENT_TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// How often [`spawn`] re-checks every agent's token.
|
||||
|
|
@ -153,7 +155,7 @@ pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
|
|||
}
|
||||
|
||||
/// Whether a forge error is a 404, whichever of its two shapes it came in.
|
||||
pub(super) fn is_not_found(e: &ForgejoError) -> bool {
|
||||
fn is_not_found(e: &ForgejoError) -> bool {
|
||||
match e {
|
||||
ForgejoError::ApiError(api) => {
|
||||
matches!(api.error_kind(), ApiErrorKind::NotFound { .. })
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
//! Making an existing human forge account a site admin: the whole job of
|
||||
//! `POST /api/forge/users/{name}/admin`, which `swarmctl forge make-admin`
|
||||
//! calls.
|
||||
//!
|
||||
//! Never creates the account. A human's account is made by their first
|
||||
//! authelia login to the forge (`oauth2_client` in
|
||||
//! `nix/host-modules/hive-forge/default.nix`), so a missing one means that
|
||||
//! login has not happened yet, and making it here would be a second way in.
|
||||
//!
|
||||
//! The decision is pure ([`plan`]), so the tests pin it; the IO on either side
|
||||
//! only reads or acts. Same split as [`super::agent_token`].
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use forgejo_api::structs::{EditUserOption, User};
|
||||
|
||||
use super::Client;
|
||||
use super::agent_token::is_not_found;
|
||||
|
||||
/// What one request does about the account.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Plan {
|
||||
/// The forge has no user by this name: its owner has not logged in yet.
|
||||
NoSuchUser,
|
||||
/// Already a site admin: nothing to send.
|
||||
AlreadyAdmin,
|
||||
/// An ordinary account: make it a site admin.
|
||||
Promote,
|
||||
}
|
||||
|
||||
/// Decide from the account as the forge reports it (`None` when there is no
|
||||
/// such user).
|
||||
pub fn plan(user: Option<&User>) -> Plan {
|
||||
match user {
|
||||
None => Plan::NoSuchUser,
|
||||
Some(u) if u.is_admin == Some(true) => Plan::AlreadyAdmin,
|
||||
Some(_) => Plan::Promote,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `admin_edit_user` body that sets `admin` and nothing else.
|
||||
///
|
||||
/// Unlike [`super::repo_creation_lockdown`], no `login_name` + `source_id`:
|
||||
/// in Forgejo 16 both are optional, and a `source_id` sets the account's login
|
||||
/// type (`services/user/update.go`, `UpdateAuth`). `source_id = 0` would turn
|
||||
/// an SSO-made account into a local one.
|
||||
fn admin_edit() -> EditUserOption {
|
||||
EditUserOption {
|
||||
active: None,
|
||||
admin: Some(true),
|
||||
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: None,
|
||||
max_repo_creation: None,
|
||||
must_change_password: None,
|
||||
password: None,
|
||||
prohibit_login: None,
|
||||
pronouns: None,
|
||||
restricted: None,
|
||||
source_id: None,
|
||||
visibility: None,
|
||||
website: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Make the existing account `name` a site admin, and say what that took.
|
||||
/// The caller has already refused an agent's name.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the forge refuses the read or the edit.
|
||||
pub async fn make_site_admin(&self, name: &str) -> Result<Plan> {
|
||||
let user = match self.api.user_get(name).await {
|
||||
Ok(user) => Some(user),
|
||||
Err(e) if is_not_found(&e) => None,
|
||||
Err(e) => return Err(e).with_context(|| format!("read forge user {name}")),
|
||||
};
|
||||
let plan = plan(user.as_ref());
|
||||
if plan == Plan::Promote {
|
||||
self.api
|
||||
.admin_edit_user(name, admin_edit())
|
||||
.await
|
||||
.with_context(|| format!("make forge user {name} a site admin"))?;
|
||||
tracing::info!(%name, "swarm forge: made the user a site admin");
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn user(is_admin: Option<bool>) -> User {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"login": "mara",
|
||||
"is_admin": is_admin,
|
||||
}))
|
||||
.expect("a user decodes")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_user_is_not_created() {
|
||||
assert_eq!(plan(None), Plan::NoSuchUser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_admin_is_left_alone() {
|
||||
assert_eq!(plan(Some(&user(Some(true)))), Plan::AlreadyAdmin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_user_is_promoted() {
|
||||
assert_eq!(plan(Some(&user(Some(false)))), Plan::Promote);
|
||||
}
|
||||
|
||||
/// No flag in the answer: the edit is sent. On an admin it is a no-op;
|
||||
/// skipping it would report success for an ordinary account.
|
||||
#[test]
|
||||
fn an_unreported_admin_flag_is_promoted() {
|
||||
assert_eq!(plan(Some(&user(None))), Plan::Promote);
|
||||
}
|
||||
}
|
||||
|
|
@ -525,7 +525,6 @@ fn socket_path() -> PathBuf {
|
|||
(name = "agents", description = "creating agent identities at swarm level"),
|
||||
(name = "webhook", description = "swarm-wide forge webhook receipt"),
|
||||
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
|
||||
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
|
@ -600,8 +599,7 @@ struct AppState {
|
|||
/// it only for a name that breaks a naming rule, to tell a new agent
|
||||
/// from an existing one, and warns when it cannot (`name_verdict`). **GET
|
||||
/// has nowhere to defer to** — there is no job, only an answer it
|
||||
/// either has or does not. `POST /api/forge/users/{name}/admin` reads
|
||||
/// it for every name and refuses when it cannot (`admin_unless_agent`).
|
||||
/// either has or does not.
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
|
||||
/// `None` when it could not be read or created — the webhook endpoint
|
||||
|
|
@ -628,7 +626,7 @@ struct AppState {
|
|||
swarm_name: Option<Arc<str>>,
|
||||
/// The forge client itself, for the read-only repo/issue-report
|
||||
/// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`)
|
||||
/// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this
|
||||
/// — distinct from `config_prs`, which holds a *cache* built off this
|
||||
/// same client rather than the client. `None` under the same
|
||||
/// "no forge configured on this host" shape every other
|
||||
/// forge-backed field here uses.
|
||||
|
|
@ -1372,9 +1370,8 @@ fn broken_name_rules(
|
|||
}
|
||||
|
||||
/// Whether `agent` is already in the swarm roster, or why that could not be
|
||||
/// told. `create_agent` asks only of a name that breaks a rule, so an
|
||||
/// ordinary creation still never waits on the bridge; `make_forge_admin`
|
||||
/// asks of every name.
|
||||
/// told. Asked only of a name that breaks a rule, so an ordinary creation
|
||||
/// still never waits on the bridge.
|
||||
async fn in_roster(auth: Option<&auth::AuthBridge>, agent: &str) -> Result<bool, String> {
|
||||
let Some(auth) = auth else {
|
||||
return Err("no identity bridge is configured".to_owned());
|
||||
|
|
@ -1831,113 +1828,6 @@ async fn mint_agent_forge_token(
|
|||
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
|
||||
}
|
||||
|
||||
/// What `POST /api/forge/users/{name}/admin` found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ForgeAdminChange {
|
||||
Promoted,
|
||||
AlreadyAdmin,
|
||||
}
|
||||
|
||||
/// Success body of `POST /api/forge/users/{name}/admin`.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
struct MakeForgeAdminResponse {
|
||||
change: ForgeAdminChange,
|
||||
}
|
||||
|
||||
/// Make an existing human forge account a site admin. Idempotent.
|
||||
///
|
||||
/// Never creates the account: a human's is made by their first SSO login to
|
||||
/// the forge, and until then this answers 404. Refuses an agent's name: a
|
||||
/// site admin can create repos whatever its `max_repo_creation`, and an
|
||||
/// agent's account is locked to 0 so its token cannot create a repo and
|
||||
/// self-merge in it.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/forge/users/{name}/admin",
|
||||
params(("name" = String, Path, description = "forge username")),
|
||||
responses(
|
||||
(status = 200, description = "the account is a site admin", body = MakeForgeAdminResponse),
|
||||
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
|
||||
(status = 404, description = "no such account: the user has not logged in via SSO yet (problem+json)", body = String),
|
||||
(status = 409, description = "`name` is an agent (problem+json)", body = String),
|
||||
(status = 500, description = "the forge refused the read or the edit (problem+json)", body = String),
|
||||
(status = 503, description = "no forge configured, or the agent roster could not be read (problem+json)", body = String),
|
||||
),
|
||||
tag = "forge"
|
||||
)]
|
||||
async fn make_forge_admin(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails> {
|
||||
let name = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let Some(forge) = state.forge.clone() else {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no forge is configured on this host",
|
||||
));
|
||||
};
|
||||
let is_agent = in_roster(state.auth.as_deref(), &name).await;
|
||||
admin_unless_agent(&name, is_agent, || async {
|
||||
forge.make_site_admin(&name).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run `promote` only if `name` is known not to be an agent, and answer with
|
||||
/// what it found.
|
||||
///
|
||||
/// An unreadable roster refuses too: a site admin is the one thing an agent's
|
||||
/// account must never become, so "could not tell" is not "no".
|
||||
async fn admin_unless_agent<F, Fut>(
|
||||
name: &str,
|
||||
is_agent: Result<bool, String>,
|
||||
promote: F,
|
||||
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<forge::site_admin::Plan>>,
|
||||
{
|
||||
match is_agent {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::CONFLICT,
|
||||
&format!("{name:?} is an agent; an agent's forge account is never a site admin"),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
&format!("cannot tell whether {name:?} is an agent, so refusing: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let plan = promote().await.map_err(|e| {
|
||||
error_problem(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{e:#}"),
|
||||
)
|
||||
})?;
|
||||
let change = match plan {
|
||||
forge::site_admin::Plan::NoSuchUser => {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
&format!(
|
||||
"the forge has no user {name:?}: the user has not logged in via SSO yet. \
|
||||
The forge creates the account on their first authelia login; run this \
|
||||
again after it"
|
||||
),
|
||||
));
|
||||
}
|
||||
forge::site_admin::Plan::AlreadyAdmin => ForgeAdminChange::AlreadyAdmin,
|
||||
forge::site_admin::Plan::Promote => ForgeAdminChange::Promoted,
|
||||
};
|
||||
Ok(Json(MakeForgeAdminResponse { change }))
|
||||
}
|
||||
|
||||
/// Every agent with an open config PR, in one response — the bulk
|
||||
/// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs
|
||||
/// every agent's status to render, and fetching them one at a time doesn't
|
||||
|
|
@ -2285,7 +2175,6 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(create_agent))
|
||||
.routes(routes!(mint_agent_identity))
|
||||
.routes(routes!(mint_agent_forge_token))
|
||||
.routes(routes!(make_forge_admin))
|
||||
.routes(routes!(get_agents))
|
||||
.routes(routes!(get_agents_status))
|
||||
.routes(routes!(set_agent_state))
|
||||
|
|
@ -2627,94 +2516,6 @@ mod tests {
|
|||
assert!(warnings[0].contains("bridge down"), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// `admin_unless_agent` with a `promote` that records whether it ran and
|
||||
/// answers `plan`.
|
||||
async fn forge_admin_route(
|
||||
is_agent: Result<bool, String>,
|
||||
plan: crate::forge::site_admin::Plan,
|
||||
) -> (
|
||||
Result<axum::Json<super::MakeForgeAdminResponse>, problem_details::ProblemDetails>,
|
||||
bool,
|
||||
) {
|
||||
let ran = std::sync::atomic::AtomicBool::new(false);
|
||||
let result = super::admin_unless_agent("atlas", is_agent, || async {
|
||||
ran.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(plan)
|
||||
})
|
||||
.await;
|
||||
(result, ran.into_inner())
|
||||
}
|
||||
|
||||
/// An agent's name never reaches the forge.
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_refuses_an_agent() {
|
||||
let (result, ran) =
|
||||
forge_admin_route(Ok(true), crate::forge::site_admin::Plan::Promote).await;
|
||||
let Err(problem) = result else {
|
||||
panic!("an agent's name must be refused");
|
||||
};
|
||||
assert!(!ran, "the account must not be touched");
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
||||
}
|
||||
|
||||
/// A roster that can't be read can't vouch for the name, so the route
|
||||
/// refuses rather than guessing "not an agent".
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_refuses_on_an_unreadable_roster() {
|
||||
let (result, ran) = forge_admin_route(
|
||||
Err("bridge down".to_owned()),
|
||||
crate::forge::site_admin::Plan::Promote,
|
||||
)
|
||||
.await;
|
||||
let Err(problem) = result else {
|
||||
panic!("an unreadable roster must refuse");
|
||||
};
|
||||
assert!(!ran, "the account must not be touched");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing account is a 404 that tells the operator what to wait for.
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_names_the_missing_sso_login() {
|
||||
let (result, ran) =
|
||||
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::NoSuchUser).await;
|
||||
let Err(problem) = result else {
|
||||
panic!("a missing account must not report success");
|
||||
};
|
||||
assert!(ran);
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
|
||||
let rendered = format!("{problem:?}");
|
||||
assert!(
|
||||
rendered.contains("has not logged in via SSO yet"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_promotes_a_non_agent() {
|
||||
let (result, ran) =
|
||||
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::Promote).await;
|
||||
let Ok(axum::Json(resp)) = result else {
|
||||
panic!("a non-agent must be promoted");
|
||||
};
|
||||
assert!(ran);
|
||||
assert_eq!(resp.change, super::ForgeAdminChange::Promoted);
|
||||
}
|
||||
|
||||
/// Idempotent: a second run succeeds and says nothing changed.
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_succeeds_for_an_admin() {
|
||||
let (result, _) =
|
||||
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::AlreadyAdmin).await;
|
||||
let Ok(axum::Json(resp)) = result else {
|
||||
panic!("an existing admin must succeed");
|
||||
};
|
||||
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
|
||||
}
|
||||
|
||||
/// The backfill route's roster check, asserted by effect for the same
|
||||
/// reason its sibling above is: a refusal that queued first would still
|
||||
/// re-mint the agent's certificate, which every running agent on the
|
||||
|
|
|
|||
|
|
@ -109,17 +109,3 @@ than shared: the controller's own types are private to its binary, this
|
|||
crate does not link it, and there is no wire-type crate between them.
|
||||
Two fields out, two in, both ends validating — a drift shows up as a
|
||||
400 naming the field.
|
||||
|
||||
## `forge make-admin`
|
||||
|
||||
```console
|
||||
# swarmctl forge make-admin mara
|
||||
forge: "mara" is now a site admin
|
||||
```
|
||||
|
||||
`POST /api/forge/users/{name}/admin` on the swarm-controller, over the same
|
||||
socket as `agent create`. It never creates an account: the forge makes a
|
||||
person's on their first login through authelia, and until then this fails
|
||||
saying so. Running it on a site admin changes nothing, and an agent's name is
|
||||
refused. The response shape is mirrored in `src/forge.rs`, for the same
|
||||
reason as `agent create`'s.
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> {
|
|||
/// Parse a CLI-supplied name into a validated identifier, naming which
|
||||
/// argument was wrong — `invalid hive` and `invalid agent name` send the
|
||||
/// operator to different flags.
|
||||
pub(crate) fn parse_ident(value: &str, what: &str) -> Result<String> {
|
||||
fn parse_ident(value: &str, what: &str) -> Result<String> {
|
||||
hive_types::Ident::parse(value)
|
||||
.map(hive_types::Ident::into_string)
|
||||
.map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}"))
|
||||
|
|
@ -186,7 +186,7 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgen
|
|||
/// `what` names the request in error messages — the operator needs to know
|
||||
/// which call failed, and every other part of this function is identical
|
||||
/// between the two verbs.
|
||||
pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
||||
async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
||||
socket: &Path,
|
||||
uri: &str,
|
||||
request: &Req,
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
//! `swarmctl forge make-admin` — make an existing human forge account a site
|
||||
//! admin, through the swarm-controller over the forge's admin API.
|
||||
//!
|
||||
//! Unlike the `agent` verbs this one waits: the controller answers with what
|
||||
//! it found. Same round trip as [`crate::agent`], over the controller's unix
|
||||
//! socket; the shape below mirrors the controller's own private
|
||||
//! `MakeForgeAdminResponse` for the reason that module's doc comment gives.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::agent::{parse_ident, post};
|
||||
|
||||
/// Success body of `POST /api/forge/users/{name}/admin`.
|
||||
#[derive(Deserialize)]
|
||||
struct MakeForgeAdminResponse {
|
||||
change: AdminChange,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum AdminChange {
|
||||
Promoted,
|
||||
AlreadyAdmin,
|
||||
}
|
||||
|
||||
/// Run `swarmctl forge make-admin`.
|
||||
///
|
||||
/// Synchronous for the same reason `agent create` is.
|
||||
pub(crate) fn make_admin(socket: &Path, name: &str) -> Result<()> {
|
||||
let name = parse_ident(name, "user name")?;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.context("starting a tokio runtime for the controller request")?;
|
||||
let resp: MakeForgeAdminResponse = rt.block_on(post(
|
||||
socket,
|
||||
&format!("/api/forge/users/{name}/admin"),
|
||||
&serde_json::json!({}),
|
||||
"forge-make-admin",
|
||||
))?;
|
||||
|
||||
match resp.change {
|
||||
AdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
|
||||
AdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AdminChange, MakeForgeAdminResponse};
|
||||
|
||||
/// The controller's two answers, as it spells them.
|
||||
#[test]
|
||||
fn both_changes_decode() {
|
||||
for (wire, want) in [("promoted", true), ("already_admin", false)] {
|
||||
let resp: MakeForgeAdminResponse =
|
||||
serde_json::from_str(&format!(r#"{{"change":"{wire}"}}"#)).expect("decodes");
|
||||
assert_eq!(matches!(resp.change, AdminChange::Promoted), want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,6 @@
|
|||
//! for the same reason `hivectl` does not link `hive-c0re`.
|
||||
|
||||
mod agent;
|
||||
mod forge;
|
||||
mod users;
|
||||
|
||||
use std::fs::{self, File, Permissions};
|
||||
|
|
@ -124,11 +123,6 @@ enum Verb {
|
|||
#[command(subcommand)]
|
||||
command: UserVerb,
|
||||
},
|
||||
/// Manage human accounts on the swarm's forge.
|
||||
Forge {
|
||||
#[command(subcommand)]
|
||||
command: ForgeVerb,
|
||||
},
|
||||
/// Emit the full CLI reference as `CommonMark` to stdout.
|
||||
///
|
||||
/// Hidden tooling command used by the docs build to keep the published
|
||||
|
|
@ -261,29 +255,6 @@ struct AgentMintIdentityArgs {
|
|||
controller_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ForgeVerb {
|
||||
/// Make an existing forge user a site admin.
|
||||
///
|
||||
/// The forge creates a human's account on their first SSO login, and
|
||||
/// this fails until that login has happened. Running it on a site admin
|
||||
/// changes nothing. Refused for an agent.
|
||||
MakeAdmin(ForgeMakeAdminArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ForgeMakeAdminArgs {
|
||||
/// Forge username, the same as the user's SSO username.
|
||||
name: String,
|
||||
/// swarm-controller's unix socket.
|
||||
///
|
||||
/// Supplied by the nix module that installs this binary, from the same
|
||||
/// `socketPath` option the daemon binds; falls back to
|
||||
/// `SWARM_CONTROLLER_SOCKET`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
controller_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum UserVerb {
|
||||
/// Add a user, generating a password for them.
|
||||
|
|
@ -366,13 +337,6 @@ fn main() -> Result<()> {
|
|||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
agent::mint_forge_token(&socket, &args.name)
|
||||
}
|
||||
// Same socket-resolution reasoning as `Create` above.
|
||||
Verb::Forge {
|
||||
command: ForgeVerb::MakeAdmin(args),
|
||||
} => {
|
||||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
forge::make_admin(&socket, &args.name)
|
||||
}
|
||||
// Resolved lazily, inside the one arm that actually touches the
|
||||
// deployment env vars — see the `MarkdownDocs` doc comment above
|
||||
// for why an unconditional resolve up front would be wrong.
|
||||
|
|
@ -787,24 +751,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_make_admin_verb_takes_a_user_name() {
|
||||
let cli = Cli::try_parse_from(["swarmctl", "forge", "make-admin", "mara"])
|
||||
.expect("the minimal form parses");
|
||||
let Verb::Forge {
|
||||
command: ForgeVerb::MakeAdmin(args),
|
||||
} = cli.command
|
||||
else {
|
||||
panic!("expected `forge make-admin`");
|
||||
};
|
||||
assert_eq!(args.name, "mara");
|
||||
assert!(args.controller_socket.is_none());
|
||||
assert!(
|
||||
Cli::try_parse_from(["swarmctl", "forge", "make-admin"]).is_err(),
|
||||
"the user must be named"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_authelia_hash_output() {
|
||||
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
||||
|
|
|
|||
Loading…
Reference in a new issue