diff --git a/src/repixelizer/access.py b/src/repixelizer/access.py index 2614358..7edd1bc 100644 --- a/src/repixelizer/access.py +++ b/src/repixelizer/access.py @@ -157,6 +157,19 @@ class AccessSubject: def has_capability(self, capability: str) -> bool: return capability in self.capabilities + @property + def primary_identity_provider(self) -> str | None: + identities = self.claims.get("identities") + if not isinstance(identities, list): + return None + for entry in identities: + if not isinstance(entry, dict): + continue + provider = entry.get("provider") + if isinstance(provider, str) and provider.strip(): + return provider.strip().lower() + return None + def owns_job(self, *, account_id: str | None, session_id: str | None) -> bool: if account_id is not None and self.account_id is not None and account_id == self.account_id: return True diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index 5d8ebab..f24088b 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -249,6 +249,9 @@ class GuiJob: account_id: str | None = None session_id: str | None = None access_revision: int | None = None + display_name: str | None = None + auth_mode: str | None = None + identity_provider: str | None = None created_at: float = field(default_factory=time.time) last_heartbeat_at: float = field(default_factory=time.time) running_at: float | None = None @@ -281,18 +284,33 @@ class GuiJob: self.events.append(record) self._condition.notify_all() + def actor_log_json(self) -> str: + payload: dict[str, str] = {} + if self.account_id: + payload["accountId"] = self.account_id + if self.session_id: + payload["sessionId"] = self.session_id + if self.display_name: + payload["displayName"] = self.display_name + if self.auth_mode: + payload["authMode"] = self.auth_mode + if self.identity_provider: + payload["provider"] = self.identity_provider + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + def mark_running(self) -> None: self.status = "running" self.running_at = time.time() self.touch_heartbeat() LOGGER.info( - "repixelizer_job_started job_id=%s wait_seconds=%.3f steps=%s target_size=%s target_width=%s target_height=%s", + "repixelizer_job_started job_id=%s wait_seconds=%.3f steps=%s target_size=%s target_width=%s target_height=%s actor=%s", self.job_id, self.running_at - self.created_at, self.options.get("steps"), self.options.get("target_size"), self.options.get("target_width"), self.options.get("target_height"), + self.actor_log_json(), ) self.publish("job_state", {"status": self.status}) @@ -301,11 +319,12 @@ class GuiJob: finished_at = time.time() run_started_at = self.running_at or finished_at LOGGER.info( - "repixelizer_job_completed job_id=%s wait_seconds=%.3f run_seconds=%.3f total_seconds=%.3f", + "repixelizer_job_completed job_id=%s wait_seconds=%.3f run_seconds=%.3f total_seconds=%.3f actor=%s", self.job_id, run_started_at - self.created_at, finished_at - run_started_at, finished_at - self.created_at, + self.actor_log_json(), ) self.publish("job_state", {"status": self.status}) @@ -315,12 +334,13 @@ class GuiJob: finished_at = time.time() run_started_at = self.running_at or finished_at LOGGER.warning( - "repixelizer_job_failed job_id=%s wait_seconds=%.3f run_seconds=%.3f total_seconds=%.3f message=%r", + "repixelizer_job_failed job_id=%s wait_seconds=%.3f run_seconds=%.3f total_seconds=%.3f message=%r actor=%s", self.job_id, run_started_at - self.created_at, finished_at - run_started_at, finished_at - self.created_at, message, + self.actor_log_json(), ) self.publish("job_failed", {"status": self.status, "message": message}) @@ -331,12 +351,13 @@ class GuiJob: finished_at = time.time() run_started_at = self.running_at or finished_at LOGGER.info( - "repixelizer_job_canceled job_id=%s wait_seconds=%.3f run_seconds=%.3f total_seconds=%.3f message=%r", + "repixelizer_job_canceled job_id=%s wait_seconds=%.3f run_seconds=%.3f total_seconds=%.3f message=%r actor=%s", self.job_id, run_started_at - self.created_at, finished_at - run_started_at, finished_at - self.created_at, message, + self.actor_log_json(), ) self.publish("job_canceled", {"status": self.status, "message": message}) self.publish("job_state", {"status": self.status, "message": message}) @@ -347,7 +368,13 @@ class GuiJob: def request_cancel(self, reason: str) -> None: self.cancel_reason = reason self._cancel_event.set() - LOGGER.info("repixelizer_job_cancel_requested job_id=%s status=%s reason=%r", self.job_id, self.status, reason) + LOGGER.info( + "repixelizer_job_cancel_requested job_id=%s status=%s reason=%r actor=%s", + self.job_id, + self.status, + reason, + self.actor_log_json(), + ) @property def cancellation_message(self) -> str: @@ -690,6 +717,9 @@ class GuiJobManager: account_id: str | None = None, session_id: str | None = None, access_revision: int | None = None, + display_name: str | None = None, + auth_mode: str | None = None, + identity_provider: str | None = None, ) -> GuiJob: self.start() suffix = Path(filename or "input.png").suffix or ".png" @@ -701,6 +731,9 @@ class GuiJobManager: account_id=account_id, session_id=session_id, access_revision=access_revision, + display_name=display_name, + auth_mode=auth_mode, + identity_provider=identity_provider, phase_field_preview_stride=self.config.phase_field_preview_stride, ) job.spool_path.write_bytes(raw) @@ -708,10 +741,11 @@ class GuiJobManager: if len(self._queued_job_ids) >= self.config.queue_capacity: self._cleanup_spool_file(job) LOGGER.warning( - "repixelizer_queue_full waiting_count=%d queue_capacity=%d upload_bytes=%d", + "repixelizer_queue_full waiting_count=%d queue_capacity=%d upload_bytes=%d actor=%s", len(self._queued_job_ids), self.config.queue_capacity, len(raw), + job.actor_log_json(), ) raise QueueFullError(f"Queue is full. {self.config.queue_capacity} waiting jobs are already lined up.") self.jobs[job.job_id] = job @@ -719,13 +753,14 @@ class GuiJobManager: job.publish("job_state", {"status": job.status}) self._publish_queue_state_locked() LOGGER.info( - "repixelizer_job_queued job_id=%s queue_position=%s queue_depth=%d waiting_count=%d queue_capacity=%d upload_bytes=%d", + "repixelizer_job_queued job_id=%s queue_position=%s queue_depth=%d waiting_count=%d queue_capacity=%d upload_bytes=%d actor=%s", job.job_id, self._queue_position_locked(job.job_id), self._queue_depth_locked(), len(self._queued_job_ids), self.config.queue_capacity, len(raw), + job.actor_log_json(), ) self._condition.notify_all() return job @@ -1201,6 +1236,9 @@ def create_app(): account_id=subject.account_id, session_id=subject.session_id, access_revision=subject.access_revision, + display_name=subject.display_name, + auth_mode=subject.auth_mode, + identity_provider=subject.primary_identity_provider, ) except AccessDenied as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/tests/test_gui.py b/tests/test_gui.py index ad3d565..b587f5f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -5,6 +5,7 @@ import io import asyncio import base64 import json +import logging import os import threading import time @@ -1048,6 +1049,56 @@ def test_gui_job_routes_enforce_bound_subject_ownership(monkeypatch, tmp_path: P assert "different local account or session" in str(excinfo.value.detail) +def test_gui_job_logs_authenticated_actor_metadata(monkeypatch, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + from repixelizer.gui import create_app + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "trusted-header") + monkeypatch.setenv("GC_ACCESS_REQUIRED", "1") + + app = create_app() + create_job = _route_endpoint(app, "/api/jobs", "POST") + + subject = AccessSubject( + account_id="acct-1", + session_id="sess-1", + access_revision=4, + display_name="Meta", + capabilities=frozenset({"app_access", "queue_submit"}), + auth_mode="heimdall", + claims={ + "identities": [ + { + "provider": "discord", + "providerUserId": "123456789", + } + ] + }, + ) + + caplog.set_level(logging.INFO, logger="repixelizer.gui") + with bind_access_subject(subject): + created = asyncio.run( + create_job( + image=UploadFile(filename="tiny.png", file=io.BytesIO(_png_bytes())), + target_size=None, + target_width=None, + target_height=None, + steps=None, + seed=7, + device="auto", + strip_background=False, + skip_candidate_rerank=False, + ) + ) + + assert created.status_code == 200 + queued_messages = [record.getMessage() for record in caplog.records if "repixelizer_job_queued" in record.getMessage()] + assert queued_messages + assert 'actor={"accountId":"acct-1","authMode":"heimdall","displayName":"Meta","provider":"discord","sessionId":"sess-1"}' in queued_messages[0] + + def test_gui_queue_panel_defaults_off_for_local_runs_and_can_be_forced(monkeypatch, tmp_path: Path) -> None: from repixelizer.gui import create_app