"""Employee self-service surface for the business owner's confirmation. Mounts under ``/api/v1/me/bounty``. Any authenticated employee may read and act on the reports assigned to **them**; ``self_service.load_my_report`` enforces that on every call, so there is no role gate here — being named on the report is the authorization (same shape as ``/api/v1/me/access-review``). This is the landing surface for the Feishu card the security team sends when they assign a report, and it is where the confirm controls live: the card must land on a page that can actually answer, not on a read-only detail view. """ from __future__ import annotations import uuid from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.idempotent_route import idempotent from app.core.response import success_response from app.domains.bounty import constants as const from app.domains.bounty import notify, self_service from app.domains.bounty import service as svc from app.identity.person_models import IdentityPerson router = APIRouter(prefix="/api/v1/me/bounty", tags=["bounty"]) class _Body(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) class RespondBody(_Body): decision: str note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) suggested_severity: str | None = Field(default=None, max_length=24) fix_eta: date | None = None remediation_status: str | None = None @field_validator("decision") @classmethod def _check_decision(cls, v: str) -> str: if v not in const.OWNER_DECISIONS: raise ValueError(f"unknown decision '{v}'") return v @field_validator("remediation_status") @classmethod def _check_remediation(cls, v: str | None) -> str | None: if v and v not in const.OWNER_SETTABLE_REMEDIATION: raise ValueError(f"'{v}' is not a state you can set") return v class RemediationBody(_Body): remediation_status: str fix_eta: date | None = None note: str | None = Field(default=None, max_length=const.MAX_COMMENT_CHARS) @field_validator("remediation_status") @classmethod def _check(cls, v: str) -> str: if v not in const.OWNER_SETTABLE_REMEDIATION: raise ValueError(f"'{v}' is not a state you can set") return v class CommentBody(_Body): body: str = Field(min_length=1, max_length=const.MAX_COMMENT_CHARS) async def _audit( db: AsyncSession, request: Request, user: IdentityPerson, *, action: str, resource_id: str, detail: dict | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type="bounty_report", resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) @router.get("") async def list_my_bounty_reports( request: Request, include_closed: bool = Query(False), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Reports assigned to the caller + the counters the todo hub badge needs. Never includes the researcher's identity or the reward.""" return success_response( await self_service.list_my_reports( db, person_id=user.id, include_closed=include_closed, ), request=request, ) @router.get("/{report_id}") async def get_my_bounty_report( report_id: uuid.UUID, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await db.get(IdentityPerson, user.id) return success_response( await self_service.my_report_detail( db, report_id, person=person or user, ), request=request, ) @router.post("/{report_id}/respond") @idempotent("bounty.owner_respond", key_params=("report_id",)) async def respond_to_report( report_id: uuid.UUID, body: RespondBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Submit the verdict: real issue / false positive / need more info. Records the decision and parks the report in ``in_discussion`` — the final severity, the scope call and the reward stay with the security team, and the owner is told so on the page. """ person = await db.get(IdentityPerson, user.id) or user report = await self_service.respond( db, report_id, person=person, decision=body.decision, note=body.note, suggested_severity=body.suggested_severity, fix_eta=body.fix_eta, remediation_status=body.remediation_status, ) await _audit( db, request, user, action="bounty.owner_respond", resource_id=str(report.id), detail={ "decision": body.decision, "suggested_severity": body.suggested_severity, }, ) notified = await notify.notify_security_of_owner_response( db, report, owner=person, ) payload = await self_service.my_report_detail(db, report.id, person=person) payload["security_notified"] = notified return success_response(payload, request=request) @router.post("/{report_id}/remediation") @idempotent("bounty.owner_remediation", key_params=("report_id",)) async def update_my_remediation( report_id: uuid.UUID, body: RemediationBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Move the fix forward: 待修复 → 修复中 → 已修复. ``wont_fix`` / ``risk_accepted`` are deliberately not settable here — accepting a security risk is a decision for the security team, so the page routes it through a comment instead. """ person = await db.get(IdentityPerson, user.id) or user report = await self_service.update_remediation( db, report_id, person=person, remediation_status=body.remediation_status, fix_eta=body.fix_eta, note=body.note, ) await _audit( db, request, user, action="bounty.owner_remediation", resource_id=str(report.id), detail={"remediation_status": body.remediation_status}, ) return success_response( await self_service.my_report_detail(db, report.id, person=person), request=request, ) @router.post("/{report_id}/comments") async def comment_on_my_report( report_id: uuid.UUID, body: CommentBody, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Talk to the security team on the report. Always shared with them — also the right place to say "this isn't my area, ask X".""" person = await db.get(IdentityPerson, user.id) or user event = await self_service.add_comment( db, report_id, person=person, body=body.body, ) return success_response(event, request=request) @router.get("/{report_id}/attachments/{attachment_id}/url") async def my_attachment_url( report_id: uuid.UUID, attachment_id: uuid.UUID, request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """A short-lived download URL for one attachment on my report. ``load_my_report`` is the authorisation and it runs FIRST: it is the same scoping the detail endpoint uses, so an owner cannot mint a URL for a report that was never assigned to them. The attachment lookup is then scoped to that report id, which is what stops an attachment id belonging to somebody else's report from being signed. A presigned URL bypasses Helix auth once minted, so it is deliberately short-lived and never stored — see ``core.storage.presign_get``. """ person = await db.get(IdentityPerson, user.id) or user report = await self_service.load_my_report(db, report_id, person_id=person.id) url = await svc.attachment_download_url( db, report_id=report.id, attachment_id=attachment_id, ) if url is None: raise HTTPException(status_code=404, detail="ATTACHMENT_NOT_FOUND") return success_response({"url": url}, request=request)