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

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