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>
195 lines
7.1 KiB
Python
195 lines
7.1 KiB
Python
"""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
|