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
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:
@@ -16,7 +16,9 @@ class ForgeConnection(Base, TimestampMixin):
|
||||
|
||||
`host` is derived from `base_url` at write time and stored because it is
|
||||
the lookup key; the service layer keeps the two in step. The token is a
|
||||
secret: to_dict never includes it, and no route may return it.
|
||||
secret: it is stored sealed (services/secrets.py — read it through
|
||||
services.forge_connections.connection_token), to_dict never includes it,
|
||||
and no route may return it.
|
||||
"""
|
||||
|
||||
__tablename__ = "forge_connections"
|
||||
@@ -31,7 +33,7 @@ class ForgeConnection(Base, TimestampMixin):
|
||||
kind: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
host: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
token: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
token_sealed: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
|
||||
@@ -125,12 +125,17 @@ async def test_search():
|
||||
@login_required
|
||||
async def list_forge_connections_route():
|
||||
from scribe.services.forge import FORGE_KINDS
|
||||
from scribe.services.forge_connections import list_connections
|
||||
from scribe.services.forge_connections import connection_token, list_connections
|
||||
|
||||
uid = get_current_user_id()
|
||||
rows = await list_connections(uid)
|
||||
# Never the token: only whether this instance can still open it, so the
|
||||
# page can ask for it again after a SECRET_KEY change.
|
||||
return jsonify({
|
||||
"connections": [r.to_dict() for r in rows],
|
||||
"connections": [
|
||||
{**r.to_dict(), "token_readable": connection_token(r) is not None}
|
||||
for r in rows
|
||||
],
|
||||
"kinds": list(FORGE_KINDS),
|
||||
})
|
||||
|
||||
@@ -198,13 +203,19 @@ async def test_forge_connection_route(connection_id: int):
|
||||
press, so a misconfiguration is visible now rather than as silent
|
||||
fallbacks later (#2663's lesson, applied per keyring row)."""
|
||||
from scribe.services.forge import ForgeError, build_adapter
|
||||
from scribe.services.forge_connections import get_connection
|
||||
from scribe.services.forge_connections import connection_token, get_connection
|
||||
|
||||
uid = get_current_user_id()
|
||||
row = await get_connection(uid, connection_id)
|
||||
if row is None:
|
||||
return jsonify({"error": "Connection not found"}), 404
|
||||
adapter = build_adapter(row.kind, row.base_url, row.token)
|
||||
token = connection_token(row)
|
||||
if token is None:
|
||||
return jsonify({
|
||||
"error": "This instance can't read the saved token: SECRET_KEY has changed "
|
||||
"since it was saved. Enter the token again."
|
||||
}), 400
|
||||
adapter = build_adapter(row.kind, row.base_url, token)
|
||||
if adapter is None:
|
||||
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
|
||||
try:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user