413 lines
16 KiB
Python
413 lines
16 KiB
Python
"""Durable active-session registry used to revoke signed operator sessions."""
|
|
|
|
import hashlib
|
|
import secrets
|
|
import sqlite3
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from src.private_state import connect_private_sqlite
|
|
|
|
|
|
class SessionStoreError(RuntimeError):
|
|
"""Raised when session state cannot be read or changed safely."""
|
|
|
|
|
|
class SessionStatus(str):
|
|
"""String-compatible status carrying the server-confirmed idle deadline."""
|
|
|
|
idle_expires_at: int | None
|
|
|
|
def __new__(cls, value: str, idle_expires_at: int | None = None):
|
|
instance = super().__new__(cls, value)
|
|
instance.idle_expires_at = idle_expires_at
|
|
return instance
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActiveDevice:
|
|
management_id: str
|
|
device_label: str
|
|
created_at: int
|
|
expires_at: int
|
|
current: bool
|
|
|
|
|
|
class SessionStore:
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
clock: Callable[[], float],
|
|
lock_timeout_seconds: float = 0.1,
|
|
) -> None:
|
|
self.path = Path(path)
|
|
self.clock = clock
|
|
self.lock_timeout_seconds = lock_timeout_seconds
|
|
|
|
@staticmethod
|
|
def _digest(session_id: str) -> str:
|
|
return hashlib.sha256(session_id.encode()).hexdigest()
|
|
|
|
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
|
|
try:
|
|
if initialize:
|
|
connection = connect_private_sqlite(
|
|
self.path, timeout=self.lock_timeout_seconds
|
|
)
|
|
else:
|
|
connection = connect_private_sqlite(
|
|
self.path,
|
|
timeout=self.lock_timeout_seconds,
|
|
existing_only=True,
|
|
)
|
|
if initialize:
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS active_sessions (
|
|
session_hash TEXT PRIMARY KEY,
|
|
expires_at INTEGER NOT NULL,
|
|
management_id TEXT,
|
|
device_label TEXT,
|
|
created_at INTEGER,
|
|
last_active_at INTEGER
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS step_up_grants (
|
|
grant_hash TEXT PRIMARY KEY,
|
|
session_hash TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
target TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"CREATE INDEX IF NOT EXISTS step_up_grants_session_hash "
|
|
"ON step_up_grants(session_hash)"
|
|
)
|
|
columns = {
|
|
row[1] for row in connection.execute("PRAGMA table_info(active_sessions)")
|
|
}
|
|
additions = {
|
|
"management_id": "TEXT",
|
|
"device_label": "TEXT",
|
|
"created_at": "INTEGER",
|
|
"last_active_at": "INTEGER",
|
|
}
|
|
for name, column_type in additions.items():
|
|
if name not in columns:
|
|
connection.execute(
|
|
f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}"
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) "
|
|
"WHERE management_id IS NULL"
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET device_label = 'Existing device' "
|
|
"WHERE device_label IS NULL"
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
|
(int(self.clock()),),
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
|
|
(int(self.clock()),),
|
|
)
|
|
connection.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
|
"ON active_sessions(management_id)"
|
|
)
|
|
return connection
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def activate(
|
|
self,
|
|
session_id: str,
|
|
expires_at: int,
|
|
*,
|
|
device_label: str = "This device",
|
|
management_id: str | None = None,
|
|
) -> None:
|
|
label = " ".join(str(device_label).split())[:64] or "This device"
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
connection.execute(
|
|
"DELETE FROM active_sessions WHERE expires_at <= ?", (now,)
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO active_sessions("
|
|
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
|
|
") VALUES (?, ?, ?, ?, ?, ?)",
|
|
(
|
|
self._digest(session_id),
|
|
expires_at,
|
|
management_id or secrets.token_urlsafe(18),
|
|
label,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def is_active(self, session_id: str, expires_at: int) -> bool:
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
).fetchone()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
return row is not None and row[0] == expires_at and expires_at > now
|
|
|
|
def status(
|
|
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
|
) -> str:
|
|
now = int(self.clock())
|
|
query = (
|
|
"SELECT expires_at, last_active_at FROM active_sessions "
|
|
"WHERE session_hash = ?"
|
|
)
|
|
parameters = (self._digest(session_id),)
|
|
try:
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(query, parameters).fetchone()
|
|
except sqlite3.OperationalError as exc:
|
|
if "no such column: last_active_at" not in str(exc):
|
|
raise
|
|
with self._connect(initialize=True) as connection:
|
|
row = connection.execute(query, parameters).fetchone()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
if row is None or row[0] != expires_at or expires_at <= now:
|
|
return SessionStatus("revoked")
|
|
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
|
|
if idle_expires_at <= now:
|
|
return SessionStatus("idle", idle_expires_at)
|
|
return SessionStatus("active", idle_expires_at)
|
|
|
|
def touch(
|
|
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
|
|
) -> bool:
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
cursor = connection.execute(
|
|
"UPDATE active_sessions SET last_active_at = ? "
|
|
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
|
|
"AND last_active_at + ? > ?",
|
|
(
|
|
now,
|
|
self._digest(session_id),
|
|
expires_at,
|
|
now,
|
|
max(1, idle_timeout_seconds),
|
|
now,
|
|
),
|
|
)
|
|
return cursor.rowcount == 1
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def revoke(self, session_id: str) -> None:
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM active_sessions WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
)
|
|
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]:
|
|
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 > ? "
|
|
"ORDER BY expires_at DESC, created_at DESC",
|
|
(now,),
|
|
).fetchall()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
return [
|
|
ActiveDevice(
|
|
management_id=row[0],
|
|
device_label=row[1],
|
|
created_at=row[2],
|
|
expires_at=row[3],
|
|
current=secrets.compare_digest(row[4], current_hash),
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def management_id(self, session_id: str) -> str:
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT management_id FROM active_sessions WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
).fetchone()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
if row is None:
|
|
raise SessionStoreError("Session is no longer active")
|
|
return row[0]
|
|
|
|
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
|
|
"""Return the authorization state for a durable device identifier."""
|
|
return self.managed_statuses(
|
|
[management_id], idle_timeout_seconds=idle_timeout_seconds
|
|
)[management_id]
|
|
|
|
def managed_statuses(
|
|
self, management_ids, *, idle_timeout_seconds: int
|
|
) -> dict[str, str]:
|
|
"""Return authorization states for durable device identifiers in one read."""
|
|
requested = set(management_ids)
|
|
if not requested:
|
|
return {}
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
placeholders = ",".join("?" for _ in requested)
|
|
rows = connection.execute(
|
|
"SELECT management_id, expires_at, last_active_at "
|
|
f"FROM active_sessions WHERE management_id IN ({placeholders})",
|
|
tuple(requested),
|
|
).fetchall()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
known = {row[0]: row[1:] for row in rows if row[0] in requested}
|
|
idle_timeout = max(1, idle_timeout_seconds)
|
|
statuses = {}
|
|
for management_id in requested:
|
|
row = known.get(management_id)
|
|
if row is None or row[0] <= now:
|
|
statuses[management_id] = "revoked"
|
|
elif row[1] + idle_timeout <= now:
|
|
statuses[management_id] = "idle"
|
|
else:
|
|
statuses[management_id] = "active"
|
|
return statuses
|
|
|
|
def revoke_managed(self, management_id: str) -> bool:
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
|
(management_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],)
|
|
)
|
|
cursor = connection.execute(
|
|
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
|
)
|
|
return cursor.rowcount == 1
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def revoke_all(self) -> None:
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
connection.execute("DELETE FROM step_up_grants")
|
|
connection.execute("DELETE FROM active_sessions")
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def mint_step_up(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
action: str,
|
|
target: str,
|
|
ttl_seconds: int,
|
|
) -> str:
|
|
now = int(self.clock())
|
|
grant = secrets.token_urlsafe(32)
|
|
session_hash = self._digest(session_id)
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
active = connection.execute(
|
|
"SELECT 1 FROM active_sessions "
|
|
"WHERE session_hash = ? AND expires_at > ?",
|
|
(session_hash, now),
|
|
).fetchone()
|
|
if active is None:
|
|
raise SessionStoreError("Session is no longer active")
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO step_up_grants("
|
|
"grant_hash, session_hash, action, target, expires_at"
|
|
") VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
self._digest(grant),
|
|
session_hash,
|
|
action,
|
|
target,
|
|
now + max(1, ttl_seconds),
|
|
),
|
|
)
|
|
except SessionStoreError:
|
|
raise
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
return grant
|
|
|
|
def consume_step_up(
|
|
self,
|
|
grant: str,
|
|
session_id: str,
|
|
*,
|
|
action: str,
|
|
target: str,
|
|
) -> bool:
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
|
)
|
|
cursor = connection.execute(
|
|
"DELETE FROM step_up_grants WHERE grant_hash = ? "
|
|
"AND session_hash = ? AND action = ? AND target = ? "
|
|
"AND expires_at > ? AND EXISTS ("
|
|
"SELECT 1 FROM active_sessions "
|
|
"WHERE active_sessions.session_hash = step_up_grants.session_hash "
|
|
"AND active_sessions.expires_at > ?)",
|
|
(
|
|
self._digest(grant),
|
|
self._digest(session_id),
|
|
action,
|
|
target,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
return cursor.rowcount == 1
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|