feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s

A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:23:22 -04:00
co-authored by Claude Fable 5
parent 7a5e2b18d9
commit 1faf8f3ece
19 changed files with 1252 additions and 310 deletions
+47 -9
View File
@@ -1,7 +1,7 @@
"""Project management routes."""
import logging
from quart import Blueprint, jsonify, request
from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination
@@ -130,7 +130,7 @@ async def get_coverage_route(project_id: int):
push or an explicit refresh).
"""
from scribe.services.coverage import cached_coverage
from scribe.services.forge import get_forge
from scribe.services.forge import get_forges
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
@@ -139,7 +139,9 @@ async def get_coverage_route(project_id: int):
project, _ = result
owner_uid = project.user_id or uid
return jsonify({
"configured": await get_forge() is not None,
# The OWNER's keyring (#2778) — whether refresh could do anything,
# regardless of who is looking.
"configured": (await get_forges(owner_uid, project_id)).configured,
"coverage": await cached_coverage(owner_uid, project_id),
})
@@ -153,7 +155,7 @@ async def refresh_coverage_route(project_id: int):
and wants the new number, and the forge timeout bounds the wait.
"""
from scribe.services.coverage import refresh_coverage
from scribe.services.forge import ForgeError, get_forge
from scribe.services.forge import ForgeError, get_forges
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
@@ -161,20 +163,56 @@ async def refresh_coverage_route(project_id: int):
return not_found("Project")
project, _ = result
owner_uid = project.user_id or uid
if await get_forge() is None:
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400
selector = await get_forges(owner_uid, project_id)
if not selector.configured:
return jsonify({
"error": "The project owner has no forge connection "
"(Settings → Git forges)"
}), 400
try:
coverage = await refresh_coverage(owner_uid, project_id)
coverage = await refresh_coverage(owner_uid, project_id, selector=selector)
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502
if coverage is None:
return jsonify({
"error": "No bound repo is served by the configured forge — "
"bind the project's repo (bind_repo) on a remote the forge hosts"
"error": "No bound repo is served by the owner's forge connections — "
"bind the project's repo (bind_repo) on a remote a connection hosts"
}), 400
return jsonify({"coverage": coverage})
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
"""Pin the project to one forge connection, or clear the pin (#2778).
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
the pin can only reference a connection the project OWNER holds — the
service enforces that, so a collaborator's token can never end up serving
someone else's project.
"""
from scribe.services.forge_connections import set_project_pin
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, permission = result
if permission != "owner" and g.user.role != "admin":
return jsonify({"error": "Only the project owner can change its forge"}), 403
data = await request.get_json() or {}
raw = data.get("connection_id")
if raw is not None and not isinstance(raw, int):
return jsonify({"error": "connection_id must be an integer or null"}), 400
owner_uid = project.user_id or uid
if not await set_project_pin(owner_uid, project_id, raw):
return jsonify({
"error": "That connection does not belong to the project owner"
}), 400
return jsonify({"forge_connection_id": raw})
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):