feat(cloud-console): add user authentication and administration
This commit is contained in:
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Engine, delete, func, select
|
||||
@@ -18,6 +18,10 @@ from cloud.db_models import (
|
||||
PooledDeviceRow,
|
||||
ScheduledTaskRow,
|
||||
TaskAttemptRow,
|
||||
AuthAuditRow,
|
||||
LoginThrottleRow,
|
||||
UserRow,
|
||||
UserSessionRow,
|
||||
)
|
||||
from cloud.observability import current_correlation_id
|
||||
from core.models import utc_now
|
||||
@@ -460,6 +464,337 @@ class SQLAlchemyCloudRepository:
|
||||
row = session.get(PluginRow, name)
|
||||
return _plugin_from_row(row) if row else None
|
||||
|
||||
# --------------------------------------------------------------- user auth
|
||||
|
||||
def create_user(self, user: Any) -> Any:
|
||||
from cloud.repository import UserConflictError
|
||||
|
||||
try:
|
||||
with self._sessions.begin() as session:
|
||||
row = UserRow(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
username_normalized=user.username_normalized,
|
||||
display_name=user.display_name,
|
||||
password_hash=user.password_hash,
|
||||
role=user.role,
|
||||
enabled=1 if user.enabled else 0,
|
||||
must_change_password=1 if user.must_change_password else 0,
|
||||
authentication_version=user.authentication_version,
|
||||
created_at=_iso(user.created_at),
|
||||
updated_at=_iso(user.updated_at),
|
||||
last_login_at=(
|
||||
_iso(user.last_login_at) if user.last_login_at is not None else None
|
||||
),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return _user_from_row(row)
|
||||
except IntegrityError as exc:
|
||||
raise UserConflictError("username is already in use") from exc
|
||||
|
||||
def get_user(self, user_id: str) -> Any | None:
|
||||
with self._sessions() as session:
|
||||
row = session.get(UserRow, user_id)
|
||||
return _user_from_row(row) if row else None
|
||||
|
||||
def get_user_by_normalized_username(self, username_normalized: str) -> Any | None:
|
||||
with self._sessions() as session:
|
||||
row = session.scalar(
|
||||
select(UserRow)
|
||||
.where(UserRow.username_normalized == username_normalized)
|
||||
.limit(1)
|
||||
)
|
||||
return _user_from_row(row) if row else None
|
||||
|
||||
def list_users(self, *, limit: int, offset: int) -> list[Any]:
|
||||
with self._sessions() as session:
|
||||
rows = session.scalars(
|
||||
select(UserRow)
|
||||
.order_by(UserRow.created_at, UserRow.id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
).all()
|
||||
return [_user_from_row(row) for row in rows]
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
role: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
updated_at: datetime,
|
||||
) -> Any:
|
||||
from cloud.repository import LastAdministratorConflictError
|
||||
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(
|
||||
UserRow,
|
||||
user_id,
|
||||
with_for_update=self.engine.dialect.name == "postgresql",
|
||||
)
|
||||
if row is None:
|
||||
raise KeyError(f"user {user_id!r} not found")
|
||||
next_role = role if role is not None else row.role
|
||||
next_enabled = enabled if enabled is not None else bool(row.enabled)
|
||||
removes_administrator = (
|
||||
bool(row.enabled)
|
||||
and row.role == "admin"
|
||||
and (next_role != "admin" or not next_enabled)
|
||||
)
|
||||
if removes_administrator:
|
||||
other_admins = session.scalar(
|
||||
select(func.count())
|
||||
.select_from(UserRow)
|
||||
.where(
|
||||
UserRow.id != user_id,
|
||||
UserRow.enabled == 1,
|
||||
UserRow.role == "admin",
|
||||
)
|
||||
)
|
||||
if int(other_admins or 0) == 0:
|
||||
raise LastAdministratorConflictError(
|
||||
"cannot remove the last enabled administrator"
|
||||
)
|
||||
security_changed = next_role != row.role or next_enabled != bool(row.enabled)
|
||||
if display_name is not None:
|
||||
row.display_name = display_name
|
||||
row.role = next_role
|
||||
row.enabled = 1 if next_enabled else 0
|
||||
row.updated_at = _iso(updated_at)
|
||||
if security_changed:
|
||||
row.authentication_version += 1
|
||||
_revoke_user_session_rows(session, user_id, updated_at)
|
||||
session.flush()
|
||||
return _user_from_row(row)
|
||||
|
||||
def rehash_user_password(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
password_hash: str,
|
||||
updated_at: datetime,
|
||||
) -> Any:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(UserRow, user_id)
|
||||
if row is None:
|
||||
raise KeyError(f"user {user_id!r} not found")
|
||||
row.password_hash = password_hash
|
||||
row.updated_at = _iso(updated_at)
|
||||
session.flush()
|
||||
return _user_from_row(row)
|
||||
|
||||
def update_user_password(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
password_hash: str,
|
||||
must_change_password: bool,
|
||||
updated_at: datetime,
|
||||
revoke_sessions: bool,
|
||||
) -> Any:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(
|
||||
UserRow,
|
||||
user_id,
|
||||
with_for_update=self.engine.dialect.name == "postgresql",
|
||||
)
|
||||
if row is None:
|
||||
raise KeyError(f"user {user_id!r} not found")
|
||||
row.password_hash = password_hash
|
||||
row.must_change_password = 1 if must_change_password else 0
|
||||
row.authentication_version += 1
|
||||
row.updated_at = _iso(updated_at)
|
||||
if revoke_sessions:
|
||||
_revoke_user_session_rows(session, user_id, updated_at)
|
||||
session.flush()
|
||||
return _user_from_row(row)
|
||||
|
||||
def mark_user_login(self, user_id: str, *, now: datetime) -> Any:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(UserRow, user_id)
|
||||
if row is None:
|
||||
raise KeyError(f"user {user_id!r} not found")
|
||||
row.last_login_at = _iso(now)
|
||||
row.updated_at = _iso(now)
|
||||
session.flush()
|
||||
return _user_from_row(row)
|
||||
|
||||
def create_user_session(self, user_session: Any) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
session.add(
|
||||
UserSessionRow(
|
||||
id=user_session.id,
|
||||
user_id=user_session.user_id,
|
||||
token_digest=user_session.token_digest,
|
||||
csrf_digest=user_session.csrf_digest,
|
||||
authentication_version=user_session.authentication_version,
|
||||
issued_at=_iso(user_session.issued_at),
|
||||
last_seen_at=_iso(user_session.last_seen_at),
|
||||
idle_expires_at=_iso(user_session.idle_expires_at),
|
||||
absolute_expires_at=_iso(user_session.absolute_expires_at),
|
||||
revoked_at=(
|
||||
_iso(user_session.revoked_at)
|
||||
if user_session.revoked_at is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def get_authenticated_user_session(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> Any | None:
|
||||
from cloud.user_auth import AuthenticatedUserSession
|
||||
|
||||
with self._sessions() as session:
|
||||
match = session.execute(
|
||||
select(UserSessionRow, UserRow)
|
||||
.join(UserRow, UserRow.id == UserSessionRow.user_id)
|
||||
.where(
|
||||
UserSessionRow.token_digest == token_digest,
|
||||
UserSessionRow.revoked_at.is_(None),
|
||||
UserRow.enabled == 1,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
if match is None:
|
||||
return None
|
||||
session_row, user_row = match
|
||||
user = _user_from_row(user_row)
|
||||
user_session = _user_session_from_row(session_row)
|
||||
if (
|
||||
user.authentication_version != user_session.authentication_version
|
||||
or user_session.idle_expires_at <= now
|
||||
or user_session.absolute_expires_at <= now
|
||||
):
|
||||
return None
|
||||
return AuthenticatedUserSession(user=user, session=user_session)
|
||||
|
||||
def touch_user_session(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
last_seen_at: datetime,
|
||||
idle_expires_at: datetime,
|
||||
) -> Any:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(UserSessionRow, session_id)
|
||||
if row is None:
|
||||
raise KeyError(f"session {session_id!r} not found")
|
||||
row.last_seen_at = _iso(last_seen_at)
|
||||
row.idle_expires_at = _iso(idle_expires_at)
|
||||
session.flush()
|
||||
return _user_session_from_row(row)
|
||||
|
||||
def revoke_user_session(self, session_id: str, *, revoked_at: datetime) -> bool:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(UserSessionRow, session_id)
|
||||
if row is None or row.revoked_at is not None:
|
||||
return False
|
||||
row.revoked_at = _iso(revoked_at)
|
||||
return True
|
||||
|
||||
def revoke_user_sessions(self, user_id: str, *, revoked_at: datetime) -> int:
|
||||
with self._sessions.begin() as session:
|
||||
return _revoke_user_session_rows(session, user_id, revoked_at)
|
||||
|
||||
def get_login_throttle(
|
||||
self,
|
||||
username_normalized: str,
|
||||
client_bucket: str,
|
||||
) -> Any | None:
|
||||
with self._sessions() as session:
|
||||
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
|
||||
return _login_throttle_from_row(row) if row else None
|
||||
|
||||
def record_login_failure(
|
||||
self,
|
||||
*,
|
||||
username_normalized: str,
|
||||
client_bucket: str,
|
||||
now: datetime,
|
||||
failure_limit: int,
|
||||
failure_window: timedelta,
|
||||
block_duration: timedelta,
|
||||
) -> Any:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
|
||||
if row is None:
|
||||
row = LoginThrottleRow(
|
||||
username_normalized=username_normalized,
|
||||
client_bucket=client_bucket,
|
||||
failure_count=0,
|
||||
window_started_at=_iso(now),
|
||||
last_attempt_at=_iso(now),
|
||||
blocked_until=None,
|
||||
)
|
||||
session.add(row)
|
||||
window_started = _parse_dt(row.window_started_at) or now
|
||||
if now - window_started > failure_window:
|
||||
row.failure_count = 0
|
||||
row.window_started_at = _iso(now)
|
||||
row.blocked_until = None
|
||||
row.failure_count += 1
|
||||
row.last_attempt_at = _iso(now)
|
||||
if row.failure_count >= failure_limit:
|
||||
row.blocked_until = _iso(now + block_duration)
|
||||
session.flush()
|
||||
return _login_throttle_from_row(row)
|
||||
|
||||
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
|
||||
if row is not None:
|
||||
session.delete(row)
|
||||
|
||||
def record_auth_audit(self, event: Any) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
session.add(
|
||||
AuthAuditRow(
|
||||
id=event.id,
|
||||
occurred_at=_iso(event.occurred_at),
|
||||
actor_principal_id=event.actor_principal_id,
|
||||
target_user_id=event.target_user_id,
|
||||
action=event.action,
|
||||
outcome=event.outcome,
|
||||
correlation_id=event.correlation_id,
|
||||
metadata_json=json.dumps(event.metadata, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
|
||||
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int:
|
||||
removed = 0
|
||||
with self._sessions.begin() as session:
|
||||
expired_sessions = session.scalars(
|
||||
select(UserSessionRow)
|
||||
.where(
|
||||
(UserSessionRow.idle_expires_at <= _iso(now))
|
||||
| (UserSessionRow.absolute_expires_at <= _iso(now))
|
||||
)
|
||||
.order_by(UserSessionRow.absolute_expires_at)
|
||||
.limit(limit)
|
||||
).all()
|
||||
for row in expired_sessions:
|
||||
session.delete(row)
|
||||
removed += len(expired_sessions)
|
||||
remaining = max(0, limit - removed)
|
||||
if remaining:
|
||||
stale_before = now - timedelta(days=1)
|
||||
stale_throttles = session.scalars(
|
||||
select(LoginThrottleRow)
|
||||
.where(LoginThrottleRow.last_attempt_at <= _iso(stale_before))
|
||||
.order_by(LoginThrottleRow.last_attempt_at)
|
||||
.limit(remaining)
|
||||
).all()
|
||||
for row in stale_throttles:
|
||||
session.delete(row)
|
||||
removed += len(stale_throttles)
|
||||
return removed
|
||||
|
||||
def list_reserved_device_ids(self, *, now: datetime) -> set[str]:
|
||||
with self._sessions() as session:
|
||||
device_ids = session.scalars(
|
||||
@@ -951,6 +1286,69 @@ def _log_task_lifecycle(event: str, task: ScheduledTaskRow) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _revoke_user_session_rows(session: Any, user_id: str, revoked_at: datetime) -> int:
|
||||
rows = session.scalars(
|
||||
select(UserSessionRow).where(
|
||||
UserSessionRow.user_id == user_id,
|
||||
UserSessionRow.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
for row in rows:
|
||||
row.revoked_at = _iso(revoked_at)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _user_from_row(row: UserRow) -> Any:
|
||||
from cloud.user_auth import UserAccount
|
||||
|
||||
return UserAccount(
|
||||
id=row.id,
|
||||
username=row.username,
|
||||
username_normalized=row.username_normalized,
|
||||
display_name=row.display_name,
|
||||
role=row.role,
|
||||
enabled=bool(row.enabled),
|
||||
must_change_password=bool(row.must_change_password),
|
||||
authentication_version=row.authentication_version,
|
||||
created_at=_parse_dt(row.created_at) or utc_now(),
|
||||
updated_at=_parse_dt(row.updated_at) or utc_now(),
|
||||
last_login_at=_parse_dt(row.last_login_at),
|
||||
password_hash=row.password_hash,
|
||||
)
|
||||
|
||||
|
||||
def _user_session_from_row(row: UserSessionRow) -> Any:
|
||||
from cloud.user_auth import UserSession
|
||||
|
||||
now = utc_now()
|
||||
return UserSession(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
token_digest=row.token_digest,
|
||||
csrf_digest=row.csrf_digest,
|
||||
authentication_version=row.authentication_version,
|
||||
issued_at=_parse_dt(row.issued_at) or now,
|
||||
last_seen_at=_parse_dt(row.last_seen_at) or now,
|
||||
idle_expires_at=_parse_dt(row.idle_expires_at) or now,
|
||||
absolute_expires_at=_parse_dt(row.absolute_expires_at) or now,
|
||||
revoked_at=_parse_dt(row.revoked_at),
|
||||
)
|
||||
|
||||
|
||||
def _login_throttle_from_row(row: LoginThrottleRow) -> Any:
|
||||
from cloud.user_auth import LoginThrottle
|
||||
|
||||
now = utc_now()
|
||||
return LoginThrottle(
|
||||
username_normalized=row.username_normalized,
|
||||
client_bucket=row.client_bucket,
|
||||
failure_count=row.failure_count,
|
||||
window_started_at=_parse_dt(row.window_started_at) or now,
|
||||
last_attempt_at=_parse_dt(row.last_attempt_at) or now,
|
||||
blocked_until=_parse_dt(row.blocked_until),
|
||||
)
|
||||
|
||||
|
||||
def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user