Forgejo admin broker: isolate admin token and webhook secret from the agent #55
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#55
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implementation: #56
Problem
Review triggers (#45 / PR #51) need the reviewer's permission, which needs a repo-admin or site-admin token (
GET /repos/{o}/{r}/collaborators/{user}/permission). The agent runs in the deck's process as the same UID, so bash can read anything the deck holds (DB,.env,/proc/<pid>/environ, memory). An admin token in the deck means admin for anyone who can trigger the agent, or for injected text. The webhook secret has the same problem today: it sits inapp_settingsand is returned byGET /api/forgejo/config.Goal: a small broker process under a dedicated UID in the same container owns the site-admin token and the webhook secret. The deck (and the agent, same UID) can only call two named, narrow, read-only actions:
verify-webhook: is this body + signature valid HMAC for the secret?permission: what isuser's permission onrepo(none/read/write/admin)?Design rule: the broker can't tell the deck from the agent, so every action must be safe for anyone to call. No generic API proxy; any future mutating action must enforce its safety inside the broker. The Forgejo write token stays in the deck (accepted for now).
Decisions:
POST /api/forgejo/webhookon the deck; the deck asks the broker to verify and the broker never listens on TCP.repository.permissionsfrom the payload;reviewerCanWriteand its 403 fallback are deleted.$OMP_DECK_DATA_DIR/broker/config.json(JSON, 0600, broker UID, dir 0700), edited by the operator. The deck UI shows the secret as "managed by the broker" and loses the generator; the API route and theforgejo.webhook_secretrow are removed.docker run --user. Starts as root;tini→docker/entrypoint.shdrops privileges per process withsetpriv: broker as fixed999:999, deck asOMP_DECK_UID:OMP_DECK_GID(default1000:1000) withHOME=/data, bind-mountable (as a whole or per subdirectory). TheJustfilerunrecipe is updated to match.hasTokenonly). It exposes the webhook secret; that goes away here.Plan
Context
Add a new workspace app
apps/broker(Bun, no runtime deps), wire the deck to it for webhook verification and permission checks, remove the webhook secret from the deck entirely, and change the Docker image to run both processes under separate UIDs. End state: nothing the deck UID can read contains the admin token or the webhook secret.Approach
1. Broker app
apps/brokerIndependent of all later steps; build it first.
apps/broker/package.json:{"name": "@omp-deck/broker", "private": true, "type": "module", "scripts": {"start": "bun src/index.ts", "typecheck": "tsc --noEmit", "test": "bun test"}}, devDependencytypescriptmatching root;tsconfig.jsonextending../../tsconfig.base.json(copyapps/server/tsconfig.json's shape). No dependency on@omp-deck/server; do not import fromapps/server.src/config.ts:export interface BrokerConfig { forgejoUrl: string; adminToken: string; webhookSecret: string; repos: string[] }andexport function loadConfig(path: string): BrokerConfig.OMP_BROKER_CONFIG, else${OMP_DECK_DATA_DIR}/broker/config.json; if neither env set, throwOMP_BROKER_CONFIG or OMP_DECK_DATA_DIR must be set.fs.statSync; throw unlessstat.uid === process.getuid()and(stat.mode & 0o777) === 0o600. Messages:<path> must be owned by uid <n>/<path> must have mode 0600 (is 0<octal>). Also stat the parent dir: owner must beprocess.getuid()and mode& 0o077must be 0, else<dir> must be owned by uid <n> with no group/other access.forgejoUrlahttp(s)://URL (strip trailing/),adminTokennon-empty string,webhookSecretstring of ≥ 32 chars,reposarray ofowner/namestrings (compare lowercased). Any violation → throwinvalid broker config: <field> …. Missing file → throwbroker config not found at <path>.src/signature.ts: moveverifyForgejoSignaturefromapps/server/src/forgejo/webhook.ts(L33-36 area: 64-hex regex +timingSafeEqualover HMAC-SHA256 of the raw body) verbatim, and move its tests fromapps/server/src/forgejo/webhook.test.tstoapps/broker/src/signature.test.ts. Delete the function and its tests from the server (step 3 removes the only caller).src/forgejo.ts:export type PermissionLevel = "none" | "read" | "write" | "admin"andexport async function fetchPermission(cfg, owner, repo, user): Promise<{ ok: true; level: PermissionLevel } | { ok: false; error: string }>using plainfetch(no shared client exists outside apps/server) to${forgejoUrl}/api/v1/repos/${enc(owner)}/${enc(repo)}/collaborators/${enc(user)}/permission, headerAuthorization: token <adminToken>,AbortSignal.timeout(10_000). Mappermission:owner/admin→admin,write→write,read→read, anything else →none. HTTP 404 →{ok: true, level: "none"}(unknown user). Other non-2xx →{ok:false, error: \forgejo ${status}`}; network error →{ok:false, error: `forgejo unreachable: ${message}`}`. Never put the token or response body in errors.src/server.ts:export function createHandler(cfg: BrokerConfig, fetchPermissionImpl = fetchPermission): (req: Request) => Promise<Response>(injectable for tests). Routes, all JSON, request body capped at 2 MiB (checkcontent-lengthand actual length; over → 413{error:"too large"}):POST /verify-webhookbody{ signature: string | null, body: string /* base64 */ }→200 {"valid": boolean}. Bad shape → 400{error:"invalid request"}.POST /permissionbody{ repo: "owner/name", user: string }→200 {"level": PermissionLevel}; repo not incfg.repos(case-insensitive) →403 {"error":"repository not allowed"}; fetch failure →502 {"error": <error>}; bad shape → 400.src/index.ts: load config (on error: log message,process.exit(1)); socket path envOMP_BROKER_SOCKET, default/run/omp-broker/broker.sock;fs.rmSync(path, {force: true});Bun.serve({ unix: path, fetch: handler }); thenfs.chmodSync(path, 0o666); logbroker listening on <path>. SIGTERM/SIGINT →server.stop(true), remove socket, exit 0. The config is read once at start; changing it needs a restart (documented).apps/broker/src/server.test.ts(bun:test): valid/invalid signature round-trip, 403 for non-allowlisted repo, level mapping incl.owner→admin, 404 →none, 502 on fake failure, 400 on bad shape.config.test.ts: mode 0644 rejected, wrong-owner can't be produced without root so only test mode + dir mode + validation errors.2. Deck broker client
apps/server/src/forgejo/broker-client.ts:export type PermissionLevel = "none" | "read" | "write" | "admin";export type BrokerResult<T> = { ok: true; data: T } | { ok: false; error: string }.export function brokerSocketPath(): string→process.env.OMP_BROKER_SOCKET?.trim() || "/run/omp-broker/broker.sock".export async function verifyWebhookSignature(body: Uint8Array, signature: string | undefined): Promise<BrokerResult<boolean>>andexport async function checkPermission(repo: string, user: string): Promise<BrokerResult<PermissionLevel>>, bothfetch("http://broker/<route>", { unix: brokerSocketPath(), method: "POST", headers: {"content-type": "application/json"}, body, signal: AbortSignal.timeout(10_000) }). Connection error →{ok:false, error: \broker unavailable: ${message}`}; non-200 →{ok:false, error: `broker ${status}: ${json.error ?? ""}`}; malformed JSON reply →{ok:false, error:"broker returned an invalid response"}`.OMP_BROKER_SOCKETtoapps/server/src/env-schema.tsnext toOMP_DECK_DATA_DIR(valueTypepath, sensitive false, restartRequired false — read per call) and todocs/configuration.md.3. Webhook route verifies via the broker; secret removed from the deck
Depends on 2.
apps/server/src/routes-forgejo-webhook.ts: delete thereadWebhookSecret()lookup. Keep the!cfg503 path with its existing record (not_configured,forgejo webhook not configured). ReplaceverifyForgejoSignature(body, signature, secret)withawait verifyWebhookSignature(body, signature):!res.ok→log.warn,record(c, { outcome: "not_configured", httpStatus: 503, verified: false, detail: clip(\webhook broker unavailable: ${res.error}`, HEADER_TEXT_MAX), bodyBytes }), returnc.json({ error: "webhook broker unavailable" }, 503). Reuse thenot_configured` outcome so migration 012's CHECK constraint and the protocol union stay unchanged.res.data === false→ today's 401 branch; change the detail text"signature does not match the deck's webhook secret"to"signature does not match the broker's webhook secret".apps/server/src/forgejo/config.ts: removeKEY_WEBHOOK_SECRET,webhookSecretfromStored/readStored/readForgejoConfigPublic,readWebhookSecret,generateWebhookSecret, therandomBytesimport, the secret line inclearForgejoConfig, and the webhook-secret paragraph of the module doc (replace with one sentence: the webhook secret lives in the broker's config, not here).packages/protocol/src/index.tsForgejoConfigResponse: deletewebhookSecret.apps/server/src/routes-forgejo.ts: deletePOST /forgejo/webhook-secret(route L152-156), its import and its line in the header comment; update the DELETE header line to drop "and webhook secret".apps/server/src/db/migrations/016-drop-forgejo-webhook-secret.sql: header comment (the secret moved to the broker config) andDELETE FROM app_settings WHERE key = 'forgejo.webhook_secret';.apps/web/src/lib/forgejo-api.ts: deletegenerateWebhookSecret.apps/web/src/components/settings/ForgejoPanel.tsx: deletegenerateSecret, the"secret"busy state, and the Secret block's generate/show UI (L256-276); in its place render labelSecretwith textManaged by the webhook broker: the "webhookSecret" in <data dir>/broker/config.json.Change the instructions bullet toSecret = the broker's webhookSecret. Remove now-unused imports (KeyRoundif unused).routes-forgejo-webhook.test.ts— replace secret seeding with a broker stub: in the test, startBun.serve({ unix: <tmp>/broker.sock, fetch })answering/verify-webhookwith{valid}computed by an HMAC over a test secret, and setOMP_BROKER_SOCKET; replace the "no secret stored" test with "broker unreachable: 503, logged as not_configured" (pointOMP_BROKER_SOCKETat a missing path).config.test.ts— delete the twogenerateWebhookSecrettests, dropwebhookSecretfrom expectations,rowCountafter save + clear becomes 3.forgejo-trigger-tools.test.tsneeds no change.4. Dispatcher checks every author via the broker
Depends on 2; independent of 3.
apps/server/src/forgejo/dispatcher.ts: add to the deps interface (next toagentUsername, ~L80)checkPermission: (repo: string, user: string) => Promise<BrokerResult<PermissionLevel>>;. Ingate()replace the wholeif (reviewSubmitted) { … } else { … }block (L312-333) with:reviewSubmitted && !ownPr→ drop"review on a pull request not authored by the agent"(unchanged).const perm = await this.deps.checkPermission(row.repo, row.commentAuthor);!perm.ok→await this.fail(job, \could not check the author's permission: ${perm.error}`, "internal", null); return null;`.perm.data !== "write" && perm.data !== "admin"→ drop\author lacks write access (${perm.data})``.repository.permissionsin the payload.repository?.permissionsfromPayloadFields(L99) and update the module doc at L19-20 to say "checked through the webhook broker".apps/server/src/forgejo/dispatch-api.ts: deletereviewerCanWrite(interface L21-30, impl L132-157),RawTeam,WRITE_PERMISSIONS.apps/server/src/index.ts(~L109-115): passcheckPermissionfrom./forgejo/broker-client.ts.dispatcher.test.ts: deletecanWrite/reviewChecks/reviewerCanWritefromFakeApi; add harness fieldspermission: BrokerResult<PermissionLevel> = { ok: true, data: "write" }andpermissionChecks: string[], wired intodeps.checkPermission. Convert payload-permission tests (L441, L503, L521) to seth.permission(read→ reasonauthor lacks write access (read)); replace "payload without permissions fails closed" with "broker failure fails the trigger" asserting statusfailedand reason containingcould not check the author's permission. Convert review tests at L1100/L1110 the same way. Add one test: payload claimspush: truebut broker saysread→ dropped.5. Container: two UIDs via entrypoint
Independent of 2-4 at build level; ship together.
Dockerfileruntime stage: addtinito the apt list; addCOPY apps/broker/package.json apps/broker/in both stages next to the other workspace manifests (frozen lockfile needs the full graph);COPY apps/broker apps/brokerandCOPY docker/entrypoint.sh /usr/local/bin/omp-deck-entrypoint(+chmod 0755);ENV OMP_DECK_DATA_DIR=/data/omp-deck OMP_BROKER_SOCKET=/run/omp-broker/broker.sock; replaceCMDwithENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/omp-deck-entrypoint"]. NoUSER, noVOLUME. Update the header run example: drop--user, use-v <host-dir>:/data, mention-e OMP_DECK_UID/OMP_DECK_GID. Regeneratebun.lockwithbun install.docker/entrypoint.sh(#!/bin/sh,set -eu), in order:[ "$(id -u)" = 0 ] || { echo "omp-deck: the container must start as root (do not pass --user); set OMP_DECK_UID/OMP_DECK_GID instead" >&2; exit 1; }.DECK_UID=${OMP_DECK_UID:-1000},DECK_GID=${OMP_DECK_GID:-1000}; refuse0or999for either:omp-deck: OMP_DECK_UID/GID must not be 0 or 999.mkdir -p /data. If/datais not a mount point (! mountpoint -q /data, util-linux; e.g. only subdirectories are bind-mounted, as in theJustfile) and is owned by uid 0,chown "$DECK_UID:$DECK_GID" /data(that directory only, never recursive). Then, ifstat -c %u /data≠DECK_UID→echo "omp-deck: /data must be owned by $DECK_UID:$DECK_GID (chown the bind-mounted host dir)"; exit 1. Never chown a mount or anything under/data.setpriv --reuid=$DECK_UID --regid=$DECK_GID --clear-groups mkdir -p "$OMP_DECK_DATA_DIR".B="$OMP_DECK_DATA_DIR/broker": if absent,mkdir "$B",chown 999:999 "$B",chmod 0700 "$B". Then verify: owner of$Bis 999 and mode700, and$B/config.jsonexists, is owned by 999 and mode600; otherwise exit 1 withomp-deck: <path> must be owned by 999:999 with mode <m>; create the broker config as described in docs/deployment.md. (A dir planted by the deck UID can't be owned by 999, so this blocks config substitution.)mkdir -p /run/omp-broker && chown 999:999 /run/omp-broker && chmod 0755 /run/omp-broker.setpriv --reuid=999 --regid=999 --clear-groups env HOME=/nonexistent bun /app/apps/broker/src/index.ts & BROKER=$!. Wait up to 10 s (poll[ -S "$OMP_BROKER_SOCKET" ]every 0.2 s); on timeout or broker exit → kill it, exit 1 withomp-deck: broker did not start.cd /app/apps/server && setpriv --reuid=$DECK_UID --regid=$DECK_GID --clear-groups env HOME=/data bun src/index.ts & DECK=$!.trap 'kill -TERM $DECK $BROKER 2>/dev/null' TERM INT. Loopwait -nis not POSIX sh: usewhile kill -0 $DECK 2>/dev/null && kill -0 $BROKER 2>/dev/null; do sleep 1; done, then kill the survivor,wait, exit with status 1 if the broker died first, else the deck's status. The deck's self-restart (scheduleRestartinapps/server/src/index.ts, spawns a detached child thenprocess.exit(0)) therefore ends the container, which matches today's behavior where the deck is PID 1; rely on the container restart policy.OMP_DECK_UID-owned paths except reading through$OMP_DECK_DATA_DIR/broker(traversal through/dataneedsxfor others: documentchmod o+xon the host dir if it's 0700).docs/deployment.mdDocker section: new run example, the broker config file format ({"forgejoUrl": "...", "adminToken": "...", "webhookSecret": "<openssl rand -hex 32>", "repos": ["owner/name"]}), how to create it (sudo install -o 999 -g 999 -m 0700 -d <host>/omp-deck/brokerandsudo install -o 999 -g 999 -m 0600 config.json …), that the admin token must belong to a site admin, that config changes need a container restart, and the--userremoval.docs/configuration.mdForgejo section (L198-227): webhook secret now in the broker config; for local dev runOMP_DECK_DATA_DIR=… OMP_BROKER_SOCKET=/tmp/omp-broker.sock bun apps/broker/src/index.tsalongside the deck (same UID is fine for dev).Justfilerunrecipe (L23-49): delete--user $(id -u):$(id -g)and-e HOME=/data(the entrypoint sets HOME); add-e OMP_DECK_UID=$(id -u)and-e OMP_DECK_GID=$(id -g). Keep every bind mount and env var, including-e OMP_DECK_DATA_DIR=/data/.config/omp-deck, so the broker config lives on the host at$HOME/.config/omp-deck/broker/config.json. Update the recipe comment: the container starts as root and the entrypoint runs the deck as your host UID/GID and the broker as UID 999; add one comment line that the broker config must exist first (sudo install -o 999 -g 999 -m 0700 -d ~/.config/omp-deck/broker, thensudo install -o 999 -g 999 -m 0600 config.json ~/.config/omp-deck/broker/). No new recipe.Critical files & anchors
apps/server/src/forgejo/dispatcher.tsgate()L268-340 — permission gate rewrite; keep sender≠author check.apps/server/src/routes-forgejo-webhook.tsL106-134 — secret lookup and HMAC replaced by broker call.apps/server/src/forgejo/config.ts— all webhook-secret code removed.apps/server/src/db/migrations/012-forgejo-deliveries.sqlL17 — outcome CHECK; why broker-unavailable reusesnot_configured.DockerfileL28-35, L85-109 — manifests copied per workspace; entrypoint replaces CMD.Verification
cd /workspace/omp-deck && bun install && bun run typecheckpasses;bun testinapps/serverandapps/brokerpass.webhookSecret|webhook_secret|reviewerCanWrite|repository.permissions|verifyForgejoSignatureunderapps/server,apps/web,packagesreturns only the migration 016 file.mkdir -m 700 /tmp/dd/brokerwithconfig.json(0600) holding a test secret, the real instance URL, a site-admin token and repolucas/omp-deck; run broker withOMP_DECK_DATA_DIR=/tmp/dd OMP_BROKER_SOCKET=/tmp/b.sock. Then:curl --unix-socket /tmp/b.sock -XPOST http://b/permission -d '{"repo":"lucas/omp-deck","user":"lucas"}'→{"level":"admin"};"repo":"other/x"→ 403repository not allowed.chmod 644 config.json→ broker refuses to start with the mode error.OMP_BROKER_SOCKET: POST a body signed with the test secret to/api/forgejo/webhook→ accepted (202) or ignored (200); wrong signature → 401; broker stopped → 503 and the Settings → Forgejo delivery log showswebhook broker unavailable: ….docker build -t omp-deck .;mkdir data && sudo chown 1000:1000 data, createdata/omp-deck/broker/config.jsonper docs;docker run --rm -p 127.0.0.1:8787:8787 -v $PWD/data:/data omp-deck. Check:docker exec <c> ps -o user,argsshows broker as 999 and deck as 1000;docker exec -u 1000 <c> cat /data/omp-deck/broker/config.json→ permission denied;--user 1000run exits with the root-required message; a bind-mounted data dir owned by root → exits with the/data must be ownedmessage and the host dir's owner is unchanged. A Forgejo review on an agent-authored PR by a write collaborator starts a session; by a read-only user is dropped withauthor lacks write access (read).~/.config/omp-deck/broker/config.jsoncreated as in the recipe comment,just runstarts the container;docker exec omp-deck ps -o user,argsshows the deck under your host UID and the broker under 999; files the deck writes under~/.omp-deckstay owned by your host user.GET /api/forgejo/confighas nowebhookSecret.Assumptions & contingencies
setprivships in theoven/bun:1.3.14Debian base (util-linux). Ifwhich setprivfails in the image, addutil-linuxto the apt list.fetchunixoption andBun.serve({ unix })are used for the socket. Iffetchwithunixmisbehaves on Bun 1.3.14, usenode:httprequest({ socketPath })inbroker-client.tsinstead; the wire contract stays the same.--userand a different HOME must move their data under the/datamount; no automatic migration of old paths.Deviation from the plan (step 1): the plan says to move
verifyForgejoSignature's tests fromapps/server/src/forgejo/webhook.test.ts, but that file has none. I copied the function intoapps/broker/src/signature.tsand covered it with new tests inapps/broker/src/server.test.ts(valid, wrong secret, missing and tampered signature). The server copy is deleted once the webhook route switches to the broker.