feat: make security-journaled actions outcome truthful (Closes #497)
CI / lint (pull_request) Successful in 1m7s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 16:30:31 +00:00
1 parent 814c5726ec
commit 78d044f1af
8 files changed
+494 -57

No files matched your search

+98 -41
View File
@@ -1020,6 +1020,7 @@ async def list_security_events(
"device_label": event.device_label,
"target": event.target,
"created_at": event.created_at,
"status": event.status,
}
for event in page.events
],
@@ -1508,25 +1509,38 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
journal = _security_event_store()
try:
await asyncio.to_thread(dashboard_auth.revoke_session, session)
await asyncio.to_thread(
_security_event_store().record,
operation_id = await asyncio.to_thread(
journal.reserve,
"sign_out",
target="current_device",
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
except SecurityEventStoreError:
return JSONResponse(
{"detail": "Security activity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
try:
await asyncio.to_thread(dashboard_auth.revoke_session, session)
except dashboard_auth.SessionStoreError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
# The pending reservation is durable evidence; do not report a
# successful revocation as failed merely because completion could not
# be marked yet.
pass
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
@@ -1592,30 +1606,44 @@ async def revoke_active_device(
target = next(
(device for device in devices if device.management_id == management_id), None
)
await asyncio.to_thread(_security_event_store().list, limit=1)
await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
revoked = await dashboard_auth.revoke_managed_session(management_id)
if target is not None and revoked:
await asyncio.to_thread(
_security_event_store().record,
"device_revoked",
device_label=target.device_label,
target="device",
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if target is None:
raise HTTPException(status_code=404, detail="Active device not found")
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"device_revoked",
device_label=target.device_label,
target="device",
)
except SecurityEventStoreError:
return JSONResponse(
{"detail": "Security activity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if target is None or not revoked:
try:
await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
revoked = await dashboard_auth.revoke_managed_session(management_id)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if not revoked:
raise HTTPException(status_code=404, detail="Active device not found")
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return {"revoked": True, "current_session": target.current}
@@ -1633,26 +1661,32 @@ async def sign_out_all_devices(
action="revoke_all_sessions",
target="all",
)
journal = _security_event_store()
try:
await asyncio.to_thread(_passkey_store().revoke_all)
await dashboard_auth.revoke_all_sessions()
await asyncio.to_thread(
_security_event_store().record,
operation_id = await asyncio.to_thread(
journal.reserve,
"all_sessions_revoked",
target="all_devices",
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
except SecurityEventStoreError:
return JSONResponse(
{"detail": "Security activity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
try:
await asyncio.to_thread(_passkey_store().revoke_all)
await dashboard_auth.revoke_all_sessions()
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
@@ -3309,21 +3343,20 @@ async def close_assigned_issue(
target=f"{repository}#{number}",
)
async def close_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.close_issue(repository, number)
target = f"{repository}#{number}"
try:
result = await asyncio.wait_for(
close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
assigned = await asyncio.wait_for(
gitea_proxy.is_assigned_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
await asyncio.to_thread(
_security_event_store().record,
if not assigned:
raise HTTPException(status_code=404, detail="Assigned issue not found")
journal = _security_event_store()
operation_id = await asyncio.to_thread(
journal.reserve,
"issue_closed",
target=f"{repository}#{number}",
target=target,
)
return result
except HTTPException:
raise
except Exception:
@@ -3333,6 +3366,30 @@ async def close_assigned_issue(
headers={"Retry-After": "1"},
)
try:
result = await asyncio.wait_for(
gitea_proxy.close_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "The issue could not be closed. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
# The close result is authoritative; the pending reservation preserves
# evidence without telling the operator the issue remains open.
pass
return result
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
async def assigned_issue_label_options(
+92 -11
View File
@@ -1,5 +1,6 @@
"""Bounded, privacy-preserving journal of operator security activity."""
import secrets
import sqlite3
from dataclasses import dataclass
from pathlib import Path
@@ -18,6 +19,7 @@ class SecurityEvent:
device_label: str | None
target: str | None
created_at: int
status: str
@dataclass(frozen=True)
@@ -61,20 +63,48 @@ class SecurityEventStore:
method TEXT,
device_label TEXT,
target TEXT,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
)
"""
)
columns = {
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
}
if "status" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
)
if "operation_id" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)"
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
connection.execute(
"DELETE FROM security_events WHERE created_at < ?",
(now - self.retention_seconds,),
)
connection.execute(
"DELETE FROM security_events WHERE id NOT IN "
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
(self.max_events,),
)
def record(
self,
kind: str,
@@ -87,12 +117,8 @@ class SecurityEventStore:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM security_events WHERE created_at < ?",
(now - self.retention_seconds,),
)
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at) "
"VALUES (?, ?, ?, ?, ?)",
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
"VALUES (?, ?, ?, ?, ?, 'completed')",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
@@ -101,10 +127,65 @@ class SecurityEventStore:
now,
),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def reserve(
self,
kind: str,
*,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
) -> str:
now = int(self.clock())
operation_id = secrets.token_urlsafe(24)
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM security_events WHERE id NOT IN "
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
(self.max_events,),
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
operation_id,
),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
return operation_id
def finalize(self, operation_id: str) -> None:
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
(operation_id,),
)
if cursor.rowcount != 1:
raise SecurityEventStoreError(
"Security activity reservation was not found"
)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def discard(self, operation_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
(operation_id,),
)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
@@ -122,7 +203,7 @@ class SecurityEventStore:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at "
"SELECT id, kind, method, device_label, target, created_at, status "
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
parameters,
).fetchall()