Scope Security Center device revocation to the signed-in identity #1455

Merged
timmy merged 1 commits from timmy/1454-principal-scoped-device-revocation into main 2026-08-27 03:21:58 +00:00
8 changed files with 186 additions and 25 deletions

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:

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:

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

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")

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

View File

@ -99,6 +99,8 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
2_000,
management_id=f"{slug}-management-id",
device_label=label,
principal_id=42,
principal_login="timmy",
)
sessions.mint_step_up(
f"{slug}-session",
@ -122,7 +124,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
)
with pytest.raises(SessionStoreError):
passkeys.revoke_device_access("phone-management-id")
passkeys.revoke_device_access("phone-management-id", principal_id=42)
with sqlite3.connect(database) as connection:
assert connection.execute(
@ -132,3 +134,69 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
def test_device_access_revocation_cannot_cross_principal_boundary(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
for principal_id in (42, 84):
sessions.activate(
f"session-{principal_id}",
2_000,
management_id=f"management-{principal_id}",
principal_id=principal_id,
principal_login=f"operator-{principal_id}",
)
sessions.mint_step_up(
f"session-{principal_id}",
action="close_issue",
target="stackchain/dashboard#1",
ttl_seconds=90,
)
passkeys.register(
credential_id=f"credential-{principal_id}".encode(),
public_key=f"public-key-{principal_id}".encode(),
sign_count=0,
device_label=f"Device {principal_id}",
management_id=f"management-{principal_id}",
principal_id=principal_id,
)
assert passkeys.revoke_device_access("management-84", principal_id=42) is False
assert passkeys.revoke_device_access("management-42", principal_id=42) is True
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
).fetchall() == [(84,)]
assert connection.execute(
"SELECT principal_id FROM passkey_credentials ORDER BY principal_id"
).fetchall() == [(84,)]
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
def test_global_access_revocation_returns_only_affected_management_ids(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
for principal_id in (42, 84):
for suffix in ("phone", "laptop"):
sessions.activate(
f"session-{principal_id}-{suffix}",
2_000,
management_id=f"management-{principal_id}-{suffix}",
principal_id=principal_id,
principal_login=f"operator-{principal_id}",
)
affected = passkeys.revoke_all_access(principal_id=42)
assert affected == ["management-42-laptop", "management-42-phone"]
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT management_id FROM active_sessions ORDER BY management_id"
).fetchall() == [
("management-84-laptop",),
("management-84-phone",),
]

View File

@ -1985,3 +1985,21 @@ async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavai
assert raised.value.status_code == 503
assert store.is_subscribed("device-a") is False
def test_delete_sessions_removes_only_the_requested_device_push_state(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("management-42-phone", "management-42-laptop", "management-84-phone"):
store.upsert(
session_id,
{
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
},
)
store.delete_sessions(["management-42-phone", "management-42-laptop"])
assert store.is_subscribed("management-42-phone") is False
assert store.is_subscribed("management-42-laptop") is False
assert store.is_subscribed("management-84-phone") is True

View File

@ -201,6 +201,38 @@ def test_managed_session_statuses_skips_database_for_an_empty_batch(tmp_path, mo
assert store.managed_statuses([], idle_timeout_seconds=900) == {}
def test_active_devices_are_scoped_to_the_signed_in_principal(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate(
"matching-session",
3_000,
management_id="matching-device",
device_label="My phone",
principal_id=42,
principal_login="timmy",
)
store.activate(
"other-session",
3_000,
management_id="other-device",
device_label="Other phone",
principal_id=84,
principal_login="other",
)
store.activate(
"legacy-session",
3_000,
management_id="legacy-device",
device_label="Legacy phone",
)
devices = store.list_active("matching-session", principal_id=42)
assert [(device.management_id, device.current) for device in devices] == [
("matching-device", True)
]
def test_touch_extends_only_the_matching_live_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
@ -229,10 +261,14 @@ def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
store.activate("laptop-session-secret", 3_000, device_label="Work laptop")
store.activate(
"phone-session-secret", 2_000, device_label="Pixel 9", principal_id=42
)
store.activate(
"laptop-session-secret", 3_000, device_label="Work laptop", principal_id=42
)
devices = store.list_active("phone-session-secret")
devices = store.list_active("phone-session-secret", principal_id=42)
assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"]
assert [device.current for device in devices] == [False, True]
@ -243,9 +279,13 @@ def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
def test_revoke_managed_device_removes_only_the_selected_session(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone", 2_000, device_label="Phone")
store.activate("laptop", 2_000, device_label="Laptop")
phone = next(device for device in store.list_active("laptop") if device.device_label == "Phone")
store.activate("phone", 2_000, device_label="Phone", principal_id=42)
store.activate("laptop", 2_000, device_label="Laptop", principal_id=42)
phone = next(
device
for device in store.list_active("laptop", principal_id=42)
if device.device_label == "Phone"
)
assert store.revoke_managed(phone.management_id) is True
assert store.is_active("phone", 2_000) is False
@ -266,9 +306,8 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
store.activate("new-session", 3_000, device_label="New phone")
assert store.is_active("existing-session", 2_000) is True
devices = store.list_active("existing-session")
assert len(devices) == 2
assert next(device for device in devices if device.current).device_label == "Existing device"
devices = store.list_active("existing-session", principal_id=42)
assert devices == []
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
@ -388,11 +427,11 @@ def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path):
def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session", 2_000, device_label="Phone")
store.activate("phone-session", 2_000, device_label="Phone", principal_id=42)
grant = store.mint_step_up(
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
)
phone = store.list_active("phone-session")[0]
phone = store.list_active("phone-session", principal_id=42)[0]
assert store.revoke_managed(phone.management_id) is True
assert store.consume_step_up(