Compare commits

...
3 Commits
51 changed files with 4948 additions and 226 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()
+33 -71
View File
@@ -1,95 +1,57 @@
# Cloud Console
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
bearer token; the console stores it in `sessionStorage`, attaches
`Authorization: Bearer <token>` to every request, and clears it whenever the
Cloud API responds `401` or `403`.
Vue 3 + Vite single-page app for the Cloud Control Plane (`apps/cloud-api`).
The primary flow is a Cloud user account: username/password login creates an
expiring, revocable `HttpOnly` session cookie, while the frontend sends the
separate CSRF cookie value on writes. The browser never stores the session
secret in JavaScript.
The app talks only to the platform SDK surface (`/v1/...`) and consumes the
two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`,
`GET /v1/tasks/{task_id}/attempts`) alongside the existing
`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes.
The login screen also offers **Use API token** for existing break-glass or
automation credentials. That token is held only in `sessionStorage`; it remains
compatible with the existing scoped `CLOUD_PUBLIC_CREDENTIALS_JSON` model.
## Prerequisites
- Node.js 20+ (matching the existing `console/` SPA project)
- A running Cloud API (`apps/cloud-api`) reachable from your browser
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
what you intend to do from the console. Recommended least-privilege set:
- `tasks:read` — task list and attempt history views
- `pool:read` — device and host views
- `plugins:read` — plugin list
- Add `tasks:submit`/`plugins:admin` only if you need the write actions from
the same tab.
- Node.js 20+
- A current Cloud API database migration and at least one administrator created
with `device-cloud-admin users create ...`
- HTTPS for production: `CLOUD_SESSION_COOKIE_SECURE=true` is required in a
production Cloud API. Terminate TLS at the origin serving `/console/`.
## Configure the backend CORS allow-list
Accounts have fixed roles:
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin your dev server prints (scheme + host + port —
no trailing slash):
- `viewer`: task, device/host, and plugin read views
- `operator`: viewer access plus task submission APIs
- `admin`: all API scopes and the Console Users view
```bash
# Example: allow the default Vite dev origin
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
```
Restart `apps/cloud-api` after changing this env. Tokens are still required —
the allow-list only says which browser origins may send them.
## Run the dev server
## Local development
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
# Point this to the local Cloud API when it is not http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
bearer token, and the task/device/host/plugin dashboards become available.
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
(defaults to `http://127.0.0.1:8001`).
## Build for production
For a Vite origin such as `http://127.0.0.1:5173`, configure the API with the
exact origin and disable secure cookies only in local/test mode:
```bash
npm run build # type-checks with vue-tsc, then emits dist/
npm run preview # serves the built bundle locally
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
export CLOUD_SESSION_COOKIE_SECURE=false
```
`dist/` is a static bundle — host it behind any static file server or CDN and
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
time.
The Console uses `credentials: include`. `401` returns to the login screen;
`403` remains an authorization error so an otherwise valid session is retained.
## Token handling
## Production
- The token is held in `sessionStorage` only. Closing the tab discards it.
- Every API request attaches `Authorization: Bearer <token>` and targets only
the configured `VITE_CLOUD_API_BASE_URL`.
- A `401`/`403` response clears the stored token and returns the operator to
the token-entry screen with the API's error detail.
`npm run build` type-checks and creates `dist/`. The repository Dockerfile
already builds this bundle into `/app/console-static`; `compose.yaml` and
`compose.deploy.yaml` mount it at the same-origin `/console/` route. No CORS
configuration is required in that deployment shape.
## Project layout
```
cloud-console/
├── src/
│ ├── api.ts # API client wrapper (token storage, fetch, errors)
│ ├── types.ts # TS interfaces mirroring the REST models
│ ├── App.vue # Shell: token gate, nav, view router
│ ├── main.ts # Vue bootstrap
│ ├── style.css # Dark theme styles
│ └── views/
│ ├── TokenScreen.vue
│ ├── TasksView.vue # list + detail with attempt history
│ ├── DevicesView.vue # device pool + host registry
│ └── PluginsView.vue # registry list + registration form
├── index.html
├── package.json
├── tsconfig.json / tsconfig.node.json
└── vite.config.ts
```
Administrators can create users, assign roles, enable/disable accounts, reset
temporary passwords, and revoke sessions. All password inputs are cleared from
the UI after a create/reset request succeeds or fails.
+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"
+64 -33
View File
@@ -186,33 +186,62 @@ PY
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders the task queue/history, device pool, host registry, and plugin
registry, and exposes the existing plugin-registration action. It authenticates
the same way `CloudClient` does: by attaching a pre-issued bearer token to
every request. There is no login or session system.
renders task history, devices, hosts, plugins, and the user directory. Human
operators sign in with a username and password; the Cloud API creates an
expiring, revocable `HttpOnly` session cookie and uses a separate CSRF
cookie/header for writes. Existing bearer tokens remain available through the
Console's explicit **Use API token** action and for SDK, Host Agent, and
automation compatibility.
### Provision an operator bearer token
### HTTPS and session configuration
Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the
console operators need to do. The least-privilege set for read-only dashboards
is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if
operators should submit ad-hoc tasks from the same tab, and `plugins:admin`
only if operators should register plugins:
`CLOUD_ENVIRONMENT=production` requires `CLOUD_SESSION_COOKIE_SECURE=true`.
Terminate TLS at a reverse proxy and open the same-origin Console through HTTPS,
for example `https://cloud.example.com/console/`. Direct `http://host:8001`
access is for local/test mode only; it cannot retain production login cookies.
```json
[
{
"principal_id": "console-operator",
"token": "replace-with-a-long-random-opaque-token",
"scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"]
}
]
The defaults are an 8-hour idle session TTL, 7-day absolute TTL, and a temporary
block after five failed logins in a 15-minute username/client-address window:
```text
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
```
Rotate the token the same way as any other credential entry: deploy the
updated Cloud API credential set and instruct operators to paste the new token
into the console. The console keeps the token only in browser `sessionStorage`
for that tab; closing the tab discards it.
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
`X-Forwarded-For` before requests reach the Cloud API.
### Create and recover administrator accounts
After migrations and Cloud API startup, create the first account interactively:
```bash
docker compose exec cloud-api \
device-cloud-admin users create \
--username admin --display-name "Cloud Administrator" --role admin
```
The command prompts twice for the password, so it does not enter shell history,
Compose configuration, process arguments, logs, or container inspection output.
Recovery commands are also interactive:
```bash
docker compose exec cloud-api device-cloud-admin users reset-password --username admin
docker compose exec cloud-api device-cloud-admin users enable --username admin
docker compose exec cloud-api device-cloud-admin users revoke-sessions --username admin
```
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
submits tasks; `admin` has unrestricted Cloud API access and manages users.
Administrators create users, reset passwords, change roles, disable accounts,
and revoke sessions from the **Users** Console view. New and reset users must
change their temporary password before accessing other resources, and the API
will not disable or demote the last enabled administrator.
### Configure the CORS allow-list
@@ -231,7 +260,7 @@ export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
Restart the Cloud API after changing this env. The middleware is added only
when the allow-list is non-empty — existing deployments see no behavior change
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
supported because every console request carries a bearer token.
supported because browser sessions are credentialed.
### Run the console
@@ -244,8 +273,10 @@ npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL,
paste the operator token, and the dashboards become available.
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. For local development
set `CLOUD_SESSION_COOKIE_SECURE=false`, then open the dev URL and sign in with
a user account. The Console sends credentialed requests and attaches CSRF proof
to writes.
For a production build, run `npm run build` and serve the resulting `dist/`
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
@@ -257,15 +288,15 @@ The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
and `compose.yaml` / `compose.deploy.yaml` set
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
307-redirects there), so operators can open `https://<cloud-api-host>:8001/`
directly — no separate dev server, no static host, no CORS allow-list needed
(the SPA and the API share one origin).
307-redirects there). Put that origin behind an HTTPS reverse proxy, then open
for example `https://cloud.example.com/` directly — no separate dev server, no
static host, and no CORS allow-list are needed because the SPA and API share one
origin.
The SPA shell (`index.html`, JS, CSS) is served without a bearer token by
design — `_authorize(...)` is called inside the `/v1/*` route handlers, not in
middleware, so the SPA can boot before the operator pastes a token. All
`/v1/*` API calls still require `tasks:read`/`pool:read`/`plugins:read` scopes
as before.
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
To opt out (e.g. for local development where you run `npm run dev`), leave
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,114 @@
## Context
The deployable Cloud API currently builds one `ChainedAuthProvider` from configured public/Host bearer credentials plus repository-backed enrolled-Host credentials. A successful provider returns `Principal(id, scopes, host_id)` and every `/v1` handler performs the same operation-specific scope check. The Cloud Console is therefore only a bearer-token holder: it stores an operator-pasted token in `sessionStorage` and has no user directory, password verification, login endpoint, session persistence, or user administration.
Cloud state already uses one SQLAlchemy repository implementation for SQLite and PostgreSQL and production startup requires Alembic to be current. The current schema ends at revision `0002_edge_host_enrollment`; adding persistent users and sessions therefore requires a normal forward migration rather than application-managed table creation in production. The deployed Console is same-origin under `/console/`, while its Vite development mode can remain cross-origin through the existing exact-origin CORS allow-list.
This change follows the active `cloud-console` change without editing its files. That change's unarchived `cloud-console-ui` capability explicitly scoped out login/session/RBAC; this follow-up supersedes the default token-entry experience while preserving token entry as a compatibility path.
## Goals / Non-Goals
**Goals:**
- Give each human operator an attributable account and a familiar username/password login.
- Reuse the existing `Principal` and scope enforcement instead of creating a second authorization model.
- Keep browser credentials revocable, expiring, protected from JavaScript access, and safe for same-origin production deployment.
- Let administrators manage user lifecycle and recover access without editing credential JSON or placing passwords in command arguments.
- Preserve all current bearer-token, Host Agent, enrollment, and `CloudClient` integrations during migration and rollback.
**Non-Goals:**
- Public registration, invitations by email, forgot-password email delivery, or identity proofing.
- OIDC, OAuth login, SAML, LDAP, SCIM, MFA, WebAuthn, or social identity providers.
- Tenants/organizations, per-resource ACLs, or user-defined roles and permissions.
- Replacing Host Agent/enrollment credentials or removing configured public bearer tokens.
- Making the Cloud API active-active; the existing single scheduler-enabled Cloud API topology remains unchanged.
## Decisions
### Persistent local users with fixed roles
Add `cloud_users` records with an opaque user id, original and normalized username, display name, Argon2id password hash, role, enabled state, `must_change_password`, authentication version, and created/updated/last-login timestamps. Usernames are trimmed and case-normalized for uniqueness, while the original spelling remains displayable.
Roles map to the existing scope vocabulary:
- `viewer`: `tasks:read`, `pool:read`, `plugins:read`.
- `operator`: viewer scopes plus `tasks:submit`.
- `admin`: unrestricted `*`, including a new `users:admin` operation scope.
The fixed mapping keeps v1 authorization auditable and lets user principals flow through the same `_authorize()` checks as bearer principals. A customizable role/permission schema was rejected because the current API has only five resource scopes plus user administration; introducing role tables and policy editing now would add migration and lockout complexity without a demonstrated need.
### Argon2id password hashing behind a small service boundary
Use a maintained Argon2id implementation through a `PasswordHasher` abstraction. Store only encoded hashes, use the library's constant-time verification and rehash signal, and rehash on successful login when parameters change. Passwords are accepted only by login/change/reset operations, are excluded from model representations and structured logs, and never cross into repository return models.
Fast hashes or reversible encryption were rejected because passwords require deliberately expensive, salted, one-way verification. Hand-rolling Argon2 parameters in route handlers was rejected so tests can inject a deterministic fake without weakening production defaults.
### Opaque server-side sessions in secure cookies
Successful login creates at least 256 bits of random session material. Only its SHA-256 digest is stored in `cloud_user_sessions`; the plaintext value is sent as an `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` cookie. Session rows include user id, issued/last-seen/absolute-expiry timestamps, authentication version, revocation timestamp, and a digest for the CSRF token. Indexed digest lookup adds one bounded database query to cookie-authenticated requests.
Production always emits `Secure` cookies and therefore requires HTTPS at the browser-facing reverse proxy. Local/test configuration may explicitly use non-secure cookies. Session TTL and idle TTL are bounded configuration values; activity can extend the idle deadline but never the absolute deadline. Logout revokes the current row and clears cookies. Password reset, account disablement, role change, or authentication-version increment revokes all affected sessions immediately.
Self-contained JWTs were rejected because immediate account disablement, password-reset invalidation, role changes, and administrator session revocation would still require server-side state or short token lifetimes. A database-backed opaque session is simpler and matches the existing durable repository.
### Cookie sessions join the existing authentication chain
Add a repository-backed `UserSessionAuthProvider` that returns the same `Principal` type, with a distinguishable user principal id and scopes derived from the stored role. Compose it with the configured bearer provider; Host-bound authorization continues to require `host_id`, so a browser session cannot act as a Host Agent. Enrollment remains on its separate provider.
Bearer authentication remains valid on all existing routes. This is both the migration path and the non-browser automation contract. Maintaining two authorization implementations was rejected: all resource routes continue to consume only a `Principal` and required scope.
### Versioned authentication and user-administration API
Add routes under `/v1/auth` for login, logout, current user, and password change, plus `/v1/users` routes for list/create/update, password reset, and session revocation. The login route is the only anonymous user endpoint. User administration requires `users:admin`; a configured bearer principal with `*` or `users:admin` can use it for automation/recovery as well as a logged-in administrator.
The first administrator is created with a separate `device-cloud-admin` console script. It connects to `CLOUD_DATABASE_URL`, checks the current schema, reads passwords interactively with confirmation via `getpass`, and supports create/reset/enable/session-revoke recovery operations. Passwords are never accepted as command-line flags. An environment bootstrap password was rejected because Compose interpolation, container inspection, deployment logs, and forgotten secret variables make one-time bootstrap credentials easy to retain accidentally.
The admin API prevents disabling or demoting the last enabled administrator. The recovery CLI remains available to deployment administrators if all browser sessions are lost.
### CSRF protection applies only to cookie-authenticated unsafe requests
Login creates a separate random CSRF value bound by digest to the session and exposes it to the same-origin Console through a non-`HttpOnly` cookie. For `POST`, `PUT`, `PATCH`, and `DELETE` requests authenticated by the session cookie, the API requires a matching `X-CSRF-Token` header. Bearer-authenticated requests are exempt because browsers do not attach those credentials automatically.
The Console sends `credentials: "include"`, reads only the CSRF cookie, and adds the header on unsafe requests. Exact-origin CORS plus credentialed requests supports Vite development; wildcard origins remain forbidden. Depending only on `SameSite` was rejected because it is defense-in-depth rather than an explicit request-intent proof and can be weakened by future deployment/domain choices.
### Bounded login throttling and generic failures
Persist short-lived failed-login counters keyed by normalized username and client-address bucket, with a bounded failure window and temporary block. Login returns the same response shape/status for unknown, disabled, blocked, and wrong-password accounts and still performs a dummy password verification for unknown users. Successful authentication clears the applicable counter. Expired throttle/session rows are deleted opportunistically in bounded batches.
Permanent account lockout was rejected because it creates a trivial denial-of-service path. Client address is taken from the direct peer unless an explicit trusted-proxy configuration permits forwarded addresses.
### Security audit events are separate from operational logs
Persist bounded structured audit events for login success/failure category, logout, password change/reset, user create/update/disable, role change, and session revocation. Events contain timestamp, actor principal id, action, outcome, target user id, correlation id, and safe metadata; they never contain submitted passwords, password hashes, raw session/CSRF values, bearer tokens, or full request bodies. Operational logs may reference the audit event id.
### Console supports account sessions and explicit token compatibility
The Console first calls `/v1/auth/me`. An authenticated user enters the dashboard; otherwise it shows username/password login with a secondary “Use API token” action. Session mode uses cookies and CSRF; compatibility mode retains the current tab-scoped bearer token. A 401 clears the active mode and returns to login, while a 403 remains an authorization error and does not destroy a valid session.
The shell adds current-user/logout/password-change controls. A Users navigation item is rendered only when the returned principal has `users:admin`, but backend scope checks remain authoritative. Users forced to change a temporary password are routed only to that flow until it succeeds.
## Risks / Trade-offs
- [Operators expose the Cloud API over plain HTTP] → Production cookies are always `Secure`; deployment documentation and readiness diagnostics state that browser login requires an HTTPS-facing origin.
- [XSS can perform actions as the current user even without reading the `HttpOnly` cookie] → Keep CSP/static asset controls tight, escape rendered data, require CSRF headers, avoid dynamic HTML, and retain short/revocable sessions.
- [Database lookup on every cookie-authenticated request] → Index the session digest and user id, return only the required user/session columns, and keep expiry cleanup bounded.
- [Brute-force throttling can be abused to delay one username] → Use temporary username-plus-address buckets, generic responses, bounded windows, and admin recovery rather than permanent account locks.
- [Static bearer tokens still bypass user lifecycle] → Keep them for compatibility but document least privilege and rotation; Console defaults to accounts and labels token mode as advanced/break-glass.
- [The base `cloud-console-ui` spec is still in an active change] → Express this follow-up as additive delta requirements now; before archiving, reconcile the earlier token-only requirement so account login is primary and token entry is explicitly compatibility-only.
- [Coarse fixed roles may not fit future teams] → Keep authorization expressed as scopes internally so a later custom-role or external-IdP change can supply a different scope set without changing resource handlers.
## Migration Plan
1. Add the Argon2 dependency, user/session domain models, repository port, SQLAlchemy implementation, and Alembic revision; verify upgrade/downgrade on SQLite and PostgreSQL.
2. Add the password/session/authentication services, login throttling, audit recording, configuration validation, and `UserSessionAuthProvider` composition while leaving bearer behavior unchanged.
3. Add `/v1/auth/*` and `/v1/users/*`, CloudClient parity, CLI administration commands, and backend contract/security tests.
4. Update the Console for credentialed session requests, login/logout/password change, token fallback, and admin user management; run frontend type-check/build and browser-level flows.
5. Deploy the migration and backend behind HTTPS. Existing bearer-token Console access continues to work.
6. Run `docker compose exec cloud-api device-cloud-admin users create --username <name> --role admin`, enter the password interactively, verify account login, then rotate/reduce shared operator tokens as appropriate.
7. Roll back application code first; the previous release continues to use configured bearer tokens and ignores the new tables. Preserve the tables for a forward fix, or back up the database and explicitly downgrade the migration only when user/session/audit history may be discarded.
## Open Questions
- OIDC/SAML and MFA are intentionally deferred. The `Principal`/scope boundary and local-user repository keep those future authentication providers additive.
- Removing Console token entry is deferred until account login has operated successfully in production and a separate compatibility decision is made.
@@ -0,0 +1,34 @@
## Why
The Cloud Console currently asks every operator to paste a long-lived, pre-shared bearer token. That is awkward for routine access and provides no per-user password lifecycle, role assignment, session revocation, or reliable attribution when several people operate the control plane.
## What Changes
- Add a persistent Cloud user directory with unique usernames, display names, password hashes, active/disabled state, fixed roles (`viewer`, `operator`, and `admin`), and security timestamps.
- Add username/password sign-in, sign-out, current-user, and password-change endpoints backed by revocable, expiring server-side sessions delivered through secure `HttpOnly` cookies.
- Map user roles to the Cloud API's existing operation scopes so browser users and configured bearer-token principals pass through the same authorization checks.
- Add admin-only user management endpoints and Console views for listing users, creating accounts, changing roles/status, resetting passwords, and revoking sessions.
- Add an interactive administration CLI for securely creating the first administrator and recovering access without embedding a bootstrap password in an image, Compose file, or shell argument.
- Replace the Console's default token-entry gate with a username/password login screen and authenticated account menu; retain an explicit bearer-token option for existing operators and break-glass access.
- Add login throttling, generic authentication failures, password/session invalidation rules, CSRF protection for cookie-authenticated writes, and audit events that never record passwords, session secrets, or bearer tokens.
- Preserve configured bearer-token authentication for `CloudClient`, Host Agents, enrollment, automation, and backward compatibility.
- Explicitly out of scope: public self-registration, email delivery or forgot-password links, OIDC/SAML/LDAP, multi-factor authentication, organization/multi-tenant membership, and customizable role definitions.
## Capabilities
### New Capabilities
- `cloud-user-authentication`: Persistent users, password verification, role-to-scope authorization, secure session lifecycle, administrator account management, bootstrap/recovery tooling, throttling, and security audit behavior.
### Modified Capabilities
- `platform-sdk`: Add versioned user-authentication and user-administration routes, and allow a valid browser session principal alongside the existing bearer-token principals without weakening operation-specific scope checks.
- `cloud-console-ui`: Make username/password login the primary operator flow, add account/session handling and an admin-only user-management view, while keeping the existing token flow as an explicit compatibility option. The base capability remains in the active `cloud-console` change, so this follow-up supplies additive delta requirements until that change is archived.
## Impact
- **Database**: a new Alembic revision adds user, session, login-attempt/audit state with equivalent SQLite and PostgreSQL behavior; rollback invalidates browser sessions and removes user records only when the explicit downgrade is run.
- **Backend**: `cloud/auth.py`, repository/database models, SQLAlchemy repository code, Cloud API composition, `/v1/auth/*` and `/v1/users/*` models/routes, and production configuration gain user-session support while retaining current bearer providers.
- **Frontend**: `cloud-console/` replaces its default token screen with account login, sends credentialed requests plus CSRF protection, exposes logout/password change, and adds admin user management.
- **Dependencies**: add a maintained Argon2id password-hashing dependency; continue storing only digests for bearer/session credentials.
- **Operations/docs**: document HTTPS and secure-cookie requirements, initial-admin creation via `docker compose exec`, role semantics, user recovery, session revocation, and migration/rollback procedures.
@@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Account login is the primary Console authentication flow
The Console SHALL use username/password login and a server-managed user session as its primary authentication flow, while retaining the existing tab-scoped bearer-token flow behind an explicit compatibility action.
#### Scenario: Operator opens the Console without authentication
- **WHEN** `/v1/auth/me` reports no valid user session and no compatibility bearer token is active
- **THEN** the Console displays the username/password login form as the primary action and offers a secondary “Use API token” action
#### Scenario: Login succeeds
- **WHEN** an operator submits valid account credentials
- **THEN** the Console enters session mode, displays the authenticated user's identity, and loads only views allowed by the returned effective scopes
#### Scenario: Login fails
- **WHEN** the login endpoint rejects credentials or throttles the attempt
- **THEN** the Console shows the generic authentication failure without revealing whether the username exists or persisting the password
#### Scenario: Operator chooses token compatibility
- **WHEN** an operator explicitly selects “Use API token” and enters a bearer token
- **THEN** the Console retains that token only in tab-scoped session storage and uses the existing bearer request behavior
### Requirement: Console manages authenticated session lifecycle
The Console SHALL send cookies on user-session requests, attach session CSRF proof to unsafe requests, provide logout and password-change controls, and distinguish authentication loss from insufficient authorization.
#### Scenario: User logs out
- **WHEN** a logged-in user activates logout
- **THEN** the Console submits CSRF-protected logout, clears local authentication state, and returns to the login screen
#### Scenario: Session expires
- **WHEN** a session-authenticated request returns `401`
- **THEN** the Console clears session UI state and returns to login with a session-expired message
#### Scenario: User lacks a required scope
- **WHEN** an otherwise valid session-authenticated request returns `403`
- **THEN** the Console preserves the session and shows an authorization error for that operation
#### Scenario: User must change a temporary password
- **WHEN** the current-user response sets `must_change_password`
- **THEN** the Console restricts navigation to password change and logout until password change succeeds
### Requirement: Administrators manage users from the Console
The Console SHALL expose a user-management view only to principals whose effective scopes include `users:admin`, with controls to list/create users, change role or enabled state, reset passwords, and revoke sessions.
#### Scenario: Administrator opens user management
- **WHEN** an authenticated administrator opens the Users view
- **THEN** the Console displays non-secret user records and the supported lifecycle controls without exposing password or session credential material
#### Scenario: Administrator creates an account
- **WHEN** an administrator submits valid user details and an initial password
- **THEN** the Console creates the account, clears password fields immediately, and shows that the new user must change the initial password
#### Scenario: Non-admin loads the Console
- **WHEN** the current principal lacks `users:admin`
- **THEN** the Console does not render the Users navigation or management controls, while backend authorization remains authoritative
#### Scenario: User-management mutation fails
- **WHEN** a create, update, reset, or revoke request returns validation, conflict, or authorization failure
- **THEN** the Console preserves non-secret form state as appropriate, clears all password fields, and displays the API error without retrying the mutation automatically
@@ -0,0 +1,136 @@
## ADDED Requirements
### Requirement: Persistent user accounts protect password material
The system SHALL persist uniquely identifiable human user accounts with a case-insensitive unique username, display name, fixed role, enabled state, forced-password-change state, and security timestamps, and SHALL store passwords only as salted Argon2id hashes that are never returned or logged.
#### Scenario: Administrator creates a user
- **WHEN** an authorized administrator creates a user with a username, display name, role, and valid initial password
- **THEN** the system stores the normalized unique identity and password hash, returns only non-secret account fields, and marks the account to change its initial password
#### Scenario: Username differs only by case
- **WHEN** an administrator attempts to create a username that differs from an existing username only by normalization or letter case
- **THEN** the system rejects the duplicate without changing either account
#### Scenario: Stored password needs stronger parameters
- **WHEN** a user successfully signs in and the password hasher reports that the stored Argon2id parameters are outdated
- **THEN** the system replaces the stored hash using current parameters without retaining or logging the submitted password
### Requirement: Fixed roles map to operation scopes
The system SHALL map `viewer`, `operator`, and `admin` users to the existing Cloud API scopes so resource handlers authorize user sessions and bearer principals through the same `Principal` scope checks.
#### Scenario: Viewer accesses read dashboards
- **WHEN** a logged-in `viewer` calls task-read, pool-read, or plugin-read operations
- **THEN** the request is authorized, while task submission, plugin administration, and user administration remain forbidden
#### Scenario: Operator submits a task
- **WHEN** a logged-in `operator` submits a task
- **THEN** the request is authorized in addition to all viewer read operations, while plugin and user administration remain forbidden
#### Scenario: Administrator manages users
- **WHEN** a logged-in `admin` invokes an operation requiring `users:admin` or another Cloud API scope
- **THEN** the request is authorized subject to the operation's normal validation
### Requirement: Password login creates a revocable server-side session
The system SHALL authenticate enabled users with username and password, SHALL create an opaque expiring session whose secret is stored only as a digest, and SHALL deliver the session secret in an `HttpOnly`, `SameSite` cookie that is `Secure` in production.
#### Scenario: Valid credentials create a session
- **WHEN** an enabled user submits a correct username and password within throttle limits
- **THEN** the system records a revocable session, returns the non-secret current-user representation, and sets the session and CSRF cookies with their required security attributes
#### Scenario: Credentials are not valid
- **WHEN** the username is unknown, disabled, temporarily throttled, or paired with a wrong password
- **THEN** the system returns the same generic authentication failure without revealing which condition occurred and without setting a session cookie
#### Scenario: Session is expired or revoked
- **WHEN** a request presents a session whose idle or absolute expiry has passed, whose row is revoked, or whose authentication version no longer matches the user
- **THEN** authentication fails and the system clears the browser session cookies
### Requirement: User session lifecycle is controllable
The system SHALL let a logged-in user inspect the current account, sign out, and change their own password, and SHALL revoke sessions when a password, role, enabled state, or authentication version changes.
#### Scenario: User signs out
- **WHEN** a logged-in user signs out with valid CSRF proof
- **THEN** the current session is revoked server-side and both browser cookies are cleared
#### Scenario: User changes password
- **WHEN** a logged-in user proves the current password and supplies a valid new password
- **THEN** the password hash and authentication version are updated, other sessions are revoked, and the user must continue only with a newly established valid session
#### Scenario: Temporary password requires replacement
- **WHEN** a user signs in while `must_change_password` is set
- **THEN** the session may call current-user, password-change, and logout operations but cannot access other Cloud API resources until the password is changed
### Requirement: Cookie-authenticated writes require CSRF proof
The system SHALL require a session-bound CSRF value on unsafe HTTP methods authenticated by a user cookie, while requests authenticated by an explicit bearer header SHALL remain exempt from cookie CSRF validation.
#### Scenario: Valid cookie session submits a write
- **WHEN** a cookie-authenticated request uses an unsafe method and its `X-CSRF-Token` header matches the CSRF cookie and session-bound digest
- **THEN** the request proceeds to normal scope and payload validation
#### Scenario: Cookie request omits CSRF proof
- **WHEN** a cookie-authenticated request uses an unsafe method without matching CSRF proof
- **THEN** the system rejects it before executing the operation
#### Scenario: Bearer client submits a write
- **WHEN** an authorized client sends an unsafe request with an `Authorization: Bearer` credential and no session cookie is used for authentication
- **THEN** the request is evaluated by existing bearer scope checks without requiring a CSRF header
### Requirement: Failed login attempts are throttled without permanent lockout
The system SHALL enforce a bounded failed-login window and temporary block by normalized username and trusted client-address bucket, SHALL use generic client responses, and SHALL clear applicable failure state after successful authentication.
#### Scenario: Repeated failures exceed the limit
- **WHEN** repeated failed logins for the same username and client bucket exceed the configured limit within the failure window
- **THEN** further attempts are temporarily blocked, audited, and answered with the generic authentication failure
#### Scenario: Temporary block expires
- **WHEN** the configured block duration passes
- **THEN** the account can attempt authentication again without administrator intervention
#### Scenario: Unknown username is submitted
- **WHEN** a login names no stored user
- **THEN** the system performs timing-resistant dummy password verification and applies the same throttle and response behavior as a wrong password
### Requirement: Administrators control user lifecycle
The system SHALL expose `users:admin`-protected operations to list and create users, change display name/role/enabled state, reset passwords, and revoke sessions, and SHALL prevent API actions that remove the last enabled administrator.
#### Scenario: Administrator disables an operator
- **WHEN** an administrator disables an enabled operator
- **THEN** the account can no longer authenticate and all of its active sessions are revoked
#### Scenario: Administrator resets a password
- **WHEN** an administrator assigns a valid temporary password to another user
- **THEN** existing sessions are revoked and the account is required to change that password after its next login
#### Scenario: Non-admin attempts user management
- **WHEN** a viewer, operator, anonymous caller, or Host principal calls a user-administration operation
- **THEN** the system rejects the request without exposing password or session state
#### Scenario: Last administrator would be removed
- **WHEN** an API request would disable or demote the only enabled administrator
- **THEN** the system rejects the request and preserves an enabled administrator
### Requirement: Deployment administrators can bootstrap and recover accounts safely
The system SHALL provide an administration CLI that checks the current database schema, reads new passwords interactively without echo, and can create, reset, enable, or revoke sessions for user accounts without accepting passwords in command arguments.
#### Scenario: First administrator is created in Compose
- **WHEN** a deployment administrator runs the user-create command inside the Cloud API container and enters a valid password twice at the interactive prompts
- **THEN** an enabled administrator account is created without placing the password in process arguments, Compose configuration, or command output
#### Scenario: Interactive password confirmation differs
- **WHEN** the two interactive password entries do not match
- **THEN** the CLI exits unsuccessfully without changing the account
#### Scenario: Database schema is not current
- **WHEN** a user administration CLI command runs against a database missing the required migration
- **THEN** it fails with a migration diagnostic rather than creating partial schema state
### Requirement: Authentication security events are auditable without secrets
The system SHALL record durable structured audit events for authentication and user-administration outcomes with actor, target, action, timestamp, outcome, and correlation context, and SHALL exclude submitted passwords, hashes, raw cookies, CSRF values, and bearer credentials.
#### Scenario: Login fails
- **WHEN** a login attempt fails or is throttled
- **THEN** the system records a safe failure category and correlation context without recording the submitted password or confirming whether the username exists
#### Scenario: Administrator changes a role
- **WHEN** an administrator changes a user's role
- **THEN** the system records the actor, target user id, old/new role metadata, outcome, and associated session revocation without any credential material
@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: Versioned user authentication routes
The public API SHALL expose username/password login, logout, current-user, and password-change operations under `/v1/auth/...`, with login as the only anonymously callable user-authentication operation.
#### Scenario: Browser logs in
- **WHEN** a caller submits valid credentials to `/v1/auth/login`
- **THEN** the API establishes the secure user session and returns the user's non-secret identity, role, and effective scopes
#### Scenario: Caller gets the current user
- **WHEN** a caller presents a valid user session to `/v1/auth/me`
- **THEN** the API returns the current user's non-secret identity, role, effective scopes, and forced-password-change state
#### Scenario: Anonymous caller invokes another auth operation
- **WHEN** an anonymous caller invokes logout, current-user, or password-change
- **THEN** the API returns an authentication error without executing the operation
### Requirement: Versioned user administration routes
The public API SHALL expose user listing, creation, update, password reset, and session-revocation operations under `/v1/users/...`, all protected by `users:admin`.
#### Scenario: Administrator lists users
- **WHEN** a principal with `users:admin` lists users
- **THEN** the API returns bounded non-secret account records and no password hash, session digest, CSRF value, or login credential
#### Scenario: User administration lacks scope
- **WHEN** an authenticated principal without `users:admin` invokes any `/v1/users/...` operation
- **THEN** the API returns an authorization error before reading or changing protected user state
### Requirement: Existing bearer authentication remains compatible
The public and internal APIs SHALL continue to accept existing configured public, Host, enrollment, and dynamically enrolled Host bearer credentials with their previous scope and host-binding semantics after user authentication is enabled.
#### Scenario: CloudClient uses a configured token
- **WHEN** an existing `CloudClient` sends a valid configured public bearer token
- **THEN** its permitted resource operation succeeds without a browser session or CSRF header
#### Scenario: Host Agent uses its bearer credential
- **WHEN** an existing Host Agent authenticates to an internal Host route
- **THEN** host identity binding and authorization behave exactly as before the user system was added
## MODIFIED Requirements
### Requirement: Pluggable authentication hook with a safe default
The system SHALL evaluate every protected route through a configurable scope-aware `AuthProvider` chain that can authenticate existing bearer credentials or a valid repository-backed user session, and the deployable Cloud Control Plane SHALL reject anonymous resource access unless an explicit insecure-development override is enabled outside production.
#### Scenario: Production starts without a safe authentication mechanism
- **WHEN** the Cloud Control Plane is configured as production without user-session authentication, a usable bearer provider, or other safe public authentication provider
- **THEN** startup or readiness fails rather than exposing anonymous platform routes
#### Scenario: Explicit local anonymous override
- **WHEN** a non-production operator explicitly enables the insecure anonymous-development override
- **THEN** platform routes may use an anonymous principal and the application records that insecure mode is active
#### Scenario: Custom AuthProvider is honored
- **WHEN** a caller configures a custom `AuthProvider` that rejects a request or omits its required scope
- **THEN** the platform SDK route returns an authentication or authorization error without executing its handler operation
#### Scenario: User session provider is honored
- **WHEN** the authentication chain resolves a valid user session to a principal with the required scope
- **THEN** the protected route authorizes that principal through the same scope check used for bearer callers
### Requirement: Python SDK client mirrors the REST API
The system SHALL provide a Python `CloudClient` exposing methods corresponding to the `/v1/...` resource, user-authentication, and user-administration routes, with cookie persistence for login sessions and continued support for injected bearer authentication.
#### Scenario: Client submits a task and retrieves status
- **WHEN** a caller uses `CloudClient` to submit a task and then fetch its status by the returned id
- **THEN** the client's methods produce the same result as calling the corresponding `/v1/...` endpoints directly over HTTP
#### Scenario: Client authenticates a user session
- **WHEN** a caller uses `CloudClient` to log in with valid user credentials and then requests the current user
- **THEN** the client preserves the session cookies and returns the same non-secret user representation as the direct REST calls
#### Scenario: Bearer administrator manages users
- **WHEN** a caller configures `CloudClient` with a bearer token having `users:admin` and invokes a user-administration method
- **THEN** the client sends bearer authentication and returns the corresponding user-administration result without requiring cookie login
### Requirement: Public API operations enforce scopes
The public platform API SHALL require operation-specific scopes for task submission, task reading, pool reading, plugin reading, plugin administration, and user administration, regardless of whether the principal came from a bearer credential or user session.
#### Scenario: Submit principal has task scope
- **WHEN** a bearer or user principal with `tasks:submit` calls the task-submission endpoint
- **THEN** the request is authorized subject to normal task validation
#### Scenario: Non-admin principal attempts plugin registration
- **WHEN** an authenticated principal without `plugins:admin` calls plugin registration
- **THEN** the API rejects the request before resolving or loading the plugin target
#### Scenario: Non-admin principal attempts user administration
- **WHEN** an authenticated principal without `users:admin` calls a user-administration endpoint
- **THEN** the API rejects the request before reading or changing protected user state
@@ -0,0 +1,56 @@
## 1. User model, persistence, and migration
- [x] 1.1 Add the maintained Argon2id password-hashing dependency to the Cloud Platform package and refresh the shared lockfile
- [x] 1.2 Define user, role, session, login-throttle, and authentication-audit domain models plus the fixed role-to-scope mapping and `users:admin` scope
- [x] 1.3 Extend the persistence port with account lookup/list/create/update, password/auth-version update, session create/authenticate/revoke, throttle, audit, and bounded cleanup operations
- [x] 1.4 Add SQLAlchemy rows, uniqueness/index/foreign-key constraints, and conversion helpers for users, sessions, throttle buckets, and audit events
- [x] 1.5 Implement all user-auth persistence operations in `SQLAlchemyCloudRepository` with equivalent SQLite and PostgreSQL behavior and atomic last-enabled-admin protection
- [x] 1.6 Add Alembic revision `0003` for the user-auth tables and indexes, including an explicit destructive downgrade
- [x] 1.7 Add repository and migration tests for normalized username conflicts, session lookup/revocation/expiry, throttle windows, audit redaction fields, last-admin protection, upgrade, and downgrade
## 2. Password, session, and authentication services
- [x] 2.1 Implement the injectable Argon2id `PasswordHasher`, password policy validation, dummy verification, and successful-login rehash behavior without secret-bearing logs or representations
- [x] 2.2 Implement user creation/update/reset/change-password services with authentication-version increments, forced-password-change handling, role scopes, and affected-session revocation
- [x] 2.3 Implement opaque session and CSRF generation, digest-only persistence, idle/absolute TTL checks, bounded touch/cleanup, logout, and secure cookie set/clear helpers
- [x] 2.4 Implement temporary username/client-bucket login throttling, trusted-proxy-aware address selection, generic failures, successful-login reset, and bounded expired-state cleanup
- [x] 2.5 Implement safe authentication audit recording for login/logout/password/user/session outcomes and add tests proving passwords, hashes, cookies, CSRF values, and bearer tokens cannot enter audit payloads
- [x] 2.6 Implement `UserSessionAuthProvider` and compose user principals into the existing auth chain without changing configured bearer, Host-bound, or enrollment provider semantics
- [x] 2.7 Enforce session-bound CSRF on unsafe cookie-authenticated requests while exempting requests authenticated by an explicit bearer header
- [x] 2.8 Extend `CloudControlConfig` with bounded session/throttle/cookie/trusted-proxy settings, production-secure defaults, and validation tests
## 3. Authentication, user administration, SDK, and CLI surfaces
- [x] 3.1 Add non-secret request/response models and `/v1/auth/login`, `/v1/auth/me`, `/v1/auth/logout`, and `/v1/auth/password` handlers with forced-password-change restrictions
- [x] 3.2 Add bounded `/v1/users` list/create/update, password-reset, and session-revocation handlers protected by `users:admin`
- [x] 3.3 Wire the user-auth services and routers into Cloud API lifespan/composition so repository access remains unavailable outside lifespan and readiness reflects required schema/configuration
- [x] 3.4 Extend `CloudClient` with cookie-preserving authentication/password methods and bearer-compatible user-administration methods, including CSRF handling for session writes
- [x] 3.5 Add the `device-cloud-admin` entry point with interactive `getpass` create/reset commands plus enable and session-revoke recovery commands, schema checks, non-secret output, and no password command-line option
- [x] 3.6 Add API/SDK/CLI tests for successful and failed login, generic errors, throttle expiry, cookie flags, CSRF, session expiry/revocation, role scopes, forced password change, user lifecycle, last-admin protection, and schema failures
- [x] 3.7 Add compatibility regression tests proving existing public bearer clients, static Host credentials, dynamically enrolled Hosts, and enrollment tokens retain their previous authorization behavior
## 4. Cloud Console account experience
- [x] 4.1 Refactor `cloud-console/src/api.ts` into explicit user-session and compatibility-token modes, using `credentials: "include"`, session CSRF headers, and distinct `401` versus `403` handling
- [x] 4.2 Replace the default token gate with username/password login and a secondary “Use API token” flow that preserves the existing tab-scoped token behavior
- [x] 4.3 Add current-user initialization, authenticated account menu, logout, session-expired messaging, password change, and forced-temporary-password routing to the Console shell
- [x] 4.4 Add an admin-only Users view for bounded listing, creation, role/enabled updates, password reset, and session revocation with immediate clearing of all password fields
- [x] 4.5 Hide actions/navigation from principals lacking their required scopes while continuing to surface backend `403` responses as the authoritative decision
- [ ] 4.6 Add frontend tests for session bootstrap, login failure, CSRF write requests, `401` session loss, preserved session on `403`, token fallback, forced password change, and admin/non-admin user navigation
## 5. Packaging, deployment, and documentation
- [x] 5.1 Ensure the Python wheel and Docker image contain the Argon2 dependency, `device-cloud-admin` entry point, migration, and rebuilt Cloud Console assets
- [x] 5.2 Update Compose examples and `.env.example` with non-secret session/throttle/cookie configuration while keeping initial passwords out of environment and Compose files
- [x] 5.3 Update `docs/CLOUD_DEPLOYMENT.md` with HTTPS requirements, migration order, interactive first-admin creation, role semantics, login/session behavior, recovery, token fallback, rotation, and rollback
- [x] 5.4 Update `cloud-console/README.md` for same-origin production login and credentialed Vite development with exact-origin CORS
- [x] 5.5 Reconcile the active `cloud-console` token-only requirement before archive so account login is primary and bearer entry is explicitly compatibility-only
## 6. Verification
- [x] 6.1 Run formatting, lint/static checks, secret-focused review, and the complete non-integration Python test suite across all workspace packages
- [ ] 6.2 Run the PostgreSQL-backed repository, concurrency, and Alembic upgrade/downgrade tests for user/session/throttle/admin invariants
- [x] 6.3 Run Cloud Console dependency install, unit tests, type-check, and production build
- [x] 6.4 Run `openspec validate cloud-console-user-authentication --strict` and resolve all artifact/spec errors
- [ ] 6.5 Manually verify a Compose deployment over HTTPS: bootstrap admin, forced password change, viewer/operator/admin authorization, session expiry/revocation, login throttling, logout, and browser restart
- [ ] 6.6 Manually verify configured bearer `CloudClient` and Host Agent flows alongside user login, then inspect logs/audit rows to confirm no credential material is emitted
@@ -1,19 +1,19 @@
## ADDED Requirements
### Requirement: Operator authenticates with a bearer token
The console SHALL require an operator-supplied bearer token before calling any Cloud Control Plane endpoint, SHALL hold that token only in browser session storage, and SHALL attach it as an `Authorization: Bearer` header on every request.
### Requirement: Operator authenticates with an account session or compatibility bearer token
The console SHALL use username/password account login and a server-managed browser session as its primary authentication flow. It SHALL retain an explicit operator-supplied bearer-token compatibility path, hold that token only in browser session storage, and attach it as an `Authorization: Bearer` header on requests made in compatibility mode.
#### Scenario: No token present
- **WHEN** an operator opens the console without a previously entered token
- **THEN** the console shows a token-entry screen instead of any dashboard view
#### Scenario: No account session or token present
- **WHEN** an operator opens the console without a valid account session or previously entered compatibility token
- **THEN** the console shows username/password login with an explicit token compatibility action instead of any dashboard view
#### Scenario: Token rejected by the Cloud API
- **WHEN** the Cloud Control Plane responds `401` or `403` to a request carrying the stored token
- **THEN** the console clears the stored token and returns to the token-entry screen with a clear message
#### Scenario: Authentication rejected by the Cloud API
- **WHEN** the Cloud Control Plane responds `401` to a request carrying the active account session or stored compatibility token
- **THEN** the console clears active authentication state and returns to login with a clear message
#### Scenario: Tab closed
- **WHEN** an operator closes the browser tab running the console
- **THEN** the stored bearer token is discarded and is not available on the next visit
- **THEN** any stored compatibility bearer token is discarded and is not available on the next visit
### Requirement: Task dashboard
The console SHALL render a task view listing tasks by status with pagination, and SHALL show a task's detail including its attempt history, using the platform SDK's task-listing and attempt-history endpoints.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,74 @@
## Context
Two systems are affected:
- **Host Agent** (`apps/device-host-agent/host_agent/`): `cli.py::main()` currently just calls `create_application().run()`. `config.py::load_host_agent_config()` defaults `HOST_AGENT_CONTROL_PLANE_URL` to `http://127.0.0.1:8001` and raises `HostAgentConfigurationError` unless one of (static `HOST_AGENT_HOST_ID`+`HOST_AGENT_TOKEN`, `HOST_AGENT_ENROLLMENT_TOKEN`, or an existing identity file at `HOST_AGENT_IDENTITY_PATH`) is present. `enrollment.py::resolve_host_identity` raises if `HOST_AGENT_ENROLLMENT_TOKEN` is empty and no cached `host_id` exists. `identity.py::HostIdentityStore` already establishes the atomic-write + `chmod 0600` pattern this change reuses for the new local account file.
- **Cloud Control Plane** (`packages/cloud-platform/cloud/`, `apps/cloud-api/`): `internal_api/api.py::enroll_host()` authenticates every enrollment request via `enrollment_auth.authenticate(request)` (`ConfiguredEnrollmentTokenProvider`, built from `CLOUD_ENROLLMENT_TOKENS_JSON`), returning `401` if no configured token digest matches the bearer header. `sql_repository.py::enroll_host()` first checks for an existing `HostRow` by `agent_instance_id` (idempotent retry, independent of the token) before checking whether the presented `enrollment_token_digest` is already bound to another host. Both `credential_digest` and `enrollment_token_digest` columns on `HostRow` (`db_models.py`) are already nullable — no migration is needed to store a `NULL` `enrollment_token_digest`.
This is a single-operator home deployment (`https://amcp.home.jerryyan.top`). The user has explicitly accepted that self-service enrollment means any network caller reaching that URL can register itself as a Host with no approval step, and asked that this not require a distribution/whitelist mechanism.
## Goals / Non-Goals
**Goals:**
- A fresh Host Agent install can complete first-run local setup and enroll against the cloud with zero pre-shared enrollment token, using a fixed default control-plane URL.
- Existing static-credential and token-based enrollment deployments keep working unchanged (`edge-host-enrollment` behavior is additive, not replaced).
- The local account gate is a one-time, interactive setup step, not an ongoing authentication mechanism for the unattended daemon.
**Non-Goals:**
- No multi-user account system, no roles, no network-facing login surface for the Host Agent. This is unrelated to the separate (unimplemented) `cloud-console-user-authentication` proposal's cloud-operator user model — different system, different threat model, not to be unified here.
- No re-authentication on every daemon start/restart. Once the local account file exists, the daemon starts unattended (required for systemd/launchd-managed services).
- No approval workflow, admin review queue, or per-request throttling/rate-limiting for self-service enrollment. The chosen mitigation for this deployment is network-perimeter control (the operator's own reverse proxy/firewall in front of `amcp.home.jerryyan.top`), not application-level throttling — see Risks.
- No removal of `CLOUD_ENROLLMENT_TOKENS_JSON`, static Host credentials, or the revocation path from `edge-host-enrollment`.
## Decisions
### D1. Local account credential storage: stdlib PBKDF2-HMAC-SHA256, not a new dependency
Store `{username, salt, iterations, hash}` as JSON via a new `host_agent/local_account.py::LocalAccountStore`, mirroring `identity.py::HostIdentityStore`'s atomic temp-file-then-`os.replace` write and `chmod 0600`. Hash with `hashlib.pbkdf2_hmac("sha256", password, salt, iterations=600_000)` (OWASP 2023 minimum for PBKDF2-SHA256), random 16-byte salt via `secrets.token_bytes`, compare with `hmac.compare_digest`.
**Alternatives considered:** Argon2id (via `argon2-cffi`, as the still-unimplemented `cloud-console-user-authentication` proposal plans for cloud operator accounts) is the stronger choice for an internet-facing, multi-user login system under credential-stuffing risk. Here the credential only gates a one-time local CLI prompt on a machine the operator already has filesystem access to — it is not re-checked on an ongoing basis and is not reachable over the network. Adding a new native-extension dependency to `device-host-agent` for that threat model is disproportionate. PBKDF2 via stdlib `hashlib` needs no new dependency and is adequate here.
### D2. First run is a real subcommand, not an implicit prompt-or-hang
`cli.py::main()` gains an explicit `setup` subcommand (`device-host-agent setup`) that interactively prompts (`getpass.getpass`) for username/password and writes the local account file, plus keeps the default (no subcommand) behavior as "run the daemon." When the default command runs and no local account file exists: if `sys.stdin.isatty()`, prompt inline (covers a manual first run in a terminal); if not a TTY (already running under systemd/launchd with no controlling terminal) and no account exists, fail fast with an explicit error telling the operator to run `device-host-agent setup` once, instead of hanging on a `getpass` call that can never be answered.
**Alternatives considered:** Always prompting inline on the default command is simpler but breaks headless service startup (a `getpass` call with no TTY either raises immediately with a confusing error or, depending on platform, blocks forever). A dedicated `setup` subcommand gives operators a clear, scriptable-once step, matching the existing precedent for interactive bootstrap (`device-cloud-admin`'s planned `getpass` flow in `cloud-console-user-authentication`, D... — same shape, independent implementation per D1/Non-Goals).
### D3. Cloud-side self-service enrollment is an additive, opt-in auth path
Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`, following the existing default-off pattern used by `AI_PLANNER_ENABLED`/`SEMANTIC_ENRICHMENT_ENABLED` elsewhere in this codebase. When enabled, `apps/cloud-api/cloud_api/app.py` composes the enrollment auth provider as a small ordered chain instead of the single `ConfiguredEnrollmentTokenProvider`:
1. Try `ConfiguredEnrollmentTokenProvider` (existing behavior — a caller presenting a valid configured token still gets a token-bound principal, preserving `edge-host-enrollment`'s conflict/idempotency semantics for that path unchanged).
2. If that fails and self-service is enabled, fall back to a new `SelfServiceEnrollmentAuthProvider` that unconditionally returns an `EnrollmentPrincipal(id="self-service", token_digest=None)`.
3. Otherwise `401`, exactly as today.
`enroll_host()`'s existing call `pool.store.enroll_host(..., enrollment_token_digest=enrollment_principal.token_digest, ...)` needs no change — passing `None` is already handled by the nullable column, and `sql_repository.py`'s idempotent-retry-by-`agent_instance_id` path already runs before the token-conflict check, so retries from the same edge instance (same `agent_instance_id`) return the same `host_id` exactly as the token-based path does. The token-conflict lookup (`WHERE enrollment_token_digest = <digest>`) simply never matches `NULL`, so self-service requests never collide with each other on that column — every self-service call with a new `agent_instance_id` creates a new Host (expected; see Risks).
**Alternatives considered:** Making `enroll_host()` accept unauthenticated requests unconditionally (removing the auth check entirely) was rejected — it would silently change behavior for every existing deployment, including ones that never opt in. A configured, default-off flag keeps this strictly additive.
### D4. Host Agent falls back to self-service automatically when no token is configured
`enrollment.py::resolve_host_identity` currently raises `HostAgentConfigurationError` when `state.host_id is None and not config.enrollment_token`. Change it to instead call `client.enroll_host(...)` with no bearer credential when `enrollment_token` is empty (the client already needs a code path for "no token" — see below), rather than raising. `config.py::load_host_agent_config` drops the corresponding startup validation branch (`if not host_id and not enrollment_token and not identity_path.is_file(): raise ...`), since a missing enrollment token is no longer a configuration error — it now means "attempt self-service enrollment."
If the target cloud has `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false` (the default), this call fails with `401` from the cloud, surfaced as a runtime enrollment error at Host Agent startup — a clear, actionable failure (not a silent hang), and no worse than today's explicit config-time rejection.
Host Agent also passes the local account's username as `display_name` in the enrollment call when `HOST_AGENT_DISPLAY_NAME` is not explicitly set, so cloud-side operators can see who set up a self-enrolled Host without building any cross-system user linkage.
### D5. `HOST_AGENT_CONTROL_PLANE_URL` default becomes `https://amcp.home.jerryyan.top`, override preserved
`load_host_agent_config()` changes only the default value passed to `values.get("HOST_AGENT_CONTROL_PLANE_URL", "https://amcp.home.jerryyan.top")`. The existing `urlparse` scheme/netloc validation, and the environment-variable override, are unchanged — this keeps local/dev/test runs (which set the env var to point at a local cloud-api instance) working exactly as before.
## Risks / Trade-offs
- **[Risk] Unlimited anonymous Host registration** — with self-service enabled, any caller reaching the control-plane URL can create arbitrarily many Host rows (no rate limit, no approval). → **Mitigation**: accepted for this single-operator deployment; primary control is keeping the flag off everywhere except `amcp.home.jerryyan.top`'s own cloud-api environment, and relying on network-perimeter controls (reverse proxy/firewall) rather than application-level throttling, which this change deliberately does not add (see Non-Goals) to avoid a false sense of security from an easily-bypassed in-process limiter.
- **[Risk] Local account file loss or corruption blocks daemon startup on a TTY-less host** — if `HOST_AGENT_LOCAL_ACCOUNT_PATH` is deleted or unreadable on a systemd-managed host, the daemon now fails fast instead of starting. → **Mitigation**: this is intentional (matches the explicit local-gate requirement); the error message names the exact `device-host-agent setup` remediation step.
- **[Risk] Self-service Hosts are indistinguishable from token-enrolled Hosts once created** — `enrollment_token_digest = NULL` is the only marker of a self-service Host. → **Mitigation**: acceptable per existing `edge-host-enrollment` scoping (no admin UI in scope there either); an operator can still find/revoke via `revoke_enrolled_host` using `host_id`, and `NULL` vs non-`NULL` `enrollment_token_digest` is queryable if this needs auditing later.
- **[Trade-off] PBKDF2 instead of Argon2id (D1)** — weaker under GPU/ASIC attack than Argon2id, acceptable only because this credential is never exposed to a network-facing verification endpoint in this change.
## Migration Plan
1. Ship the cloud-side change first (`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` defaulting to `false`) — no behavior change for any existing deployment.
2. Explicitly set `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true` in the `amcp.home.jerryyan.top` cloud-api deployment configuration only.
3. Ship the Host Agent change (new default URL, local-account gate, self-service fallback). Existing installs with a populated `HOST_AGENT_IDENTITY_PATH` are unaffected — `resolve_host_identity` returns early when `config.host_id and config.token` or a cached `host_id` already exists, so already-enrolled Hosts never re-enroll.
4. New installs: operator runs `device-host-agent setup` once, then starts the service normally; it self-enrolls against the fixed URL.
**Rollback**: setting `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false` again immediately stops new self-service enrollments; Hosts already enrolled that way keep authenticating normally (auth is by credential digest, independent of how `enrollment_token_digest` was populated) and can be individually revoked via the existing `revoke_enrolled_host` path if needed. Reverting `HOST_AGENT_CONTROL_PLANE_URL`'s default requires a Host Agent redeploy but does not affect already-enrolled identity state.
## Open Questions
- None blocking; the `edge-host-enrollment` change is implemented but not yet archived into `openspec/specs/` — this change's delta spec is authored against its pending spec content and should be reconciled (or the two archived together) when `edge-host-enrollment` is archived.
@@ -0,0 +1,28 @@
## Why
The Host Agent today cannot start unattended on a fresh install: it requires an operator to hand-carry an out-of-band `HOST_AGENT_ENROLLMENT_TOKEN` (issued via `CLOUD_ENROLLMENT_TOKENS_JSON` on the cloud side) before it will enroll, and it accepts any `HOST_AGENT_CONTROL_PLANE_URL` an installer happens to set, which for the single home deployment at `https://amcp.home.jerryyan.top` is unnecessary ceremony. There is also no local gate preventing the daemon from starting with no operator ever having touched the machine, and no local record of who set it up. This change removes the pre-issued-token requirement for this deployment, fixes the control-plane address, and adds a one-time local setup gate, while leaving the existing token-based and static-credential paths intact for other deployments.
## What Changes
- Add a Host Agent CLI first-run bootstrap: if no local account file exists, `device-host-agent` interactively prompts (via `getpass`) for a username and password, hashes the password, and persists the credential atomically with restricted file permissions before continuing. On later starts, if the account file exists, the daemon starts straight into background polling with no prompt.
- Change `host_agent/config.py::load_host_agent_config` default for `HOST_AGENT_CONTROL_PLANE_URL` from `http://127.0.0.1:8001` to `https://amcp.home.jerryyan.top`; the environment variable still overrides it for development/testing.
- **BREAKING** (new deployments only, additive for existing ones): Remove the hard requirement for `HOST_AGENT_ENROLLMENT_TOKEN` when no local identity and no static `HOST_AGENT_HOST_ID`/`HOST_AGENT_TOKEN` are configured. `host_agent/enrollment.py::resolve_host_identity` now falls back to an unauthenticated self-service enrollment call when no enrollment token is configured, instead of raising `HostAgentConfigurationError`.
- Add a cloud-side self-service enrollment mode, gated by a new `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` flag (default `false`): when enabled, `POST /internal/v1/enrollments` accepts requests with no enrollment-token bearer credential and stores `enrollment_token_digest = NULL` for that Host row (the column is already nullable). When the flag is disabled, current behavior (token required, `401` otherwise) is unchanged.
- Existing static-credential (`HOST_AGENT_HOST_ID`/`HOST_AGENT_TOKEN`), token-based enrollment (`HOST_AGENT_ENROLLMENT_TOKEN` + `CLOUD_ENROLLMENT_TOKENS_JSON`), and revocation paths from `edge-host-enrollment` are unchanged and remain fully supported side by side with self-service enrollment.
- No changes to `host_agent/heartbeat.py` — periodic device-status reporting already exists and continues to run against the (now-fixed) control-plane URL; this change only confirms the wiring stays intact once the URL and enrollment path change.
## Capabilities
### New Capabilities
- `host-agent-local-bootstrap`: First-run interactive local account creation gate for the Host Agent CLI — credential storage format, hashing, one-time prompt behavior, and non-interactive skip on subsequent starts.
### Modified Capabilities
- `edge-host-enrollment`: Add a cloud-side self-service enrollment mode that does not require a pre-issued enrollment token, and change the Host Agent's default control-plane URL and fallback behavior when no enrollment token is configured. (Note: this capability's spec currently lives only in the not-yet-archived `openspec/changes/edge-host-enrollment/` change, not in canonical `openspec/specs/`; this change's delta is authored against that pending spec and should be reconciled when `edge-host-enrollment` is archived.)
## Impact
- **Host Agent (`apps/device-host-agent`)**: new `host_agent/local_account.py` (or similarly named) module and CLI wiring in `host_agent/cli.py`; `host_agent/config.py` default URL change; `host_agent/enrollment.py::resolve_host_identity` fallback behavior change; new local credential file (default path under the existing `tasks/` state directory, permissions `0600`).
- **Cloud Platform (`packages/cloud-platform/cloud`, `apps/cloud-api`)**: `control_config.py` gains `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`; `internal_api/api.py::enroll_host` gains a self-service path; no schema migration required (`enrollment_token_digest` is already nullable).
- **Dependencies**: no new third-party dependency for password hashing is planned (stdlib-based); to be confirmed in design.md.
- **Deployment/docs**: `.env.example`, `compose.yaml`/`compose.deploy.yaml`, and `docs/CLOUD_DEPLOYMENT.md` need the new flag documented; `docs/MACOS_IPHONE_SETUP.md` needs the first-run local-account step documented.
- **Security posture**: enabling `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` means any network caller reaching the control-plane URL can self-register as a Host with no approval step; this is an explicit, accepted tradeoff for the single-operator home deployment and is opt-in (default off) for other deployments.
@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: Cloud supports opt-in self-service Host enrollment without a pre-issued token
The Cloud Control Plane SHALL support a configuration flag that, when enabled, allows the Host enrollment operation to succeed for a caller presenting no valid enrollment-token credential, generating a `host_id` and persisting a Host row with no enrollment-token binding. When the flag is disabled (the default), enrollment behavior SHALL be unchanged from the existing token-required path.
#### Scenario: Self-service enrollment is enabled and no token is presented
- **WHEN** self-service enrollment is enabled and an edge instance presents a new instance identifier and a high-entropy candidate Host credential with no enrollment-token bearer credential
- **THEN** the control plane returns a generated `host_id` and durably stores the Host credential digest with no enrollment-token binding
#### Scenario: Self-service enrollment is disabled
- **WHEN** self-service enrollment is disabled and an edge instance presents no enrollment-token bearer credential
- **THEN** the control plane rejects the request exactly as it does today, without creating a Host identity
#### Scenario: A valid configured enrollment token is still presented while self-service is enabled
- **WHEN** self-service enrollment is enabled and an edge instance presents a valid unused configured enrollment token
- **THEN** the control plane enrolls the Host using the existing token-bound path, including its idempotency and conflict semantics
#### Scenario: Self-service enrollment retried by the same instance
- **WHEN** an edge instance that previously completed self-service enrollment repeats enrollment with the same instance identifier and Host credential digest
- **THEN** the control plane returns the original `host_id` without creating a duplicate Host
### Requirement: Host Agent falls back to self-service enrollment when no enrollment token is configured
The Host Agent SHALL attempt Host enrollment without an enrollment-token bearer credential when no `HOST_AGENT_ENROLLMENT_TOKEN` is configured and no cached Host identity exists, rather than treating the missing token as a startup configuration error.
#### Scenario: Fresh install with no enrollment token configured
- **WHEN** the Host Agent starts with no static Host credentials, no enrollment token, and no cached identity state
- **THEN** it generates a local instance identifier and candidate Host credential and submits an enrollment request with no enrollment-token bearer credential
#### Scenario: Self-service enrollment is rejected by the cloud
- **WHEN** the Host Agent submits a self-service enrollment request and the cloud rejects it because self-service enrollment is disabled there
- **THEN** the Host Agent surfaces a clear enrollment failure at startup and does not start background polling
#### Scenario: A configured enrollment token is still honored
- **WHEN** the Host Agent starts with `HOST_AGENT_ENROLLMENT_TOKEN` configured
- **THEN** it uses the existing token-bound enrollment path unchanged
### Requirement: Host Agent control-plane URL defaults to the managed cloud platform address
The Host Agent SHALL default `HOST_AGENT_CONTROL_PLANE_URL` to `https://amcp.home.jerryyan.top` when the environment variable is not explicitly set, while continuing to allow the environment variable to override it.
#### Scenario: No control-plane URL is configured
- **WHEN** the Host Agent starts with `HOST_AGENT_CONTROL_PLANE_URL` unset
- **THEN** it connects to `https://amcp.home.jerryyan.top`
#### Scenario: Control-plane URL is explicitly configured
- **WHEN** the Host Agent starts with `HOST_AGENT_CONTROL_PLANE_URL` set to a different HTTP(S) URL
- **THEN** it connects to the explicitly configured URL instead of the default
@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Host Agent requires a local account before unattended operation
The Host Agent SHALL refuse to start its background polling loop until a local account (username and hashed password) exists on disk, and SHALL provide an explicit interactive command to create that account.
#### Scenario: No local account exists and setup is run interactively
- **WHEN** an operator runs the Host Agent's setup command on a machine with no local account file
- **THEN** the process prompts for a username and password, persists a hashed credential, and does not echo the password back
#### Scenario: Local account already exists
- **WHEN** the Host Agent's default (run) command starts and a valid local account file is already present
- **THEN** the process starts background polling immediately without prompting for any credential
#### Scenario: No local account exists and the process has no interactive terminal
- **WHEN** the Host Agent's default (run) command starts with no local account file and no controlling terminal available
- **THEN** the process exits with an error identifying the setup command to run, without hanging or prompting
### Requirement: Local account credentials are stored hashed and access-restricted
The Host Agent SHALL persist the local account password only as a salted hash, never in plaintext, and SHALL restrict the credential file's filesystem permissions to the owning user.
#### Scenario: Local account file is written
- **WHEN** the setup command creates a new local account
- **THEN** the persisted file contains the username, a random per-account salt, an iteration count, and a derived password hash, and contains no plaintext password
#### Scenario: Local account file permissions
- **WHEN** the Host Agent writes or rewrites the local account file
- **THEN** the file is only readable and writable by the owning user
#### Scenario: Local account file is corrupted or unreadable
- **WHEN** the Host Agent attempts to load a local account file that is not valid, readable JSON in the expected shape
- **THEN** the process reports a clear error and does not start background polling
@@ -0,0 +1,46 @@
## 1. Host Agent local account storage
- [ ] 1.1 Add `host_agent/local_account.py` with `LocalAccountState` (username, salt, iterations, password_hash) and `LocalAccountStore` (load/create), reusing `identity.py`'s atomic temp-file-then-`os.replace` write and `chmod 0600` pattern
- [ ] 1.2 Implement PBKDF2-HMAC-SHA256 hashing (`hashlib.pbkdf2_hmac`, 600,000 iterations, `secrets.token_bytes(16)` salt) and `hmac.compare_digest`-based verification
- [ ] 1.3 Add `HOST_AGENT_LOCAL_ACCOUNT_PATH` to `config.py::HostAgentConfig`/`load_host_agent_config` with a default alongside the existing `tasks/` state directory (e.g. `tasks/host_local_account.json`)
- [ ] 1.4 Add unit tests: file creation is atomic and `0600`, corrupted/invalid file raises a clear error, password hash roundtrips correctly, no plaintext password ever appears in the persisted file or in `repr()`/logging paths
## 2. Host Agent CLI first-run bootstrap
- [ ] 2.1 Add a `setup` subcommand to `cli.py` (argparse subparsers) that prompts via `getpass.getpass` for username/password and creates the local account if none exists, refusing to overwrite an existing account without explicit confirmation
- [ ] 2.2 Change the default (no subcommand) path in `cli.py`/`app.py::create_application` to check for the local account before starting: if present, proceed unchanged; if absent and `sys.stdin.isatty()`, prompt inline; if absent and not a TTY, exit with a clear error naming the `setup` subcommand
- [ ] 2.3 Add tests covering: existing-account fast path, interactive TTY prompt path (mocked), non-interactive no-account failure path, and the `setup` subcommand itself
## 3. Host Agent enrollment fallback and fixed control-plane URL
- [ ] 3.1 Change `config.py::load_host_agent_config`'s `HOST_AGENT_CONTROL_PLANE_URL` default to `https://amcp.home.jerryyan.top`, keeping the existing `urlparse` validation and environment-variable override behavior
- [ ] 3.2 Remove the `HostAgentConfigurationError` raised when no host_id/token, no enrollment token, and no identity file are present; a missing enrollment token is no longer a startup configuration error
- [ ] 3.3 Update `enrollment.py::resolve_host_identity` to call the enrollment client with no bearer credential when `config.enrollment_token` is empty, instead of raising
- [ ] 3.4 Update `client.py::HostAgentEnrollmentClient.enroll_host` (or equivalent) to support an unauthenticated (no `Authorization` header) enrollment request path, and to pass the local account username as `display_name` when `HOST_AGENT_DISPLAY_NAME` is unset
- [ ] 3.5 Add/update tests: fresh install with no token self-enrolls successfully (mocked cloud response), self-service rejection (`401` from cloud) surfaces as a clear startup failure and does not start polling, configured `HOST_AGENT_ENROLLMENT_TOKEN` still takes the existing token-bound path unchanged, existing cached identity skips enrollment entirely
## 4. Cloud self-service enrollment configuration and auth
- [ ] 4.1 Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`/`load_control_config`
- [ ] 4.2 Add `SelfServiceEnrollmentAuthProvider` to `auth.py`, returning a fixed `EnrollmentPrincipal(id="self-service", token_digest=None)` unconditionally
- [ ] 4.3 Add an ordered enrollment-auth chain (configured-token provider first, self-service provider second when enabled) and wire it into `apps/cloud-api/cloud_api/app.py::create_app()` in place of the single `ConfiguredEnrollmentTokenProvider`
- [ ] 4.4 Add tests: self-service enabled + no bearer token enrolls successfully with `enrollment_token_digest = NULL`; self-service disabled + no bearer token still returns `401` (current behavior unchanged); self-service enabled + a valid configured token still uses the token-bound path with existing conflict/idempotency semantics; self-service enabled + an invalid/unknown token still falls through to the self-service principal (since only a *presented and mismatched* token, or none at all, should reach self-service — confirm and encode the exact fallback condition from design.md D3)
## 5. Cloud enrollment idempotency and conflict behavior verification
- [ ] 5.1 Add repository-level tests confirming `sql_repository.py::enroll_host` idempotent-retry-by-`agent_instance_id` behavior works correctly when `enrollment_token_digest` is `NULL` (repeat self-service enrollment from the same instance returns the same `host_id`)
- [ ] 5.2 Add repository-level tests confirming multiple distinct self-service Hosts (each with `enrollment_token_digest = NULL`) can coexist without violating the unique constraint on that column, for both SQLite and PostgreSQL
## 6. Deployment and documentation
- [ ] 6.1 Update `.env.example` with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (documented default `false`) and the new default `HOST_AGENT_CONTROL_PLANE_URL` behavior
- [ ] 6.2 Update `compose.yaml`/`compose.deploy.yaml` examples to show the flag left disabled by default, with a comment on how the `amcp.home.jerryyan.top` deployment enables it
- [ ] 6.3 Update `docs/CLOUD_DEPLOYMENT.md` with the self-service enrollment flag, its security implications, and rollback steps
- [ ] 6.4 Update `docs/MACOS_IPHONE_SETUP.md` with the new `device-host-agent setup` first-run step
## 7. Verification
- [ ] 7.1 Run formatting, lint, and the full non-integration test suite across the workspace (`uv run --all-packages pytest -m "not integration"`)
- [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths
- [ ] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors
- [ ] 7.4 Manually verify end-to-end: fresh Host Agent install, `device-host-agent setup`, then `device-host-agent` self-enrolls against a cloud-api instance with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`, and heartbeat/device-status reporting continues on the expected interval
+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" },