"""Private registry credentials. Admin-only throughout, including the reads: even masked, the rows say which registries this install talks to and under what account. """ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select from auth import require_admin from database import get_session from models.registry import ( Registry, RegistryCreate, RegistryRead, RegistryTestRequest, RegistryUpdate, ) from models.user import User from services import audit_service, crypto_service, registry_service router = APIRouter(prefix="/api/registries", tags=["registries"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _to_read(row: Registry) -> RegistryRead: # The password never leaves the server, not even masked — the UI only needs # to know whether one is stored, so it can leave the field blank on edit. return RegistryRead( id=row.id, name=row.name, host=row.host, username=row.username, has_password=bool(row.password), created_at=row.created_at, updated_at=row.updated_at, ) def _get_or_404(session: Session, registry_id: int) -> Registry: row = session.get(Registry, registry_id) if not row: raise HTTPException(status_code=404, detail=f"Registry {registry_id} not found") return row def _canonical(host: str) -> str: try: return registry_service.canonical_host(host) except registry_service.RegistryError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @router.get("", response_model=list[RegistryRead]) def list_registries( session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> list[RegistryRead]: rows = session.exec(select(Registry).order_by(Registry.host)).all() return [_to_read(r) for r in rows] @router.post("", response_model=RegistryRead, status_code=201) def create_registry( body: RegistryCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> RegistryRead: host = _canonical(body.host) if session.exec(select(Registry).where(Registry.host == host)).first(): # One set of credentials per registry: two rows for the same host would # make "which account are we using" unanswerable. raise HTTPException( status_code=409, detail=f"Credentials for '{host}' already exist" ) if not body.username or not body.password: raise HTTPException(status_code=400, detail="Username and password are required") row = Registry( name=body.name or host, host=host, username=body.username, password=crypto_service.encrypt(body.password), ) session.add(row) session.commit() session.refresh(row) registry_service.reload(session) audit_service.record( session, user=user.username, action="registry.create", target=host, detail=f"as {body.username}", ip=_ip(request), ) return _to_read(row) @router.put("/{registry_id}", response_model=RegistryRead) def update_registry( registry_id: int, body: RegistryUpdate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> RegistryRead: row = _get_or_404(session, registry_id) if body.host is not None: host = _canonical(body.host) clash = session.exec(select(Registry).where(Registry.host == host)).first() if clash and clash.id != row.id: raise HTTPException( status_code=409, detail=f"Credentials for '{host}' already exist" ) row.host = host if body.name is not None: row.name = body.name if body.username is not None: row.username = body.username # An omitted password keeps the stored one: the UI never received it, so it # cannot send it back. if body.password: row.password = crypto_service.encrypt(body.password) row.updated_at = datetime.now(timezone.utc) session.add(row) session.commit() session.refresh(row) registry_service.reload(session) audit_service.record( session, user=user.username, action="registry.update", target=row.host, ip=_ip(request), ) return _to_read(row) @router.delete("/{registry_id}") def delete_registry( registry_id: int, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: row = _get_or_404(session, registry_id) host = row.host session.delete(row) session.commit() # Rewrites config.json without this host, so the CLI loses the login too. registry_service.reload(session) audit_service.record( session, user=user.username, action="registry.delete", target=host, ip=_ip(request), ) return {"ok": True} @router.post("/test") async def test_credentials( body: RegistryTestRequest, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> dict: """Try a set of credentials against the registry. With no password in the body, the stored one for that host is used — that is how the UI can re-test a saved registry it never received the password for. """ host = _canonical(body.host) password = body.password username = body.username if not password: stored = session.exec(select(Registry).where(Registry.host == host)).first() if not stored: raise HTTPException(status_code=400, detail="A password is required") try: password = crypto_service.decrypt(stored.password) except crypto_service.DecryptError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc username = username or stored.username try: await registry_service.verify(host, username, password) except registry_service.RegistryError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, "host": host}