From 07bc51b890d547252f347876619b0055844e18df Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen https://github.com (or your GitHub
Enterprise URL).
+ {{ unreadableConnections.length === 1 ? "One connection's token" : `${unreadableConnections.length} connections' tokens` }}
+ can't be read on this instance: SECRET_KEY has changed since
+ {{ unreadableConnections.length === 1 ? "it was" : "they were" }} saved. Edit
+ {{ unreadableConnections.map((c) => c.host).join(", ") }} and enter the token again.
+ Until then, forge reads for that host don't run.
+
| Host | Kind | Base URL | ||
|---|---|---|---|---|
| {{ c.host }} | ++ {{ c.host }} + token needs re-entering + | {{ c.kind }} | {{ c.base_url }} |
@@ -4593,6 +4606,11 @@ async function deleteUser(userId: number) { border-radius: var(--fs-radius-md); background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised)); } +.conn-token-lost { + margin-left: var(--fs-space-2); + font-size: var(--fs-size-body-sm); + color: var(--fs-warning-fg); +} .calibration-warning-title { margin: 0 0 var(--fs-space-2); font-size: 0.95rem; diff --git a/src/scribe/models/forge_connection.py b/src/scribe/models/forge_connection.py index 9ecbdeed..d922456b 100644 --- a/src/scribe/models/forge_connection.py +++ b/src/scribe/models/forge_connection.py @@ -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 { diff --git a/src/scribe/routes/settings.py b/src/scribe/routes/settings.py index c5e2f3f9..47ba0d1d 100644 --- a/src/scribe/routes/settings.py +++ b/src/scribe/routes/settings.py @@ -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: diff --git a/src/scribe/services/forge.py b/src/scribe/services/forge.py index 97119aae..c22b96b2 100644 --- a/src/scribe/services/forge.py +++ b/src/scribe/services/forge.py @@ -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": diff --git a/src/scribe/services/forge_connections.py b/src/scribe/services/forge_connections.py index f18b59c5..9fdf633f 100644 --- a/src/scribe/services/forge_connections.py +++ b/src/scribe/services/forge_connections.py @@ -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 diff --git a/tests/test_forge_token_sealing.py b/tests/test_forge_token_sealing.py new file mode 100644 index 00000000..41744c79 --- /dev/null +++ b/tests/test_forge_token_sealing.py @@ -0,0 +1,47 @@ +"""Forge-connection tokens are stored sealed (milestone 523, #5877). + +Migration 0122 seals the rows that existed before, with its OWN frozen copy of +the v1 key derivation (so a later edit to services/secrets.py can never change +what an old migration writes). That copy is only right while it agrees with +what the app opens today — which is what this pins. If secrets.py ever moves +to a v2 key, it must still open v1 values, and this test says so. +""" +import importlib.util +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from scribe.services import secrets +from scribe.services.forge_connections import connection_token + +_MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0122_seal_forge_tokens.py" + + +def _migration(): + spec = importlib.util.spec_from_file_location("migration_0122", _MIGRATION) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_what_the_migration_seals_the_app_opens(): + sealed = _migration().seal_v1("gitea-read-token") + assert "gitea-read-token" not in sealed + assert secrets.unseal(sealed) == "gitea-read-token" + + +def test_a_row_reads_through_connection_token(): + row = SimpleNamespace(token_sealed=secrets.seal("ghp_abc")) + assert connection_token(row) == "ghp_abc" + + +def test_a_row_sealed_under_another_key_reads_as_none(): + row = SimpleNamespace(token_sealed=secrets.seal("ghp_abc")) + with patch.object(secrets.Config, "SECRET_KEY", "a-different-instance"): + assert connection_token(row) is None + + +def test_a_left_over_plain_text_token_is_not_used_as_one(): + """A value that never went through seal is refused, not passed to the + forge as if it were the token: a reader sees None, never the raw bytes.""" + assert connection_token(SimpleNamespace(token_sealed="plain-text-token")) is None diff --git a/tests/test_integration_forge_keyring.py b/tests/test_integration_forge_keyring.py index 27f142ec..0bf28ee5 100644 --- a/tests/test_integration_forge_keyring.py +++ b/tests/test_integration_forge_keyring.py @@ -169,3 +169,29 @@ async def test_env_config_serves_admins_only_and_rows_beat_it(seeded): assert len(selector.adapters) == 1 assert selector.adapters[0]._token == "row-tok" await delete_connection(admin, row.id) + + +@pytest.mark.integration +async def test_the_token_is_stored_sealed_and_a_key_change_drops_the_row(seeded): + """What a copy of the database holds is ciphertext; the reads still get + the token; and a row this instance can no longer open serves nothing + rather than sending ciphertext to the forge as a token.""" + from sqlalchemy import text + + from scribe.services import secrets + + owner = seeded["owner"] + row = await create_connection(owner, kind="gitea", base_url=GITEA, token="plain-tok") + await update_connection(owner, row.id, token="rotated-tok") + async with async_session() as s: + stored = (await s.execute( + text("SELECT token_sealed FROM forge_connections WHERE id = :id"), {"id": row.id} + )).scalar_one() + assert "rotated-tok" not in stored + assert secrets.unseal(stored) == "rotated-tok" + + selector = await get_forges(owner, seeded["project"]) + assert [a._token for a in selector.adapters] == ["rotated-tok"] + + with patch.object(secrets.Config, "SECRET_KEY", "a-different-instance"): + assert not (await get_forges(owner, seeded["project"])).configured |