feat: authorize and audit recap time logging (Closes #589)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 20:26:10 +00:00
1 parent 53bf89bd47
commit 379f2b8203
4 files changed
+242 -5

No files matched your search

+51 -1
View File
@@ -279,6 +279,7 @@ StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
"close_issue",
"log_recap_time",
"revoke_device",
"revoke_all_sessions",
"enroll_passkey",
@@ -430,6 +431,19 @@ class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
def _recap_time_log_target(payload: TodayRecapTimeLog) -> str:
selected_minutes = {
item.identity: item.actual_minutes
for item in payload.items
if item.identity in payload.log_identities
}
material = "\0".join(
[payload.session_id]
+ [f"{identity}\0{selected_minutes.get(identity, '')}" for identity in sorted(payload.log_identities)]
)
return f"today-recap:{hashlib.sha256(material.encode()).hexdigest()[:32]}"
class LaterOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["defer", "restore"]
@@ -1921,7 +1935,14 @@ async def save_today_recap(payload: TodayRecap, response: Response):
@app.post("/api/v1/today/recaps/log-time")
async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Response):
async def save_today_recap_and_log_time(
payload: TodayRecapTimeLog,
request: Request,
response: Response,
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
login = await _confirmed_login()
if len(set(payload.log_identities)) != len(payload.log_identities):
raise HTTPException(status_code=422, detail="time log targets must be unique")
@@ -1936,6 +1957,12 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
if requested.actual_minutes <= 0:
raise ValueError("only non-zero recap time can be logged")
selected.append(requested)
await _require_step_up(
request,
step_up_grant,
action="log_recap_time",
target=_recap_time_log_target(payload),
)
recap = await asyncio.to_thread(
_today_store().save_recap,
login,
@@ -1959,6 +1986,21 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
)
if state == "claimed":
repository, number = gitea_proxy.issue_time_target(item.identity)
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"gitea_time_logged",
target=f"{repository}#{number}",
)
except SecurityEventStoreError:
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=False,
)
results.append({"identity": item.identity, "status": "retry"})
continue
try:
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
except Exception as error:
@@ -1967,6 +2009,10 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=False,
)
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
status = "retry"
else:
status = "verify"
@@ -1976,6 +2022,10 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=True,
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
state = "logged"
results.append({
"identity": item.identity,