diff --git a/.env.example b/.env.example index bfb7676..3f82cf1 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,15 @@ CLOUD_SCHEDULER_INTERVAL_SECONDS=1 CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5 CLOUD_LEASE_DURATION_SECONDS=60 CLOUD_MAX_TASK_ATTEMPTS=3 +# Browser user sessions are secure-by-default in production. Terminate TLS at a +# reverse proxy before exposing the Console; do not put initial-user passwords here. +CLOUD_USER_SESSION_IDLE_SECONDS=28800 +CLOUD_USER_SESSION_ABSOLUTE_SECONDS=604800 +CLOUD_LOGIN_FAILURE_LIMIT=5 +CLOUD_LOGIN_FAILURE_WINDOW_SECONDS=900 +CLOUD_LOGIN_BLOCK_SECONDS=900 +CLOUD_SESSION_COOKIE_SECURE=true +CLOUD_TRUST_PROXY_HEADERS=false HOST_AGENT_HOST_ID=host-local HOST_AGENT_TOKEN=change-me-host-token diff --git a/apps/cloud-api/cloud_api/admin_cli.py b/apps/cloud-api/cloud_api/admin_cli.py new file mode 100644 index 0000000..f695f24 --- /dev/null +++ b/apps/cloud-api/cloud_api/admin_cli.py @@ -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 diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index 200da83..ea06567 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -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, diff --git a/apps/cloud-api/pyproject.toml b/apps/cloud-api/pyproject.toml index 859debc..aea2865 100644 --- a/apps/cloud-api/pyproject.toml +++ b/apps/cloud-api/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ [project.scripts] device-cloud-api = "cloud_api.cli:main" +device-cloud-admin = "cloud_api.admin_cli:main" [build-system] requires = ["setuptools>=69"] diff --git a/apps/cloud-api/tests/test_admin_cli.py b/apps/cloud-api/tests/test_admin_cli.py new file mode 100644 index 0000000..4c33baa --- /dev/null +++ b/apps/cloud-api/tests/test_admin_cli.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from cloud.control_config import CloudControlConfig +from cloud.database import CloudDatabase +from cloud.schema import upgrade_database +from cloud_api import admin_cli + + +def test_admin_cli_creates_user_with_interactive_password(monkeypatch, tmp_path, capsys) -> None: + database_url = f"sqlite:///{(tmp_path / 'cloud.sqlite3').as_posix()}" + upgrade_database(database_url) + monkeypatch.setattr( + admin_cli, + "load_control_config", + lambda: CloudControlConfig(database_url=database_url), + ) + answers = iter(("correct-horse-battery-staple", "correct-horse-battery-staple")) + monkeypatch.setattr(admin_cli, "getpass", lambda _: next(answers)) + + admin_cli.main( + [ + "users", + "create", + "--username", + "admin", + "--display-name", + "Administrator", + "--role", + "admin", + ] + ) + + database = CloudDatabase(database_url, create_schema=False) + try: + user = database.repository.get_user_by_normalized_username("admin") + assert user is not None + assert user.role == "admin" + assert "correct-horse-battery-staple" not in capsys.readouterr().out + finally: + database.close() + + +def test_admin_cli_rejects_password_confirmation_mismatch(monkeypatch, tmp_path) -> None: + database_url = f"sqlite:///{(tmp_path / 'cloud.sqlite3').as_posix()}" + upgrade_database(database_url) + monkeypatch.setattr( + admin_cli, + "load_control_config", + lambda: CloudControlConfig(database_url=database_url), + ) + answers = iter(("correct-horse-battery-staple", "different-password")) + monkeypatch.setattr(admin_cli, "getpass", lambda _: next(answers)) + + try: + admin_cli.main(["users", "create", "--username", "admin", "--role", "admin"]) + except SystemExit as error: + assert str(error) == "password confirmation did not match" + else: + raise AssertionError("expected password confirmation failure") + + database = CloudDatabase(database_url, create_schema=False) + try: + assert database.repository.get_user_by_normalized_username("admin") is None + finally: + database.close() diff --git a/cloud-console/package-lock.json b/cloud-console/package-lock.json index 5394b31..a450494 100644 --- a/cloud-console/package-lock.json +++ b/cloud-console/package-lock.json @@ -13,11 +13,55 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.7", + "jsdom": "^27.1.0", "typescript": "^6.0.3", "vite": "^8.1.3", + "vitest": "^4.0.18", "vue-tsc": "^3.3.6" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -64,6 +108,146 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -98,6 +282,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -406,6 +608,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -417,6 +626,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -434,6 +668,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -579,6 +936,16 @@ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/alien-signals": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", @@ -586,12 +953,128 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -614,12 +1097,29 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -653,6 +1153,94 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -914,6 +1502,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -923,6 +1521,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -948,6 +1560,46 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -955,6 +1607,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1002,6 +1661,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1036,6 +1715,26 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1045,6 +1744,44 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1062,6 +1799,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1162,6 +1955,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -1206,6 +2089,109 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/cloud-console/package.json b/cloud-console/package.json index 2cfdd64..a74f3ce 100644 --- a/cloud-console/package.json +++ b/cloud-console/package.json @@ -7,7 +7,8 @@ "dev": "vite --host 127.0.0.1", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview --host 127.0.0.1", - "typecheck": "vue-tsc --noEmit" + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@lucide/vue": "^1.23.0", @@ -15,8 +16,10 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.7", + "jsdom": "^27.1.0", "typescript": "^6.0.3", "vite": "^8.1.3", + "vitest": "^4.0.18", "vue-tsc": "^3.3.6" } } diff --git a/cloud-console/src/App.vue b/cloud-console/src/App.vue index 54bf70a..c8d2849 100644 --- a/cloud-console/src/App.vue +++ b/cloud-console/src/App.vue @@ -7,110 +7,145 @@ import { LogOut, MonitorSmartphone, Puzzle, + Users, } from "@lucide/vue"; import { - TOKEN_INVALID_EVENT, + AUTH_INVALID_EVENT, clearStoredToken, - getStoredToken, + getCurrentUser, + hasTokenMode, + logout, } from "./api"; -import TokenScreen from "./views/TokenScreen.vue"; +import type { CloudUser } from "./types"; +import LoginScreen from "./views/LoginScreen.vue"; +import PasswordChangeScreen from "./views/PasswordChangeScreen.vue"; import TasksView from "./views/TasksView.vue"; import DevicesView from "./views/DevicesView.vue"; import PluginsView from "./views/PluginsView.vue"; +import UsersView from "./views/UsersView.vue"; -type ViewId = "tasks" | "devices" | "plugins"; - -const navItems: { id: ViewId; label: string; icon: Component }[] = [ - { id: "tasks", label: "Tasks", icon: ListChecks }, - { id: "devices", label: "Devices", icon: MonitorSmartphone }, - { id: "plugins", label: "Plugins", icon: Puzzle }, -]; +type ViewId = "tasks" | "devices" | "plugins" | "users"; const activeView = ref("tasks"); -const tokenRejectedMessage = ref(""); -const hasToken = ref(false); +const currentUser = ref(null); +const tokenMode = ref(false); +const loading = ref(true); +const authMessage = ref(""); -function refreshTokenState() { - hasToken.value = getStoredToken() !== null; -} +const isAdmin = computed( + () => currentUser.value?.scopes.includes("*") || currentUser.value?.scopes.includes("users:admin"), +); +const canAdminPlugins = computed( + () => + tokenMode.value || + currentUser.value?.scopes.includes("*") || + currentUser.value?.scopes.includes("plugins:admin"), +); +const isAuthenticated = computed(() => currentUser.value !== null || tokenMode.value); +const mustChangePassword = computed(() => currentUser.value?.must_change_password ?? false); +const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() => { + const items: { id: ViewId; label: string; icon: Component }[] = [ + { id: "tasks", label: "Tasks", icon: ListChecks }, + { id: "devices", label: "Devices", icon: MonitorSmartphone }, + { id: "plugins", label: "Plugins", icon: Puzzle }, + ]; + if (isAdmin.value) items.push({ id: "users", label: "Users", icon: Users }); + return items; +}); -function onTokenInvalid() { - hasToken.value = false; - tokenRejectedMessage.value = - "the cloud api rejected the stored token (401/403). paste a new token to continue."; -} - -function onStorage(event: StorageEvent) { - if (event.key === null) { - // Tab-wide sessionStorage clear (some browsers fire this on logout). - refreshTokenState(); +async function initializeAuthentication() { + loading.value = true; + currentUser.value = null; + tokenMode.value = hasTokenMode(); + if (!tokenMode.value) { + try { + currentUser.value = await getCurrentUser(); + } catch { + // A missing session is the normal initial state. + } } + loading.value = false; } -function signOut() { +async function onAuthenticated() { + authMessage.value = ""; + await initializeAuthentication(); +} + +function onAuthInvalid() { + currentUser.value = null; + tokenMode.value = false; + authMessage.value = "your session expired or credentials were rejected. sign in again."; +} + +async function signOut() { + try { + if (currentUser.value) await logout(); + } catch { + // Local state must still be cleared when the already-expired session rejects logout. + } clearStoredToken(); - hasToken.value = false; - tokenRejectedMessage.value = ""; + currentUser.value = null; + tokenMode.value = false; + authMessage.value = ""; +} + +function onPasswordChanged() { + currentUser.value = null; + tokenMode.value = false; + authMessage.value = "password changed. sign in with the new password."; } onMounted(() => { - refreshTokenState(); - window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener); - window.addEventListener("storage", onStorage as EventListener); + void initializeAuthentication(); + window.addEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener); }); onUnmounted(() => { - window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener); - window.removeEventListener("storage", onStorage as EventListener); + window.removeEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener); }); const activeComponent = computed(() => { switch (activeView.value) { - case "tasks": - return TasksView; case "devices": return DevicesView; case "plugins": return PluginsView; + case "users": + return UsersView; + default: + return TasksView; } - return TasksView; }); - -function onTokenSubmitted() { - tokenRejectedMessage.value = ""; - refreshTokenState(); -} diff --git a/cloud-console/src/api.test.ts b/cloud-console/src/api.test.ts new file mode 100644 index 0000000..80ec152 --- /dev/null +++ b/cloud-console/src/api.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AUTH_INVALID_EVENT, + clearStoredToken, + getStoredToken, + listDevices, + login, + registerPlugin, + storeToken, +} from "./api"; + +function response(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Cloud Console API authentication", () => { + beforeEach(() => { + clearStoredToken(); + document.cookie = "amcp_csrf=; Max-Age=0; path=/"; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearStoredToken(); + }); + + it("uses credentialed account login without a bearer header", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + response({ + id: "user-a", + username: "admin", + display_name: "Administrator", + role: "admin", + enabled: true, + must_change_password: false, + scopes: ["*"], + created_at: "2026-01-01T00:00:00+00:00", + updated_at: "2026-01-01T00:00:00+00:00", + last_login_at: null, + }), + ); + + await login("admin", "correct-horse-battery-staple"); + + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/auth\/login$/), + expect.objectContaining({ + credentials: "include", + headers: expect.not.objectContaining({ Authorization: expect.any(String) }), + }), + ); + }); + + it("uses CSRF proof for session-authenticated writes", async () => { + document.cookie = "amcp_csrf=csrf-value; path=/"; + vi.mocked(fetch).mockResolvedValueOnce( + response({ name: "demo", version: "1", entry_point_kind: "tool", target: "m:t", wired: false }), + ); + + await registerPlugin({ + name: "demo", + version: "1", + entry_point_kind: "tool", + target: "m:t", + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/plugins$/), + expect.objectContaining({ + credentials: "include", + headers: expect.objectContaining({ "X-CSRF-Token": "csrf-value" }), + }), + ); + }); + + it("keeps the explicit compatibility bearer-token path", async () => { + storeToken("compatibility-token"); + vi.mocked(fetch).mockResolvedValueOnce(response([])); + + await listDevices(); + + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/devices$/), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer compatibility-token" }), + }), + ); + }); + + it("clears authentication only on 401, not on 403", async () => { + storeToken("compatibility-token"); + const invalidated = vi.fn(); + window.addEventListener(AUTH_INVALID_EVENT, invalidated); + vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "unauthorized" }, 401)); + + await expect(listDevices()).rejects.toMatchObject({ status: 401 }); + expect(getStoredToken()).toBeNull(); + expect(invalidated).toHaveBeenCalledTimes(1); + + storeToken("compatibility-token"); + vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "forbidden" }, 403)); + await expect(listDevices()).rejects.toMatchObject({ status: 403 }); + expect(getStoredToken()).toBe("compatibility-token"); + window.removeEventListener(AUTH_INVALID_EVENT, invalidated); + }); +}); diff --git a/cloud-console/src/api.ts b/cloud-console/src/api.ts index 09f9da0..094db33 100644 --- a/cloud-console/src/api.ts +++ b/cloud-console/src/api.ts @@ -1,4 +1,5 @@ import type { + CloudUser, DeviceRecord, HostRecord, PluginRecord, @@ -6,21 +7,24 @@ import type { TaskAttempt, TaskListResponse, TaskStatus, + UserCreatePayload, + UserListResponse, + UserUpdatePayload, } from "./types"; const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as | string | undefined; -// Same-origin Docker deployments (CLOUD_CONSOLE_STATIC_DIR) serve this SPA -// straight off the Cloud API, so without a build-time override the API lives -// at whatever host the browser loaded the page from, not a hardcoded IP. -export const API_BASE_URL = ( - configuredBaseUrl || window.location.origin -).replace(/\/$/, ""); + +export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replace( + /\/$/, + "", +); const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken"; +const CSRF_COOKIE_NAME = "amcp_csrf"; -export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid"; +export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid"; export class CloudApiError extends Error { readonly status: number; @@ -47,64 +51,109 @@ export function clearStoredToken(): void { sessionStorage.removeItem(TOKEN_STORAGE_KEY); } +export function hasTokenMode(): boolean { + return getStoredToken() !== null; +} + interface RequestInitLike { method?: string; body?: string | null; headers?: Record; + allowAnonymous?: boolean; + sessionOnly?: boolean; } async function request(path: string, init: RequestInitLike = {}): Promise { - const token = getStoredToken(); - if (!token) { - throw new CloudApiError(401, "no bearer token stored"); + const token = init.sessionOnly ? null : getStoredToken(); + if (!init.allowAnonymous && !token && !init.sessionOnly) { + // Session mode is permitted, so a missing token is not itself an error. } + const method = init.method || "GET"; const headers: Record = { Accept: "application/json", - Authorization: `Bearer ${token}`, ...init.headers, }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } else if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + const csrfToken = getCookie(CSRF_COOKIE_NAME); + if (csrfToken) headers["X-CSRF-Token"] = csrfToken; + } if (init.body !== undefined && init.body !== null) { headers["Content-Type"] = "application/json"; } const response = await fetch(`${API_BASE_URL}${path}`, { - method: init.method || "GET", + method, body: init.body ?? null, headers, + credentials: "include", }); - if (response.status === 401 || response.status === 403) { - clearStoredToken(); - window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT)); - let detail = "token rejected by cloud api"; - try { - const payload = (await response.json()) as { detail?: unknown }; - if (typeof payload.detail === "string") { - detail = payload.detail; - } - } catch { - // fall back to the default detail - } - throw new CloudApiError(response.status, detail); + if (response.status === 401) { + if (token) clearStoredToken(); + window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT)); + throw new CloudApiError(401, await responseDetail(response, "authentication expired")); } if (!response.ok) { - let message = `${response.status} ${response.statusText}`; - try { - const payload = (await response.json()) as { detail?: unknown }; - if (typeof payload.detail === "string") { - message = payload.detail; - } else if (payload.detail) { - message = JSON.stringify(payload.detail); - } - } catch { - message = await response.text().catch(() => message); - } - throw new CloudApiError(response.status, message); - } - if (response.status === 204) { - return undefined as T; + throw new CloudApiError( + response.status, + await responseDetail(response, `${response.status} ${response.statusText}`), + ); } + if (response.status === 204) return undefined as T; return (await response.json()) as T; } +async function responseDetail(response: Response, fallback: string): Promise { + try { + const payload = (await response.json()) as { detail?: unknown }; + if (typeof payload.detail === "string") return payload.detail; + if (payload.detail) return JSON.stringify(payload.detail); + } catch { + // Preserve the fallback for empty or non-JSON responses. + } + return fallback; +} + +function getCookie(name: string): string | null { + const prefix = `${encodeURIComponent(name)}=`; + for (const part of document.cookie.split(";")) { + const value = part.trim(); + if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length)); + } + return null; +} + +export function login(username: string, password: string): Promise { + return request("/v1/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + allowAnonymous: true, + sessionOnly: true, + }); +} + +export function getCurrentUser(): Promise { + return request("/v1/auth/me", { sessionOnly: true }); +} + +export function logout(): Promise { + return request("/v1/auth/logout", { method: "POST", sessionOnly: true }); +} + +export function changePassword( + currentPassword: string, + newPassword: string, +): Promise { + return request("/v1/auth/password", { + method: "POST", + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + sessionOnly: true, + }); +} + export function listTasks(options?: { status?: TaskStatus; limit?: number; @@ -119,9 +168,7 @@ export function listTasks(options?: { } export function getTaskAttempts(taskId: string): Promise { - return request( - `/v1/tasks/${encodeURIComponent(taskId)}/attempts`, - ); + return request(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`); } export function listDevices(): Promise { @@ -136,11 +183,47 @@ export function listPlugins(): Promise { return request("/v1/plugins"); } -export function registerPlugin( - payload: PluginRegistrationPayload, -): Promise { +export function registerPlugin(payload: PluginRegistrationPayload): Promise { return request("/v1/plugins", { method: "POST", body: JSON.stringify(payload), }); } + +export function listUsers(options?: { + limit?: number; + offset?: number; +}): Promise { + const params = new URLSearchParams({ + limit: String(options?.limit ?? 50), + offset: String(options?.offset ?? 0), + }); + return request(`/v1/users?${params.toString()}`); +} + +export function createUser(payload: UserCreatePayload): Promise { + return request("/v1/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateUser(userId: string, payload: UserUpdatePayload): Promise { + return request(`/v1/users/${encodeURIComponent(userId)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export function resetUserPassword(userId: string, password: string): Promise { + return request(`/v1/users/${encodeURIComponent(userId)}/password`, { + method: "POST", + body: JSON.stringify({ password }), + }); +} + +export function revokeUserSessions(userId: string): Promise { + return request(`/v1/users/${encodeURIComponent(userId)}/sessions`, { + method: "DELETE", + }); +} diff --git a/cloud-console/src/style.css b/cloud-console/src/style.css index 3f655e5..7f6f69f 100644 --- a/cloud-console/src/style.css +++ b/cloud-console/src/style.css @@ -293,9 +293,14 @@ tr.row-selected { font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace; } +.token-screen input { + width: 100%; +} + .token-screen .actions { - display: flex; - justify-content: flex-end; + display: flex; + gap: 8px; + justify-content: flex-end; margin-top: 16px; } diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts index a8ea8cf..3bdcb7d 100644 --- a/cloud-console/src/types.ts +++ b/cloud-console/src/types.ts @@ -68,3 +68,37 @@ export interface PluginRegistrationPayload { entry_point_kind: PluginEntryPointKind; target: string; } + +export type UserRole = "viewer" | "operator" | "admin"; + +export interface CloudUser { + id: string; + username: string; + display_name: string; + role: UserRole; + enabled: boolean; + must_change_password: boolean; + scopes: string[]; + created_at: string; + updated_at: string; + last_login_at: string | null; +} + +export interface UserListResponse { + items: CloudUser[]; + limit: number; + offset: number; +} + +export interface UserCreatePayload { + username: string; + display_name: string; + role: UserRole; + password: string; +} + +export interface UserUpdatePayload { + display_name?: string; + role?: UserRole; + enabled?: boolean; +} diff --git a/cloud-console/src/views/LoginScreen.vue b/cloud-console/src/views/LoginScreen.vue new file mode 100644 index 0000000..a8c7347 --- /dev/null +++ b/cloud-console/src/views/LoginScreen.vue @@ -0,0 +1,84 @@ + + + diff --git a/cloud-console/src/views/PasswordChangeScreen.vue b/cloud-console/src/views/PasswordChangeScreen.vue new file mode 100644 index 0000000..1354876 --- /dev/null +++ b/cloud-console/src/views/PasswordChangeScreen.vue @@ -0,0 +1,54 @@ + + + diff --git a/cloud-console/src/views/PluginsView.vue b/cloud-console/src/views/PluginsView.vue index a0d022c..97eca8a 100644 --- a/cloud-console/src/views/PluginsView.vue +++ b/cloud-console/src/views/PluginsView.vue @@ -7,6 +7,8 @@ import type { PluginRecord, } from "../types"; +defineProps<{ canAdmin?: boolean }>(); + const loading = ref(false); const errorMessage = ref(""); const plugins = ref([]); @@ -99,7 +101,7 @@ onMounted(refresh); Refresh - @@ -111,7 +113,7 @@ onMounted(refresh);
{{ errorMessage }}
{{ formSuccess }}
-
+

Register a plugin

The cloud api requires the plugins:admin scope for this diff --git a/cloud-console/src/views/UsersView.vue b/cloud-console/src/views/UsersView.vue new file mode 100644 index 0000000..39bec6c --- /dev/null +++ b/cloud-console/src/views/UsersView.vue @@ -0,0 +1,174 @@ + + + diff --git a/compose.deploy.yaml b/compose.deploy.yaml index 593c621..f0523b2 100644 --- a/compose.deploy.yaml +++ b/compose.deploy.yaml @@ -32,6 +32,13 @@ services: CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3} + CLOUD_USER_SESSION_IDLE_SECONDS: ${CLOUD_USER_SESSION_IDLE_SECONDS:-28800} + CLOUD_USER_SESSION_ABSOLUTE_SECONDS: ${CLOUD_USER_SESSION_ABSOLUTE_SECONDS:-604800} + CLOUD_LOGIN_FAILURE_LIMIT: ${CLOUD_LOGIN_FAILURE_LIMIT:-5} + CLOUD_LOGIN_FAILURE_WINDOW_SECONDS: ${CLOUD_LOGIN_FAILURE_WINDOW_SECONDS:-900} + CLOUD_LOGIN_BLOCK_SECONDS: ${CLOUD_LOGIN_BLOCK_SECONDS:-900} + CLOUD_SESSION_COOKIE_SECURE: ${CLOUD_SESSION_COOKIE_SECURE:-true} + CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false} CLOUD_CONSOLE_STATIC_DIR: /app/console-static ports: - "${CLOUD_API_PORT:-8001}:8001" diff --git a/compose.yaml b/compose.yaml index 29b0ceb..a85a244 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,13 @@ services: CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3} + CLOUD_USER_SESSION_IDLE_SECONDS: ${CLOUD_USER_SESSION_IDLE_SECONDS:-28800} + CLOUD_USER_SESSION_ABSOLUTE_SECONDS: ${CLOUD_USER_SESSION_ABSOLUTE_SECONDS:-604800} + CLOUD_LOGIN_FAILURE_LIMIT: ${CLOUD_LOGIN_FAILURE_LIMIT:-5} + CLOUD_LOGIN_FAILURE_WINDOW_SECONDS: ${CLOUD_LOGIN_FAILURE_WINDOW_SECONDS:-900} + CLOUD_LOGIN_BLOCK_SECONDS: ${CLOUD_LOGIN_BLOCK_SECONDS:-900} + CLOUD_SESSION_COOKIE_SECURE: ${CLOUD_SESSION_COOKIE_SECURE:-true} + CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false} CLOUD_CONSOLE_STATIC_DIR: /app/console-static ports: - "${CLOUD_API_PORT:-8001}:8001" diff --git a/packages/cloud-platform/cloud/auth.py b/packages/cloud-platform/cloud/auth.py index 7fce34b..e6fb62e 100644 --- a/packages/cloud-platform/cloud/auth.py +++ b/packages/cloud-platform/cloud/auth.py @@ -15,6 +15,7 @@ TASKS_READ_SCOPE = "tasks:read" POOL_READ_SCOPE = "pool:read" PLUGINS_READ_SCOPE = "plugins:read" PLUGINS_ADMIN_SCOPE = "plugins:admin" +USERS_ADMIN_SCOPE = "users:admin" @dataclass(frozen=True) @@ -22,6 +23,8 @@ class Principal: id: str = "anonymous" scopes: frozenset[str] = field(default_factory=frozenset) host_id: str | None = None + session_id: str | None = None + must_change_password: bool = False def has_scope(self, scope: str) -> bool: return "*" in self.scopes or scope in self.scopes @@ -167,6 +170,30 @@ class RepositoryHostAuthProvider: return Principal(id=f"enrolled-host:{host_id}", host_id=host_id) +class UserSessionAuthProvider: + """Resolve the opaque browser session cookie to an existing Principal.""" + + def __init__(self, user_auth_service: object) -> None: + self.user_auth_service = user_auth_service + + def authenticate(self, request: object) -> Principal | None: + from cloud.user_auth import USER_SESSION_COOKIE + + token = _extract_cookie(request, USER_SESSION_COOKIE) + if token is None: + return None + authenticated = self.user_auth_service.authenticate_session(token) # type: ignore[attr-defined] + if authenticated is None: + return None + user = authenticated.user + return Principal( + id=f"user:{user.id}", + scopes=user.scopes, + session_id=authenticated.session.id, + must_change_password=user.must_change_password, + ) + + class ChainedAuthProvider: def __init__(self, providers: Iterable[AuthProvider]) -> None: self.providers = tuple(providers) @@ -205,6 +232,14 @@ def _extract_bearer_token(request: object) -> str | None: return token +def _extract_cookie(request: object, name: str) -> str | None: + cookies = getattr(request, "cookies", None) + if cookies is None: + return None + value = cookies.get(name) + return value if isinstance(value, str) and value else None + + def bearer_token_digest(request: object) -> str | None: token = _extract_bearer_token(request) return digest_token(token) if token is not None else None diff --git a/packages/cloud-platform/cloud/control_config.py b/packages/cloud-platform/cloud/control_config.py index 0b0618c..9d25723 100644 --- a/packages/cloud-platform/cloud/control_config.py +++ b/packages/cloud-platform/cloud/control_config.py @@ -34,6 +34,13 @@ class CloudControlConfig: enrollment_credentials: tuple[EnrollmentCredential, ...] = () cors_allowed_origins: tuple[str, ...] = () console_static_dir: str | None = None + user_session_idle_seconds: int = 28_800 + user_session_absolute_seconds: int = 604_800 + login_failure_limit: int = 5 + login_failure_window_seconds: int = 900 + login_block_seconds: int = 900 + session_cookie_secure: bool = False + trust_proxy_headers: bool = False def load_control_config( @@ -98,6 +105,31 @@ def load_control_config( console_static_dir=_parse_optional_string( values.get("CLOUD_CONSOLE_STATIC_DIR") ), + user_session_idle_seconds=_positive_int( + values, + "CLOUD_USER_SESSION_IDLE_SECONDS", + 28_800, + ), + user_session_absolute_seconds=_positive_int( + values, + "CLOUD_USER_SESSION_ABSOLUTE_SECONDS", + 604_800, + ), + login_failure_limit=_positive_int(values, "CLOUD_LOGIN_FAILURE_LIMIT", 5), + login_failure_window_seconds=_positive_int( + values, + "CLOUD_LOGIN_FAILURE_WINDOW_SECONDS", + 900, + ), + login_block_seconds=_positive_int(values, "CLOUD_LOGIN_BLOCK_SECONDS", 900), + session_cookie_secure=_parse_bool( + values.get("CLOUD_SESSION_COOKIE_SECURE"), + default=environment == "production", + ), + trust_proxy_headers=_parse_bool( + values.get("CLOUD_TRUST_PROXY_HEADERS"), + default=False, + ), ) validate_control_config(config) return config @@ -112,6 +144,14 @@ def validate_control_config(config: CloudControlConfig) -> None: raise CloudConfigurationError( "production requires at least one configured bearer credential" ) + if config.user_session_absolute_seconds < config.user_session_idle_seconds: + raise CloudConfigurationError( + "CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL" + ) + if config.environment == "production" and not config.session_cookie_secure: + raise CloudConfigurationError( + "production requires secure user session cookies" + ) def _parse_credentials( diff --git a/packages/cloud-platform/cloud/db_models.py b/packages/cloud-platform/cloud/db_models.py index 3f76010..23472c1 100644 --- a/packages/cloud-platform/cloud/db_models.py +++ b/packages/cloud-platform/cloud/db_models.py @@ -1,6 +1,14 @@ from __future__ import annotations -from sqlalchemy import Index, Integer, String, Text, UniqueConstraint, text +from sqlalchemy import ( + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + text, +) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -125,3 +133,85 @@ class PluginRow(Base): entry_point_kind: Mapped[str] = mapped_column(String, nullable=False) target: Mapped[str] = mapped_column(String, nullable=False) wired: Mapped[int] = mapped_column(Integer, nullable=False) + + +class UserRow(Base): + __tablename__ = "cloud_users" + __table_args__ = ( + UniqueConstraint("username_normalized", name="uq_cloud_users_username_normalized"), + Index("ix_cloud_users_enabled_role", "enabled", "role"), + ) + + id: Mapped[str] = mapped_column(String, primary_key=True) + username: Mapped[str] = mapped_column(String, nullable=False) + username_normalized: Mapped[str] = mapped_column(String, nullable=False) + display_name: Mapped[str] = mapped_column(String, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(String, nullable=False) + enabled: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1")) + must_change_password: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + ) + authentication_version: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1, + server_default=text("1"), + ) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + last_login_at: Mapped[str | None] = mapped_column(String, nullable=True) + + +class UserSessionRow(Base): + __tablename__ = "cloud_user_sessions" + __table_args__ = ( + UniqueConstraint("token_digest", name="uq_cloud_user_sessions_token_digest"), + Index("ix_cloud_user_sessions_user_id", "user_id"), + Index("ix_cloud_user_sessions_absolute_expires_at", "absolute_expires_at"), + ) + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("cloud_users.id", ondelete="CASCADE"), + nullable=False, + ) + token_digest: Mapped[str] = mapped_column(String, nullable=False) + csrf_digest: Mapped[str] = mapped_column(String, nullable=False) + authentication_version: Mapped[int] = mapped_column(Integer, nullable=False) + issued_at: Mapped[str] = mapped_column(String, nullable=False) + last_seen_at: Mapped[str] = mapped_column(String, nullable=False) + idle_expires_at: Mapped[str] = mapped_column(String, nullable=False) + absolute_expires_at: Mapped[str] = mapped_column(String, nullable=False) + revoked_at: Mapped[str | None] = mapped_column(String, nullable=True) + + +class LoginThrottleRow(Base): + __tablename__ = "cloud_login_throttles" + + username_normalized: Mapped[str] = mapped_column(String, primary_key=True) + client_bucket: Mapped[str] = mapped_column(String, primary_key=True) + failure_count: Mapped[int] = mapped_column(Integer, nullable=False) + window_started_at: Mapped[str] = mapped_column(String, nullable=False) + last_attempt_at: Mapped[str] = mapped_column(String, nullable=False) + blocked_until: Mapped[str | None] = mapped_column(String, nullable=True) + + +class AuthAuditRow(Base): + __tablename__ = "cloud_auth_audit_events" + __table_args__ = ( + Index("ix_cloud_auth_audit_events_occurred_at", "occurred_at"), + Index("ix_cloud_auth_audit_events_target_user_id", "target_user_id"), + ) + + id: Mapped[str] = mapped_column(String, primary_key=True) + occurred_at: Mapped[str] = mapped_column(String, nullable=False) + actor_principal_id: Mapped[str | None] = mapped_column(String, nullable=True) + target_user_id: Mapped[str | None] = mapped_column(String, nullable=True) + action: Mapped[str] = mapped_column(String, nullable=False) + outcome: Mapped[str] = mapped_column(String, nullable=False) + correlation_id: Mapped[str | None] = mapped_column(String, nullable=True) + metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") diff --git a/packages/cloud-platform/cloud/migrations/versions/0003_cloud_user_authentication.py b/packages/cloud-platform/cloud/migrations/versions/0003_cloud_user_authentication.py new file mode 100644 index 0000000..e3bb488 --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/versions/0003_cloud_user_authentication.py @@ -0,0 +1,147 @@ +"""Add persistent Cloud Console user authentication state.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0003_cloud_user_authentication" +down_revision = "0002_edge_host_enrollment" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "cloud_users" not in tables: + op.create_table( + "cloud_users", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("username", sa.String(), nullable=False), + sa.Column("username_normalized", sa.String(), nullable=False), + sa.Column("display_name", sa.String(), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("role", sa.String(), nullable=False), + sa.Column("enabled", sa.Integer(), nullable=False, server_default=sa.text("1")), + sa.Column( + "must_change_password", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "authentication_version", + sa.Integer(), + nullable=False, + server_default=sa.text("1"), + ), + sa.Column("created_at", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.Column("last_login_at", sa.String(), nullable=True), + sa.UniqueConstraint( + "username_normalized", + name="uq_cloud_users_username_normalized", + ), + ) + op.create_index( + "ix_cloud_users_enabled_role", + "cloud_users", + ["enabled", "role"], + ) + if "cloud_user_sessions" not in tables: + op.create_table( + "cloud_user_sessions", + sa.Column("id", sa.String(), primary_key=True), + sa.Column( + "user_id", + sa.String(), + sa.ForeignKey("cloud_users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("token_digest", sa.String(), nullable=False), + sa.Column("csrf_digest", sa.String(), nullable=False), + sa.Column("authentication_version", sa.Integer(), nullable=False), + sa.Column("issued_at", sa.String(), nullable=False), + sa.Column("last_seen_at", sa.String(), nullable=False), + sa.Column("idle_expires_at", sa.String(), nullable=False), + sa.Column("absolute_expires_at", sa.String(), nullable=False), + sa.Column("revoked_at", sa.String(), nullable=True), + sa.UniqueConstraint( + "token_digest", + name="uq_cloud_user_sessions_token_digest", + ), + ) + op.create_index( + "ix_cloud_user_sessions_user_id", + "cloud_user_sessions", + ["user_id"], + ) + op.create_index( + "ix_cloud_user_sessions_absolute_expires_at", + "cloud_user_sessions", + ["absolute_expires_at"], + ) + if "cloud_login_throttles" not in tables: + op.create_table( + "cloud_login_throttles", + sa.Column("username_normalized", sa.String(), primary_key=True), + sa.Column("client_bucket", sa.String(), primary_key=True), + sa.Column("failure_count", sa.Integer(), nullable=False), + sa.Column("window_started_at", sa.String(), nullable=False), + sa.Column("last_attempt_at", sa.String(), nullable=False), + sa.Column("blocked_until", sa.String(), nullable=True), + ) + if "cloud_auth_audit_events" not in tables: + op.create_table( + "cloud_auth_audit_events", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("occurred_at", sa.String(), nullable=False), + sa.Column("actor_principal_id", sa.String(), nullable=True), + sa.Column("target_user_id", sa.String(), nullable=True), + sa.Column("action", sa.String(), nullable=False), + sa.Column("outcome", sa.String(), nullable=False), + sa.Column("correlation_id", sa.String(), nullable=True), + sa.Column("metadata_json", sa.Text(), nullable=False, server_default=sa.text("'{}'")), + ) + op.create_index( + "ix_cloud_auth_audit_events_occurred_at", + "cloud_auth_audit_events", + ["occurred_at"], + ) + op.create_index( + "ix_cloud_auth_audit_events_target_user_id", + "cloud_auth_audit_events", + ["target_user_id"], + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "cloud_auth_audit_events" in tables: + op.drop_index( + "ix_cloud_auth_audit_events_target_user_id", + table_name="cloud_auth_audit_events", + ) + op.drop_index( + "ix_cloud_auth_audit_events_occurred_at", + table_name="cloud_auth_audit_events", + ) + op.drop_table("cloud_auth_audit_events") + if "cloud_login_throttles" in tables: + op.drop_table("cloud_login_throttles") + if "cloud_user_sessions" in tables: + op.drop_index( + "ix_cloud_user_sessions_absolute_expires_at", + table_name="cloud_user_sessions", + ) + op.drop_index( + "ix_cloud_user_sessions_user_id", + table_name="cloud_user_sessions", + ) + op.drop_table("cloud_user_sessions") + if "cloud_users" in tables: + op.drop_index("ix_cloud_users_enabled_role", table_name="cloud_users") + op.drop_table("cloud_users") diff --git a/packages/cloud-platform/cloud/repository.py b/packages/cloud-platform/cloud/repository.py index e4fa663..2422747 100644 --- a/packages/cloud-platform/cloud/repository.py +++ b/packages/cloud-platform/cloud/repository.py @@ -1,13 +1,20 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Literal, Protocol if TYPE_CHECKING: from cloud.plugins import PluginManifest from cloud.pool import HostRegistration, PooledDevice from cloud.scheduler import ScheduledTask, ScheduledTaskStatus + from cloud.user_auth import ( + AuthAuditEvent, + AuthenticatedUserSession, + LoginThrottle, + UserAccount, + UserSession, + ) AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"] @@ -28,6 +35,14 @@ class DeviceEnrollmentConflictError(RuntimeError): """Raised when a local device enrollment conflicts with stored identity.""" +class UserConflictError(RuntimeError): + """Raised when a user operation violates a durable identity invariant.""" + + +class LastAdministratorConflictError(UserConflictError): + """Raised when a write would remove the last enabled administrator.""" + + @dataclass(frozen=True) class HostEnrollment: host_id: str @@ -183,6 +198,91 @@ class CloudRepository(Protocol): def get_plugin(self, name: str) -> tuple[PluginManifest, bool] | None: ... + def create_user(self, user: UserAccount) -> UserAccount: ... + + def get_user(self, user_id: str) -> UserAccount | None: ... + + def get_user_by_normalized_username( + self, + username_normalized: str, + ) -> UserAccount | None: ... + + def list_users(self, *, limit: int, offset: int) -> list[UserAccount]: ... + + def update_user( + self, + user_id: str, + *, + display_name: str | None = None, + role: str | None = None, + enabled: bool | None = None, + updated_at: datetime, + ) -> UserAccount: ... + + def rehash_user_password( + self, + user_id: str, + *, + password_hash: str, + updated_at: datetime, + ) -> UserAccount: ... + + def update_user_password( + self, + user_id: str, + *, + password_hash: str, + must_change_password: bool, + updated_at: datetime, + revoke_sessions: bool, + ) -> UserAccount: ... + + def mark_user_login(self, user_id: str, *, now: datetime) -> UserAccount: ... + + def create_user_session(self, session: UserSession) -> None: ... + + def get_authenticated_user_session( + self, + token_digest: str, + *, + now: datetime, + ) -> AuthenticatedUserSession | None: ... + + def touch_user_session( + self, + session_id: str, + *, + last_seen_at: datetime, + idle_expires_at: datetime, + ) -> UserSession: ... + + def revoke_user_session(self, session_id: str, *, revoked_at: datetime) -> bool: ... + + def revoke_user_sessions(self, user_id: str, *, revoked_at: datetime) -> int: ... + + def get_login_throttle( + self, + username_normalized: str, + client_bucket: str, + ) -> LoginThrottle | None: ... + + def record_login_failure( + self, + *, + username_normalized: str, + client_bucket: str, + now: datetime, + failure_limit: int, + failure_window: timedelta, + block_duration: timedelta, + ) -> LoginThrottle: ... + + def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None: ... + + def record_auth_audit(self, event: AuthAuditEvent) -> None: ... + + def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ... + def list_reserved_device_ids(self, *, now: datetime) -> set[str]: ... def assign_task( diff --git a/packages/cloud-platform/cloud/schema.py b/packages/cloud-platform/cloud/schema.py index 6812aee..cbb923a 100644 --- a/packages/cloud-platform/cloud/schema.py +++ b/packages/cloud-platform/cloud/schema.py @@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext from cloud.database import create_database_engine, normalize_database_url -HEAD_REVISION = "0002_edge_host_enrollment" +HEAD_REVISION = "0003_cloud_user_authentication" class SchemaVersionError(RuntimeError): diff --git a/packages/cloud-platform/cloud/sdk/api.py b/packages/cloud-platform/cloud/sdk/api.py index 617aa98..02436aa 100644 --- a/packages/cloud-platform/cloud/sdk/api.py +++ b/packages/cloud-platform/cloud/sdk/api.py @@ -10,7 +10,7 @@ authentication can be added later without changing route signatures. from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Callable, Literal from cloud.auth import ( PLUGINS_ADMIN_SCOPE, @@ -49,6 +49,7 @@ def create_cloud_router( scheduler: "TaskScheduler", plugin_registry: "PluginRegistry", auth_provider: AuthProvider | None = None, + csrf_validator: Callable[[Request, Principal], bool] | None = None, version_prefix: str = "/v1", ) -> APIRouter: """Build the ``/v1`` APIRouter exposing the platform SDK surface.""" @@ -63,11 +64,25 @@ def create_cloud_router( detail="unauthorized", headers={"WWW-Authenticate": "Bearer"}, ) + if principal.must_change_password: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="password must be changed before accessing this resource", + ) if not principal.has_scope(required_scope): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"missing required scope: {required_scope}", ) + if ( + principal.session_id is not None + and request.method in {"POST", "PUT", "PATCH", "DELETE"} + and (csrf_validator is None or not csrf_validator(request, principal)) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF validation failed", + ) return principal @router.post( diff --git a/packages/cloud-platform/cloud/sdk/client.py b/packages/cloud-platform/cloud/sdk/client.py index 9597ead..2fff538 100644 --- a/packages/cloud-platform/cloud/sdk/client.py +++ b/packages/cloud-platform/cloud/sdk/client.py @@ -139,6 +139,76 @@ class CloudClient: resp = self._request("POST", "/plugins", json=payload) return resp.json() + # --------------------------------------------------------------- user auth + + def login(self, *, username: str, password: str) -> dict[str, Any]: + resp = self._request( + "POST", + "/auth/login", + json={"username": username, "password": password}, + ) + return resp.json() + + def current_user(self) -> dict[str, Any]: + resp = self._request("GET", "/auth/me") + return resp.json() + + def logout(self) -> None: + self._request("POST", "/auth/logout") + + def change_password(self, *, current_password: str, new_password: str) -> None: + self._request( + "POST", + "/auth/password", + json={ + "current_password": current_password, + "new_password": new_password, + }, + ) + + def list_users(self, *, limit: int = 50, offset: int = 0) -> dict[str, Any]: + resp = self._request( + "GET", + "/users", + params={"limit": limit, "offset": offset}, + ) + return resp.json() + + def create_user( + self, + *, + username: str, + display_name: str, + role: str, + password: str, + ) -> dict[str, Any]: + resp = self._request( + "POST", + "/users", + json={ + "username": username, + "display_name": display_name, + "role": role, + "password": password, + }, + ) + return resp.json() + + def update_user(self, user_id: str, **changes: Any) -> dict[str, Any]: + resp = self._request("PATCH", f"/users/{user_id}", json=changes) + return resp.json() + + def reset_user_password(self, user_id: str, *, password: str) -> dict[str, Any]: + resp = self._request( + "POST", + f"/users/{user_id}/password", + json={"password": password}, + ) + return resp.json() + + def revoke_user_sessions(self, user_id: str) -> None: + self._request("DELETE", f"/users/{user_id}/sessions") + # ------------------------------------------------------------------ helpers def _url(self, path: str) -> str: @@ -152,12 +222,17 @@ class CloudClient: json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> httpx.Response: + headers = dict(self._headers or {}) + if method in {"POST", "PUT", "PATCH", "DELETE"} and not headers: + csrf_token = _cookie_value(self._http, "amcp_csrf") + if csrf_token: + headers["X-CSRF-Token"] = csrf_token response = self._http.request( method, self._url(path), json=json, params=params, - headers=self._headers, + headers=headers or None, auth=self._auth, ) if response.is_success: @@ -173,3 +248,11 @@ class CloudClient: else CloudAPIError ) raise error_type(response, str(detail)) + + +def _cookie_value(client: Any, name: str) -> str | None: + cookies = getattr(client, "cookies", None) + if cookies is None: + return None + value = cookies.get(name) + return value if isinstance(value, str) else None diff --git a/packages/cloud-platform/cloud/sdk/models.py b/packages/cloud-platform/cloud/sdk/models.py index 7528f9f..7ec6cc5 100644 --- a/packages/cloud-platform/cloud/sdk/models.py +++ b/packages/cloud-platform/cloud/sdk/models.py @@ -97,5 +97,51 @@ class PluginResponse(BaseModel): wired: bool +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=256) + + +class PasswordChangeRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + new_password: str = Field(min_length=1, max_length=256) + + +class UserResponse(BaseModel): + id: str + username: str + display_name: str + role: Literal["viewer", "operator", "admin"] + enabled: bool + must_change_password: bool + scopes: list[str] + created_at: datetime + updated_at: datetime + last_login_at: datetime | None = None + + +class UserListResponse(BaseModel): + items: list[UserResponse] + limit: int + offset: int + + +class UserCreateRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + display_name: str = Field(min_length=1, max_length=120) + role: Literal["viewer", "operator", "admin"] + password: str = Field(min_length=1, max_length=256) + + +class UserUpdateRequest(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=120) + role: Literal["viewer", "operator", "admin"] | None = None + enabled: bool | None = None + + +class PasswordResetRequest(BaseModel): + password: str = Field(min_length=1, max_length=256) + + class ErrorResponse(BaseModel): detail: str diff --git a/packages/cloud-platform/cloud/sdk/user_api.py b/packages/cloud-platform/cloud/sdk/user_api.py new file mode 100644 index 0000000..079e458 --- /dev/null +++ b/packages/cloud-platform/cloud/sdk/user_api.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import APIRouter, HTTPException, Query, Request, Response, status + +from cloud.auth import AuthProvider, Principal, USERS_ADMIN_SCOPE +from cloud.observability import current_correlation_id +from cloud.repository import LastAdministratorConflictError, UserConflictError +from cloud.sdk.models import ( + LoginRequest, + PasswordChangeRequest, + PasswordResetRequest, + UserCreateRequest, + UserListResponse, + UserResponse, + UserUpdateRequest, +) +from cloud.user_auth import ( + USER_CSRF_COOKIE, + USER_SESSION_COOKIE, + UserAuthenticationError, + UserValidationError, + utc_now, + validate_display_name, + validate_role, +) + +if TYPE_CHECKING: + from cloud.control_config import CloudControlConfig + from cloud.user_auth import UserAccount, UserAuthService + + +def create_user_auth_router( + *, + user_auth_service: UserAuthService, + auth_provider: AuthProvider, + config: CloudControlConfig, + version_prefix: str = "/v1", +) -> APIRouter: + router = APIRouter(prefix=version_prefix, tags=["cloud-user-authentication"]) + + def _principal( + request: Request, + *, + required_scope: str | None = None, + allow_password_change: bool = False, + ) -> Principal: + principal = auth_provider.authenticate(request) + if principal is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="unauthorized", + headers={"WWW-Authenticate": "Bearer"}, + ) + if principal.must_change_password and not allow_password_change: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="password must be changed before accessing this resource", + ) + if required_scope is not None and not principal.has_scope(required_scope): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"missing required scope: {required_scope}", + ) + return principal + + def _require_session(principal: Principal) -> tuple[str, str]: + if principal.session_id is None or not principal.id.startswith("user:"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="a user session is required", + ) + return principal.id.removeprefix("user:"), principal.session_id + + def _require_csrf(request: Request, principal: Principal) -> None: + if principal.session_id is None: + return + if not 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"), + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF validation failed", + ) + + def _clear_cookies(response: Response) -> 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", + ) + + @router.post("/auth/login", response_model=UserResponse) + def login(payload: LoginRequest, request: Request, response: Response) -> UserResponse: + try: + result = user_auth_service.login( + username=payload.username, + password=payload.password, + client_bucket=_client_bucket(request, config.trust_proxy_headers), + correlation_id=current_correlation_id(), + ) + except UserAuthenticationError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) from exc + response.set_cookie( + USER_SESSION_COOKIE, + result.session_token, + max_age=config.user_session_absolute_seconds, + path="/", + secure=config.session_cookie_secure, + httponly=True, + samesite="lax", + ) + response.set_cookie( + USER_CSRF_COOKIE, + result.csrf_token, + max_age=config.user_session_absolute_seconds, + path="/", + secure=config.session_cookie_secure, + httponly=False, + samesite="lax", + ) + return _user_response(result.user) + + @router.get("/auth/me", response_model=UserResponse) + def current_user(request: Request) -> UserResponse: + principal = _principal(request, allow_password_change=True) + user_id, _ = _require_session(principal) + user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined] + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") + return _user_response(user) + + @router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT) + def logout(request: Request, response: Response) -> Response: + principal = _principal(request, allow_password_change=True) + user_id, session_id = _require_session(principal) + _require_csrf(request, principal) + user_auth_service.logout( + session_id=session_id, + user_id=user_id, + correlation_id=current_correlation_id(), + ) + _clear_cookies(response) + response.status_code = status.HTTP_204_NO_CONTENT + return response + + @router.post("/auth/password", status_code=status.HTTP_204_NO_CONTENT) + def change_password( + payload: PasswordChangeRequest, + request: Request, + response: Response, + ) -> Response: + principal = _principal(request, allow_password_change=True) + user_id, _ = _require_session(principal) + _require_csrf(request, principal) + try: + user_auth_service.change_password( + user_id=user_id, + current_password=payload.current_password, + new_password=payload.new_password, + correlation_id=current_correlation_id(), + ) + except UserAuthenticationError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) from exc + except UserValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + _clear_cookies(response) + response.status_code = status.HTTP_204_NO_CONTENT + return response + + @router.get("/users", response_model=UserListResponse) + def list_users( + request: Request, + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + ) -> UserListResponse: + _principal(request, required_scope=USERS_ADMIN_SCOPE) + users = user_auth_service.repository.list_users(limit=limit, offset=offset) # type: ignore[attr-defined] + return UserListResponse( + items=[_user_response(user) for user in users], + limit=limit, + offset=offset, + ) + + @router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED) + def create_user(payload: UserCreateRequest, request: Request) -> UserResponse: + principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) + _require_csrf(request, principal) + try: + user = user_auth_service.create_user( + username=payload.username, + display_name=payload.display_name, + role=payload.role, + password=payload.password, + ) + except (UserValidationError, UserConflictError) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + user_auth_service.record_admin_action( + actor_principal_id=principal.id, + target_user_id=user.id, + action="user_create", + correlation_id=current_correlation_id(), + metadata={"role": user.role}, + ) + return _user_response(user) + + @router.patch("/users/{user_id}", response_model=UserResponse) + def update_user( + user_id: str, + payload: UserUpdateRequest, + request: Request, + ) -> UserResponse: + principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) + _require_csrf(request, principal) + try: + user = user_auth_service.repository.update_user( # type: ignore[attr-defined] + user_id, + display_name=( + validate_display_name(payload.display_name) + if payload.display_name is not None + else None + ), + role=validate_role(payload.role) if payload.role is not None else None, + enabled=payload.enabled, + updated_at=utc_now(), + ) + except KeyError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc + except LastAdministratorConflictError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except UserValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + user_auth_service.record_admin_action( + actor_principal_id=principal.id, + target_user_id=user.id, + action="user_update", + correlation_id=current_correlation_id(), + metadata={"role": user.role, "enabled": str(user.enabled)}, + ) + return _user_response(user) + + @router.post("/users/{user_id}/password", response_model=UserResponse) + def reset_password( + user_id: str, + payload: PasswordResetRequest, + request: Request, + ) -> UserResponse: + principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) + _require_csrf(request, principal) + try: + user = user_auth_service.reset_password( + user_id=user_id, + new_password=payload.password, + actor_principal_id=principal.id, + correlation_id=current_correlation_id(), + ) + except KeyError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc + except UserValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + return _user_response(user) + + @router.delete("/users/{user_id}/sessions", status_code=status.HTTP_204_NO_CONTENT) + def revoke_user_sessions(user_id: str, request: Request) -> Response: + principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) + _require_csrf(request, principal) + user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined] + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") + user_auth_service.repository.revoke_user_sessions( # type: ignore[attr-defined] + user_id, + revoked_at=utc_now(), + ) + user_auth_service.record_admin_action( + actor_principal_id=principal.id, + target_user_id=user_id, + action="session_revoke", + correlation_id=current_correlation_id(), + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + return router + + +def _user_response(user: UserAccount) -> UserResponse: + return UserResponse( + id=user.id, + username=user.username, + display_name=user.display_name, + role=user.role, + enabled=user.enabled, + must_change_password=user.must_change_password, + scopes=sorted(user.scopes), + created_at=user.created_at, + updated_at=user.updated_at, + last_login_at=user.last_login_at, + ) + + +def _client_bucket(request: Request, trust_proxy_headers: bool) -> str: + if trust_proxy_headers: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",", maxsplit=1)[0].strip() or "unknown" + return request.client.host if request.client is not None else "unknown" diff --git a/packages/cloud-platform/cloud/sql_repository.py b/packages/cloud-platform/cloud/sql_repository.py index d709442..358d944 100644 --- a/packages/cloud-platform/cloud/sql_repository.py +++ b/packages/cloud-platform/cloud/sql_repository.py @@ -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 diff --git a/packages/cloud-platform/cloud/user_auth.py b/packages/cloud-platform/cloud/user_auth.py new file mode 100644 index 0000000..470fe3f --- /dev/null +++ b/packages/cloud-platform/cloud/user_auth.py @@ -0,0 +1,527 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from hmac import compare_digest +from secrets import token_urlsafe +from typing import Literal +from uuid import uuid4 + +from argon2 import PasswordHasher as Argon2PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError + + +UserRole = Literal["viewer", "operator", "admin"] + +USER_SESSION_COOKIE = "amcp_session" +USER_CSRF_COOKIE = "amcp_csrf" +USERS_ADMIN_SCOPE = "users:admin" + +VIEWER_SCOPES = frozenset({"tasks:read", "pool:read", "plugins:read"}) +OPERATOR_SCOPES = frozenset({*VIEWER_SCOPES, "tasks:submit"}) +ROLE_SCOPES: dict[UserRole, frozenset[str]] = { + "viewer": VIEWER_SCOPES, + "operator": OPERATOR_SCOPES, + "admin": frozenset({"*"}), +} + + +class UserAuthenticationError(PermissionError): + """A deliberately non-specific user-authentication failure.""" + + +class UserValidationError(ValueError): + pass + + +class UsernameConflictError(UserValidationError): + pass + + +class LastAdministratorError(UserValidationError): + pass + + +@dataclass(frozen=True) +class UserAccount: + id: str + username: str + username_normalized: str + display_name: str + role: UserRole + enabled: bool + must_change_password: bool + authentication_version: int + created_at: datetime + updated_at: datetime + last_login_at: datetime | None = None + password_hash: str = field(repr=False, default="") + + @property + def scopes(self) -> frozenset[str]: + return ROLE_SCOPES[self.role] + + +@dataclass(frozen=True) +class UserSession: + id: str + user_id: str + token_digest: str = field(repr=False) + csrf_digest: str = field(repr=False) + authentication_version: int = 1 + issued_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + last_seen_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + idle_expires_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + absolute_expires_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + revoked_at: datetime | None = None + + +@dataclass(frozen=True) +class AuthenticatedUserSession: + user: UserAccount + session: UserSession + + +@dataclass(frozen=True) +class LoginThrottle: + username_normalized: str + client_bucket: str + failure_count: int + window_started_at: datetime + last_attempt_at: datetime + blocked_until: datetime | None = None + + +@dataclass(frozen=True) +class AuthAuditEvent: + id: str + occurred_at: datetime + actor_principal_id: str | None + target_user_id: str | None + action: str + outcome: str + correlation_id: str | None + metadata: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class UserAuthSettings: + session_idle_ttl: timedelta = timedelta(hours=8) + session_absolute_ttl: timedelta = timedelta(days=7) + login_failure_limit: int = 5 + login_failure_window: timedelta = timedelta(minutes=15) + login_block_duration: timedelta = timedelta(minutes=15) + cookie_secure: bool = False + + +@dataclass(frozen=True) +class LoginResult: + user: UserAccount + session_token: str = field(repr=False) + csrf_token: str = field(repr=False) + + +class PasswordHasher: + """Argon2id password hashing behind a testable small interface.""" + + def __init__(self) -> None: + self._hasher = Argon2PasswordHasher() + self._dummy_hash = self._hasher.hash("not-a-valid-user-password") + + def validate(self, password: str) -> None: + if len(password) < 12: + raise UserValidationError("password must be at least 12 characters") + if len(password) > 256: + raise UserValidationError("password must be at most 256 characters") + + def hash(self, password: str) -> str: + self.validate(password) + return self._hasher.hash(password) + + def verify(self, password_hash: str, password: str) -> bool: + try: + return self._hasher.verify(password_hash, password) + except (InvalidHashError, VerificationError): + return False + + def verify_dummy(self, password: str) -> None: + self.verify(self._dummy_hash, password) + + def needs_rehash(self, password_hash: str) -> bool: + try: + return self._hasher.check_needs_rehash(password_hash) + except InvalidHashError: + return False + + +def normalize_username(username: str) -> str: + normalized = username.strip().casefold() + if not 3 <= len(normalized) <= 64: + raise UserValidationError("username must contain 3 to 64 characters") + if not all(char.isalnum() or char in {".", "_", "-"} for char in normalized): + raise UserValidationError("username contains unsupported characters") + return normalized + + +def validate_display_name(display_name: str) -> str: + value = display_name.strip() + if not 1 <= len(value) <= 120: + raise UserValidationError("display name must contain 1 to 120 characters") + return value + + +def validate_role(role: str) -> UserRole: + if role not in ROLE_SCOPES: + raise UserValidationError("role must be viewer, operator, or admin") + return role # type: ignore[return-value] + + +def digest_secret(value: str) -> str: + return sha256(value.encode("utf-8")).hexdigest() + + +def generate_secret() -> str: + return token_urlsafe(32) + + +def new_user_id() -> str: + return f"user-{uuid4()}" + + +def new_session_id() -> str: + return f"session-{uuid4()}" + + +def new_audit_event_id() -> str: + return f"audit-{uuid4()}" + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def csrf_matches(*, csrf_cookie: str | None, csrf_header: str | None, session: UserSession) -> bool: + if not csrf_cookie or not csrf_header: + return False + if not compare_digest(csrf_cookie, csrf_header): + return False + return compare_digest(digest_secret(csrf_header), session.csrf_digest) + + +class UserAuthService: + """Application service for passwords, sessions, throttling, and audit state.""" + + def __init__( + self, + repository: object, + *, + settings: UserAuthSettings, + password_hasher: PasswordHasher | None = None, + ) -> None: + self.repository = repository + self.settings = settings + self.password_hasher = password_hasher or PasswordHasher() + + def create_user( + self, + *, + username: str, + display_name: str, + role: str, + password: str, + must_change_password: bool = True, + now: datetime | None = None, + ) -> UserAccount: + now = now or utc_now() + normalized = normalize_username(username) + account = UserAccount( + id=new_user_id(), + username=username.strip(), + username_normalized=normalized, + display_name=validate_display_name(display_name), + role=validate_role(role), + enabled=True, + must_change_password=must_change_password, + authentication_version=1, + created_at=now, + updated_at=now, + password_hash=self.password_hasher.hash(password), + ) + return self.repository.create_user(account) # type: ignore[attr-defined,no-any-return] + + def login( + self, + *, + username: str, + password: str, + client_bucket: str, + correlation_id: str | None = None, + now: datetime | None = None, + ) -> LoginResult: + now = now or utc_now() + self.repository.cleanup_auth_state(now=now, limit=100) # type: ignore[attr-defined] + try: + normalized = normalize_username(username) + except UserValidationError: + self.password_hasher.verify_dummy(password) + raise UserAuthenticationError("invalid username or password") from None + + throttle = self.repository.get_login_throttle( # type: ignore[attr-defined] + normalized, + client_bucket, + ) + if throttle is not None and throttle.blocked_until and throttle.blocked_until > now: + self.password_hasher.verify_dummy(password) + self._audit( + action="login", + outcome="throttled", + correlation_id=correlation_id, + metadata={"client_bucket": client_bucket}, + now=now, + ) + raise UserAuthenticationError("invalid username or password") + + account = self.repository.get_user_by_normalized_username(normalized) # type: ignore[attr-defined] + if account is None: + self.password_hasher.verify_dummy(password) + self._record_failed_login(normalized, client_bucket, correlation_id, now) + raise UserAuthenticationError("invalid username or password") + + valid_password = self.password_hasher.verify(account.password_hash, password) + if not account.enabled or not valid_password: + self._record_failed_login( + normalized, + client_bucket, + correlation_id, + now, + target_user_id=account.id, + ) + raise UserAuthenticationError("invalid username or password") + + if self.password_hasher.needs_rehash(account.password_hash): + account = self.repository.rehash_user_password( # type: ignore[attr-defined] + account.id, + password_hash=self.password_hasher.hash(password), + updated_at=now, + ) + self.repository.clear_login_throttle(normalized, client_bucket) # type: ignore[attr-defined] + session_token = generate_secret() + csrf_token = generate_secret() + session = UserSession( + id=new_session_id(), + user_id=account.id, + token_digest=digest_secret(session_token), + csrf_digest=digest_secret(csrf_token), + authentication_version=account.authentication_version, + issued_at=now, + last_seen_at=now, + idle_expires_at=now + self.settings.session_idle_ttl, + absolute_expires_at=now + self.settings.session_absolute_ttl, + ) + self.repository.create_user_session(session) # type: ignore[attr-defined] + account = self.repository.mark_user_login(account.id, now=now) # type: ignore[attr-defined] + self._audit( + action="login", + outcome="success", + actor_principal_id=f"user:{account.id}", + target_user_id=account.id, + correlation_id=correlation_id, + metadata={"client_bucket": client_bucket}, + now=now, + ) + return LoginResult(account, session_token, csrf_token) + + def authenticate_session( + self, + session_token: str, + *, + now: datetime | None = None, + ) -> AuthenticatedUserSession | None: + now = now or utc_now() + authenticated = self.repository.get_authenticated_user_session( # type: ignore[attr-defined] + digest_secret(session_token), + now=now, + ) + if authenticated is None: + return None + session = authenticated.session + if session.idle_expires_at <= now or session.absolute_expires_at <= now: + self.repository.revoke_user_session(session.id, revoked_at=now) # type: ignore[attr-defined] + return None + if session.last_seen_at + timedelta(minutes=5) <= now: + session = self.repository.touch_user_session( # type: ignore[attr-defined] + session.id, + last_seen_at=now, + idle_expires_at=min( + now + self.settings.session_idle_ttl, + session.absolute_expires_at, + ), + ) + authenticated = AuthenticatedUserSession(authenticated.user, session) + return authenticated + + def change_password( + self, + *, + user_id: str, + current_password: str, + new_password: str, + correlation_id: str | None = None, + now: datetime | None = None, + ) -> UserAccount: + now = now or utc_now() + account = self.repository.get_user(user_id) # type: ignore[attr-defined] + if account is None or not self.password_hasher.verify( + account.password_hash, + current_password, + ): + raise UserAuthenticationError("invalid username or password") + updated = self.repository.update_user_password( # type: ignore[attr-defined] + user_id, + password_hash=self.password_hasher.hash(new_password), + must_change_password=False, + updated_at=now, + revoke_sessions=True, + ) + self._audit( + action="password_change", + outcome="success", + actor_principal_id=f"user:{user_id}", + target_user_id=user_id, + correlation_id=correlation_id, + now=now, + ) + return updated + + def logout( + self, + *, + session_id: str, + user_id: str, + correlation_id: str | None = None, + now: datetime | None = None, + ) -> None: + now = now or utc_now() + self.repository.revoke_user_session(session_id, revoked_at=now) # type: ignore[attr-defined] + self._audit( + action="logout", + outcome="success", + actor_principal_id=f"user:{user_id}", + target_user_id=user_id, + correlation_id=correlation_id, + now=now, + ) + + def validate_csrf( + self, + *, + session_token: str | None, + csrf_cookie: str | None, + csrf_header: str | None, + ) -> bool: + if session_token is None: + return False + authenticated = self.authenticate_session(session_token) + return authenticated is not None and csrf_matches( + csrf_cookie=csrf_cookie, + csrf_header=csrf_header, + session=authenticated.session, + ) + + def record_admin_action( + self, + *, + actor_principal_id: str, + target_user_id: str, + action: str, + correlation_id: str | None = None, + metadata: dict[str, str] | None = None, + now: datetime | None = None, + ) -> None: + self._audit( + action=action, + outcome="success", + actor_principal_id=actor_principal_id, + target_user_id=target_user_id, + correlation_id=correlation_id, + metadata=metadata, + now=now or utc_now(), + ) + + def reset_password( + self, + *, + user_id: str, + new_password: str, + actor_principal_id: str, + correlation_id: str | None = None, + now: datetime | None = None, + ) -> UserAccount: + now = now or utc_now() + updated = self.repository.update_user_password( # type: ignore[attr-defined] + user_id, + password_hash=self.password_hasher.hash(new_password), + must_change_password=True, + updated_at=now, + revoke_sessions=True, + ) + self._audit( + action="password_reset", + outcome="success", + actor_principal_id=actor_principal_id, + target_user_id=user_id, + correlation_id=correlation_id, + now=now, + ) + return updated + + def _record_failed_login( + self, + normalized: str, + client_bucket: str, + correlation_id: str | None, + now: datetime, + *, + target_user_id: str | None = None, + ) -> None: + self.repository.record_login_failure( # type: ignore[attr-defined] + username_normalized=normalized, + client_bucket=client_bucket, + now=now, + failure_limit=self.settings.login_failure_limit, + failure_window=self.settings.login_failure_window, + block_duration=self.settings.login_block_duration, + ) + self._audit( + action="login", + outcome="failed", + target_user_id=target_user_id, + correlation_id=correlation_id, + metadata={"client_bucket": client_bucket}, + now=now, + ) + + def _audit( + self, + *, + action: str, + outcome: str, + now: datetime, + actor_principal_id: str | None = None, + target_user_id: str | None = None, + correlation_id: str | None = None, + metadata: dict[str, str] | None = None, + ) -> None: + self.repository.record_auth_audit( # type: ignore[attr-defined] + AuthAuditEvent( + id=new_audit_event_id(), + occurred_at=now, + actor_principal_id=actor_principal_id, + target_user_id=target_user_id, + action=action, + outcome=outcome, + correlation_id=correlation_id, + metadata=metadata or {}, + ) + ) diff --git a/packages/cloud-platform/pyproject.toml b/packages/cloud-platform/pyproject.toml index 82f939b..d0177ea 100644 --- a/packages/cloud-platform/pyproject.toml +++ b/packages/cloud-platform/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.14" dependencies = [ "alembic>=1.14.0", + "argon2-cffi>=25.0.0", "device-agent-runtime==0.1.0", "psycopg[binary]>=3.2.0", "sqlalchemy>=2.0.0", diff --git a/tests/test_cloud_control_config.py b/tests/test_cloud_control_config.py index 76adafc..c0f4f42 100644 --- a/tests/test_cloud_control_config.py +++ b/tests/test_cloud_control_config.py @@ -102,6 +102,51 @@ def test_load_control_config_rejects_missing_production_credentials() -> None: ) +def test_load_control_config_parses_user_session_settings() -> None: + config = load_control_config( + { + "CLOUD_USER_SESSION_IDLE_SECONDS": "600", + "CLOUD_USER_SESSION_ABSOLUTE_SECONDS": "1200", + "CLOUD_LOGIN_FAILURE_LIMIT": "3", + "CLOUD_LOGIN_FAILURE_WINDOW_SECONDS": "60", + "CLOUD_LOGIN_BLOCK_SECONDS": "90", + "CLOUD_SESSION_COOKIE_SECURE": "true", + "CLOUD_TRUST_PROXY_HEADERS": "true", + } + ) + + assert config.user_session_idle_seconds == 600 + assert config.user_session_absolute_seconds == 1200 + assert config.login_failure_limit == 3 + assert config.login_block_seconds == 90 + assert config.session_cookie_secure is True + assert config.trust_proxy_headers is True + + +def test_load_control_config_rejects_unsafe_user_session_ttls() -> None: + with pytest.raises(CloudConfigurationError, match="ABSOLUTE"): + load_control_config( + { + "CLOUD_USER_SESSION_IDLE_SECONDS": "1200", + "CLOUD_USER_SESSION_ABSOLUTE_SECONDS": "600", + } + ) + + +def test_production_requires_secure_user_session_cookie() -> None: + with pytest.raises(CloudConfigurationError, match="secure user session"): + load_control_config( + { + "CLOUD_ENVIRONMENT": "production", + "CLOUD_DATABASE_URL": "postgresql://db/cloud", + "CLOUD_PUBLIC_CREDENTIALS_JSON": ( + '[{"principal_id":"sdk","token":"secret","scopes":[]}]' + ), + "CLOUD_SESSION_COOKIE_SECURE": "false", + } + ) + + @pytest.mark.parametrize( "name,value", [ diff --git a/tests/test_cloud_migrations.py b/tests/test_cloud_migrations.py index fa8d859..43cb9a9 100644 --- a/tests/test_cloud_migrations.py +++ b/tests/test_cloud_migrations.py @@ -34,6 +34,10 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None: "scheduled_tasks", "plugins", "task_attempts", + "cloud_users", + "cloud_user_sessions", + "cloud_login_throttles", + "cloud_auth_audit_events", } <= table_names assert current_revision(database_url) == HEAD_REVISION host_columns = { @@ -56,6 +60,8 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None: inspector = inspect(engine) assert "device_enrollments" not in inspector.get_table_names() assert "task_attempts" not in inspector.get_table_names() + assert "cloud_users" not in inspector.get_table_names() + assert "cloud_user_sessions" not in inspector.get_table_names() task_columns = { column["name"] for column in inspector.get_columns("scheduled_tasks") } diff --git a/tests/test_cloud_user_auth.py b/tests/test_cloud_user_auth.py new file mode 100644 index 0000000..7b6368d --- /dev/null +++ b/tests/test_cloud_user_auth.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from datetime import timedelta + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from cloud.control_config import CloudControlConfig +from cloud.database import CloudDatabase +from cloud.repository import LastAdministratorConflictError +from cloud.sdk.client import CloudAuthorizationError, CloudClient +from cloud.user_auth import UserAuthService, UserAuthSettings, UserAuthenticationError, utc_now +from cloud_api.app import create_app + + +def _create_admin(client: TestClient): + service = client.app.state.cloud_services.user_auth_service + return service.create_user( + username="admin", + display_name="Administrator", + role="admin", + password="correct-horse-battery-staple", + must_change_password=False, + ) + + +def test_user_login_session_csrf_and_admin_lifecycle() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as client: + admin = _create_admin(client) + login = client.post( + "/v1/auth/login", + json={"username": "ADMIN", "password": "correct-horse-battery-staple"}, + ) + + assert login.status_code == 200 + assert login.json()["role"] == "admin" + assert "amcp_session" in login.headers["set-cookie"] + assert "HttpOnly" in login.headers["set-cookie"] + assert client.get("/v1/auth/me").json()["id"] == admin.id + + missing_csrf = client.post( + "/v1/users", + json={ + "username": "viewer", + "display_name": "Viewer", + "role": "viewer", + "password": "another-secure-password", + }, + ) + assert missing_csrf.status_code == 403 + + csrf = client.cookies.get("amcp_csrf") + created = client.post( + "/v1/users", + headers={"X-CSRF-Token": csrf}, + json={ + "username": "viewer", + "display_name": "Viewer", + "role": "viewer", + "password": "another-secure-password", + }, + ) + assert created.status_code == 201 + viewer_id = created.json()["id"] + assert created.json()["must_change_password"] is True + + users = client.get("/v1/users") + assert users.status_code == 200 + assert {item["id"] for item in users.json()["items"]} == {admin.id, viewer_id} + + logout = client.post("/v1/auth/logout", headers={"X-CSRF-Token": csrf}) + assert logout.status_code == 204 + assert client.get("/v1/auth/me").status_code == 401 + + +def test_session_user_is_scope_limited_and_must_change_password() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as client: + service = client.app.state.cloud_services.user_auth_service + service.create_user( + username="operator", + display_name="Operator", + role="operator", + password="correct-horse-battery-staple", + must_change_password=False, + ) + service.create_user( + username="temporary", + display_name="Temporary", + role="viewer", + password="correct-horse-battery-staple", + ) + + assert client.post( + "/v1/auth/login", + json={"username": "operator", "password": "correct-horse-battery-staple"}, + ).status_code == 200 + csrf = client.cookies.get("amcp_csrf") + assert client.get("/v1/tasks").status_code == 200 + assert client.post( + "/v1/tasks", + headers={"X-CSRF-Token": csrf}, + json={"goal": "inspect"}, + ).status_code == 201 + assert client.get("/v1/users").status_code == 403 + client.post("/v1/auth/logout", headers={"X-CSRF-Token": csrf}) + + assert client.post( + "/v1/auth/login", + json={"username": "temporary", "password": "correct-horse-battery-staple"}, + ).status_code == 200 + assert client.get("/v1/tasks").status_code == 403 + assert client.get("/v1/auth/me").status_code == 200 + + +def test_login_failure_is_generic_and_throttled() -> None: + app = create_app( + config=CloudControlConfig( + database_url="sqlite:///:memory:", + login_failure_limit=2, + login_block_seconds=60, + ) + ) + with TestClient(app) as client: + _create_admin(client) + for password in ("wrong-password", "wrong-password", "correct-horse-battery-staple"): + response = client.post( + "/v1/auth/login", + json={"username": "admin", "password": password}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == "invalid username or password" + + +def test_password_change_revokes_existing_session() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as client: + admin = _create_admin(client) + client.post( + "/v1/auth/login", + json={"username": "admin", "password": "correct-horse-battery-staple"}, + ) + csrf = client.cookies.get("amcp_csrf") + changed = client.post( + "/v1/auth/password", + headers={"X-CSRF-Token": csrf}, + json={ + "current_password": "correct-horse-battery-staple", + "new_password": "new-correct-horse-battery-staple", + }, + ) + assert changed.status_code == 204 + assert client.get("/v1/auth/me").status_code == 401 + relogin = client.post( + "/v1/auth/login", + json={"username": "admin", "password": "new-correct-horse-battery-staple"}, + ) + assert relogin.status_code == 200 + assert relogin.json()["id"] == admin.id + + +def test_last_administrator_is_preserved() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as client: + admin = _create_admin(client) + repository = client.app.state.cloud_services.repository + with pytest.raises(LastAdministratorConflictError): + repository.update_user( + admin.id, + enabled=False, + updated_at=utc_now(), + ) + + +def test_user_auth_service_expires_sessions() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as client: + repository = client.app.state.cloud_services.repository + service = UserAuthService( + repository, + settings=UserAuthSettings( + session_idle_ttl=timedelta(seconds=1), + session_absolute_ttl=timedelta(seconds=1), + ), + ) + service.create_user( + username="expired", + display_name="Expired", + role="viewer", + password="correct-horse-battery-staple", + must_change_password=False, + ) + login = service.login( + username="expired", + password="correct-horse-battery-staple", + client_bucket="test", + now=utc_now(), + ) + assert service.authenticate_session( + login.session_token, + now=utc_now() + timedelta(seconds=2), + ) is None + + +def test_cloud_client_preserves_user_session_and_csrf() -> None: + app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:")) + with TestClient(app) as http_client: + _create_admin(http_client) + client = CloudClient("http://testserver", http_client=http_client) + assert client.login( + username="admin", + password="correct-horse-battery-staple", + )["username"] == "admin" + assert client.current_user()["role"] == "admin" + created = client.create_user( + username="client-user", + display_name="Client User", + role="viewer", + password="another-secure-password", + ) + assert created["must_change_password"] is True + assert {item["username"] for item in client.list_users()["items"]} == { + "admin", + "client-user", + } + client.logout() + with pytest.raises(CloudAuthorizationError): + client.current_user() + + +def test_audit_events_do_not_contain_password_or_session_secrets() -> None: + database = CloudDatabase("sqlite:///:memory:") + try: + service = UserAuthService(database.repository, settings=UserAuthSettings()) + service.create_user( + username="admin", + display_name="Administrator", + role="admin", + password="correct-horse-battery-staple", + must_change_password=False, + ) + with pytest.raises(UserAuthenticationError): + service.login( + username="admin", + password="incorrect-secret-password", + client_bucket="127.0.0.1", + ) + with database.engine.connect() as connection: + values = connection.scalars( + text("select metadata_json from cloud_auth_audit_events") + ).all() + rendered = " ".join(values) + assert "incorrect-secret-password" not in rendered + assert "correct-horse-battery-staple" not in rendered + finally: + database.close() diff --git a/uv.lock b/uv.lock index 6fad45b..77aa818 100644 --- a/uv.lock +++ b/uv.lock @@ -188,6 +188,49 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/14/fe/ced736d8cd0e8563003ba729214c73f3b6e28be9322c6c0fda1e331de325/appium_python_client-5.3.1-py3-none-any.whl", hash = "sha256:da0d3227ee059c31908a16f30a131424713ea96998fcd48e255d2cf9d107b557" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -501,6 +544,7 @@ version = "0.1.0" source = { editable = "packages/cloud-platform" } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "device-agent-runtime" }, { name = "psycopg", extra = ["binary"] }, { name = "sqlalchemy" }, @@ -509,6 +553,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, + { name = "argon2-cffi", specifier = ">=25.0.0" }, { name = "device-agent-runtime", editable = "." }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },