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:
+16
-62
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user