Add reviewed resolution for missing user identity links
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m29s

This commit is contained in:
2026-09-10 15:02:04 +12:00
parent b310e86f80
commit 77f2c1b42a
7 changed files with 231 additions and 6 deletions
+23 -1
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import require_admin
from ..services.identity_review import confirm_identities, review_identities
from ..services.identity_review import confirm_identities, review_identities, resolve_identity
router = APIRouter(prefix="/admin/identities", tags=["admin"], dependencies=[Depends(require_admin)])
@@ -31,3 +31,25 @@ async def review(response: Response):
async def confirm(payload: Confirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await confirm_identities(payload.revision, payload.user_ids, admin)
class Resolution(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: int = Field(gt=0, strict=True)
jellyfin_user_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ResolutionConfirmation(Resolution):
revision: str = Field(pattern=r"^[a-f0-9]{64}$")
@router.post("/resolve/check")
async def check_resolution(payload: Resolution, response: Response):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id)
@router.post("/resolve/confirm")
async def confirm_resolution(payload: ResolutionConfirmation, response: Response, admin: dict = Depends(require_admin)):
response.headers["Cache-Control"] = "no-store"
return await resolve_identity(payload.user_id, payload.jellyfin_user_id, payload.revision, admin)
+22 -3
View File
@@ -116,7 +116,10 @@ async def seerr_directory(runtime):
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime):
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None):
selections = selections or {}
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
@@ -155,6 +158,11 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
candidate, basis = by_name[0], "suggested_username"
if user["id"] in selections:
chosen = selections[user["id"]]
if saved and chosen != saved["jellyfin_user_id"]:
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
candidate, basis = chosen, "admin_selected"
if len(local_by_name[name_key(user["username"])]) > 1:
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
@@ -217,6 +225,7 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
"not_checked" if not jellystat else
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
"jellyfin_users": jellyfin["users"],
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
@@ -225,7 +234,7 @@ def build_report(local, jellyfin, seerr, jellystat, runtime):
return report
async def review_identities():
async def review_identities(selections=None):
runtime = await asyncio.to_thread(get_runtime_settings)
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
if len(local["users"]) > MAX_USERS:
@@ -238,7 +247,7 @@ async def review_identities():
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
return build_report(local, jf, seerr, js, runtime), local, runtime
return build_report(local, jf, seerr, js, runtime, selections), local, runtime
def save_confirmations(report, local, runtime, user_ids, admin):
@@ -275,3 +284,13 @@ async def confirm_identities(revision, user_ids, admin):
if report["revision"] != revision:
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
if revision is not None:
if report["revision"] != revision:
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
return {"revision": report["revision"], "server_id": report["server_id"],
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}