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")
|
||||
Reference in New Issue
Block a user