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
+130
View File
@@ -0,0 +1,130 @@
"""Forge connections move to the user level (#2778)
Revision ID: 0078
Revises: 0077
Create Date: 2026-08-19
A forge token is a user's credential, not an instance's: the single
admin-settings config meant every user's snippet-freshness and coverage reads
ran under the operator's token. Each user now owns a keyring of connections —
one per forge host — and projects resolve forge reads on their OWNER's
keyring, with an optional per-project pin (projects.forge_connection_id).
The data move carries the existing admin config into a connection row for the
first admin user (host parsed from the base URL), then deletes the old
setting keys outright — no legacy dual-read (rule #22). The env-var channel
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
survives as an implicit keyring entry for admin users only.
"""
from urllib.parse import urlsplit
import sqlalchemy as sa
from alembic import op
revision = "0078"
down_revision = "0077"
branch_labels = None
depends_on = None
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
def upgrade() -> None:
op.create_table(
"forge_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("base_url", sa.Text(), nullable=False),
sa.Column("host", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
op.add_column(
"projects",
sa.Column(
"forge_connection_id",
sa.BigInteger(),
sa.ForeignKey(
"forge_connections.id",
ondelete="SET NULL",
name="fk_projects_forge_connection_id",
),
nullable=True,
),
)
# Data move: the admin-settings config becomes the first admin's keyring
# row. All three values must be present — a partial config never produced
# an adapter, so carrying it over would invent a connection that never
# worked.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT s.key, s.value FROM settings s"
" JOIN users u ON u.id = s.user_id"
" WHERE u.role = 'admin' AND s.key IN :keys"
" AND s.user_id = ("
" SELECT MIN(id) FROM users WHERE role = 'admin'"
" )"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
).fetchall()
values = {key: (value or "").strip() for key, value in row}
kind = values.get("forge_kind", "").lower()
base_url = values.get("forge_base_url", "").rstrip("/")
token = values.get("forge_token", "")
host = (urlsplit(base_url).hostname or "").lower()
if kind and base_url and token and host:
conn.execute(
sa.text(
"INSERT INTO forge_connections"
" (user_id, kind, base_url, host, token, created_at, updated_at)"
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
" FROM users WHERE role = 'admin'"
),
{"kind": kind, "base_url": base_url, "host": host, "token": token},
)
conn.execute(
sa.text(
"DELETE FROM settings WHERE key IN :keys"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
)
def downgrade() -> None:
# Reverse data move: the first admin's row (if any) becomes the admin
# settings again. Other users' rows have no pre-0078 representation and
# are dropped with the table.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT user_id, kind, base_url, token FROM forge_connections"
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
" ORDER BY id LIMIT 1"
)
).fetchone()
if row is not None:
for key, value in (
("forge_kind", row.kind),
("forge_base_url", row.base_url),
("forge_token", row.token),
):
conn.execute(
sa.text(
"INSERT INTO settings (user_id, key, value)"
" VALUES (:uid, :key, :value)"
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
),
{"uid": row.user_id, "key": key, "value": value},
)
op.drop_column("projects", "forge_connection_id")
op.drop_table("forge_connections")
+77 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client"; import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime"; import { relativeTime } from "@/composables/useRelativeTime";
@@ -48,6 +49,7 @@ interface Project {
status: "active" | "paused" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
design_system_id: number | null; design_system_id: number | null;
forge_connection_id: number | null;
permission?: string; permission?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -472,12 +474,56 @@ async function refreshCoverage() {
} }
} }
/* ── Forge pin (#2778) — which of the OWNER's connections serves this
project. Owner-only UI: the select lists the viewer's own keyring, which
is only the eligible set when the viewer IS the owner. ── */
const authStore = useAuthStore();
const isProjectOwner = computed(
() => !!project.value && project.value.user_id === authStore.user?.id
);
interface ForgeConnectionOption { id: number; host: string; kind: string }
const forgeOptions = ref<ForgeConnectionOption[]>([]);
const forgePin = ref<number | null>(null);
const savingForgePin = ref(false);
async function loadForgeOptions() {
if (!isProjectOwner.value) return;
try {
const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
"/api/settings/forge-connections"
);
forgeOptions.value = res.connections;
forgePin.value = project.value?.forge_connection_id ?? null;
} catch {
forgeOptions.value = [];
}
}
async function saveForgePin() {
savingForgePin.value = true;
try {
await apiPut(`/api/projects/${projectId.value}/forge`, {
connection_id: forgePin.value,
});
if (project.value) project.value.forge_connection_id = forgePin.value;
await loadCoverage();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toast.show(body?.error || "Failed to change the project's forge", "error");
forgePin.value = project.value?.forge_connection_id ?? null;
} finally {
savingForgePin.value = false;
}
}
onMounted(async () => { onMounted(async () => {
await loadProject(); await loadProject();
loadTasks(); loadTasks();
loadNotes(); loadNotes();
loadDesignSystems(); loadDesignSystems();
loadCoverage(); loadCoverage();
loadForgeOptions();
}); });
/** Populate the design-system picker. Swallows failure on purpose: with no /** Populate the design-system picker. Swallows failure on purpose: with no
@@ -496,6 +542,7 @@ watch(projectId, async () => {
loadTasks(); loadTasks();
loadNotes(); loadNotes();
loadCoverage(); loadCoverage();
loadForgeOptions();
}); });
watch( watch(
@@ -693,6 +740,23 @@ async function confirmDelete() {
against recorded snippets. against recorded snippets.
</p> </p>
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p> <p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
<!-- Forge pin (#2778): owner-only, because the eligible set is the
owner's own keyring. Automatic = resolve by repo host. -->
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
<label for="forge-pin" class="coverage-gaps-label">Forge</label>
<select
id="forge-pin"
v-model="forgePin"
class="input"
:disabled="savingForgePin"
@change="saveForgePin"
>
<option :value="null">Automatic (by repo host)</option>
<option v-for="c in forgeOptions" :key="c.id" :value="c.id">
{{ c.host }} ({{ c.kind }})
</option>
</select>
</div>
</div> </div>
<div class="project-body"> <div class="project-body">
@@ -1202,6 +1266,18 @@ async function confirmDelete() {
flex-wrap: wrap; flex-wrap: wrap;
font-size: 0.78rem; font-size: 0.78rem;
} }
.coverage-forge-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: 0.78rem;
}
.coverage-forge-row select {
max-width: 20rem;
}
.coverage-gaps-label { color: var(--fs-text-tertiary); } .coverage-gaps-label { color: var(--fs-text-tertiary); }
.coverage-gap-chip { .coverage-gap-chip {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
+167 -88
View File
@@ -420,15 +420,25 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false); const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false); const baseUrlSaved = ref(false);
// Git forge integration (admin only, #2689). The token round-trips masked; // Git forge connections (#2778) — the user's keyring: one read-only
// the server treats the mask as "unchanged". // credential per forge host, used server-side for every forge read on
const forge = ref({ kind: "", base_url: "", token: "", webhook_secret: "" }); // projects this user owns. The token round-trips masked; the server treats
const forgeKinds = ref<string[]>(["gitea"]); // the mask as "unchanged".
const forgeConfigured = ref(false); interface ForgeConnectionEntry {
const savingForge = ref(false); id: number; kind: string; base_url: string; host: string;
const forgeSaved = ref(false); }
const testingForge = ref(false); const forgeConnections = ref<ForgeConnectionEntry[]>([]);
const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null); const forgeKinds = ref<string[]>(["gitea", "github"]);
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
const connFormOpen = ref(false);
const savingConn = ref(false);
const testingConnId = ref(0);
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
// The webhook secret stays admin: the push endpoint is one URL per instance
// and authenticates deliveries, not users.
const forgeWebhookSecret = ref("");
const savingForgeWebhook = ref(false);
const forgeWebhookSaved = ref(false);
// Search test (SearXNG) // Search test (SearXNG)
@@ -576,25 +586,30 @@ onMounted(async () => {
// base URL not configured yet // base URL not configured yet
} }
try { try {
await loadForgeSettings(); await loadForgeWebhook();
} catch { } catch {
// forge not configured yet // webhook secret not configured yet
} }
} }
try {
await loadForgeConnections();
} catch {
// no keyring yet — the ordinary state
}
_loadTabContent(activeTab.value); _loadTabContent(activeTab.value);
}); });
async function loadForgeSettings() { async function loadForgeConnections() {
const cfg = await apiGet<{ const res = await apiGet<{
kind: string; base_url: string; token: string; webhook_secret: string; connections: ForgeConnectionEntry[]; kinds: string[];
configured: boolean; kinds: string[]; }>("/api/settings/forge-connections");
}>("/api/admin/forge"); forgeConnections.value = res.connections;
forge.value = { if (res.kinds?.length) forgeKinds.value = res.kinds;
kind: cfg.kind, base_url: cfg.base_url, token: cfg.token, }
webhook_secret: cfg.webhook_secret,
}; async function loadForgeWebhook() {
forgeConfigured.value = cfg.configured; const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds; forgeWebhookSecret.value = cfg.webhook_secret;
} }
async function changeEmail() { async function changeEmail() {
@@ -762,42 +777,77 @@ async function sendTestEmail() {
} }
} }
async function saveForge() { function editConnection(c: ForgeConnectionEntry | null) {
savingForge.value = true; connTestResult.value = null;
forgeSaved.value = false; connFormOpen.value = true;
forgeTestResult.value = null; connForm.value = c
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
}
async function saveConnection() {
savingConn.value = true;
try { try {
await apiPut("/api/admin/forge", forge.value); const { id, ...values } = connForm.value;
await loadForgeSettings(); if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
forgeSaved.value = true; else await apiPost("/api/settings/forge-connections", values);
setTimeout(() => (forgeSaved.value = false), 2000); connFormOpen.value = false;
await loadForgeConnections();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save forge settings", "error"); toastStore.show(body?.error || "Failed to save forge connection", "error");
} finally { } finally {
savingForge.value = false; savingConn.value = false;
} }
} }
async function testForge() { async function removeConnection(id: number) {
testingForge.value = true; try {
forgeTestResult.value = null; await apiDelete(`/api/settings/forge-connections/${id}`);
connTestResult.value = null;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete forge connection", "error");
}
}
async function testConnection(id: number) {
testingConnId.value = id;
connTestResult.value = null;
try { try {
const res = await apiPost<{ version: string; username: string }>( const res = await apiPost<{ version: string; username: string }>(
"/api/admin/forge/test", {}, `/api/settings/forge-connections/${id}/test`, {},
); );
forgeTestResult.value = { connTestResult.value = {
ok: true, id, ok: true,
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`, message: `Connected — ${res.version}, authenticated as ${res.username}`,
}; };
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; const body = (e as { body?: { error?: string } }).body;
forgeTestResult.value = { connTestResult.value = {
ok: false, id, ok: false,
message: body?.error || "Connection test failed", message: body?.error || "Connection test failed",
}; };
} finally { } finally {
testingForge.value = false; testingConnId.value = 0;
}
}
async function saveForgeWebhook() {
savingForgeWebhook.value = true;
try {
await apiPut("/api/admin/forge-webhook", {
webhook_secret: forgeWebhookSecret.value,
});
await loadForgeWebhook();
forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save webhook secret", "error");
} finally {
savingForgeWebhook.value = false;
} }
} }
@@ -1710,6 +1760,70 @@ function formatUserDate(iso: string): string {
</template> </template>
</section> </section>
<section class="settings-section full-width">
<h2>Git Forges</h2>
<p class="section-desc">
Read-only connections to your git forges, one per host. Projects you
own use them server-side — resolved by repo host — to fetch and
drift-check recorded snippets and measure pattern coverage. A
read-scope token is enough. Gitea: an access token with read scope
on repositories. GitHub: a fine-grained PAT with Contents:
Read-only, base URL <code>https://github.com</code> (or your GitHub
Enterprise URL).
</p>
<table v-if="forgeConnections.length" class="api-keys-table">
<thead>
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
</thead>
<tbody>
<tr v-for="c in forgeConnections" :key="c.id">
<td>{{ c.host }}</td>
<td>{{ c.kind }}</td>
<td><code>{{ c.base_url }}</code></td>
<td>
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
{{ testingConnId === c.id ? "Testing…" : "Test" }}
</button>
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="section-desc not-configured">No forge connections yet.</p>
<p
v-if="connTestResult"
:class="connTestResult.ok ? 'text-success' : 'text-error'"
>
{{ connTestResult.message }}
</p>
<div v-if="connFormOpen" class="smtp-grid">
<div class="field">
<label for="conn-kind">Forge</label>
<select id="conn-kind" v-model="connForm.kind" class="input">
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="conn-base-url">Base URL</label>
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="conn-token">API Token (read scope)</label>
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
</div>
</div>
<div class="actions">
<template v-if="connFormOpen">
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
</button>
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
</template>
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
</div>
</section>
</div> </div>
<!-- ── Data ── --> <!-- ── Data ── -->
@@ -2161,61 +2275,26 @@ function formatUserDate(iso: string): string {
</section> </section>
<section class="settings-section full-width"> <section class="settings-section full-width">
<h2>Git Forge</h2> <h2>Forge Webhook</h2>
<p class="section-desc"> <p class="section-desc">
Optional read-only connection to your git forge (Gitea or GitHub) so Forge connections are per-user (Settings Integrations Git
snippet code can be fetched, drift-checked, and coverage-measured Forges). What stays instance-wide is the push webhook: create one on
server-side. A read-scope token is enough. Leave the kind unset to the forge pointing at <code>/api/webhooks/forge</code> with this
keep the integration off. secret, and snippets whose recorded files change get flagged for
re-verification.
</p> </p>
<div class="smtp-grid"> <div class="smtp-grid">
<div class="field">
<label for="forge-kind">Forge</label>
<select id="forge-kind" v-model="forge.kind" class="input">
<option value="">Off</option>
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="forge-base-url">Base URL</label>
<input id="forge-base-url" v-model="forge.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="forge-token">API Token (read scope)</label>
<input id="forge-token" v-model="forge.token" type="password" class="input" />
<p class="field-hint">
Gitea: an access token with read scope on repositories. GitHub:
a fine-grained PAT with Contents: Read-only (or a classic token
with repo read). For GitHub, use
<code>https://github.com</code> as the base URL — or your
GitHub Enterprise instance's URL.
</p>
</div>
<div class="field"> <div class="field">
<label for="forge-webhook-secret">Webhook Secret</label> <label for="forge-webhook-secret">Webhook Secret</label>
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" /> <input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
<p class="field-hint">
Optional: create a push webhook on the forge pointing at
<code>/api/webhooks/forge</code> with this secret, and snippets
whose recorded files change get flagged for re-verification.
</p>
</div> </div>
</div> </div>
<div class="actions" style="margin-bottom: 1.25rem;"> <div class="actions">
<button class="btn-primary" @click="saveForge" :disabled="savingForge"> <button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
{{ savingForge ? "Saving..." : "Save Forge Settings" }} {{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
</button> </button>
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured"> <span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
{{ testingForge ? "Testing..." : "Test Connection" }}
</button>
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
</div> </div>
<p
v-if="forgeTestResult"
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
>
{{ forgeTestResult.message }}
</p>
</section> </section>
</div> </div>
+6 -5
View File
@@ -60,11 +60,12 @@ class Config:
# the MCP layer doesn't proxy web search (Claude has its own). # the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "") SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
# Git forge integration (#2689) — optional read access to the operator's # Git forge integration (#2689) — optional read access to a git forge so
# forge so snippet bodies can be fetched/verified server-side. Normally # snippet bodies can be fetched/verified server-side. Connections are
# configured in Settings → Config (stored as admin settings); these env # per-user keyring rows (#2778, Settings → Git forges); these env values
# fallbacks exist so a deployment can keep the token in a Docker secret # survive as an implicit keyring entry for ADMIN users' projects only, so
# instead of the database. DB value wins when both are set. # a deployment can keep the operator's token in a Docker secret instead
# of the database. A stored row for the same host wins over the env entry.
FORGE_KIND: str = os.environ.get("FORGE_KIND", "") FORGE_KIND: str = os.environ.get("FORGE_KIND", "")
FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/") FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/")
FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "") FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "")
+1
View File
@@ -43,5 +43,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions, Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
) )
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401 from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401 from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+45
View File
@@ -0,0 +1,45 @@
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
class ForgeConnection(Base, TimestampMixin):
"""One user's read-only credential for one git forge host (#2778).
The keyring model: a user owns a set of connections and every server-side
forge read for a project runs on the PROJECT OWNER's set, resolved by the
repo's host. One row per (user, host) — the repo's host picks the
connection deterministically, so there is no "default forge" pointer to
maintain or tie-break.
`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.
"""
__tablename__ = "forge_connections"
__table_args__ = (
UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
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)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"kind": self.kind,
"base_url": self.base_url,
"host": self.host,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+10
View File
@@ -27,6 +27,15 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
design_system_id: Mapped[int | None] = mapped_column( design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
) )
# The per-project forge pin (#2778). NULL is the ordinary state: forge
# reads resolve against the owner's keyring by repo host. When set, the
# project's forge reads use ONLY this connection — an explicit, auditable
# choice, constrained by the service layer to a connection the project
# OWNER holds (never a collaborator's token).
forge_connection_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
nullable=True,
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -38,6 +47,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"status": self.status, "status": self.status,
"color": self.color, "color": self.color,
"design_system_id": self.design_system_id, "design_system_id": self.design_system_id,
"forge_connection_id": self.forge_connection_id,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
+16 -62
View File
@@ -19,15 +19,6 @@ from scribe.services.backup import (
restore_full_backup, restore_full_backup,
) )
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.forge import (
FORGE_BASE_URL_KEY,
FORGE_KIND_KEY,
FORGE_KINDS,
FORGE_TOKEN_KEY,
ForgeError,
forge_config,
get_forge,
)
from scribe.services.logging import get_logs, get_log_stats, log_audit from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email from scribe.services.notifications import send_invitation_email
from scribe.services.settings import ( from scribe.services.settings import (
@@ -169,85 +160,48 @@ async def test_smtp():
_TOKEN_MASK = "********" _TOKEN_MASK = "********"
@admin_bp.route("/forge", methods=["GET"]) # The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git
# forges); what stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users.
@admin_bp.route("/forge-webhook", methods=["GET"])
@admin_required @admin_required
async def get_forge_settings(): async def get_forge_webhook_settings():
from scribe.config import Config from scribe.config import Config
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
cfg = await forge_config()
webhook_secret = ( webhook_secret = (
await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "") await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "")
or Config.FORGE_WEBHOOK_SECRET or Config.FORGE_WEBHOOK_SECRET
) )
return jsonify({ return jsonify({
"kind": cfg["kind"],
"base_url": cfg["base_url"],
# Secrets never leave the server — the smtp_password convention: # Secrets never leave the server — the smtp_password convention:
# masked when set, empty when not. # masked when set, empty when not.
"token": _TOKEN_MASK if cfg["token"] else "",
"webhook_secret": _TOKEN_MASK if webhook_secret else "", "webhook_secret": _TOKEN_MASK if webhook_secret else "",
"configured": bool(await get_forge()),
"kinds": list(FORGE_KINDS),
}) })
@admin_bp.route("/forge", methods=["PUT"]) @admin_bp.route("/forge-webhook", methods=["PUT"])
@admin_required @admin_required
async def update_forge_settings(): async def update_forge_webhook_settings():
data = await request.get_json() or {}
uid = get_current_user_id()
kind = str(data.get("kind", "")).strip().lower()
if kind and kind not in FORGE_KINDS:
return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400
base_url = str(data.get("base_url", "")).strip().rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
return jsonify({"error": "Forge base URL must use http or https"}), 400
await set_admin_setting(FORGE_KIND_KEY, kind)
await set_admin_setting(FORGE_BASE_URL_KEY, base_url)
token = data.get("token")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if token is not None and token != _TOKEN_MASK:
await set_admin_setting(FORGE_TOKEN_KEY, str(token))
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
data = await request.get_json() or {}
uid = get_current_user_id()
webhook_secret = data.get("webhook_secret") webhook_secret = data.get("webhook_secret")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if webhook_secret is not None and webhook_secret != _TOKEN_MASK: if webhook_secret is not None and webhook_secret != _TOKEN_MASK:
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret)) await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
# The token is deliberately absent from the audit detail. # The secret is deliberately absent from the audit detail.
await log_audit( await log_audit(
"forge_config", user_id=uid, username=g.user.username, "forge_webhook_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr, ip_address=request.remote_addr, details={},
details={"kind": kind, "base_url": base_url},
) )
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@admin_bp.route("/forge/test", methods=["POST"])
@admin_required
async def test_forge():
"""Probe the SAVED forge config: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied to integrations)."""
uid = get_current_user_id()
forge = await get_forge()
if forge is None:
return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400
try:
result = await forge.check()
except ForgeError as e:
return jsonify({"error": str(e)}), 502
await log_audit(
"forge_test", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"ok": True, "username": result.get("username", "")},
)
return jsonify(result)
@admin_bp.route("/logs", methods=["GET"]) @admin_bp.route("/logs", methods=["GET"])
@admin_required @admin_required
async def list_logs(): async def list_logs():
+47 -9
View File
@@ -1,7 +1,7 @@
"""Project management routes.""" """Project management routes."""
import logging import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination from scribe.routes.utils import not_found, parse_pagination
@@ -130,7 +130,7 @@ async def get_coverage_route(project_id: int):
push or an explicit refresh). push or an explicit refresh).
""" """
from scribe.services.coverage import cached_coverage from scribe.services.coverage import cached_coverage
from scribe.services.forge import get_forge from scribe.services.forge import get_forges
uid = get_current_user_id() uid = get_current_user_id()
result = await get_project_for_user(uid, project_id) result = await get_project_for_user(uid, project_id)
@@ -139,7 +139,9 @@ async def get_coverage_route(project_id: int):
project, _ = result project, _ = result
owner_uid = project.user_id or uid owner_uid = project.user_id or uid
return jsonify({ return jsonify({
"configured": await get_forge() is not None, # The OWNER's keyring (#2778) — whether refresh could do anything,
# regardless of who is looking.
"configured": (await get_forges(owner_uid, project_id)).configured,
"coverage": await cached_coverage(owner_uid, project_id), "coverage": await cached_coverage(owner_uid, project_id),
}) })
@@ -153,7 +155,7 @@ async def refresh_coverage_route(project_id: int):
and wants the new number, and the forge timeout bounds the wait. and wants the new number, and the forge timeout bounds the wait.
""" """
from scribe.services.coverage import refresh_coverage from scribe.services.coverage import refresh_coverage
from scribe.services.forge import ForgeError, get_forge from scribe.services.forge import ForgeError, get_forges
uid = get_current_user_id() uid = get_current_user_id()
result = await get_project_for_user(uid, project_id) result = await get_project_for_user(uid, project_id)
@@ -161,20 +163,56 @@ async def refresh_coverage_route(project_id: int):
return not_found("Project") return not_found("Project")
project, _ = result project, _ = result
owner_uid = project.user_id or uid owner_uid = project.user_id or uid
if await get_forge() is None: selector = await get_forges(owner_uid, project_id)
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400 if not selector.configured:
return jsonify({
"error": "The project owner has no forge connection "
"(Settings → Git forges)"
}), 400
try: try:
coverage = await refresh_coverage(owner_uid, project_id) coverage = await refresh_coverage(owner_uid, project_id, selector=selector)
except ForgeError as exc: except ForgeError as exc:
return jsonify({"error": str(exc)}), 502 return jsonify({"error": str(exc)}), 502
if coverage is None: if coverage is None:
return jsonify({ return jsonify({
"error": "No bound repo is served by the configured forge" "error": "No bound repo is served by the owner's forge connections"
"bind the project's repo (bind_repo) on a remote the forge hosts" "bind the project's repo (bind_repo) on a remote a connection hosts"
}), 400 }), 400
return jsonify({"coverage": coverage}) return jsonify({"coverage": coverage})
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
"""Pin the project to one forge connection, or clear the pin (#2778).
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
the pin can only reference a connection the project OWNER holds — the
service enforces that, so a collaborator's token can never end up serving
someone else's project.
"""
from scribe.services.forge_connections import set_project_pin
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, permission = result
if permission != "owner" and g.user.role != "admin":
return jsonify({"error": "Only the project owner can change its forge"}), 403
data = await request.get_json() or {}
raw = data.get("connection_id")
if raw is not None and not isinstance(raw, int):
return jsonify({"error": "connection_id must be an integer or null"}), 400
owner_uid = project.user_id or uid
if not await set_project_pin(owner_uid, project_id, raw):
return jsonify({
"error": "That connection does not belong to the project owner"
}), 400
return jsonify({"forge_connection_id": raw})
@projects_bp.route("/<int:project_id>/notes", methods=["GET"]) @projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required @login_required
async def get_project_notes_route(project_id: int): async def get_project_notes_route(project_id: int):
+102 -1
View File
@@ -20,7 +20,8 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# read and skip the mask on write; this generic KV surface has to apply the # read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the # same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw. # rows live on the admin's own user_id, so the plain GET returned them raw.
_SECRET_KEYS = frozenset({"smtp_password", "forge_token", "forge_webhook_secret"}) # (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
_SECRET_MASK = "********" _SECRET_MASK = "********"
@@ -73,3 +74,103 @@ async def test_search():
if not Config.searxng_enabled(): if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""}) return jsonify({"configured": False, "results": [], "searxng_url": ""})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL}) return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
# another user's keyring. Tokens never leave the server: the model's to_dict
# omits them, and the routes never echo the submitted value back.
@settings_bp.route("/forge-connections", methods=["GET"])
@login_required
async def list_forge_connections_route():
from scribe.services.forge import FORGE_KINDS
from scribe.services.forge_connections import list_connections
uid = get_current_user_id()
rows = await list_connections(uid)
return jsonify({
"connections": [r.to_dict() for r in rows],
"kinds": list(FORGE_KINDS),
})
@settings_bp.route("/forge-connections", methods=["POST"])
@login_required
async def create_forge_connection_route():
from scribe.services.forge_connections import create_connection
uid = get_current_user_id()
data = await request.get_json() or {}
try:
row = await create_connection(
uid,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=str(data.get("token", "")),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(row.to_dict()), 201
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["PUT"])
@login_required
async def update_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import update_connection
uid = get_current_user_id()
data = await request.get_json() or {}
token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection.
if token == _SECRET_MASK:
token = ""
try:
row = await update_connection(
uid, connection_id,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=token,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if row is None:
return jsonify({"error": "Connection not found"}), 404
return jsonify(row.to_dict())
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["DELETE"])
@login_required
async def delete_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import delete_connection
uid = get_current_user_id()
if not await delete_connection(uid, connection_id):
return jsonify({"error": "Connection not found"}), 404
return "", 204
@settings_bp.route("/forge-connections/<int:connection_id>/test", methods=["POST"])
@login_required
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
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
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)
if adapter is None:
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
try:
return jsonify(await adapter.check())
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502
+6
View File
@@ -72,6 +72,12 @@ _NOT_INCLUDED = [
"api_keys", "note_embeddings", "app_logs", "notifications", "api_keys", "note_embeddings", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles", "invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs", "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 import tarfile
from datetime import datetime, timezone 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.repo_bindings import keys_for_project
from scribe.services.settings import get_setting, set_setting 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( 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: ) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos. """Measure a project's pattern-library coverage against its bound repos.
None means "nothing to measure"no forge configured, or none of the None means "nothing to measure"the owner's keyring serves none of the
project's bound repos is served by it. That is the ordinary state for a project's bound repos (#2778). That is the ordinary state for a
forge-less install and every caller treats it as silence, not failure. forge-less user and every caller treats it as silence, not failure.
Forge errors (unreachable, bad token) RAISE — the two callers are a Forge errors (unreachable, bad token) RAISE — the two callers are a
refresh button and a background task, and both want to know. 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 selector is None:
if forge is None: selector = await get_forges(user_id, project_id)
if not selector.configured:
return None return None
repos: list[dict] = [] repos: list[dict] = []
matched_all: list[tuple[str, str, str, bool]] = [] matched_all: list[tuple[str, str, str, bool]] = []
recorded = await _recorded_locations(user_id, project_id) recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id): for key in await keys_for_project(user_id, project_id):
api_repo = forge.resolve_repo(key) hit = selector.resolve(key)
if api_repo is None: if hit is None:
continue # bound to a host this forge doesn't serve continue # bound to a host no connection serves
forge, api_repo = hit
ref = await forge.default_branch(api_repo) ref = await forge.default_branch(api_repo)
shapes = shapes_from_archive(await forge.archive(api_repo, ref)) shapes = shapes_from_archive(await forge.archive(api_repo, ref))
matched = match_shapes(shapes, recorded) matched = match_shapes(shapes, recorded)
@@ -299,10 +304,10 @@ async def compute_coverage(
async def refresh_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: ) -> dict | None:
"""Compute and cache. The only writer of the cache key.""" """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: if coverage is not None:
await set_setting( await set_setting(
user_id, f"{_CACHE_KEY_PREFIX}{project_id}", json.dumps(coverage) 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: Design constraints, in force everywhere below:
- OPTIONAL per instance (rule #115). `get_forge()` returns None when nothing - OPTIONAL per user (rule #115, sharpened by #2778). Connections are
is configured, and every consumer must treat None as "keep today's per-user keyring rows resolved by repo host on the PROJECT OWNER's
behavior". An install that never configures a forge is not degraded — it keyring; `get_forges()` returns an empty selector when the owner has
is the baseline. 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 - 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 method to misuse. The token an operator mints for it only ever needs read
scope, and the docs say so. scope, and the docs say so.
@@ -40,18 +42,14 @@ from dataclasses import dataclass
from urllib.parse import quote, urlsplit from urllib.parse import quote, urlsplit
import httpx import httpx
from sqlalchemy import select
from scribe.config import Config from scribe.config import Config
from scribe.services.repo_bindings import normalize_repo_key from scribe.services.repo_bindings import normalize_repo_key
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FORGE_KIND_KEY = "forge_kind" # Kinds a connection can use. Matches _FORGE_CLASSES below.
FORGE_BASE_URL_KEY = "forge_base_url"
FORGE_TOKEN_KEY = "forge_token"
# Kinds an instance can configure. Matches _FORGE_CLASSES below.
FORGE_KINDS = ("gitea", "github") FORGE_KINDS = ("gitea", "github")
# Total budget per forge call. Consumers either have a cache to fall back to # Total budget per forge call. Consumers either have a cache to fall back to
@@ -86,7 +84,8 @@ class ForgeFile:
path: str 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() return (urlsplit(url).hostname or "").lower()
@@ -111,7 +110,7 @@ class ForgeAdapter:
@property @property
def host(self) -> str: 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: def resolve_repo(self, repo_or_url: str) -> str | None:
"""The forge-API repo path for a recorded repo — or None if this forge """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: def build_adapter(
"""The instance's forge configuration, DB-first with env fallback. 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 None means "this connection cannot serve reads" — the same contract the
database entirely (Docker secret via FORGE_TOKEN_FILE) — the DB value wins old instance-wide lookup had, applied per keyring row. Misconfigurations
when both are present because the admin UI writes there, and a UI edit are logged, never raised: a bad row must not break the reads the good
that silently loses to an env var would look exactly like a broken form. rows can still serve.
""" """
return { kind = (kind or "").strip().lower()
"kind": (await get_admin_setting(FORGE_KIND_KEY, "") or Config.FORGE_KIND) base_url = (base_url or "").rstrip("/")
.strip() cls = _FORGE_CLASSES.get(kind)
.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"])
if cls is None: if cls is None:
if cfg["kind"]: if kind:
# A kind we don't implement is a misconfiguration, not "off" — # A kind we don't implement is a misconfiguration, not "off" —
# say so once per lookup rather than silently reading as absent. # 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 return None
if not cfg["base_url"] or not cfg["token"]: if not base_url or not token:
return None return None
if not cfg["base_url"].startswith(("http://", "https://")): if not base_url.startswith(("http://", "https://")):
logger.warning("forge base URL %r has no http(s) scheme — forge disabled", cfg["base_url"]) logger.warning("forge base URL %r has no http(s) scheme — connection disabled", base_url)
return None 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) --------------------------------------------- # --- pull-time freshness (#2690) ---------------------------------------------
# A pull is the moment freshness matters: the reader is about to trust the # 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 # cached body. When the record owner's keyring serves a forge, the pull
# recorded file and answers the one mechanically-answerable question — does # fetches the recorded file and answers the one mechanically-answerable
# the cached code still appear in the source, verbatim after whitespace # question — does the cached code still appear in the source, verbatim after
# normalization? The body is a FRAGMENT of the file, so "serve the fetched # whitespace normalization? The body is a FRAGMENT of the file, so "serve the fetched
# file" would clobber the record; confirmation + provenance refresh is what # file" would clobber the record; confirmation + provenance refresh is what
# fetching can honestly deliver, and divergence is reported, not overwritten. # 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: async def attach_live_body(note, data: dict) -> None:
"""Decorate a PULL response with forge-checked freshness (#2690). """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 - ``body_source``: "forge" (confirmed against the source just now) or
"cache" (the stored body, for whatever reason follows) "cache" (the stored body, for whatever reason follows)
- ``body_freshness``: "current" | "diverged" | "missing" | - ``body_freshness``: "current" | "diverged" | "missing" |
@@ -1004,14 +1005,17 @@ async def attach_live_body(note, data: dict) -> None:
attention state verify_snippet uses. attention state verify_snippet uses.
""" """
from scribe.services.background import spawn 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: 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: except Exception:
logger.warning("forge lookup failed during pull", exc_info=True) logger.warning("forge lookup failed during pull", exc_info=True)
return return
if forge is None: if not selector.configured:
return return
fields = data.get("snippet") if isinstance(data.get("snippet"), dict) else None 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 # 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), # can (#2691). Try the location string first (it may be a real remote),
# then fall back to the bindings of the snippet's project. # then fall back to the bindings of the snippet's project.
repo = forge.resolve_repo(loc["repo"]) resolved = selector.resolve(loc["repo"])
if repo is None and getattr(note, "project_id", None): if resolved is None and getattr(note, "project_id", None):
from scribe.services.repo_bindings import keys_for_project from scribe.services.repo_bindings import keys_for_project
for key in await keys_for_project(note.user_id, note.project_id): for key in await keys_for_project(note.user_id, note.project_id):
repo = forge.resolve_repo(key) resolved = selector.resolve(key)
if repo is not None: if resolved is not None:
break break
if repo is None: if resolved is None:
data["body_source"] = "cache" data["body_source"] = "cache"
data["body_freshness"] = "repo-not-on-this-forge" data["body_freshness"] = "repo-not-on-this-forge"
return return
forge, repo = resolved
stored_prov_sha = (fields.get("provenance") or {}).get("commit_sha") or "" stored_prov_sha = (fields.get("provenance") or {}).get("commit_sha") or ""
+191
View File
@@ -0,0 +1,191 @@
"""Real-Postgres integration tests for the forge keyring (#2778).
Runs only in the CI integration lane (real Postgres, schema built by
`alembic upgrade head`, which includes migration 0078's forge_connections
table and the projects.forge_connection_id pin). This exercises what the
unit tests cannot: the own-rows ACL on connection CRUD, host-keyed
resolution against stored rows, the pin's only-that-connection semantics,
the owner-only pin eligibility, and the admin-only scoping of the env
fallback — every one of which is a cross-user isolation property, and
isolation properties are exactly what mocks cannot prove.
"""
from unittest.mock import patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.config import Config
from scribe.models import async_session, engine
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services.forge import get_forges
from scribe.services.forge_connections import (
create_connection,
delete_connection,
list_connections,
set_project_pin,
update_connection,
)
pytestmark = pytest.mark.integration
GITEA = "https://git.example.com"
GITHUB = "https://github.com"
@pytest_asyncio.fixture(autouse=True)
async def _dispose_engine():
# Per-loop pool: dispose after each test (see test_integration_db_maintenance).
yield
await engine.dispose()
async def _user(session, username: str, role: str = "user") -> User:
existing = (
await session.execute(select(User).where(User.username == username))
).scalar_one_or_none()
if existing is not None:
return existing
user = User(username=username, role=role)
session.add(user)
await session.flush()
return user
@pytest_asyncio.fixture
async def seeded():
"""An owner with a project, plus an unrelated user and an admin."""
async with async_session() as s:
owner = await _user(s, "keyring_owner")
other = await _user(s, "keyring_other")
admin = await _user(s, "keyring_admin", role="admin")
project = Project(user_id=owner.id, title="Keyring project")
s.add(project)
await s.flush()
ids = {
"owner": owner.id, "other": other.id, "admin": admin.id,
"project": project.id,
}
await s.commit()
# Start each test from a clean keyring — the fixture users persist
# across tests in one lane run.
for uid in (ids["owner"], ids["other"], ids["admin"]):
for row in await list_connections(uid):
await delete_connection(uid, row.id)
return ids
@pytest.mark.integration
async def test_crud_is_own_rows_only(seeded):
owner, other = seeded["owner"], seeded["other"]
row = await create_connection(owner, kind="gitea", base_url=GITEA, token="tok")
assert row.host == "git.example.com"
# One row per (user, host) IS the resolution model.
with pytest.raises(ValueError):
await create_connection(owner, kind="gitea", base_url=GITEA + "/", token="t2")
# ...but the same host under ANOTHER user is that user's own business.
theirs = await create_connection(other, kind="gitea", base_url=GITEA, token="t3")
# Another user can neither see, edit, nor delete it.
assert [r.id for r in await list_connections(other)] == [theirs.id]
assert await update_connection(other, row.id, token="stolen") is None
assert await delete_connection(other, row.id) is False
updated = await update_connection(owner, row.id, base_url=GITEA + ":3000")
assert updated is not None and updated.base_url.endswith(":3000")
assert await delete_connection(owner, row.id) is True
assert await delete_connection(other, theirs.id) is True
@pytest.mark.integration
async def test_resolution_runs_on_the_owners_keyring_only(seeded):
owner, other = seeded["owner"], seeded["other"]
await create_connection(owner, kind="gitea", base_url=GITEA, token="tok")
mine = await get_forges(owner)
assert mine.configured
hit = mine.resolve(f"{GITEA}/alice/widget.git")
assert hit is not None and hit[1] == "alice/widget"
# The other user's reads never ride the owner's token.
assert not (await get_forges(other)).configured
@pytest.mark.integration
async def test_pin_means_only_that_connection(seeded):
owner, project = seeded["owner"], seeded["project"]
gitea = await create_connection(owner, kind="gitea", base_url=GITEA, token="t1")
await create_connection(owner, kind="github", base_url=GITHUB, token="t2")
# Unpinned: both hosts resolve from the keyring.
selector = await get_forges(owner, project)
assert selector.resolve(f"{GITEA}/a/b") is not None
assert selector.resolve(f"{GITHUB}/a/b") is not None
assert await set_project_pin(owner, project, gitea.id) is True
pinned = await get_forges(owner, project)
assert pinned.resolve(f"{GITEA}/a/b") is not None
# The pin is exclusive: a host the pinned connection can't serve reads as
# unserved, exactly like the documented repo-not-on-this-forge state.
assert pinned.resolve(f"{GITHUB}/a/b") is None
assert await set_project_pin(owner, project, None) is True
assert (await get_forges(owner, project)).resolve(f"{GITHUB}/a/b") is not None
@pytest.mark.integration
async def test_pin_only_accepts_the_owners_connections(seeded):
owner, other, project = seeded["owner"], seeded["other"], seeded["project"]
theirs = await create_connection(other, kind="gitea", base_url=GITEA, token="t")
# A collaborator's token can never be attached to someone else's project —
# the confused-deputy channel this feature exists to close.
assert await set_project_pin(owner, project, theirs.id) is False
# And only the owner's projects accept a pin at all.
assert await set_project_pin(other, project, theirs.id) is False
@pytest.mark.integration
async def test_stale_pin_after_ownership_transfer_is_ignored_not_honored(seeded):
owner, other, project = seeded["owner"], seeded["other"], seeded["project"]
gitea = await create_connection(owner, kind="gitea", base_url=GITEA, token="t")
assert await set_project_pin(owner, project, gitea.id) is True
async with async_session() as s:
proj = await s.get(Project, project)
proj.user_id = other
await s.commit()
try:
# The pin now names a connection the (new) owner does not hold: it
# must fall back to the new owner's keyring — empty — never keep
# reading with the previous owner's token.
assert not (await get_forges(other, project)).configured
finally:
async with async_session() as s:
proj = await s.get(Project, project)
proj.user_id = owner
proj.forge_connection_id = None
await s.commit()
@pytest.mark.integration
async def test_env_config_serves_admins_only_and_rows_beat_it(seeded):
owner, admin = seeded["owner"], seeded["admin"]
with patch.object(Config, "FORGE_KIND", "gitea"), \
patch.object(Config, "FORGE_BASE_URL", GITEA), \
patch.object(Config, "FORGE_TOKEN", "env-tok"):
# The env entry is the OPERATOR's token: admin projects only.
assert (await get_forges(admin)).configured
assert not (await get_forges(owner)).configured
# A stored row for the same host wins — the UI writes rows, and a UI
# edit that silently lost to an env var would look like a broken form.
row = await create_connection(
admin, kind="gitea", base_url=GITEA, token="row-tok"
)
selector = await get_forges(admin)
assert len(selector.adapters) == 1
assert selector.adapters[0]._token == "row-tok"
await delete_connection(admin, row.id)
+13 -5
View File
@@ -194,6 +194,14 @@ def _forge(tar_bytes: bytes):
) )
def _selector(tar_bytes: bytes):
"""The keyring shape compute_coverage consumes since #2778 — one owner
keyring holding the mocked Gitea adapter."""
from scribe.services.forge import ForgeSelector
return ForgeSelector((_forge(tar_bytes),))
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def _dispose_engine(): async def _dispose_engine():
from scribe.models import engine from scribe.models import engine
@@ -250,9 +258,9 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
) )
uid, pid = seeded["uid"], seeded["pid"] uid, pid = seeded["uid"], seeded["pid"]
forge = _forge(_tarball(TREE)) selector = _selector(_tarball(TREE))
coverage = await compute_coverage(uid, pid, forge=forge) coverage = await compute_coverage(uid, pid, selector=selector)
assert coverage is not None assert coverage is not None
assert coverage["total"] == 4 assert coverage["total"] == 4
assert coverage["recorded"] == 2 assert coverage["recorded"] == 2
@@ -266,7 +274,7 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
# Nothing computed → nothing cached; refresh writes; the cache reads back # Nothing computed → nothing cached; refresh writes; the cache reads back
# byte-equal, because enter_project will serve exactly this. # byte-equal, because enter_project will serve exactly this.
assert await cached_coverage(uid, pid) is None assert await cached_coverage(uid, pid) is None
stored = await refresh_coverage(uid, pid, forge=forge) stored = await refresh_coverage(uid, pid, selector=selector)
assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored)) assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored))
@@ -284,7 +292,7 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
before = await enter_project(project_id=pid) before = await enter_project(project_id=pid)
assert before["pattern_coverage"] is None assert before["pattern_coverage"] is None
await refresh_coverage(uid, pid, forge=_forge(_tarball(TREE))) await refresh_coverage(uid, pid, selector=_selector(_tarball(TREE)))
after = await enter_project(project_id=pid) after = await enter_project(project_id=pid)
line = after["pattern_coverage"] line = after["pattern_coverage"]
assert line.startswith( assert line.startswith(
@@ -314,4 +322,4 @@ async def test_unservable_binding_measures_nothing(seeded):
await s.commit() await s.commit()
await set_binding(uid, "https://github.com/somebody/else.git", other_pid) await set_binding(uid, "https://github.com/somebody/else.git", other_pid)
assert await compute_coverage(uid, other_pid, forge=_forge(_tarball(TREE))) is None assert await compute_coverage(uid, other_pid, selector=_selector(_tarball(TREE))) is None
+79 -72
View File
@@ -7,12 +7,13 @@ decoding, error taxonomy) is testable with no live server and no new
dependency. These are the reference behaviors step 8's GitHub adapter must dependency. These are the reference behaviors step 8's GitHub adapter must
reproduce. reproduce.
The most load-bearing tests are the OFF ones: an unconfigured instance must The most load-bearing tests are the OFF ones: a user with no usable connection
get None from get_forge(), because every consumer treats None as "behave as if must get an empty selector, because every consumer treats that as "behave as
the module didn't exist" (rule #115 — the baseline install has no forge). if the module didn't exist" (rule #115 — the baseline user has no forge).
DB-backed keyring behavior (get_forges: rows, the project pin, the admin-only
env entry) lives in tests/test_integration_forge_keyring.py.
""" """
import base64 import base64
from unittest.mock import AsyncMock, patch
import httpx import httpx
import pytest import pytest
@@ -20,8 +21,9 @@ import pytest
from scribe.services.forge import ( from scribe.services.forge import (
ForgeError, ForgeError,
ForgeNotFound, ForgeNotFound,
ForgeSelector,
GiteaForge, GiteaForge,
get_forge, build_adapter,
) )
BASE = "https://git.example.com" BASE = "https://git.example.com"
@@ -140,63 +142,59 @@ async def test_check_reports_version_and_identity():
assert result == {"ok": True, "version": "1.23.1", "username": "scribe-bot"} assert result == {"ok": True, "version": "1.23.1", "username": "scribe-bot"}
# --- the configuration gate -------------------------------------------------- # --- the configuration gate (build_adapter) ----------------------------------
def _settings(values: dict): def test_empty_or_partial_values_build_nothing():
async def fake(key, default=""):
return values.get(key, default)
return patch("scribe.services.forge.get_admin_setting", AsyncMock(side_effect=fake))
async def test_unconfigured_instance_gets_none():
with _settings({}), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
assert await get_forge() is None
async def test_partial_config_is_still_off():
# A base URL with no token (or vice versa) must not half-enable anything. # A base URL with no token (or vice versa) must not half-enable anything.
for values in ( assert build_adapter("", "", "") is None
{"forge_kind": "gitea", "forge_base_url": BASE}, assert build_adapter("gitea", BASE, "") is None
{"forge_kind": "gitea", "forge_token": "tok"}, assert build_adapter("gitea", "", "tok") is None
{"forge_base_url": BASE, "forge_token": "tok"}, # no kind selected assert build_adapter("", BASE, "tok") is None # no kind selected
):
with _settings(values), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
assert await get_forge() is None
async def test_unknown_kind_disables_with_a_warning_not_a_crash(): def test_unknown_kind_disables_with_a_warning_not_a_crash():
with _settings({ assert build_adapter("sourcehut", BASE, "tok") is None
"forge_kind": "sourcehut", "forge_base_url": BASE, "forge_token": "tok",
}), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
assert await get_forge() is None
async def test_full_config_builds_a_gitea_adapter(): def test_schemeless_base_url_disables():
with _settings({ assert build_adapter("gitea", "git.example.com", "tok") is None
"forge_kind": "gitea",
"forge_base_url": BASE + "/", # trailing slash normalized away
"forge_token": "tok", def test_full_values_build_a_gitea_adapter():
}), patch("scribe.services.forge.Config") as cfg: forge = build_adapter("Gitea", BASE + "/", "tok") # case + slash normalized
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
forge = await get_forge()
assert isinstance(forge, GiteaForge) assert isinstance(forge, GiteaForge)
assert forge.base_url == BASE assert forge.base_url == BASE
assert forge.host == "git.example.com" assert forge.host == "git.example.com"
async def test_env_channel_fills_gaps_but_db_wins(): def test_full_values_build_a_github_adapter():
# Docker-secret deployments set FORGE_* env; an admin-UI value overrides. from scribe.services.forge import GitHubForge
with _settings({"forge_base_url": "https://db.example.com"}), \
patch("scribe.services.forge.Config") as cfg: assert isinstance(
cfg.FORGE_KIND = "gitea" build_adapter("github", "https://github.com", "tok"), GitHubForge
cfg.FORGE_BASE_URL = "https://env.example.com" )
cfg.FORGE_TOKEN = "env-tok"
forge = await get_forge()
assert isinstance(forge, GiteaForge) # --- the selector (#2778): host-keyed resolution over a keyring ---------------
assert forge.host == "db.example.com"
def test_empty_selector_is_the_rule_115_baseline():
selector = ForgeSelector()
assert not selector.configured
assert selector.resolve(f"{BASE}/alice/widget") is None
def test_selector_resolves_by_host_across_the_keyring():
gitea = build_adapter("gitea", BASE, "tok")
github = build_adapter("github", "https://github.com", "tok2")
selector = ForgeSelector((gitea, github))
assert selector.configured
hit = selector.resolve("https://git.example.com/alice/widget")
assert hit == (gitea, "alice/widget")
hit = selector.resolve("git@github.com:alice/Widget.git")
assert hit == (github, "alice/widget")
# A host neither connection serves is a NORMAL miss, not an error.
assert selector.resolve("https://elsewhere.example.org/a/b") is None
def test_forge_error_taxonomy_is_catchable_as_one_family(): def test_forge_error_taxonomy_is_catchable_as_one_family():
@@ -220,30 +218,50 @@ def test_adapter_contract_surface():
assert set(FORGE_KINDS) == {"gitea", "github"} assert set(FORGE_KINDS) == {"gitea", "github"}
def test_admin_routes_registered(): def test_forge_routes_registered_at_their_post_2778_homes():
"""Connections are USER settings; only the webhook secret stays admin.
The old instance-wide /api/admin/forge endpoints must be GONE, not
coexisting — a legacy config surface that still wrote admin settings
would silently configure nothing (rule #22: no dual path)."""
from scribe.app import create_app from scribe.app import create_app
from scribe.routes import admin as admin_routes from scribe.routes import admin as admin_routes
for name in ("get_forge_settings", "update_forge_settings", "test_forge"): for name in ("get_forge_webhook_settings", "update_forge_webhook_settings"):
assert callable(getattr(admin_routes, name)) assert callable(getattr(admin_routes, name))
rules = {r.rule for r in create_app().url_map.iter_rules()} rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/admin/forge" in rules assert "/api/admin/forge-webhook" in rules
assert "/api/admin/forge/test" in rules assert "/api/admin/forge" not in rules
assert "/api/admin/forge/test" not in rules
assert "/api/settings/forge-connections" in rules
assert "/api/settings/forge-connections/<int:connection_id>" in rules
assert "/api/settings/forge-connections/<int:connection_id>/test" in rules
assert "/api/projects/<int:project_id>/forge" in rules
def test_settings_kv_surface_masks_the_forge_token(): def test_settings_kv_surface_masks_the_webhook_secret():
"""The generic /api/settings dump masked nothing — the admin endpoints' """The generic /api/settings dump masked nothing — the admin endpoints'
masking was bypassable by reading the raw KV rows (found while wiring the masking was bypassable by reading the raw KV rows (found while wiring the
forge token; smtp_password had the same exposure).""" forge token; smtp_password had the same exposure). forge_token left the
KV with #2778 (keyring rows carry it now), so it is deliberately no
longer a secret KEY here."""
from scribe.routes.settings import _SECRET_KEYS, _masked from scribe.routes.settings import _SECRET_KEYS, _masked
out = _masked({"forge_token": "tok-123", "smtp_password": "pw", "theme": "dark"}) out = _masked({"forge_webhook_secret": "sec", "smtp_password": "pw", "theme": "dark"})
assert out["forge_token"] == "********" assert out["forge_webhook_secret"] == "********"
assert out["smtp_password"] == "********" assert out["smtp_password"] == "********"
assert out["theme"] == "dark" assert out["theme"] == "dark"
assert {"forge_token", "smtp_password"} <= set(_SECRET_KEYS) assert {"forge_webhook_secret", "smtp_password"} <= set(_SECRET_KEYS)
assert "forge_token" not in _SECRET_KEYS
# An unset secret stays empty rather than reading as a set-but-masked one. # An unset secret stays empty rather than reading as a set-but-masked one.
assert _masked({"forge_token": ""})["forge_token"] == "" assert _masked({"forge_webhook_secret": ""})["forge_webhook_secret"] == ""
def test_connection_to_dict_never_carries_the_token():
"""The model is the last line: every list/create/update route returns
to_dict(), so a field added here is a field leaked there."""
from scribe.models.forge_connection import ForgeConnection
assert "token" not in ForgeConnection.to_dict.__code__.co_consts
def test_config_has_the_docker_secret_channel(): def test_config_has_the_docker_secret_channel():
@@ -358,14 +376,3 @@ async def test_latest_commit_parses_tolerantly_on_both_adapters():
assert await _forge(lambda r: _json(200, [])).latest_commit("a/w", "x.py") == "" assert await _forge(lambda r: _json(200, [])).latest_commit("a/w", "x.py") == ""
async def test_full_config_builds_a_github_adapter():
from scribe.services.forge import GitHubForge
with _settings({
"forge_kind": "github",
"forge_base_url": "https://github.com",
"forge_token": "tok",
}), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
forge = await get_forge()
assert isinstance(forge, GitHubForge)
+9 -2
View File
@@ -57,7 +57,14 @@ def _file_response(content: str, commit_sha: str = SHA) -> httpx.Response:
def _patched(forge): def _patched(forge):
return patch("scribe.services.forge.get_forge", AsyncMock(return_value=forge)) """Stub the owner-keyring lookup (#2778): None → an empty selector, i.e.
the owner has no connections — the old 'no forge configured' state."""
from scribe.services.forge import ForgeSelector
selector = ForgeSelector(() if forge is None else (forge,))
return patch(
"scribe.services.forge.get_forges", AsyncMock(return_value=selector)
)
async def test_no_forge_attaches_nothing(): async def test_no_forge_attaches_nothing():
@@ -260,7 +267,7 @@ def test_both_pull_surfaces_attach_freshness():
async def test_forge_failure_inside_lookup_never_breaks_the_pull(): async def test_forge_failure_inside_lookup_never_breaks_the_pull():
with patch( with patch(
"scribe.services.forge.get_forge", AsyncMock(side_effect=RuntimeError("cfg")) "scribe.services.forge.get_forges", AsyncMock(side_effect=RuntimeError("cfg"))
): ):
data = _data() data = _data()
await svc.attach_live_body(_note(), data) await svc.attach_live_body(_note(), data)