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
+16 -62
View File
@@ -19,15 +19,6 @@ from scribe.services.backup import (
restore_full_backup,
)
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.forge import (
FORGE_BASE_URL_KEY,
FORGE_KIND_KEY,
FORGE_KINDS,
FORGE_TOKEN_KEY,
ForgeError,
forge_config,
get_forge,
)
from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email
from scribe.services.settings import (
@@ -169,85 +160,48 @@ async def test_smtp():
_TOKEN_MASK = "********"
@admin_bp.route("/forge", methods=["GET"])
# The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git
# forges); what stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users.
@admin_bp.route("/forge-webhook", methods=["GET"])
@admin_required
async def get_forge_settings():
async def get_forge_webhook_settings():
from scribe.config import Config
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
cfg = await forge_config()
webhook_secret = (
await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "")
or Config.FORGE_WEBHOOK_SECRET
)
return jsonify({
"kind": cfg["kind"],
"base_url": cfg["base_url"],
# Secrets never leave the server — the smtp_password convention:
# masked when set, empty when not.
"token": _TOKEN_MASK if cfg["token"] else "",
"webhook_secret": _TOKEN_MASK if webhook_secret else "",
"configured": bool(await get_forge()),
"kinds": list(FORGE_KINDS),
})
@admin_bp.route("/forge", methods=["PUT"])
@admin_bp.route("/forge-webhook", methods=["PUT"])
@admin_required
async def update_forge_settings():
data = await request.get_json() or {}
uid = get_current_user_id()
kind = str(data.get("kind", "")).strip().lower()
if kind and kind not in FORGE_KINDS:
return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400
base_url = str(data.get("base_url", "")).strip().rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
return jsonify({"error": "Forge base URL must use http or https"}), 400
await set_admin_setting(FORGE_KIND_KEY, kind)
await set_admin_setting(FORGE_BASE_URL_KEY, base_url)
token = data.get("token")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if token is not None and token != _TOKEN_MASK:
await set_admin_setting(FORGE_TOKEN_KEY, str(token))
async def update_forge_webhook_settings():
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
data = await request.get_json() or {}
uid = get_current_user_id()
webhook_secret = data.get("webhook_secret")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if webhook_secret is not None and webhook_secret != _TOKEN_MASK:
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
# The token is deliberately absent from the audit detail.
# The secret is deliberately absent from the audit detail.
await log_audit(
"forge_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"kind": kind, "base_url": base_url},
"forge_webhook_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr, details={},
)
return jsonify({"status": "ok"})
@admin_bp.route("/forge/test", methods=["POST"])
@admin_required
async def test_forge():
"""Probe the SAVED forge config: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied to integrations)."""
uid = get_current_user_id()
forge = await get_forge()
if forge is None:
return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400
try:
result = await forge.check()
except ForgeError as e:
return jsonify({"error": str(e)}), 502
await log_audit(
"forge_test", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"ok": True, "username": result.get("username", "")},
)
return jsonify(result)
@admin_bp.route("/logs", methods=["GET"])
@admin_required
async def list_logs():
+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):
+102 -1
View File
@@ -20,7 +20,8 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw.
_SECRET_KEYS = frozenset({"smtp_password", "forge_token", "forge_webhook_secret"})
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
_SECRET_MASK = "********"
@@ -73,3 +74,103 @@ async def test_search():
if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
# another user's keyring. Tokens never leave the server: the model's to_dict
# omits them, and the routes never echo the submitted value back.
@settings_bp.route("/forge-connections", methods=["GET"])
@login_required
async def list_forge_connections_route():
from scribe.services.forge import FORGE_KINDS
from scribe.services.forge_connections import list_connections
uid = get_current_user_id()
rows = await list_connections(uid)
return jsonify({
"connections": [r.to_dict() for r in rows],
"kinds": list(FORGE_KINDS),
})
@settings_bp.route("/forge-connections", methods=["POST"])
@login_required
async def create_forge_connection_route():
from scribe.services.forge_connections import create_connection
uid = get_current_user_id()
data = await request.get_json() or {}
try:
row = await create_connection(
uid,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=str(data.get("token", "")),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(row.to_dict()), 201
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["PUT"])
@login_required
async def update_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import update_connection
uid = get_current_user_id()
data = await request.get_json() or {}
token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection.
if token == _SECRET_MASK:
token = ""
try:
row = await update_connection(
uid, connection_id,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=token,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if row is None:
return jsonify({"error": "Connection not found"}), 404
return jsonify(row.to_dict())
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["DELETE"])
@login_required
async def delete_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import delete_connection
uid = get_current_user_id()
if not await delete_connection(uid, connection_id):
return jsonify({"error": "Connection not found"}), 404
return "", 204
@settings_bp.route("/forge-connections/<int:connection_id>/test", methods=["POST"])
@login_required
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied per keyring row)."""
from scribe.services.forge import ForgeError, build_adapter
from scribe.services.forge_connections import get_connection
uid = get_current_user_id()
row = await get_connection(uid, connection_id)
if row is None:
return jsonify({"error": "Connection not found"}), 404
adapter = build_adapter(row.kind, row.base_url, row.token)
if adapter is None:
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
try:
return jsonify(await adapter.check())
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502