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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user