security: scope device revocation by principal (Closes #1454)
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-27 03:09:59 +00:00
1 parent 7a09452bef
commit 0328e197ac
8 files changed
+186 -25

No files matched your search

+5 -1
View File
@@ -236,7 +236,11 @@ async def revoke_all_sessions() -> None:
async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
return await asyncio.to_thread(
_session_store().list_active,
session.session_id,
principal_id=session.principal_id,
)
async def session_management_id(session: Session) -> str:
+5 -3
View File
@@ -4027,7 +4027,9 @@ async def revoke_active_device(
)
try:
revoked = await asyncio.to_thread(
_passkey_store().revoke_device_access, management_id
_passkey_store().revoke_device_access,
management_id,
principal_id=request.state.dashboard_session.principal_id,
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@@ -4075,7 +4077,7 @@ async def sign_out_all_devices(
headers={"Cache-Control": "no-store"},
)
try:
await asyncio.to_thread(
management_ids = await asyncio.to_thread(
_passkey_store().revoke_all_access,
principal_id=request.state.dashboard_session.principal_id,
)
@@ -4085,7 +4087,7 @@ async def sign_out_all_devices(
status_code=503,
headers={"Cache-Control": "no-store"},
)
await asyncio.to_thread(_push_subscription_store.delete_all)
await asyncio.to_thread(_push_subscription_store.delete_sessions, management_ids)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
+20 -6
View File
@@ -322,13 +322,14 @@ class PasskeyStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_device_access(self, management_id: str) -> bool:
def revoke_device_access(self, management_id: str, *, principal_id: int) -> bool:
"""Atomically remove one active session, its grants, and its linked passkey."""
try:
with self._connect() as connection:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
"SELECT session_hash FROM active_sessions "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
).fetchone()
if session is None:
return False
@@ -336,19 +337,31 @@ class PasskeyStore:
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
"DELETE FROM passkey_credentials "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
"DELETE FROM active_sessions "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all_access(self, *, principal_id: int) -> None:
def revoke_all_access(self, *, principal_id: int) -> list[str]:
"""Atomically remove one principal's passkeys, grants, and active sessions."""
try:
with self._connect() as connection:
management_ids = [
row[0]
for row in connection.execute(
"SELECT management_id FROM active_sessions "
"WHERE principal_id = ? ORDER BY management_id",
(principal_id,),
).fetchall()
]
connection.execute(
"DELETE FROM passkey_credentials WHERE principal_id = ?",
(principal_id,),
@@ -362,5 +375,6 @@ class PasskeyStore:
"DELETE FROM active_sessions WHERE principal_id = ?",
(principal_id,),
)
return management_ids
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+14
View File
@@ -133,6 +133,9 @@ class DisabledPushSubscriptionStore:
def delete_session(self, *args, **kwargs) -> None:
return None
def delete_sessions(self, *args, **kwargs) -> None:
return None
def delete_all(self, *args, **kwargs) -> None:
return None
@@ -442,6 +445,17 @@ class PushSubscriptionStore:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
def delete_sessions(self, session_ids) -> None:
requested = set(session_ids)
if not requested:
return
placeholders = ",".join("?" for _ in requested)
with self._connect() as connection:
connection.execute(
f"DELETE FROM push_subscriptions WHERE session_id IN ({placeholders})",
tuple(requested),
)
def delete_all(self) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions")
+5 -3
View File
@@ -259,16 +259,18 @@ class SessionStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
def list_active(
self, current_session_id: str, *, principal_id: int
) -> list[ActiveDevice]:
now = int(self.clock())
current_hash = self._digest(current_session_id)
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT management_id, device_label, created_at, expires_at, session_hash "
"FROM active_sessions WHERE expires_at > ? "
"FROM active_sessions WHERE expires_at > ? AND principal_id = ? "
"ORDER BY expires_at DESC, created_at DESC",
(now,),
(now, principal_id),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc