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

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:
2026-08-19 11:23:22 -04:00
co-authored by Claude Fable 5
parent 7a5e2b18d9
commit 1faf8f3ece
19 changed files with 1252 additions and 310 deletions
+6
View File
@@ -72,6 +72,12 @@ _NOT_INCLUDED = [
"api_keys", "note_embeddings", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs",
# Sensitive credentials, same reasoning as api_keys: a backup that carries
# forge tokens is a token-exfiltration file. Users re-add connections
# after a restore; the per-project pin (projects.forge_connection_id) is
# deliberately not exported either, so restored projects fall back to
# keyring-by-host resolution — the documented unpinned behavior (#2778).
"forge_connections",
]
+17 -12
View File
@@ -33,7 +33,7 @@ import re
import tarfile
from datetime import datetime, timezone
from scribe.services.forge import ForgeAdapter, get_forge
from scribe.services.forge import ForgeSelector, get_forges
from scribe.services.repo_bindings import keys_for_project
from scribe.services.settings import get_setting, set_setting
@@ -252,27 +252,32 @@ async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str,
async def compute_coverage(
user_id: int, project_id: int, *, forge: ForgeAdapter | None = None
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos.
None means "nothing to measure"no forge configured, or none of the
project's bound repos is served by it. That is the ordinary state for a
forge-less install and every caller treats it as silence, not failure.
None means "nothing to measure"the owner's keyring serves none of the
project's bound repos (#2778). That is the ordinary state for a
forge-less user and every caller treats it as silence, not failure.
Forge errors (unreachable, bad token) RAISE — the two callers are a
refresh button and a background task, and both want to know.
``user_id`` is the project OWNER's id: the cache lives there, and the
keyring resolved here must be the same one every other read uses.
"""
forge = forge if forge is not None else await get_forge()
if forge is None:
if selector is None:
selector = await get_forges(user_id, project_id)
if not selector.configured:
return None
repos: list[dict] = []
matched_all: list[tuple[str, str, str, bool]] = []
recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id):
api_repo = forge.resolve_repo(key)
if api_repo is None:
continue # bound to a host this forge doesn't serve
hit = selector.resolve(key)
if hit is None:
continue # bound to a host no connection serves
forge, api_repo = hit
ref = await forge.default_branch(api_repo)
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
matched = match_shapes(shapes, recorded)
@@ -299,10 +304,10 @@ async def compute_coverage(
async def refresh_coverage(
user_id: int, project_id: int, *, forge: ForgeAdapter | None = None
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None:
"""Compute and cache. The only writer of the cache key."""
coverage = await compute_coverage(user_id, project_id, forge=forge)
coverage = await compute_coverage(user_id, project_id, selector=selector)
if coverage is not None:
await set_setting(
user_id, f"{_CACHE_KEY_PREFIX}{project_id}", json.dumps(coverage)
+124 -40
View File
@@ -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))
+194
View File
@@ -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
+18 -13
View File
@@ -941,10 +941,10 @@ async def record_verification(
# --- pull-time freshness (#2690) ---------------------------------------------
# A pull is the moment freshness matters: the reader is about to trust the
# cached body. When the instance has a forge configured, the pull fetches the
# recorded file and answers the one mechanically-answerable question — does
# the cached code still appear in the source, verbatim after whitespace
# normalization? The body is a FRAGMENT of the file, so "serve the fetched
# cached body. When the record owner's keyring serves a forge, the pull
# fetches the recorded file and answers the one mechanically-answerable
# question — does the cached code still appear in the source, verbatim after
# whitespace normalization? The body is a FRAGMENT of the file, so "serve the fetched
# file" would clobber the record; confirmation + provenance refresh is what
# fetching can honestly deliver, and divergence is reported, not overwritten.
#
@@ -990,7 +990,8 @@ async def _refresh_provenance(note, commit_sha: str) -> None:
async def attach_live_body(note, data: dict) -> None:
"""Decorate a PULL response with forge-checked freshness (#2690).
Adds, when (and only when) a forge is configured:
Adds, when (and only when) the record owner's keyring serves a forge
(#2778):
- ``body_source``: "forge" (confirmed against the source just now) or
"cache" (the stored body, for whatever reason follows)
- ``body_freshness``: "current" | "diverged" | "missing" |
@@ -1004,14 +1005,17 @@ async def attach_live_body(note, data: dict) -> None:
attention state verify_snippet uses.
"""
from scribe.services.background import spawn
from scribe.services.forge import ForgeError, ForgeNotFound, get_forge
from scribe.services.forge import ForgeError, ForgeNotFound, get_forges
try:
forge = await get_forge()
# The OWNER's keyring, honoring the project pin (#2778) — freshness
# for a record is checked with its owner's credential, never the
# reader's.
selector = await get_forges(note.user_id, getattr(note, "project_id", None))
except Exception:
logger.warning("forge lookup failed during pull", exc_info=True)
return
if forge is None:
if not selector.configured:
return
fields = data.get("snippet") if isinstance(data.get("snippet"), dict) else None
@@ -1033,18 +1037,19 @@ async def attach_live_body(note, data: dict) -> None:
# address a forge API — the project's repo BINDING is the identity that
# can (#2691). Try the location string first (it may be a real remote),
# then fall back to the bindings of the snippet's project.
repo = forge.resolve_repo(loc["repo"])
if repo is None and getattr(note, "project_id", None):
resolved = selector.resolve(loc["repo"])
if resolved is None and getattr(note, "project_id", None):
from scribe.services.repo_bindings import keys_for_project
for key in await keys_for_project(note.user_id, note.project_id):
repo = forge.resolve_repo(key)
if repo is not None:
resolved = selector.resolve(key)
if resolved is not None:
break
if repo is None:
if resolved is None:
data["body_source"] = "cache"
data["body_freshness"] = "repo-not-on-this-forge"
return
forge, repo = resolved
stored_prov_sha = (fields.get("provenance") or {}).get("commit_sha") or ""