D2 — Forge user lifecycle (REST client + create/check/token-gen flow) #4

Open
opened 2026-05-20 21:42:09 +00:00 by mik-tf · 1 comment
Owner

D2 — Forge user lifecycle (REST client + create/check/token-gen flow)

Sub-issue of #? (v0.1 scope). Wires the deployer to Forge as the identity authority.

What this does

Per the meeting notes: "go on forge, over rest — check that user exists, if user does not exist, create the user — random password, generate a forge key".

Implementation:

  1. Add a Forge REST client to hero_os_tfgrid_deployer_server. Probably crates/hero_os_tfgrid_deployer_server/src/forge/mod.rs with a single ForgeClient struct holding the base URL + admin token.
  2. Method: ForgeClient::user_exists(username) -> Result<Option<User>> — GET /api/v1/users/<username> — returns the user if present, None on 404, error on other failures.
  3. Method: ForgeClient::create_user(username, display_name, email) -> Result<User> — POST /api/v1/admin/users with a generated random password (alphanumeric, 32 chars). Requires admin scope on the deployer's Forge token.
  4. Method: ForgeClient::generate_token_for(user, scopes) -> Result<TokenString> — Forge's "create access token" admin endpoint, on behalf of the new user. Token captured + stored as a hero_proc secret keyed deployer/users/<user_id>/forge_token.
  5. Store password OOB — random password generated in step 3 is returned to the admin operator (deployer admin UI shows it once + a "copy" button + a "regenerate" action). NEVER stored persistently in the deployer.

OpenRPC additions to deployer

  • deployer.create_user(username, display_name?, email?) -> { user_id, forge_username, initial_password, forge_token_set: bool }
  • deployer.get_user(user_id) -> User
  • deployer.list_users() -> [User]
  • deployer.delete_user(user_id) — removes from deployer sqlite; does NOT delete from Forge (admin operator handles Forge cleanup if wanted)

Admin UI

In _admin crate, add /users page:

  • Table of users (forge username, display name, # of VMs, created at, last activity)
  • "Create user" form → calls deployer.create_user
  • After creation, modal shows the initial password + "copy to clipboard" + note "share this OOB; deployer doesn't store it persistently"
  • Per-user action: "Generate new Forge token" (rotates the stored token)

Auth model

The deployer's Forge admin token is itself a hero_proc secret: deployer/forge_admin_token. Set once during deployer install. NEVER in code, env vars, or sqlite.

Open questions for Forge / admin team

  • Q1: Does forge.ourworld.tf's POST /api/v1/admin/users allow a service token to set the initial password directly, or does it auto-generate + email the user? We want admin-set, share-OOB.
  • Q2: Does Forge support creating access tokens on behalf of another user (with admin scope), or do we need to instruct the user to create their own token first? Affects step 4 — if not, the BYO-key-flow in cockpit becomes mandatory for FORGE_TOKEN.
  • Q3: Email field — Forge requires this? If yes, we use a placeholder for demo accounts (e.g. <username>@nomail.demo.ourworld.tf) and let users update it later via cockpit's settings page.

These map to /forge_api skill notes; will check there first, then escalate to admin if unanswered.

Acceptance criteria

  • deployer.create_user end-to-end against forge.ourworld.tf — checks if exists, creates if not, generates Forge token, stores in hero_proc secret
  • Idempotent: re-running with same username returns the existing user without erroring
  • Admin UI form + result modal work
  • Deployer's Forge admin token never appears in logs, sqlite, or git
  • Tests: integration test against forge.ourworld.tf in a CI-controlled namespace (need a deployer_test_* username convention so we can re-run without polluting Forge)

References

## D2 — Forge user lifecycle (REST client + create/check/token-gen flow) Sub-issue of [`#?` (v0.1 scope)](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2). Wires the deployer to Forge as the identity authority. ## What this does Per the [meeting notes](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/1): "go on forge, over rest — check that user exists, if user does not exist, create the user — random password, generate a forge key". Implementation: 1. **Add a Forge REST client** to `hero_os_tfgrid_deployer_server`. Probably `crates/hero_os_tfgrid_deployer_server/src/forge/mod.rs` with a single `ForgeClient` struct holding the base URL + admin token. 2. **Method: `ForgeClient::user_exists(username) -> Result<Option<User>>`** — GET `/api/v1/users/<username>` — returns the user if present, None on 404, error on other failures. 3. **Method: `ForgeClient::create_user(username, display_name, email) -> Result<User>`** — POST `/api/v1/admin/users` with a generated random password (alphanumeric, 32 chars). Requires admin scope on the deployer's Forge token. 4. **Method: `ForgeClient::generate_token_for(user, scopes) -> Result<TokenString>`** — Forge's "create access token" admin endpoint, on behalf of the new user. Token captured + stored as a hero_proc secret keyed `deployer/users/<user_id>/forge_token`. 5. **Store password OOB** — random password generated in step 3 is returned to the admin operator (deployer admin UI shows it once + a "copy" button + a "regenerate" action). NEVER stored persistently in the deployer. ## OpenRPC additions to deployer - `deployer.create_user(username, display_name?, email?) -> { user_id, forge_username, initial_password, forge_token_set: bool }` - `deployer.get_user(user_id) -> User` - `deployer.list_users() -> [User]` - `deployer.delete_user(user_id)` — removes from deployer sqlite; does NOT delete from Forge (admin operator handles Forge cleanup if wanted) ## Admin UI In `_admin` crate, add `/users` page: - Table of users (forge username, display name, # of VMs, created at, last activity) - "Create user" form → calls `deployer.create_user` - After creation, modal shows the initial password + "copy to clipboard" + note "share this OOB; deployer doesn't store it persistently" - Per-user action: "Generate new Forge token" (rotates the stored token) ## Auth model The deployer's Forge admin token is itself a hero_proc secret: `deployer/forge_admin_token`. Set once during deployer install. NEVER in code, env vars, or sqlite. ## Open questions for Forge / admin team - **Q1:** Does forge.ourworld.tf's `POST /api/v1/admin/users` allow a service token to set the initial password directly, or does it auto-generate + email the user? We want admin-set, share-OOB. - **Q2:** Does Forge support creating access tokens on behalf of another user (with admin scope), or do we need to instruct the user to create their own token first? Affects step 4 — if not, the BYO-key-flow in cockpit becomes mandatory for FORGE_TOKEN. - **Q3:** Email field — Forge requires this? If yes, we use a placeholder for demo accounts (e.g. `<username>@nomail.demo.ourworld.tf`) and let users update it later via cockpit's settings page. These map to `/forge_api` skill notes; will check there first, then escalate to admin if unanswered. ## Acceptance criteria - `deployer.create_user` end-to-end against forge.ourworld.tf — checks if exists, creates if not, generates Forge token, stores in hero_proc secret - Idempotent: re-running with same username returns the existing user without erroring - Admin UI form + result modal work - Deployer's Forge admin token never appears in logs, sqlite, or git - Tests: integration test against forge.ourworld.tf in a CI-controlled namespace (need a `deployer_test_*` username convention so we can re-run without polluting Forge) ## References - Meeting notes: [`#1`](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/1) - Skill: `/forge_api` - Umbrella: [`#?` (v0.1 scope)](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2)
Author
Owner

s140 (2026-05-21) — landing: BYO via cockpit Settings (Bundle B); generate_token_for dropped from D2 scope

s140 (Track A → Track D pivot) ran Phase B.5 + a live API probe to resolve the open hedge in #2 step 1 ("may need to ask admin to generate one, or use the deployer's admin token to mint one if Forge allows it"). Outcome: deployer never mints or stores a token on behalf of the end user.

Probe result (verbatim)

Path Auth Endpoint Result
A Authorization: token <admin> POST /api/v1/users/<u>/tokens HTTP 401 "auth method not allowed"
B Authorization: token <admin> + Sudo: <u> header POST /api/v1/users/<u>/tokens HTTP 401 "auth method not allowed"
C HTTP basic <u>:<initial_password> POST /api/v1/users/<u>/tokens HTTP 201 with sha1

Forgejo refuses bearer-token auth on the user-token endpoint regardless of admin scope or Sudo: header. Only HTTP basic auth as the user works. Basic-auth bridge would be technically viable (deployer holds the password it just set), but is rejected on security grounds — see locked decision D-22 for the full rationale (deployer would become a long-lived impersonation-credential vault, repudiation breaks, deployer-minted token outlives password change, conflates one-shot bootstrap with long-lived bearer).

Decision (D-22, locked)

  • Deployer creates the Forge account via POST /api/v1/admin/users with Authorization: token <deployer_admin_token> (token at hero_proc secret slot deployer/FORGE_TOKEN, never core/FORGE_TOKEN per D-16 precedent).
  • Body includes "must_change_password": true — production hardening; first Forge login forces password change, neutering OOB password disclosure within minutes.
  • No generate_token_for call. That surface is dropped from D2 scope.
  • CreateUserOutput { user_id, forge_username, initial_password }forge_token field removed.
  • Initial password shown once in admin UI modal (copy button), shared OOB with user; never persisted in deployer.
  • User mints their own scoped token via Forge UI (Settings → Applications → New Token, scope write:issue for v1 feedback). Already on forge.ourworld.tf from the OAuth-gated VM front-door login per #2 goal — ~30s detour in the same session.
  • Pasted into cockpit Settings, lands at cockpit/USER_FORGE_TOKEN per D-16 (already shipped at hero_cockpit s135).
  • hero_proc secret slot deployer/users/<id>/forge_token is not created in D2 (reserved-but-unused; available if a future-Bundle-A pivot is ever made).

Spec hedge resolved

The four canonical sources together point at BYO once the hedge in #2 step 1 is read alongside:

  • #1 §3.4 "Forge key/token where needed" (qualified)
  • #1 §4 "Generate or store the required Forge key/token" (qualified)
  • #1 §7 cockpit features explicitly include "Set or update: Forge token" as a UI surface — would be redundant under Bundle A
  • #2 step 1 "Admin captures the password to share OOB with the user (initial credentials)" — the BYO input

Code shape for D2 (revised from the original "create/check/token-gen flow" framing)

crates/hero_tfgrid_deployer_server/src/forge.rs (new module):

  • Wrapper around herolib_tools::forge::ForgeClient constructed via a new upstream connect_with_secret(context: &str, key: &str) constructor (lands as a separate squash on hero_lib before D2 code — see s141 plan).
  • user_exists(username) -> Result<Option<AccountInfo>> via GET /api/v1/users/<username> (404 → None).
  • create_user(username, display_name, email) -> Result<CreateUserOutput> via POST /api/v1/admin/users with random 32-char alphanumeric password + must_change_password=true. Idempotent: user_exists check before POST; on 409 return existing.

OpenRPC additions: deployer.create_user, deployer.get_user, deployer.list_users. (Skip delete_user for D2 — per #2 decommission flow the Forge user is never deleted.)

Followup: Option 3 (OAuth-session-reuse) filed on hero_cockpit

A polish path was identified: if hero_proxy can be made to pass through the OAuth access token (used to gate the VM front door) to backend cockpit calls, cockpit could transparently reuse that session for Forge API calls without ever asking the user to paste a token. Filed as a separate hero_cockpit issue at s140 close-out. Forward-compatible — the cockpit/USER_FORGE_TOKEN slot stays valid as fallback. Not in D2 scope.

References

  • Decision: D-22 deployer-forge-token-namespacing-and-byo-landing (workspace decisions/D-22-deployer-forge-token-namespacing-and-byo-landing.md)
  • Probe write-up: memory/investigation_forge_admin_token_mint_2026_05_21.md
  • D-16 precedent: cockpit-byok-user-forge-token-namespacing.md
  • Session manifest: sessions/140.yml (s140 close-out)

Signed-by: mik-tf mik-tf@noreply.invalid

## s140 (2026-05-21) — landing: BYO via cockpit Settings (Bundle B); `generate_token_for` dropped from D2 scope s140 (Track A → Track D pivot) ran Phase B.5 + a live API probe to resolve the open hedge in [#2 step 1](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2) ("may need to ask admin to generate one, or use the deployer's admin token to mint one if Forge allows it"). Outcome: **deployer never mints or stores a token on behalf of the end user**. ### Probe result (verbatim) | Path | Auth | Endpoint | Result | |---|---|---|---| | A | `Authorization: token <admin>` | `POST /api/v1/users/<u>/tokens` | **HTTP 401 "auth method not allowed"** | | B | `Authorization: token <admin>` + `Sudo: <u>` header | `POST /api/v1/users/<u>/tokens` | **HTTP 401 "auth method not allowed"** | | C | HTTP basic `<u>:<initial_password>` | `POST /api/v1/users/<u>/tokens` | **HTTP 201 with `sha1`** | Forgejo refuses bearer-token auth on the user-token endpoint regardless of admin scope or `Sudo:` header. Only HTTP basic auth as the user works. Basic-auth bridge would be technically viable (deployer holds the password it just set), but is rejected on security grounds — see locked decision [D-22](https://forge.ourworld.tf/lhumina_code/home/...) for the full rationale (deployer would become a long-lived impersonation-credential vault, repudiation breaks, deployer-minted token outlives password change, conflates one-shot bootstrap with long-lived bearer). ### Decision (D-22, locked) - **Deployer creates the Forge account** via `POST /api/v1/admin/users` with `Authorization: token <deployer_admin_token>` (token at hero_proc secret slot `deployer/FORGE_TOKEN`, never `core/FORGE_TOKEN` per D-16 precedent). - Body includes **`"must_change_password": true`** — production hardening; first Forge login forces password change, neutering OOB password disclosure within minutes. - **No `generate_token_for` call.** That surface is dropped from D2 scope. - `CreateUserOutput { user_id, forge_username, initial_password }` — `forge_token` field removed. - Initial password shown once in admin UI modal (copy button), shared OOB with user; never persisted in deployer. - **User mints their own scoped token** via Forge UI (Settings → Applications → New Token, scope `write:issue` for v1 feedback). Already on forge.ourworld.tf from the OAuth-gated VM front-door login per [#2 goal](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2) — ~30s detour in the same session. - Pasted into cockpit Settings, lands at `cockpit/USER_FORGE_TOKEN` per [D-16](https://forge.ourworld.tf/lhumina_code/home/...) (already shipped at hero_cockpit s135). - hero_proc secret slot `deployer/users/<id>/forge_token` is **not created** in D2 (reserved-but-unused; available if a future-Bundle-A pivot is ever made). ### Spec hedge resolved The four canonical sources together point at BYO once the hedge in [#2 step 1](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2) is read alongside: - [#1 §3.4](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/1) "Forge key/token **where needed**" (qualified) - [#1 §4](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/1) "Generate or store the **required** Forge key/token" (qualified) - [#1 §7](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/1) cockpit features explicitly include **"Set or update: Forge token"** as a UI surface — would be redundant under Bundle A - [#2 step 1](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2) "Admin captures the password to share OOB with the user (initial credentials)" — the BYO input ### Code shape for D2 (revised from the original "create/check/token-gen flow" framing) `crates/hero_tfgrid_deployer_server/src/forge.rs` (new module): - Wrapper around `herolib_tools::forge::ForgeClient` constructed via a new upstream `connect_with_secret(context: &str, key: &str)` constructor (lands as a separate squash on `hero_lib` before D2 code — see s141 plan). - `user_exists(username) -> Result<Option<AccountInfo>>` via `GET /api/v1/users/<username>` (404 → None). - `create_user(username, display_name, email) -> Result<CreateUserOutput>` via `POST /api/v1/admin/users` with random 32-char alphanumeric password + `must_change_password=true`. Idempotent: `user_exists` check before POST; on 409 return existing. OpenRPC additions: `deployer.create_user`, `deployer.get_user`, `deployer.list_users`. (Skip `delete_user` for D2 — per [#2 decommission flow](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/2) the Forge user is never deleted.) ### Followup: Option 3 (OAuth-session-reuse) filed on `hero_cockpit` A polish path was identified: if hero_proxy can be made to pass through the OAuth access token (used to gate the VM front door) to backend cockpit calls, cockpit could transparently reuse that session for Forge API calls without ever asking the user to paste a token. Filed as a separate hero_cockpit issue at s140 close-out. Forward-compatible — the `cockpit/USER_FORGE_TOKEN` slot stays valid as fallback. **Not in D2 scope.** ### References - Decision: [D-22 deployer-forge-token-namespacing-and-byo-landing](https://forge.ourworld.tf/lhumina_code/hero_os_tfgrid_deployer/issues/4) (workspace `decisions/D-22-deployer-forge-token-namespacing-and-byo-landing.md`) - Probe write-up: `memory/investigation_forge_admin_token_mint_2026_05_21.md` - D-16 precedent: cockpit-byok-user-forge-token-namespacing.md - Session manifest: `sessions/140.yml` (s140 close-out) Signed-by: mik-tf <mik-tf@noreply.invalid>
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_os_tfgrid_deployer#4
No description provided.