fix(P1): 成片库越权漏洞修复-全链路加user_id校验 #573

Merged
auto-approve-bot merged 1 commits from fix/p1-video-library-authz into develop 2026-07-19 12:01:14 +08:00
7 changed files with 53 additions and 38 deletions
+8 -8
View File
@@ -93,7 +93,7 @@ def get_video(
):
"""获取单个成片详情。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id)
item = use_case.execute(video_id, user_id=current_user.user.id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
return _to_video_response(item, storage)
@@ -109,7 +109,7 @@ def update_video_review_status(
):
"""更新成片复核状态:pending_review / approved / rejected。"""
use_case = UpdateVideoReviewStatusUseCase(repo)
item = use_case.execute(video_id, request.review_status)
item = use_case.execute(video_id, request.review_status, user_id=current_user.user.id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
logger.info(
@@ -127,7 +127,7 @@ def delete_video(
):
"""删除单个成片(硬删除)。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id)
item = use_case.execute(video_id, user_id=current_user.user.id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
@@ -139,7 +139,7 @@ def delete_video(
except Exception:
logger.warning("删除 OSS 视频文件失败,跳过: video_id=%s", video_id)
repo.delete(video_id)
repo.delete(video_id, user_id=current_user.user.id)
return Response(status_code=204)
@@ -151,7 +151,7 @@ def batch_delete_videos(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量删除成片。"""
videos = repo.get_by_ids(request.video_ids)
videos = repo.get_by_ids(request.video_ids, user_id=current_user.user.id)
existing_ids = {v.id for v in videos}
failed_ids = [vid for vid in request.video_ids if vid not in existing_ids]
failed_details = {vid: "Video not found" for vid in failed_ids}
@@ -165,7 +165,7 @@ def batch_delete_videos(
except Exception:
logger.warning("批量删除 OSS 视频文件失败,跳过: video_id=%s", video.id)
success_count = repo.batch_delete(list(existing_ids))
success_count = repo.batch_delete(list(existing_ids), user_id=current_user.user.id)
return BatchOperationResponse(
success_count=success_count,
failed_ids=failed_ids,
@@ -188,9 +188,9 @@ def batch_download_videos(
if len(request.video_ids) > 50:
raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download")
# 校验视频都存在
# 校验视频都存在且属于当前用户
use_case = GetVideosByIdsUseCase(repo)
videos = use_case.execute(request.video_ids)
videos = use_case.execute(request.video_ids, user_id=current_user.user.id)
if len(videos) != len(request.video_ids):
raise HTTPException(status_code=404, detail="Some videos not found")
@@ -37,8 +37,11 @@ class SQLAlchemyGeneratedVideoRepository:
self.session.commit()
return video
def get(self, video_id: str) -> GeneratedVideo | None:
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
def get(self, video_id: str, *, user_id: str | None = None) -> GeneratedVideo | None:
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
model = query.first()
if model is None:
return None
return GeneratedVideo(
@@ -135,9 +138,14 @@ class SQLAlchemyGeneratedVideoRepository:
return [self._to_domain(model) for model in models], total
def update_review_status(self, video_id: str, review_status: str) -> GeneratedVideo | None:
def update_review_status(
self, video_id: str, review_status: str, *, user_id: str | None = None
) -> GeneratedVideo | None:
"""更新成片复核状态。"""
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
model = query.first()
if model is None:
return None
model.review_status = review_status
@@ -155,31 +163,36 @@ class SQLAlchemyGeneratedVideoRepository:
self.session.commit()
return True
def get_by_ids(self, video_ids: list[str]) -> list[GeneratedVideo]:
def get_by_ids(self, video_ids: list[str], *, user_id: str | None = None) -> list[GeneratedVideo]:
"""批量获取成片记录。"""
if not video_ids:
return []
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids)).all()
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids))
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
models = query.all()
return [self._to_domain(model) for model in models]
def delete(self, video_id: str) -> bool:
def delete(self, video_id: str, *, user_id: str | None = None) -> bool:
"""删除单个成片记录(硬删除)。"""
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
model = query.first()
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
def batch_delete(self, video_ids: list[str]) -> int:
def batch_delete(self, video_ids: list[str], *, user_id: str | None = None) -> int:
"""批量删除成片记录,返回成功删除数量。"""
if not video_ids:
return 0
result = (
self.session.query(GeneratedVideoModel)
.filter(GeneratedVideoModel.id.in_(video_ids))
.delete(synchronize_session=False)
)
query = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids))
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
result = query.delete(synchronize_session=False)
self.session.commit()
return result
+8 -8
View File
@@ -46,8 +46,8 @@ class GetGeneratedVideoUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_id: str) -> GeneratedVideo | None:
return self.generated_video_repository.get(video_id)
def execute(self, video_id: str, *, user_id: str | None = None) -> GeneratedVideo | None:
return self.generated_video_repository.get(video_id, user_id=user_id)
class ListGeneratedVideosByTaskUseCase:
@@ -64,8 +64,8 @@ class GetGeneratedVideoDownloadUrlUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_id: str) -> str | None:
item = self.generated_video_repository.get(video_id)
def execute(self, video_id: str, *, user_id: str | None = None) -> str | None:
item = self.generated_video_repository.get(video_id, user_id=user_id)
if item is None:
return None
return item.file_url
@@ -75,17 +75,17 @@ class UpdateVideoReviewStatusUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_id: str, review_status: str) -> GeneratedVideo | None:
def execute(self, video_id: str, review_status: str, *, user_id: str | None = None) -> GeneratedVideo | None:
if not video_id.strip():
raise ValueError("video_id 不能为空")
if review_status not in ("pending_review", "approved", "rejected"):
raise ValueError(f"无效的 review_status: {review_status}")
return self.generated_video_repository.update_review_status(video_id.strip(), review_status)
return self.generated_video_repository.update_review_status(video_id.strip(), review_status, user_id=user_id)
class GetVideosByIdsUseCase:
def __init__(self, generated_video_repository: GeneratedVideoRepository):
self.generated_video_repository = generated_video_repository
def execute(self, video_ids: list[str]) -> list[GeneratedVideo]:
return self.generated_video_repository.get_by_ids(video_ids)
def execute(self, video_ids: list[str], *, user_id: str | None = None) -> list[GeneratedVideo]:
return self.generated_video_repository.get_by_ids(video_ids, user_id=user_id)
+7 -5
View File
@@ -8,7 +8,7 @@ from packages.domain import GeneratedVideo
class GeneratedVideoRepository(Protocol):
def create(self, video: GeneratedVideo) -> GeneratedVideo: ...
def get(self, video_id: str) -> GeneratedVideo | None: ...
def get(self, video_id: str, *, user_id: str | None = None) -> GeneratedVideo | None: ...
def list_by_project(self, project_id: str) -> list[GeneratedVideo]: ...
@@ -27,12 +27,14 @@ class GeneratedVideoRepository(Protocol):
page_size: int = 20,
) -> tuple[list[GeneratedVideo], int]: ...
def update_review_status(self, video_id: str, review_status: str) -> GeneratedVideo | None: ...
def update_review_status(
self, video_id: str, review_status: str, *, user_id: str | None = None
) -> GeneratedVideo | None: ...
def update_thumbnail(self, video_id: str, thumbnail_url: str) -> bool: ...
def get_by_ids(self, video_ids: list[str]) -> list[GeneratedVideo]: ...
def get_by_ids(self, video_ids: list[str], *, user_id: str | None = None) -> list[GeneratedVideo]: ...
def delete(self, video_id: str) -> bool: ...
def delete(self, video_id: str, *, user_id: str | None = None) -> bool: ...
def batch_delete(self, video_ids: list[str]) -> int: ...
def batch_delete(self, video_ids: list[str], *, user_id: str | None = None) -> int: ...
+1 -1
View File
@@ -211,7 +211,7 @@ class StubGeneratedVideoRepository:
self._videos[video.id] = video
return video
def get(self, video_id: str) -> GeneratedVideo | None:
def get(self, video_id: str, **kwargs) -> GeneratedVideo | None:
return self._videos.get(video_id)
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
@@ -35,7 +35,7 @@ class DummyGeneratedVideoRepository:
self.items[video.id] = video
return video
def get(self, video_id):
def get(self, video_id, **kwargs):
return self.items.get(video_id)
def list_by_project(self, project_id):
@@ -255,7 +255,7 @@ class TestGeneratedVideoUseCases:
use_case = GetGeneratedVideoUseCase(repo)
result = use_case.execute("v1")
repo.get.assert_called_once_with("v1")
repo.get.assert_called_once_with("v1", user_id=None)
assert result.id == "v1"
def test_list_by_task_use_case(self):