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:
@@ -0,0 +1,194 @@
|
||||
"""User-level forge connection CRUD — the keyring rows get_forges reads (#2778).
|
||||
|
||||
A connection is a user's read-only credential for one forge host; one row per
|
||||
(user, host) keeps host-keyed resolution deterministic with no default-pointer
|
||||
machinery. Everything here is own-rows-only: a connection is a credential, and
|
||||
no caller — admin included — reads or edits another user's. The token never
|
||||
leaves the server (model.to_dict omits it; routes mask "set/unset").
|
||||
|
||||
Validation matches what build_adapter will accept, checked here so a bad
|
||||
value is a 400 at the form instead of a silently dead keyring row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.forge_connection import ForgeConnection
|
||||
from scribe.models.project import Project
|
||||
from scribe.services.forge import FORGE_KINDS, host_of
|
||||
|
||||
|
||||
def validate_connection(kind: str, base_url: str) -> str | None:
|
||||
"""The error a connection's non-secret values would earn, or None."""
|
||||
if kind not in FORGE_KINDS:
|
||||
return f"Unknown forge kind {kind!r} (one of: {', '.join(FORGE_KINDS)})"
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
return "Forge base URL must use http or https"
|
||||
if not host_of(base_url):
|
||||
return "Forge base URL carries no hostname"
|
||||
return None
|
||||
|
||||
|
||||
async def list_connections(user_id: int) -> list[ForgeConnection]:
|
||||
async with async_session() as session:
|
||||
rows = await session.execute(
|
||||
select(ForgeConnection)
|
||||
.where(ForgeConnection.user_id == user_id)
|
||||
.order_by(ForgeConnection.host)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
async def get_connection(user_id: int, connection_id: int) -> ForgeConnection | None:
|
||||
async with async_session() as session:
|
||||
return (
|
||||
await session.execute(
|
||||
select(ForgeConnection).where(
|
||||
ForgeConnection.id == connection_id,
|
||||
ForgeConnection.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_connection(
|
||||
user_id: int, *, kind: str, base_url: str, token: str
|
||||
) -> ForgeConnection:
|
||||
"""Create a keyring row. Raises ValueError on bad values or a host the
|
||||
user already holds — one row per (user, host) IS the resolution model,
|
||||
so a second token for the same host is an update, not a create."""
|
||||
kind = (kind or "").strip().lower()
|
||||
base_url = (base_url or "").strip().rstrip("/")
|
||||
token = token or ""
|
||||
error = validate_connection(kind, base_url)
|
||||
if error is None and not token:
|
||||
error = "A token is required (read scope is enough)"
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
host = host_of(base_url)
|
||||
async with async_session() as session:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ForgeConnection).where(
|
||||
ForgeConnection.user_id == user_id,
|
||||
ForgeConnection.host == host,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise ValueError(
|
||||
f"You already have a connection for {host} — edit that one; "
|
||||
"resolution is by host, so a second row could never be reached"
|
||||
)
|
||||
row = ForgeConnection(
|
||||
user_id=user_id, kind=kind, base_url=base_url, host=host, token=token
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def update_connection(
|
||||
user_id: int,
|
||||
connection_id: int,
|
||||
*,
|
||||
kind: str = "",
|
||||
base_url: str = "",
|
||||
token: str = "",
|
||||
) -> ForgeConnection | None:
|
||||
"""Update own row; empty string = leave unchanged (the settings-form
|
||||
sentinel convention). None when the row isn't the caller's."""
|
||||
async with async_session() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(ForgeConnection).where(
|
||||
ForgeConnection.id == connection_id,
|
||||
ForgeConnection.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
new_kind = (kind or "").strip().lower() or row.kind
|
||||
new_base = (base_url or "").strip().rstrip("/") or row.base_url
|
||||
error = validate_connection(new_kind, new_base)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
new_host = host_of(new_base)
|
||||
if new_host != row.host:
|
||||
clash = (
|
||||
await session.execute(
|
||||
select(ForgeConnection.id).where(
|
||||
ForgeConnection.user_id == user_id,
|
||||
ForgeConnection.host == new_host,
|
||||
ForgeConnection.id != row.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if clash is not None:
|
||||
raise ValueError(
|
||||
f"You already have a connection for {new_host} — edit that one"
|
||||
)
|
||||
row.kind = new_kind
|
||||
row.base_url = new_base
|
||||
row.host = new_host
|
||||
if token:
|
||||
row.token = token
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def delete_connection(user_id: int, connection_id: int) -> bool:
|
||||
"""Delete own row. Project pins pointing at it go NULL (FK SET NULL) —
|
||||
those projects fall back to keyring resolution, which is the documented
|
||||
unpinned behavior, not a surprise."""
|
||||
async with async_session() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(ForgeConnection).where(
|
||||
ForgeConnection.id == connection_id,
|
||||
ForgeConnection.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def set_project_pin(
|
||||
owner_id: int, project_id: int, connection_id: int | None
|
||||
) -> bool:
|
||||
"""Point a project at one of its OWNER's connections, or clear the pin.
|
||||
|
||||
The caller settles WHO may ask (routes check owner-or-admin); this
|
||||
settles WHOSE connection is eligible: only the project owner's — pinning
|
||||
a collaborator's token to someone else's project is the confused-deputy
|
||||
channel this feature exists to close. False = project or connection not
|
||||
eligible.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
project = (
|
||||
await session.execute(select(Project).where(Project.id == project_id))
|
||||
).scalar_one_or_none()
|
||||
if project is None or (project.user_id or 0) != owner_id:
|
||||
return False
|
||||
if connection_id:
|
||||
held = (
|
||||
await session.execute(
|
||||
select(ForgeConnection.id).where(
|
||||
ForgeConnection.id == connection_id,
|
||||
ForgeConnection.user_id == owner_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if held is None:
|
||||
return False
|
||||
project.forge_connection_id = connection_id or None
|
||||
await session.commit()
|
||||
return True
|
||||
Reference in New Issue
Block a user