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
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:
@@ -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")
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user