feat(523): forge-connection tokens are stored sealed under SECRET_KEY, like the backup S3 secret
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 36s

The column becomes token_sealed (migration 0122 seals existing rows with a
frozen copy of the v1 key), every reader opens it through connection_token,
a row this instance cannot open is skipped with a warning, and Settings asks
for that token again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 18:08:11 -04:00
co-authored by Claude Opus 5.5
parent 3008d29f89
commit 07bc51b890
10 changed files with 232 additions and 16 deletions
+18 -2
View File
@@ -358,6 +358,22 @@ _FORGE_CLASSES: dict[str, type[ForgeAdapter]] = {
}
def _token_of(row) -> str:
"""A keyring row's token, opened; empty when this instance can't open it,
which build_adapter reads as "this connection cannot serve reads"."""
from scribe.services.forge_connections import connection_token
token = connection_token(row)
if token is None:
logger.warning(
"forge connection %s (%s) holds a token this instance can't open — "
"SECRET_KEY changed since it was saved; its owner must enter it again",
row.id, row.host,
)
return ""
return token
def build_adapter(
kind: str, base_url: str, token: str, *, transport=None
) -> ForgeAdapter | None:
@@ -459,7 +475,7 @@ async def get_forges(
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)
adapter = build_adapter(pin.kind, pin.base_url, _token_of(pin), 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",
@@ -468,7 +484,7 @@ async def get_forges(
adapters: list[ForgeAdapter] = []
for row in rows:
adapter = build_adapter(row.kind, row.base_url, row.token, transport=transport)
adapter = build_adapter(row.kind, row.base_url, _token_of(row), transport=transport)
if adapter is not None:
adapters.append(adapter)
if role == "admin":
+14 -3
View File
@@ -4,7 +4,9 @@ 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").
leaves the server (model.to_dict omits it; routes mask "set/unset"), and it is
stored sealed (services/secrets.py): a copy of the database alone cannot read
it. Every reader opens it through `connection_token`.
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.
@@ -17,6 +19,14 @@ 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
from scribe.services.secrets import seal, unseal
def connection_token(row: ForgeConnection) -> str | None:
"""The connection's token in the clear, or None when this instance can't
open it — sealed under a different SECRET_KEY (a restore onto a new
instance, a rotated key). The owner enters it again; nothing else can."""
return unseal(row.token_sealed)
def validate_connection(kind: str, base_url: str) -> str | None:
@@ -82,7 +92,8 @@ async def create_connection(
"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
user_id=user_id, kind=kind, base_url=base_url, host=host,
token_sealed=seal(token),
)
session.add(row)
await session.commit()
@@ -135,7 +146,7 @@ async def update_connection(
row.base_url = new_base
row.host = new_host
if token:
row.token = token
row.token_sealed = seal(token)
await session.commit()
await session.refresh(row)
return row