CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m46s
CI & Build / Python tests (push) Successful in 2m36s
CI & Build / Build & push image (push) Successful in 31s
The 1-2 hit tail: access, background, backup, backup_job, canonical_systems, design_stylesheet, forge_connections, moment_actions, notes, notifications, placement, planning, platforms, projects, record_batch, recurrence, report_check, retrieval_review, rule_scope, rule_versions, scheduler, shape_check, supersession, system_rulings, system_usage, text, trash, verification, version_pinning. Also corrected while reading: - backup.py said restore step 16 fills the System map; step 15 does. - version_pinning.py pointed at a design doc that is not in the repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
"""User-level forge connection CRUD — the keyring rows get_forges reads.
|
|
|
|
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"), and it is
|
|
stored sealed (services/secrets.py): a copy of the database alone cannot read
|
|
it. Every reader opens it through `connection_token`.
|
|
|
|
Validation matches what build_adapter will accept, checked here so a bad
|
|
value is a 400 at the form instead of a silently dead keyring row.
|
|
"""
|
|
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
|
|
from scribe.services.secrets import seal, unseal
|
|
|
|
|
|
def connection_token(row: ForgeConnection) -> str | None:
|
|
"""The connection's token in the clear, or None when this instance can't
|
|
open it — sealed under a different SECRET_KEY (a restore onto a new
|
|
instance, a rotated key). The owner enters it again; nothing else can."""
|
|
return unseal(row.token_sealed)
|
|
|
|
|
|
def validate_connection(kind: str, base_url: str) -> str | None:
|
|
"""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_sealed=seal(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_sealed = seal(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
|