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:
+124
-40
@@ -8,10 +8,12 @@ webhooks (step 6), and coverage can be measured (step 7).
|
||||
|
||||
Design constraints, in force everywhere below:
|
||||
|
||||
- OPTIONAL per instance (rule #115). `get_forge()` returns None when nothing
|
||||
is configured, and every consumer must treat None as "keep today's
|
||||
behavior". An install that never configures a forge is not degraded — it
|
||||
is the baseline.
|
||||
- OPTIONAL per user (rule #115, sharpened by #2778). Connections are
|
||||
per-user keyring rows resolved by repo host on the PROJECT OWNER's
|
||||
keyring; `get_forges()` returns an empty selector when the owner has
|
||||
nothing configured, and every consumer must treat that as "keep today's
|
||||
behavior". A user who never configures a forge is not degraded — that is
|
||||
the baseline.
|
||||
- READ-ONLY by construction. The adapter exposes reads; there is no write
|
||||
method to misuse. The token an operator mints for it only ever needs read
|
||||
scope, and the docs say so.
|
||||
@@ -40,18 +42,14 @@ from dataclasses import dataclass
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.config import Config
|
||||
from scribe.services.repo_bindings import normalize_repo_key
|
||||
from scribe.services.settings import get_admin_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FORGE_KIND_KEY = "forge_kind"
|
||||
FORGE_BASE_URL_KEY = "forge_base_url"
|
||||
FORGE_TOKEN_KEY = "forge_token"
|
||||
|
||||
# Kinds an instance can configure. Matches _FORGE_CLASSES below.
|
||||
# Kinds a connection can use. Matches _FORGE_CLASSES below.
|
||||
FORGE_KINDS = ("gitea", "github")
|
||||
|
||||
# Total budget per forge call. Consumers either have a cache to fall back to
|
||||
@@ -86,7 +84,8 @@ class ForgeFile:
|
||||
path: str
|
||||
|
||||
|
||||
def _host_of(url: str) -> str:
|
||||
def host_of(url: str) -> str:
|
||||
"""The lowercase hostname of a URL — the keyring's lookup key (#2778)."""
|
||||
return (urlsplit(url).hostname or "").lower()
|
||||
|
||||
|
||||
@@ -111,7 +110,7 @@ class ForgeAdapter:
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return _host_of(self.base_url)
|
||||
return host_of(self.base_url)
|
||||
|
||||
def resolve_repo(self, repo_or_url: str) -> str | None:
|
||||
"""The forge-API repo path for a recorded repo — or None if this forge
|
||||
@@ -359,39 +358,124 @@ _FORGE_CLASSES: dict[str, type[ForgeAdapter]] = {
|
||||
}
|
||||
|
||||
|
||||
async def forge_config() -> dict:
|
||||
"""The instance's forge configuration, DB-first with env fallback.
|
||||
def build_adapter(
|
||||
kind: str, base_url: str, token: str, *, transport=None
|
||||
) -> ForgeAdapter | None:
|
||||
"""One validated adapter from raw connection values, or None.
|
||||
|
||||
The env channel exists so a deployment can keep the token out of the
|
||||
database entirely (Docker secret via FORGE_TOKEN_FILE) — the DB value wins
|
||||
when both are present because the admin UI writes there, and a UI edit
|
||||
that silently loses to an env var would look exactly like a broken form.
|
||||
None means "this connection cannot serve reads" — the same contract the
|
||||
old instance-wide lookup had, applied per keyring row. Misconfigurations
|
||||
are logged, never raised: a bad row must not break the reads the good
|
||||
rows can still serve.
|
||||
"""
|
||||
return {
|
||||
"kind": (await get_admin_setting(FORGE_KIND_KEY, "") or Config.FORGE_KIND)
|
||||
.strip()
|
||||
.lower(),
|
||||
"base_url": (
|
||||
await get_admin_setting(FORGE_BASE_URL_KEY, "") or Config.FORGE_BASE_URL
|
||||
).rstrip("/"),
|
||||
"token": await get_admin_setting(FORGE_TOKEN_KEY, "") or Config.FORGE_TOKEN,
|
||||
}
|
||||
|
||||
|
||||
async def get_forge(*, transport=None) -> ForgeAdapter | None:
|
||||
"""The configured forge adapter, or None — and None means "behave exactly
|
||||
as if this module did not exist", which every consumer must honor."""
|
||||
cfg = await forge_config()
|
||||
cls = _FORGE_CLASSES.get(cfg["kind"])
|
||||
kind = (kind or "").strip().lower()
|
||||
base_url = (base_url or "").rstrip("/")
|
||||
cls = _FORGE_CLASSES.get(kind)
|
||||
if cls is None:
|
||||
if cfg["kind"]:
|
||||
if kind:
|
||||
# A kind we don't implement is a misconfiguration, not "off" —
|
||||
# say so once per lookup rather than silently reading as absent.
|
||||
logger.warning("unknown forge kind %r configured — forge disabled", cfg["kind"])
|
||||
logger.warning("unknown forge kind %r configured — connection disabled", kind)
|
||||
return None
|
||||
if not cfg["base_url"] or not cfg["token"]:
|
||||
if not base_url or not token:
|
||||
return None
|
||||
if not cfg["base_url"].startswith(("http://", "https://")):
|
||||
logger.warning("forge base URL %r has no http(s) scheme — forge disabled", cfg["base_url"])
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
logger.warning("forge base URL %r has no http(s) scheme — connection disabled", base_url)
|
||||
return None
|
||||
return cls(cfg["base_url"], cfg["token"], transport=transport)
|
||||
return cls(base_url, token, transport=transport)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForgeSelector:
|
||||
"""The forge reads available to one project owner (#2778).
|
||||
|
||||
Consumers ask it to serve a REPO, not to hand over "the forge": resolve()
|
||||
walks the owner's adapters and returns the (adapter, api_repo) pair for
|
||||
the first one whose host serves the repo — or None, which every consumer
|
||||
treats exactly as the old "no forge configured" state. An empty selector
|
||||
IS rule #115's baseline.
|
||||
"""
|
||||
|
||||
adapters: tuple[ForgeAdapter, ...] = ()
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.adapters)
|
||||
|
||||
def resolve(self, repo_or_url: str) -> tuple[ForgeAdapter, str] | None:
|
||||
for adapter in self.adapters:
|
||||
repo = adapter.resolve_repo(repo_or_url)
|
||||
if repo is not None:
|
||||
return adapter, repo
|
||||
return None
|
||||
|
||||
|
||||
async def get_forges(
|
||||
owner_id: int, project_id: int | None = None, *, transport=None
|
||||
) -> ForgeSelector:
|
||||
"""The forge selector for reads on behalf of ``owner_id``'s records.
|
||||
|
||||
The keyring model (#2778): every server-side forge read for a record runs
|
||||
on the PROJECT OWNER's connections, resolved by repo host — a forge token
|
||||
is a user's credential, and one user's reads must never ride another
|
||||
user's token. Pass the record's ``project_id`` so the per-project pin
|
||||
applies: a pinned project uses ONLY its pinned connection (explicit and
|
||||
auditable); a pin that no longer belongs to the owner (ownership moved) is
|
||||
ignored with a warning rather than honored across users.
|
||||
|
||||
The env config (FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) survives as an
|
||||
implicit keyring entry for ADMIN owners only — it is the operator's
|
||||
token, so it must not serve other users' reads — and a stored row for the
|
||||
same host beats it, because the UI writes rows.
|
||||
"""
|
||||
from scribe.models import async_session
|
||||
from scribe.models.forge_connection import ForgeConnection
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.user import User
|
||||
|
||||
async with async_session() as session:
|
||||
pinned_id = None
|
||||
if project_id:
|
||||
pinned_id = (
|
||||
await session.execute(
|
||||
select(Project.forge_connection_id).where(Project.id == project_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
rows = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ForgeConnection)
|
||||
.where(ForgeConnection.user_id == owner_id)
|
||||
.order_by(ForgeConnection.id)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
role = ""
|
||||
if Config.FORGE_KIND and Config.FORGE_BASE_URL and Config.FORGE_TOKEN:
|
||||
role = (
|
||||
await session.execute(select(User.role).where(User.id == owner_id))
|
||||
).scalar_one_or_none() or ""
|
||||
|
||||
if pinned_id:
|
||||
pin = next((r for r in rows if r.id == pinned_id), None)
|
||||
if pin is not None:
|
||||
adapter = build_adapter(pin.kind, pin.base_url, pin.token, transport=transport)
|
||||
return ForgeSelector((adapter,) if adapter is not None else ())
|
||||
logger.warning(
|
||||
"project %s pins forge connection %s the owner (%s) does not hold — pin ignored",
|
||||
project_id, pinned_id, owner_id,
|
||||
)
|
||||
|
||||
adapters: list[ForgeAdapter] = []
|
||||
for row in rows:
|
||||
adapter = build_adapter(row.kind, row.base_url, row.token, transport=transport)
|
||||
if adapter is not None:
|
||||
adapters.append(adapter)
|
||||
if role == "admin":
|
||||
env = build_adapter(
|
||||
Config.FORGE_KIND, Config.FORGE_BASE_URL, Config.FORGE_TOKEN,
|
||||
transport=transport,
|
||||
)
|
||||
if env is not None and all(a.host != env.host for a in adapters):
|
||||
adapters.append(env)
|
||||
return ForgeSelector(tuple(adapters))
|
||||
|
||||
Reference in New Issue
Block a user