feat(cloud-console): add user authentication and administration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
Generated
+986
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+91
-56
@@ -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<ViewId>("tasks");
|
||||
const tokenRejectedMessage = ref("");
|
||||
const hasToken = ref(false);
|
||||
const currentUser = ref<CloudUser | null>(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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TokenScreen
|
||||
v-if="!hasToken"
|
||||
:rejection-message="tokenRejectedMessage"
|
||||
@submitted="onTokenSubmitted"
|
||||
<div v-if="loading" class="token-screen"><p>Checking session…</p></div>
|
||||
<LoginScreen v-else-if="!isAuthenticated" :message="authMessage" @authenticated="onAuthenticated" />
|
||||
<PasswordChangeScreen
|
||||
v-else-if="mustChangePassword"
|
||||
@changed="onPasswordChanged"
|
||||
@sign-out="signOut"
|
||||
/>
|
||||
<div v-else class="app-shell">
|
||||
<nav class="app-nav">
|
||||
<h1>
|
||||
<Boxes :size="14" />
|
||||
Cloud Console
|
||||
</h1>
|
||||
<h1><Boxes :size="14" /> Cloud Console</h1>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
:class="{ active: activeView === item.id }"
|
||||
@click="activeView = item.id"
|
||||
>
|
||||
<component :is="item.icon" :size="14" />
|
||||
{{ item.label }}
|
||||
<component :is="item.icon" :size="14" /> {{ item.label }}
|
||||
</button>
|
||||
<div class="spacer" />
|
||||
<button @click="signOut">
|
||||
<LogOut :size="14" />
|
||||
Clear token
|
||||
</button>
|
||||
<div class="dim">{{ currentUser ? `${currentUser.display_name} (${currentUser.role})` : "API token" }}</div>
|
||||
<button @click="signOut"><LogOut :size="14" /> Sign out</button>
|
||||
</nav>
|
||||
<main class="app-main">
|
||||
<component :is="activeComponent" />
|
||||
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
|
||||
<UsersView v-else-if="activeView === 'users' && isAdmin" />
|
||||
<component v-else :is="activeComponent" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+129
-46
@@ -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<string, string>;
|
||||
allowAnonymous?: boolean;
|
||||
sessionOnly?: boolean;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
|
||||
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<string, string> = {
|
||||
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<string> {
|
||||
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<CloudUser> {
|
||||
return request<CloudUser>("/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
allowAnonymous: true,
|
||||
sessionOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentUser(): Promise<CloudUser> {
|
||||
return request<CloudUser>("/v1/auth/me", { sessionOnly: true });
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return request<void>("/v1/auth/logout", { method: "POST", sessionOnly: true });
|
||||
}
|
||||
|
||||
export function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
return request<void>("/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<TaskAttempt[]> {
|
||||
return request<TaskAttempt[]>(
|
||||
`/v1/tasks/${encodeURIComponent(taskId)}/attempts`,
|
||||
);
|
||||
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
||||
}
|
||||
|
||||
export function listDevices(): Promise<DeviceRecord[]> {
|
||||
@@ -136,11 +183,47 @@ export function listPlugins(): Promise<PluginRecord[]> {
|
||||
return request<PluginRecord[]>("/v1/plugins");
|
||||
}
|
||||
|
||||
export function registerPlugin(
|
||||
payload: PluginRegistrationPayload,
|
||||
): Promise<PluginRecord> {
|
||||
export function registerPlugin(payload: PluginRegistrationPayload): Promise<PluginRecord> {
|
||||
return request<PluginRecord>("/v1/plugins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function listUsers(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<UserListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(options?.limit ?? 50),
|
||||
offset: String(options?.offset ?? 0),
|
||||
});
|
||||
return request<UserListResponse>(`/v1/users?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function createUser(payload: UserCreatePayload): Promise<CloudUser> {
|
||||
return request<CloudUser>("/v1/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(userId: string, payload: UserUpdatePayload): Promise<CloudUser> {
|
||||
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
|
||||
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeUserSessions(userId: string): Promise<void> {
|
||||
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { API_BASE_URL, CloudApiError, login, storeToken } from "../api";
|
||||
|
||||
defineProps<{ message?: string }>();
|
||||
const emit = defineEmits<{ (e: "authenticated"): void }>();
|
||||
|
||||
const useToken = ref(false);
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const token = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
async function submitLogin() {
|
||||
error.value = "";
|
||||
if (!username.value.trim() || !password.value) {
|
||||
error.value = "username and password are required";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await login(username.value.trim(), password.value);
|
||||
password.value = "";
|
||||
emit("authenticated");
|
||||
} catch (err) {
|
||||
password.value = "";
|
||||
error.value = err instanceof CloudApiError ? err.message : "sign in failed";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitToken() {
|
||||
const value = token.value.trim();
|
||||
if (!value) {
|
||||
error.value = "paste a bearer token issued by the cloud control plane";
|
||||
return;
|
||||
}
|
||||
storeToken(value);
|
||||
token.value = "";
|
||||
error.value = "";
|
||||
emit("authenticated");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="token-screen">
|
||||
<h1>Cloud Console</h1>
|
||||
<p>
|
||||
Sign in to the Cloud Control Plane at <code>{{ API_BASE_URL }}</code>.
|
||||
</p>
|
||||
<div v-if="message" class="notice error" style="margin-bottom: 16px">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div v-if="error" class="notice error" style="margin-bottom: 16px">{{ error }}</div>
|
||||
|
||||
<form v-if="!useToken" @submit.prevent="submitLogin">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" v-model="username" autocomplete="username" />
|
||||
<label for="password" style="margin-top: 12px">Password</label>
|
||||
<input id="password" v-model="password" type="password" autocomplete="current-password" />
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" :disabled="submitting">Sign in</button>
|
||||
<button type="button" @click="useToken = true">Use API token</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="submitToken">
|
||||
<label for="token">Bearer token</label>
|
||||
<textarea
|
||||
id="token"
|
||||
v-model="token"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="paste a break-glass or compatibility token"
|
||||
></textarea>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit">Connect</button>
|
||||
<button type="button" @click="useToken = false">Use account login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { CloudApiError, changePassword } from "../api";
|
||||
|
||||
const emit = defineEmits<{ (e: "changed"): void; (e: "signOut"): void }>();
|
||||
const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmation = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
if (newPassword.value !== confirmation.value) {
|
||||
error.value = "new password confirmation does not match";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await changePassword(currentPassword.value, newPassword.value);
|
||||
currentPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmation.value = "";
|
||||
emit("changed");
|
||||
} catch (err) {
|
||||
currentPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmation.value = "";
|
||||
error.value = err instanceof CloudApiError ? err.message : "password change failed";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="token-screen">
|
||||
<h1>Change password</h1>
|
||||
<p>Your administrator requires you to replace this temporary password.</p>
|
||||
<div v-if="error" class="notice error" style="margin-bottom: 16px">{{ error }}</div>
|
||||
<form @submit.prevent="submit">
|
||||
<label for="current-password">Current password</label>
|
||||
<input id="current-password" v-model="currentPassword" type="password" autocomplete="current-password" />
|
||||
<label for="new-password" style="margin-top: 12px">New password</label>
|
||||
<input id="new-password" v-model="newPassword" type="password" autocomplete="new-password" />
|
||||
<label for="confirm-password" style="margin-top: 12px">Confirm new password</label>
|
||||
<input id="confirm-password" v-model="confirmation" type="password" autocomplete="new-password" />
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" :disabled="submitting">Change password</button>
|
||||
<button type="button" @click="emit('signOut')">Sign out</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
PluginRecord,
|
||||
} from "../types";
|
||||
|
||||
defineProps<{ canAdmin?: boolean }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const plugins = ref<PluginRecord[]>([]);
|
||||
@@ -99,7 +101,7 @@ onMounted(refresh);
|
||||
<RefreshCw :size="14" />
|
||||
Refresh
|
||||
</button>
|
||||
<button class="primary" @click="showForm = !showForm">
|
||||
<button v-if="canAdmin" class="primary" @click="showForm = !showForm">
|
||||
<Plus :size="14" />
|
||||
{{ showForm ? "Close form" : "Register plugin" }}
|
||||
</button>
|
||||
@@ -111,7 +113,7 @@ onMounted(refresh);
|
||||
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||
<div v-if="formSuccess" class="notice success">{{ formSuccess }}</div>
|
||||
|
||||
<div class="panel" v-if="showForm">
|
||||
<div class="panel" v-if="showForm && canAdmin">
|
||||
<h3>Register a plugin</h3>
|
||||
<p class="dim">
|
||||
The cloud api requires the <code>plugins:admin</code> scope for this
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { LoaderCircle, RefreshCw, UserPlus } from "@lucide/vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
createUser,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
revokeUserSessions,
|
||||
updateUser,
|
||||
} from "../api";
|
||||
import type { CloudUser, UserRole } from "../types";
|
||||
|
||||
const users = ref<CloudUser[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const showCreate = ref(false);
|
||||
const submitting = ref(false);
|
||||
const passwordInputs = reactive<Record<string, string>>({});
|
||||
const form = reactive({
|
||||
username: "",
|
||||
display_name: "",
|
||||
role: "viewer" as UserRole,
|
||||
password: "",
|
||||
});
|
||||
|
||||
const roles: UserRole[] = ["viewer", "operator", "admin"];
|
||||
|
||||
function clearCreatePassword() {
|
||||
form.password = "";
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
users.value = (await listUsers()).items;
|
||||
} catch (err) {
|
||||
error.value = describeError(err, "failed to load users");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
if (!form.username.trim() || !form.display_name.trim() || !form.password) {
|
||||
error.value = "username, display name, role, and password are required";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const created = await createUser({
|
||||
username: form.username.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
role: form.role,
|
||||
password: form.password,
|
||||
});
|
||||
form.username = "";
|
||||
form.display_name = "";
|
||||
form.role = "viewer";
|
||||
clearCreatePassword();
|
||||
showCreate.value = false;
|
||||
success.value = `${created.username} was created and must change their password`;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
clearCreatePassword();
|
||||
error.value = describeError(err, "failed to create user");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser(user: CloudUser) {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
const updated = await updateUser(user.id, {
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
replaceUser(updated);
|
||||
success.value = `updated ${updated.username}`;
|
||||
} catch (err) {
|
||||
error.value = describeError(err, "failed to update user");
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(user: CloudUser) {
|
||||
const password = passwordInputs[user.id] || "";
|
||||
if (!password) {
|
||||
error.value = "enter a temporary password first";
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
try {
|
||||
const updated = await resetUserPassword(user.id, password);
|
||||
passwordInputs[user.id] = "";
|
||||
replaceUser(updated);
|
||||
success.value = `reset password for ${updated.username}`;
|
||||
} catch (err) {
|
||||
passwordInputs[user.id] = "";
|
||||
error.value = describeError(err, "failed to reset password");
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSessions(user: CloudUser) {
|
||||
error.value = "";
|
||||
try {
|
||||
await revokeUserSessions(user.id);
|
||||
success.value = `revoked sessions for ${user.username}`;
|
||||
} catch (err) {
|
||||
error.value = describeError(err, "failed to revoke sessions");
|
||||
}
|
||||
}
|
||||
|
||||
function replaceUser(updated: CloudUser) {
|
||||
users.value = users.value.map((user) => (user.id === updated.id ? updated : user));
|
||||
}
|
||||
|
||||
function describeError(err: unknown, fallback: string): string {
|
||||
return err instanceof CloudApiError || err instanceof Error ? err.message : fallback;
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<h2>Users</h2>
|
||||
<button :disabled="loading" @click="refresh"><RefreshCw :size="14" /> Refresh</button>
|
||||
<button class="primary" @click="showCreate = !showCreate"><UserPlus :size="14" /> {{ showCreate ? "Close form" : "Create user" }}</button>
|
||||
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading…</span>
|
||||
</div>
|
||||
<div v-if="error" class="notice error" style="margin-bottom: 12px">{{ error }}</div>
|
||||
<div v-if="success" class="notice success" style="margin-bottom: 12px">{{ success }}</div>
|
||||
|
||||
<div v-if="showCreate" class="panel">
|
||||
<h3>Create user</h3>
|
||||
<form @submit.prevent="submitCreate">
|
||||
<div class="form-grid">
|
||||
<div><label for="user-name">Username</label><input id="user-name" v-model="form.username" autocomplete="off" /></div>
|
||||
<div><label for="display-name">Display name</label><input id="display-name" v-model="form.display_name" autocomplete="name" /></div>
|
||||
<div><label for="user-role">Role</label><select id="user-role" v-model="form.role"><option v-for="role in roles" :key="role" :value="role">{{ role }}</option></select></div>
|
||||
<div><label for="user-password">Initial password</label><input id="user-password" v-model="form.password" type="password" autocomplete="new-password" /></div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top: 12px"><button class="primary" type="submit" :disabled="submitting">Create</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table v-if="users.length">
|
||||
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td><strong>{{ user.display_name }}</strong><div class="dim">{{ user.username }}</div></td>
|
||||
<td><select v-model="user.role"><option v-for="role in roles" :key="role" :value="role">{{ role }}</option></select></td>
|
||||
<td><label><input v-model="user.enabled" type="checkbox" /> enabled</label><div v-if="user.must_change_password" class="dim">password change required</div></td>
|
||||
<td class="dim">{{ user.last_login_at || "never" }}</td>
|
||||
<td>
|
||||
<div class="toolbar" style="margin: 0"><button @click="saveUser(user)">Save</button><input v-model="passwordInputs[user.id]" type="password" placeholder="temporary password" autocomplete="new-password" /><button @click="resetPassword(user)">Reset password</button><button @click="revokeSessions(user)">Revoke sessions</button></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="muted">No user accounts found.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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="{}")
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user