diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py index 8eac08d..bd596db 100644 --- a/backend/src/backup_tool/api/app.py +++ b/backend/src/backup_tool/api/app.py @@ -244,6 +244,10 @@ def create_app(settings: Settings) -> FastAPI: raise Problem(403, "csrf_failed", "CSRF validation failed.") return user, scopes, cookie_auth + def enforce_scope(scopes: set[str], required: str) -> None: + if "*" not in scopes and required not in scopes: + raise Problem(403, "insufficient_scope", "Required scope is missing.") + async def audit( db: AsyncSession, request: Request, @@ -767,8 +771,9 @@ def create_app(settings: Settings) -> FastAPI: async def enqueue_execution( job_id: str, db: Annotated[AsyncSession, Depends(session)], - _: Annotated[tuple[User, set[str], bool], Depends(require)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> dict[str, Any]: + enforce_scope(identity[1], "execution:control") try: execution = await enqueue(db, job_id) except EnqueueError as error: @@ -793,8 +798,9 @@ def create_app(settings: Settings) -> FastAPI: async def get_execution( execution_id: str, db: Annotated[AsyncSession, Depends(session)], - _: Annotated[tuple[User, set[str], bool], Depends(actor)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> dict[str, object]: + enforce_scope(identity[1], "execution:read") execution = await db.get(Execution, execution_id) if execution is None: raise Problem(404, "resource_not_found", "Execution was not found.") @@ -804,8 +810,9 @@ def create_app(settings: Settings) -> FastAPI: async def cancel_execution( execution_id: str, db: Annotated[AsyncSession, Depends(session)], - _: Annotated[tuple[User, set[str], bool], Depends(require)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> dict[str, object]: + enforce_scope(identity[1], "execution:control") execution = await request_cancellation(db, execution_id) if execution is None: raise Problem(409, "cancellation_not_allowed", "Execution cannot be cancelled.") @@ -815,8 +822,9 @@ def create_app(settings: Settings) -> FastAPI: async def retry_execution( execution_id: str, db: Annotated[AsyncSession, Depends(session)], - _: Annotated[tuple[User, set[str], bool], Depends(require)], + identity: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> dict[str, object]: + enforce_scope(identity[1], "execution:control") try: execution = await retry(db, execution_id) except EnqueueError as error: @@ -829,8 +837,9 @@ def create_app(settings: Settings) -> FastAPI: async def execution_events( execution_id: str, db: Annotated[AsyncSession, Depends(session)], - _: Annotated[tuple[User, set[str], bool], Depends(actor)], + identity: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> StreamingResponse: + enforce_scope(identity[1], "execution:read") execution = await db.get(Execution, execution_id) if execution is None: raise Problem(404, "resource_not_found", "Execution was not found.") diff --git a/backend/src/backup_tool/execution.py b/backend/src/backup_tool/execution.py index 70387bb..aaab7a5 100644 --- a/backend/src/backup_tool/execution.py +++ b/backend/src/backup_tool/execution.py @@ -169,9 +169,25 @@ async def retry(db: AsyncSession, execution_id: str) -> Execution | None: return execution +def _redact_progress(value: object) -> object: + """Apply common redaction plus execution-specific path redaction recursively.""" + if isinstance(value, Mapping): + safe: dict[str, object] = {} + for key, item in value.items(): + normalized = str(key).lower() + if any(marker in normalized for marker in ("path", "secret", "token", "password")): + safe[str(key)] = "[REDACTED]" + else: + safe[str(key)] = _redact_progress(item) + return safe + if isinstance(value, list): + return [_redact_progress(item) for item in value] + return redact(value) + + def public_event(execution: Execution) -> dict[str, object]: """Return redacted progress suitable for polling or SSE.""" - progress = redact(execution.progress) + progress = _redact_progress(execution.progress) return { "id": execution.id, "state": execution.state, diff --git a/tests/integration/test_sources_jobs.py b/tests/integration/test_sources_jobs.py index 793d676..c70e4f2 100644 --- a/tests/integration/test_sources_jobs.py +++ b/tests/integration/test_sources_jobs.py @@ -115,9 +115,7 @@ async def test_local_source_probe_archive_and_repository_targeted_job( await client.get(f"/api/v2/executions/{execution_id}", headers=token_headers) ).status_code == 403 assert ( - await client.post( - f"/api/v2/executions/{execution_id}/cancel", headers=token_headers - ) + await client.post(f"/api/v2/executions/{execution_id}/cancel", headers=token_headers) ).status_code == 403 async with app.state.sessions() as db: assert await claim(db, execution_id, "expired-worker") is not None