security: scope activity journal by principal (Closes #1462)
This commit is contained in:
1 parent
bf716cab6e
commit
3b9f184a47
9 files changed
+162
-58
No files matched your search
+35
-2
@@ -496,6 +496,15 @@ async def _upstream_identity() -> tuple[int, str]:
|
||||
return principal_id, principal_login
|
||||
|
||||
|
||||
async def _security_principal_id(request: Request) -> int:
|
||||
session = getattr(request.state, "dashboard_session", None)
|
||||
principal_id = getattr(session, "principal_id", None)
|
||||
if isinstance(principal_id, int) and not isinstance(principal_id, bool) and principal_id > 0:
|
||||
return principal_id
|
||||
principal_id, _login = await _upstream_identity()
|
||||
return principal_id
|
||||
|
||||
|
||||
class DashboardSignIn(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
||||
@@ -1929,6 +1938,7 @@ async def decide_human_gate(
|
||||
target=gate_id,
|
||||
)
|
||||
login = await _human_gate_login(request)
|
||||
principal_id = int(login.partition(":")[0])
|
||||
journal = _security_event_store()
|
||||
operation_id = None
|
||||
try:
|
||||
@@ -1939,6 +1949,7 @@ async def decide_human_gate(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"human_gate_decision",
|
||||
principal_id=principal_id,
|
||||
method=payload.decision,
|
||||
target=gate_id,
|
||||
)
|
||||
@@ -2062,6 +2073,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
principal_id=principal_id,
|
||||
method="token",
|
||||
device_label=payload.device_label,
|
||||
target="dashboard",
|
||||
@@ -2099,12 +2111,16 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
|
||||
@app.get("/api/v1/security-events")
|
||||
async def list_security_events(
|
||||
request: Request,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
cursor: int | None = Query(default=None, ge=1),
|
||||
):
|
||||
try:
|
||||
page = await asyncio.to_thread(
|
||||
_security_event_store().list, limit=limit, cursor=cursor
|
||||
_security_event_store().list,
|
||||
principal_id=await _security_principal_id(request),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
authentication_alerts = await asyncio.to_thread(
|
||||
_login_attempt_store().list_alerts, limit=24
|
||||
@@ -2270,6 +2286,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_enrolled",
|
||||
principal_id=await _security_principal_id(request),
|
||||
method="passkey",
|
||||
device_label=current.device_label,
|
||||
target="passkey",
|
||||
@@ -2381,6 +2398,7 @@ async def revoke_enrolled_passkey(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"passkey_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
device_label=credential.device_label,
|
||||
target="passkey",
|
||||
)
|
||||
@@ -2556,6 +2574,7 @@ async def verify_passkey_authorization(
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
principal_id=await _security_principal_id(request),
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target=f"{payload.action}:{payload.target}",
|
||||
@@ -2667,6 +2686,7 @@ async def verify_passkey_authentication(
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
principal_id=stored.principal_id,
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="sign_in:dashboard",
|
||||
@@ -2705,6 +2725,7 @@ async def verify_passkey_authentication(
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"sign_in",
|
||||
principal_id=stored.principal_id,
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="dashboard",
|
||||
@@ -3806,6 +3827,7 @@ async def save_today_recap_and_log_time(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"gitea_time_logged",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -3906,6 +3928,7 @@ async def sign_out(request: Request, response: Response):
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"sign_out",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target="current_device",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -4016,6 +4039,7 @@ async def revoke_active_device(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"device_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
device_label=target.device_label,
|
||||
target="device",
|
||||
)
|
||||
@@ -4068,6 +4092,7 @@ async def sign_out_all_devices(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"all_sessions_revoked",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target="all_devices",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -6641,6 +6666,7 @@ async def _delete_conversation_comment(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"comment_deleted",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -7096,6 +7122,7 @@ async def close_assigned_issue(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"issue_closed",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except HTTPException:
|
||||
@@ -8001,6 +8028,7 @@ async def merge_assigned_pull(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"pull_merged",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -8092,6 +8120,7 @@ async def delete_merged_source_branch(
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"source_branch_deleted",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}@{submission.expected_head_sha}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
@@ -8279,7 +8308,10 @@ async def prepare_release_rollback(
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve, "release_rollback_prepared", target=target
|
||||
journal.reserve,
|
||||
"release_rollback_prepared",
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=target,
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
return JSONResponse(
|
||||
@@ -8377,6 +8409,7 @@ async def submit_review(
|
||||
if submission.decision == "approve"
|
||||
else "pull_review_changes_requested"
|
||||
),
|
||||
principal_id=await _security_principal_id(request),
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
|
||||
+26
-10
@@ -76,6 +76,7 @@ class SecurityEventStore:
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
@@ -93,6 +94,10 @@ class SecurityEventStore:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
if "principal_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN principal_id INTEGER"
|
||||
)
|
||||
plaintext_columns = {"kind", "method", "device_label", "target"}
|
||||
if "payload" not in columns or plaintext_columns.intersection(columns):
|
||||
self._migrate_plaintext(
|
||||
@@ -102,6 +107,10 @@ class SecurityEventStore:
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_principal "
|
||||
"ON security_events(principal_id, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
@@ -126,6 +135,7 @@ class SecurityEventStore:
|
||||
CREATE TABLE security_events_encrypted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT,
|
||||
principal_id INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
@@ -145,7 +155,8 @@ class SecurityEventStore:
|
||||
) in rows:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events_encrypted "
|
||||
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
|
||||
"(id, payload, principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, NULL, ?, ?, ?)",
|
||||
(
|
||||
event_id,
|
||||
payload
|
||||
@@ -195,6 +206,7 @@ class SecurityEventStore:
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
@@ -203,8 +215,9 @@ class SecurityEventStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
|
||||
(now,),
|
||||
"INSERT INTO security_events(principal_id, created_at, status) "
|
||||
"VALUES (?, ?, 'completed')",
|
||||
(principal_id, now),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
payload = self._seal_event(
|
||||
@@ -224,6 +237,7 @@ class SecurityEventStore:
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
principal_id: int,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
@@ -233,9 +247,9 @@ class SecurityEventStore:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO security_events(created_at, status, operation_id) "
|
||||
"VALUES (?, 'pending', ?)",
|
||||
(now, operation_id),
|
||||
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, 'pending', ?)",
|
||||
(principal_id, now, operation_id),
|
||||
)
|
||||
event_id = cursor.lastrowid
|
||||
connection.execute(
|
||||
@@ -282,12 +296,14 @@ class SecurityEventStore:
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
||||
def list(
|
||||
self, *, principal_id: int, limit: int = 50, cursor: int | None = None
|
||||
) -> SecurityEventPage:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
parameters: list[int] = [principal_id]
|
||||
where = "WHERE principal_id = ?"
|
||||
if cursor is not None:
|
||||
where = "WHERE id < ?"
|
||||
where += " AND id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user