feat(cloud-console): add user authentication and administration

This commit is contained in:
2026-07-13 17:54:53 +08:00
parent 035b177128
commit cdef630e67
35 changed files with 4126 additions and 113 deletions
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from datetime import timedelta
from getpass import getpass
from cloud.control_config import load_control_config
from cloud.database import CloudDatabase
from cloud.schema import require_current_schema
from cloud.user_auth import UserAuthService, UserAuthSettings, normalize_username, utc_now
def main(argv: Sequence[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Administer Cloud Console user accounts")
commands = parser.add_subparsers(dest="command", required=True)
users = commands.add_parser("users", help="manage user accounts")
user_commands = users.add_subparsers(dest="user_command", required=True)
create = user_commands.add_parser("create", help="create a user interactively")
create.add_argument("--username", required=True)
create.add_argument("--display-name")
create.add_argument("--role", choices=("viewer", "operator", "admin"), required=True)
reset = user_commands.add_parser("reset-password", help="reset a user password")
reset.add_argument("--username", required=True)
enable = user_commands.add_parser("enable", help="enable a disabled user")
enable.add_argument("--username", required=True)
revoke = user_commands.add_parser("revoke-sessions", help="revoke a user's sessions")
revoke.add_argument("--username", required=True)
args = parser.parse_args(argv)
config = load_control_config()
require_current_schema(config.database_url)
database = CloudDatabase(config.database_url, create_schema=False)
service = UserAuthService(
database.repository,
settings=UserAuthSettings(
session_idle_ttl=timedelta(seconds=config.user_session_idle_seconds),
session_absolute_ttl=timedelta(
seconds=config.user_session_absolute_seconds
),
login_failure_limit=config.login_failure_limit,
login_failure_window=timedelta(
seconds=config.login_failure_window_seconds
),
login_block_duration=timedelta(seconds=config.login_block_seconds),
cookie_secure=config.session_cookie_secure,
),
)
try:
_run_user_command(args, service)
finally:
database.close()
def _run_user_command(args: argparse.Namespace, service: UserAuthService) -> None:
username = normalize_username(args.username)
if args.user_command == "create":
password = _read_password()
user = service.create_user(
username=args.username,
display_name=args.display_name or args.username,
role=args.role,
password=password,
)
service.record_admin_action(
actor_principal_id="deployment-cli",
target_user_id=user.id,
action="user_create",
metadata={"role": user.role},
)
print(f"created user {user.username!r} with role {user.role}")
return
user = service.repository.get_user_by_normalized_username(username) # type: ignore[attr-defined]
if user is None:
raise SystemExit("user not found")
if args.user_command == "reset-password":
service.reset_password(
user_id=user.id,
new_password=_read_password(),
actor_principal_id="deployment-cli",
)
print(f"reset password for {user.username!r}")
return
if args.user_command == "enable":
updated = service.repository.update_user( # type: ignore[attr-defined]
user.id,
enabled=True,
updated_at=utc_now(),
)
service.record_admin_action(
actor_principal_id="deployment-cli",
target_user_id=updated.id,
action="user_enable",
)
print(f"enabled user {updated.username!r}")
return
if args.user_command == "revoke-sessions":
service.repository.revoke_user_sessions( # type: ignore[attr-defined]
user.id,
revoked_at=utc_now(),
)
service.record_admin_action(
actor_principal_id="deployment-cli",
target_user_id=user.id,
action="session_revoke",
)
print(f"revoked sessions for {user.username!r}")
return
raise AssertionError(f"unsupported command {args.user_command!r}")
def _read_password() -> str:
password = getpass("Password: ")
confirmation = getpass("Confirm password: ")
if password != confirmation:
raise SystemExit("password confirmation did not match")
return password
+71
View File
@@ -5,6 +5,7 @@ import logging
from collections.abc import Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from typing import Any
@@ -19,6 +20,7 @@ from cloud.auth import (
ChainedAuthProvider,
ConfiguredEnrollmentTokenProvider,
RepositoryHostAuthProvider,
UserSessionAuthProvider,
create_auth_provider,
)
from cloud.config import CloudConfig
@@ -42,6 +44,8 @@ from cloud.pool import DevicePool
from cloud.scheduler import TaskScheduler
from cloud.schema import require_current_schema
from cloud.sdk.api import create_cloud_router
from cloud.sdk.user_api import create_user_auth_router
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
from core.models import utc_now
@@ -72,6 +76,7 @@ class CloudApplicationServices:
scheduler: TaskScheduler
plugin_registry: PluginRegistry
auth_provider: Any
user_auth_service: UserAuthService
class SpaStaticFiles(StaticFiles):
@@ -107,9 +112,25 @@ def create_app(
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
)
repository = _RepositoryProxy()
user_auth_service = UserAuthService(
repository,
settings=UserAuthSettings(
session_idle_ttl=timedelta(seconds=control_config.user_session_idle_seconds),
session_absolute_ttl=timedelta(
seconds=control_config.user_session_absolute_seconds
),
login_failure_limit=control_config.login_failure_limit,
login_failure_window=timedelta(
seconds=control_config.login_failure_window_seconds
),
login_block_duration=timedelta(seconds=control_config.login_block_seconds),
cookie_secure=control_config.session_cookie_secure,
),
)
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
UserSessionAuthProvider(user_auth_service),
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
)
)
@@ -128,6 +149,7 @@ def create_app(
scheduler=scheduler,
plugin_registry=plugin_registry,
auth_provider=auth_provider,
user_auth_service=user_auth_service,
)
@asynccontextmanager
@@ -201,6 +223,12 @@ def create_app(
correlation_token = bind_correlation_id(correlation_id)
try:
response = await call_next(request)
if (
request.cookies.get(USER_SESSION_COOKIE)
and not request.headers.get("authorization")
and response.status_code == status.HTTP_401_UNAUTHORIZED
):
_clear_user_auth_cookies(response, control_config)
logger.info(
"cloud request completed",
extra={
@@ -251,6 +279,18 @@ def create_app(
scheduler=scheduler,
plugin_registry=plugin_registry,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_user_auth_router(
user_auth_service=user_auth_service,
auth_provider=auth_provider,
config=control_config,
)
)
app.include_router(
@@ -289,6 +329,37 @@ def _default_database_factory(config: CloudControlConfig) -> CloudDatabase:
)
def _valid_csrf_request(
request: Request,
principal: Any,
user_auth_service: UserAuthService,
) -> bool:
if principal.session_id is None:
return True
return user_auth_service.validate_csrf(
session_token=request.cookies.get(USER_SESSION_COOKIE),
csrf_cookie=request.cookies.get(USER_CSRF_COOKIE),
csrf_header=request.headers.get("x-csrf-token"),
)
def _clear_user_auth_cookies(response: Any, config: CloudControlConfig) -> None:
response.delete_cookie(
USER_SESSION_COOKIE,
path="/",
secure=config.session_cookie_secure,
httponly=True,
samesite="lax",
)
response.delete_cookie(
USER_CSRF_COOKIE,
path="/",
secure=config.session_cookie_secure,
httponly=False,
samesite="lax",
)
async def _run_scheduler_loop(
services: CloudApplicationServices,
stop: asyncio.Event,