Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97a4814c04 | |||
| a2dc9e3b03 | |||
| 155f27ca62 | |||
| a27ff49171 | |||
| c5be55dcbc | |||
| b2c3868619 | |||
| 36c00acf62 | |||
| 9d1df2b611 | |||
| 9bf4c92bfe | |||
| e616c82646 | |||
| 2b396d22d6 | |||
| 1f1faa437c | |||
| dc92f51cf8 | |||
| d675f8ec9b | |||
| e8d45f9d3d | |||
| f3918b863c | |||
| 3ee8583f6f | |||
| 591d5b8df0 | |||
| d2cdcc9776 | |||
| 8b2789b9b2 | |||
| 71d7e1853a | |||
| 60ebb75cd2 | |||
| 734cd9c5fd | |||
| 423ed9b3a3 | |||
| 43f004499b | |||
| 559be6de6e | |||
| e77f04b55e | |||
| e536fdd2c0 | |||
| 429d5fdda3 | |||
| 3cdd5aee1f | |||
| 6a03698f65 | |||
| 67d68ce058 | |||
| a6fa673e08 | |||
| ee22eb000c | |||
| 07914d5cdb | |||
| 7b9a337d25 | |||
| fc8b0c8d58 | |||
| 828cf87656 | |||
| ac9af8e4f5 | |||
| 8869ac864f | |||
| 0677e74cf8 | |||
| 56b8191a78 | |||
| 93761056eb | |||
| a92da7dcc0 | |||
| b39aac36b7 | |||
| 19f168ab7e | |||
| d827ceedff | |||
| 4e8c52c28c | |||
| fb927aa67b | |||
| d728af53b1 | |||
| 53e5e03f4e | |||
| 4d62c1130b | |||
| 3183e7e5c5 | |||
| 94d23a593c | |||
| b0a02c010b | |||
| 303e7a3365 | |||
| 7c87ae2af8 | |||
| 4c85e7646c | |||
| 05f82220a6 | |||
| 5227eb0d5f | |||
| 74359c72ab | |||
| 43f69272f0 | |||
| 2de5c8412d | |||
| a4fbd60441 | |||
| 1377d9005b | |||
| e3ad19c110 | |||
| a8ee742533 | |||
| 041e159e2a | |||
| cede8743a8 | |||
| cc31c993dd | |||
| 21f0eec807 | |||
| dc39e3a5d3 | |||
| 989425e550 | |||
| 5b1ab2c568 | |||
| e78f49461a | |||
| 9abf07f2cb | |||
| 4cec26edde | |||
| dc3879eca6 | |||
| 9669b8f477 | |||
| a7871ed7c6 | |||
| 79f5db2636 | |||
| 84146fc903 | |||
| c6f1fe317e | |||
| f46e5f5729 | |||
| 69fcc24a4d | |||
| 6a4f902e38 | |||
| 5f9c202d65 | |||
| 477454ec3c | |||
| 10812606bf | |||
| 94dec19976 | |||
| 9046f6e026 | |||
| 03504b61be | |||
| 6047d1a9f7 | |||
| 289eca8076 | |||
| 766f43931c | |||
| 14c24f8fda | |||
| e5916d522a | |||
| 4224dbe96c | |||
| 17ee290d90 | |||
| 7caafbaf80 | |||
| bc285799d1 | |||
| dcb2a86f9a | |||
| be739c319f | |||
| 28cf9faf77 | |||
| 279ef6cf8b | |||
| a602ff3efc | |||
| 79afd05ea5 | |||
| 42e846f876 | |||
| debc4bf78c | |||
| 36468a68ed | |||
| 885ba73d12 | |||
| 1451682cdd |
@@ -0,0 +1,151 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# The release workflow gates a tag, which is late: a broken commit sits green until
|
||||||
|
# somebody decides to publish. This runs the same checks on the way in.
|
||||||
|
#
|
||||||
|
# push is scoped to main rather than all branches so that a branch pushed as part of a
|
||||||
|
# pull request is not checked twice.
|
||||||
|
#
|
||||||
|
# No actions/checkout, deliberately -- same as the letsvisit and charts workflows. The
|
||||||
|
# runner image is ubuntu:22.04 whose `nodejs` package is Node 12, and actions/checkout@v4
|
||||||
|
# is built with ES2022 static initialiser blocks, so it dies with
|
||||||
|
# `SyntaxError: Unexpected token '{'` before running. Cloning with git directly avoids JS
|
||||||
|
# actions entirely. This repo is public, so the clone needs no credential at all.
|
||||||
|
#
|
||||||
|
# `${{ }}` values are passed through `env:` and referenced as quoted shell variables: a
|
||||||
|
# ref name is attacker-influenced by anyone who can push a branch or open a PR, and
|
||||||
|
# expanding one straight into `run:` is a shell-injection vector.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
# A rapid series of pushes only needs the last one checked.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REPO_URL: https://git.ryuvia.com/niklas/terdut-server.git
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
# Runs inside the toolchain image rather than installing Go per job. Note this puts
|
||||||
|
# the job on the dind bridge, which cannot reach github.com or get.helm.sh --
|
||||||
|
# proxy.golang.org and git.ryuvia.com are reachable, which is all this job needs.
|
||||||
|
image: golang:1.26.6-bookworm
|
||||||
|
# act_runner destroys a job's own volumes when it finishes, so without these every
|
||||||
|
# run re-downloads the whole module graph. The names must appear in the runner's
|
||||||
|
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
|
||||||
|
# volumes are dropped silently, so a workflow that looks correct can still be
|
||||||
|
# running uncached.
|
||||||
|
volumes:
|
||||||
|
- go-mod-cache:/go/pkg/mod
|
||||||
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
- gobin-cache:/go/bin
|
||||||
|
|
||||||
|
# The suite needs a real Postgres -- there is no in-memory Postgres the way there was
|
||||||
|
# an in-memory SQLite, so each test gets its own schema on a shared server instead.
|
||||||
|
# The job and the service share the dind bridge, so the service is reachable by its
|
||||||
|
# name rather than on localhost.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: terdut
|
||||||
|
POSTGRES_PASSWORD: terdut
|
||||||
|
POSTGRES_DB: terdut_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U terdut -d terdut_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 12
|
||||||
|
|
||||||
|
env:
|
||||||
|
# `make test` fails without this rather than skipping, so a green job here means
|
||||||
|
# the tests actually ran against a database.
|
||||||
|
TERDUT_TEST_DSN: postgres://terdut:terdut@postgres:5432/terdut_test?sslmode=disable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$HEAD_SHA" ]; then
|
||||||
|
# A pull_request ref_name is "<n>/merge", which is not a fetchable branch.
|
||||||
|
git clone "$REPO_URL" .
|
||||||
|
git checkout -q "$HEAD_SHA"
|
||||||
|
else
|
||||||
|
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The gate is the Makefile's rather than a second copy of it here, the way riksdata
|
||||||
|
# and rd-web already do it. `make fmt lint test` is exactly what a developer runs, so
|
||||||
|
# a green pipeline and a green working copy mean the same thing by construction
|
||||||
|
# instead of by remembering to update two files together.
|
||||||
|
#
|
||||||
|
# The reasoning that used to live here moved with the targets: why gofmt is checked
|
||||||
|
# at all (import order survives `go vet`, and both repos sat unformatted through a
|
||||||
|
# green run and a release -- 9046f6e), why both of gofmt's failure modes need
|
||||||
|
# handling, and why `test` adds -race when this job does not have to.
|
||||||
|
- name: Format, vet and test
|
||||||
|
run: make fmt lint test
|
||||||
|
|
||||||
|
# Runs on every push and pull request, unlike the image scan, which needs something
|
||||||
|
# published to scan and so lives in release.yaml. Both are needed: govulncheck reads the
|
||||||
|
# source and its module graph, trivy reads the built artifact, and neither sees what the
|
||||||
|
# other does.
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.26.6-bookworm
|
||||||
|
volumes:
|
||||||
|
- go-mod-cache:/go/pkg/mod
|
||||||
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
- gobin-cache:/go/bin
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$HEAD_SHA" ]; then
|
||||||
|
git clone "$REPO_URL" .
|
||||||
|
git checkout -q "$HEAD_SHA"
|
||||||
|
else
|
||||||
|
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Go vulnerability scan (govulncheck)
|
||||||
|
run: make security-go
|
||||||
|
|
||||||
|
- name: Secret scan (gitleaks)
|
||||||
|
run: make security-secrets
|
||||||
|
|
||||||
|
# Host mode, no `container:`: helm is baked into the runner image, and a container job
|
||||||
|
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
|
||||||
|
# release.yaml's chart job runs on the host.
|
||||||
|
#
|
||||||
|
# The chart had no lint step in any workflow until 2026-09-01: release.yaml packaged and
|
||||||
|
# pushed it without rendering it first, so a template that did not compile would have
|
||||||
|
# been found by Flux rather than here.
|
||||||
|
chart:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$HEAD_SHA" ]; then
|
||||||
|
git clone "$REPO_URL" .
|
||||||
|
git checkout -q "$HEAD_SHA"
|
||||||
|
else
|
||||||
|
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Lint and render the chart
|
||||||
|
run: make helm-lint
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Checkout, interpolation and caching conventions match ci.yaml -- see the header there
|
||||||
|
# for why there are no JS actions and why every `${{ }}` goes through `env:`.
|
||||||
|
#
|
||||||
|
# There is no upload-artifact/download-artifact equivalent here (both are JS actions, and
|
||||||
|
# this Gitea has no artifact store wired up), so the job that builds the binaries is also
|
||||||
|
# the job that publishes them. Nothing is handed between jobs at all.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# A tag is not normally re-pushed, so this mostly matters when one is force-moved during
|
||||||
|
# a botched release -- the superseded run stops holding runner slots.
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REPO_URL: https://git.ryuvia.com/niklas/terdut-server.git
|
||||||
|
API: https://git.ryuvia.com/api/v1/repos/niklas/terdut-server
|
||||||
|
REGISTRY: git.ryuvia.com
|
||||||
|
IMAGE: git.ryuvia.com/niklas/terdut-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Gates every publishing job below. A tag that fails here publishes nothing: the
|
||||||
|
# binaries, the image and the chart are all downstream of it.
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.26.6-bookworm
|
||||||
|
volumes:
|
||||||
|
- go-mod-cache:/go/pkg/mod
|
||||||
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
- gobin-cache:/go/bin
|
||||||
|
|
||||||
|
# The same database ci.yaml's test job gets, for the same reason: `make test` needs a
|
||||||
|
# real Postgres and fails without TERDUT_TEST_DSN rather than skipping. This job is
|
||||||
|
# the gate every publishing job below hangs off, so it has to be able to run the
|
||||||
|
# suite -- v0.11.0 was tagged with the service here missing and published nothing.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: terdut
|
||||||
|
POSTGRES_PASSWORD: terdut
|
||||||
|
POSTGRES_DB: terdut_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U terdut -d terdut_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 12
|
||||||
|
|
||||||
|
env:
|
||||||
|
TERDUT_TEST_DSN: postgres://terdut:terdut@postgres:5432/terdut_test?sslmode=disable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
|
||||||
|
# Same gate as ci.yaml, and the same one a developer runs. See the Makefile for why
|
||||||
|
# each check is there; restating it here is how the two drift apart.
|
||||||
|
- name: Format, vet and test
|
||||||
|
run: make fmt lint test
|
||||||
|
|
||||||
|
binaries:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.26.6-bookworm
|
||||||
|
volumes:
|
||||||
|
- go-mod-cache:/go/pkg/mod
|
||||||
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
- gobin-cache:/go/bin
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
|
||||||
|
# Compiling is the Makefile's; uploading is not. `make binaries` is runnable on a
|
||||||
|
# laptop, while the step below needs a token and the Gitea release API, which is
|
||||||
|
# this workflow's business and nothing a developer wants a target for.
|
||||||
|
- name: Build every target
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: make binaries VERSION="$REF_NAME"
|
||||||
|
|
||||||
|
# Creating the release is made idempotent rather than assumed-new: a re-run of a
|
||||||
|
# failed release must not die on the release that already exists. Assets are
|
||||||
|
# replaced the same way, so a re-run repairs a partial upload.
|
||||||
|
- name: Publish the release
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
auth="Authorization: token $TOKEN"
|
||||||
|
|
||||||
|
body=$(curl -sf -H "$auth" "$API/releases/tags/$REF_NAME" || true)
|
||||||
|
if [ -z "$body" ]; then
|
||||||
|
body=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"tag_name\":\"$REF_NAME\",\"name\":\"$REF_NAME\"}" \
|
||||||
|
"$API/releases")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The release object serialises `id` first, so the first match is the release's
|
||||||
|
# own id and not one of the nested author/asset ids.
|
||||||
|
release_id=$(printf '%s' "$body" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
[ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; }
|
||||||
|
echo "release id $release_id"
|
||||||
|
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
# Drop an existing asset of the same name first: Gitea happily stores two
|
||||||
|
# attachments with one name, and the updater matches by name.
|
||||||
|
old=$(curl -sf -H "$auth" "$API/releases/$release_id/assets" \
|
||||||
|
| tr '}' '\n' | grep "\"name\":\"$name\"" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -n "$old" ]; then
|
||||||
|
curl -sf -X DELETE -H "$auth" "$API/releases/$release_id/assets/$old" || true
|
||||||
|
fi
|
||||||
|
echo "uploading $name"
|
||||||
|
curl -sf -X POST -H "$auth" -F "attachment=@$f" \
|
||||||
|
"$API/releases/$release_id/assets?name=$name" > /dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Host mode on purpose (no `container:`): this is the only context with a Docker CLI
|
||||||
|
# pointed at the dind daemon. A `container:` job would sit on the dind bridge with no
|
||||||
|
# docker socket at all.
|
||||||
|
image:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
|
||||||
|
- name: Log in to the registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: echo "$TOKEN" | docker login "$REGISTRY" -u niklas --password-stdin
|
||||||
|
|
||||||
|
# buildx setup, the platform list and why there is no QEMU all live on the `push`
|
||||||
|
# target now, so the same command publishes from a laptop and from here.
|
||||||
|
- name: Build and push
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: make push VERSION="$REF_NAME"
|
||||||
|
|
||||||
|
# Also host mode: helm is baked into the runner image, and a `container:` job could not
|
||||||
|
# install it -- get.helm.sh is unreachable from the dind bridge.
|
||||||
|
chart:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
|
||||||
|
# This job is the only thing that publishes the chart, which is what keeps the
|
||||||
|
# published metadata honest. There used to be a second publisher on every charts/**
|
||||||
|
# push to main, and the two raced for the same chart version with different answers:
|
||||||
|
# this one stamps version and appVersion from the tag, that one took Chart.yaml
|
||||||
|
# verbatim, where appVersion is the hardcoded "latest". Whichever landed first won,
|
||||||
|
# so the metadata of a release depended on which runner was quicker -- chart 0.9.0
|
||||||
|
# went out on 2026-08-08 reading appVersion "latest" that way.
|
||||||
|
#
|
||||||
|
# It could not be fixed by making both agree: the tag is pushed after the branch, so
|
||||||
|
# a workflow triggered by the main push cannot know the version it is about to be
|
||||||
|
# tagged with. One publisher, triggered by the tag.
|
||||||
|
#
|
||||||
|
# The cost is that the chart only ships with an app release. That is no real loss --
|
||||||
|
# `make helm-package` derives the chart version from the tag, so a chart-only change
|
||||||
|
# has no version of its own to be released under anyway. Chart fixes ride the next
|
||||||
|
# tag.
|
||||||
|
- name: Refuse a non-version tag
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if ! echo "$REF_NAME" | grep -qE '^v[0-9]'; then
|
||||||
|
echo "::error::refusing to publish a chart for non-version tag ${REF_NAME}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render before publishing. Until 2026-09-01 this job packaged and pushed without
|
||||||
|
# linting, so a template that did not compile reached the registry and was found by
|
||||||
|
# Flux instead.
|
||||||
|
- name: Lint and render the chart
|
||||||
|
run: make helm-lint
|
||||||
|
|
||||||
|
# The version and appVersion are no longer sed'd into Chart.yaml before packaging:
|
||||||
|
# `helm package --version --app-version` sets both from the tag without mutating the
|
||||||
|
# tree mid-build, which is what the rest of the release process already assumed
|
||||||
|
# happened. The isolated helm repo list moved onto the targets with them.
|
||||||
|
- name: Package and push
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin
|
||||||
|
make helm-package helm-push VERSION="$REF_NAME"
|
||||||
|
|
||||||
|
# Host mode, like image and chart: this needs a docker daemon to run trivy in, and a
|
||||||
|
# `container:` job would sit on the dind bridge with none.
|
||||||
|
#
|
||||||
|
# It scans the pushed image rather than a locally built one, because trivy cannot read a
|
||||||
|
# local image on this runner -- Talos has no docker socket and the dind sidecar shares no
|
||||||
|
# filesystem with the job -- so it pulls from the registry. That is also why this runs
|
||||||
|
# after `image` rather than gating it: a red scan does not unpublish anything.
|
||||||
|
#
|
||||||
|
# What a red scan means is therefore not "the release failed" but "do not bump the wrapper
|
||||||
|
# chart in Ryuvia/charts to this version". The image and chart are already published by
|
||||||
|
# the time this runs, and deliberately so -- this pipeline does not deploy.
|
||||||
|
#
|
||||||
|
# riksdata and rd-web have had this since they were set up; terdut-server went without any
|
||||||
|
# image scanning until 2026-09-02, so every release before v0.9.4 was published with no
|
||||||
|
# CVE check at all.
|
||||||
|
scan-image:
|
||||||
|
needs: image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
|
||||||
|
# Credentials are passed even though these packages are anonymously pullable -- that
|
||||||
|
# is a property of the personal namespace this publishes to, not something a release
|
||||||
|
# should depend on staying true.
|
||||||
|
- name: Scan the pushed image (trivy)
|
||||||
|
env:
|
||||||
|
TRIVY_USERNAME: niklas
|
||||||
|
TRIVY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: make security-image VERSION="$REF_NAME"
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Release Helm Chart
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- charts/**
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pages: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config user.name "$GITHUB_ACTOR"
|
|
||||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Install Helm
|
|
||||||
uses: azure/setup-helm@v4
|
|
||||||
|
|
||||||
- name: Run chart-releaser
|
|
||||||
uses: helm/chart-releaser-action@v1.6.0
|
|
||||||
env:
|
|
||||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- goos: linux
|
|
||||||
goarch: amd64
|
|
||||||
- goos: linux
|
|
||||||
goarch: arm64
|
|
||||||
- goos: darwin
|
|
||||||
goarch: amd64
|
|
||||||
- goos: darwin
|
|
||||||
goarch: arm64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
env:
|
|
||||||
GOOS: ${{ matrix.goos }}
|
|
||||||
GOARCH: ${{ matrix.goarch }}
|
|
||||||
run: |
|
|
||||||
go build \
|
|
||||||
-ldflags "-w -s -X main.version=${{ github.ref_name }}" \
|
|
||||||
-o terdut-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} \
|
|
||||||
./cmd/terdut
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: terdut-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
|
||||||
path: terdut-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
build-args: VERSION=${{ github.ref_name }}
|
|
||||||
tags: |
|
|
||||||
ghcr.io/yeniklas/terdut-server:latest
|
|
||||||
ghcr.io/yeniklas/terdut-server:${{ github.ref_name }}
|
|
||||||
|
|
||||||
chart:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config user.name "$GITHUB_ACTOR"
|
|
||||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Install Helm
|
|
||||||
uses: azure/setup-helm@v4
|
|
||||||
|
|
||||||
- name: Update chart versions
|
|
||||||
run: |
|
|
||||||
VERSION="${{ github.ref_name }}"
|
|
||||||
CHART_VERSION="${VERSION#v}"
|
|
||||||
sed -i "s/^version:.*/version: ${CHART_VERSION}/" charts/terdut-server/Chart.yaml
|
|
||||||
sed -i "s/^appVersion:.*/appVersion: \"${VERSION}\"/" charts/terdut-server/Chart.yaml
|
|
||||||
|
|
||||||
- name: Run chart-releaser
|
|
||||||
uses: helm/chart-releaser-action@v1.6.0
|
|
||||||
env:
|
|
||||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: 'terdut-*'
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
# build output
|
# build output
|
||||||
/terdut
|
/terdut
|
||||||
/terdut-server
|
/terdut-server
|
||||||
|
# `make binaries` and `make helm-package` write here
|
||||||
|
/dist/
|
||||||
|
# isolated helm repo list written by the publishing targets, so the machine-wide
|
||||||
|
# one (which has an unreachable entry) cannot abort a release
|
||||||
|
/.helm-repos.yaml
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Read by the `release` skill (~/.claude/skills/release).
|
||||||
|
#
|
||||||
|
# Only what the Makefile cannot already say. IMAGE, HELM_CHART and HELM_REPO come from
|
||||||
|
# `make release-vars`, so they have one definition and cannot drift from what is built.
|
||||||
|
#
|
||||||
|
# Defaults, set here only where this repo differs:
|
||||||
|
# CHARTS_REPO=$HOME/git/charts CHARTS_DIR=<image basename>
|
||||||
|
# GITEA_LOGIN=Ryuvia APPVERSION_PREFIX=
|
||||||
|
# PROSE_LANG=en
|
||||||
|
|
||||||
|
# Same as the image basename, so this is only stated to be read rather than derived.
|
||||||
|
CHARTS_DIR=terdut-server
|
||||||
|
|
||||||
|
# riksdata writes appVersion: "v0.3.1", rd-web writes a bare 0.5.0; this repo writes the
|
||||||
|
# v, like riksdata. Nothing reads the field -- .gitea/workflows/release.yaml stamps both
|
||||||
|
# version and appVersion from the tag when it publishes -- but people read it, and until
|
||||||
|
# 2026-09-01 it said "latest" while the tree headed for a numbered release.
|
||||||
|
APPVERSION_PREFIX=v
|
||||||
|
|
||||||
|
# English. The Swedish in riksdata and rd-web follows from their subject matter, not from a
|
||||||
|
# house style: terdut-server is an on-call tool whose labels, API and data are English, and
|
||||||
|
# nothing about it is coupled to Swedish. Code comments and docs here were always English;
|
||||||
|
# from 2026-09-01 the release prose is too.
|
||||||
|
PROSE_LANG=en
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
## Release
|
||||||
|
|
||||||
|
Say **"Release"** (or "Release X.Y.Z") and the `release` skill runs it: commit, push, tag,
|
||||||
|
wait for the pipeline, then open the wrapper-chart PR against `Ryuvia/charts`. It stops
|
||||||
|
there — merging and the Flux reconcile stay manual, deliberately.
|
||||||
|
|
||||||
|
Preconditions and the plan, without side effects:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
~/.claude/skills/release/scripts/release-preflight # state + suggested version
|
||||||
|
~/.claude/skills/release/scripts/release-preflight vX.Y.Z # validate that release
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is `.release.conf` here plus `make release-vars`. The process itself lives in
|
||||||
|
`~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing.
|
||||||
|
|
||||||
|
Two things about this repo specifically:
|
||||||
|
|
||||||
|
- **The image is scanned after it is published, not before.** `scan-image` runs trivy
|
||||||
|
against the pushed image, because trivy cannot read a locally built one on this runner.
|
||||||
|
A red scan therefore unpublishes nothing — it means: do not bump the wrapper chart in
|
||||||
|
`Ryuvia/charts` to this version. Added 2026-09-02; every release up to and including
|
||||||
|
v0.9.3 was published with no CVE check at all.
|
||||||
|
- **The wrapper chart has two `tag:` lines** — the app image and the python backup sidecar —
|
||||||
|
so `chart-bump` needs `--image "$IMAGE"` to know which one moves. That sidecar backs up
|
||||||
|
SQLite; the Postgres move (#2) retires it in favour of a `postgresql` CR with a k8up
|
||||||
|
`pg_dump` annotation, after which only the app image's tag is left.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
The tests need a Postgres: `make test-db` starts one and prints the DSN, `make test-db-stop`
|
||||||
|
removes it, and `TERDUT_TEST_DSN` is how both the Makefile and `ci.yaml`'s service container
|
||||||
|
point the suite at it. Without it the suite fails rather than skipping, on purpose.
|
||||||
|
|
||||||
|
`make fmt lint test helm-lint` **is** what the pipeline runs — `ci.yaml` and `release.yaml`
|
||||||
|
call these targets rather than restating them, the way riksdata and rd-web do. A green gate
|
||||||
|
here and a green pipeline are the same code, not two descriptions of it. `test` adds `-race`,
|
||||||
|
which the workflows do not have to ask for since they call the target; see the comment on it
|
||||||
|
for why.
|
||||||
|
|
||||||
|
`make release` (build + push the multi-arch image, package + push the chart) is what
|
||||||
|
`release.yaml` invokes. Do not run it by hand — it refuses `VERSION=dev` for that reason, and
|
||||||
|
publishing happens by pushing a tag.
|
||||||
|
|
||||||
|
Three scans, and they see different things: `security-go` (govulncheck) reads the source and
|
||||||
|
its module graph and reports only vulnerabilities the code can actually reach;
|
||||||
|
`security-secrets` (gitleaks) reads the working tree, not the history, so it catches a secret
|
||||||
|
on the way in rather than auditing what is already committed; `security-image` (trivy) reads
|
||||||
|
the published artifact and therefore only runs on a tag. The first two gate every push.
|
||||||
+17
-2
@@ -1,12 +1,27 @@
|
|||||||
FROM golang:1.25-alpine AS builder
|
# --platform=$BUILDPLATFORM pins the builder to the machine doing the building, so a
|
||||||
|
# multi-arch build compiles both targets natively instead of running an emulated arm64
|
||||||
|
# toolchain under QEMU. Go cross-compiles from TARGETOS/TARGETARCH, which BuildKit fills
|
||||||
|
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
||||||
|
# JS action that used to install it cannot run there), so this is not just an
|
||||||
|
# optimisation -- it is what makes the arm64 image buildable at all.
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||||
|
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
# scratch has no trust store, and a Go binary on it fails every HTTPS call with
|
||||||
|
# "x509: certificate signed by unknown authority". Nothing needed one until single
|
||||||
|
# sign-on: discovery and the token exchange are HTTPS calls to the identity provider.
|
||||||
|
# The bundle is the builder's, copied by name so a missing file fails the build
|
||||||
|
# rather than shipping an image that cannot sign anybody in.
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
COPY --from=builder /terdut /terdut
|
COPY --from=builder /terdut /terdut
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/terdut"]
|
ENTRYPOINT ["/terdut"]
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
REGISTRY := git.ryuvia.com
|
||||||
|
# The personal namespace, not ryuvia — deliberately, and for one reason: Gitea
|
||||||
|
# scopes package visibility to the owner with no per-package override, so
|
||||||
|
# ryuvia/* is private because the org is. Publishing here keeps the image and
|
||||||
|
# chart anonymously pullable, so no pull secret is needed in the cluster and
|
||||||
|
# Flux needs no registry credentials. Same choice riksdata and rd-web made.
|
||||||
|
OWNER := niklas
|
||||||
|
|
||||||
|
IMAGE := $(REGISTRY)/$(OWNER)/terdut-server
|
||||||
|
HELM_CHART := charts/terdut-server
|
||||||
|
HELM_REPO := oci://$(REGISTRY)/$(OWNER)
|
||||||
|
|
||||||
|
# go.mod pins an exact patch release so nobody builds the shipped binary with a
|
||||||
|
# toolchain carrying known stdlib CVEs. Fedora's Go package overrides the
|
||||||
|
# upstream GOTOOLCHAIN default to `local`, which turns that pin into a hard
|
||||||
|
# failure on a dev box one patch behind, so restore the upstream default here.
|
||||||
|
export GOTOOLCHAIN ?= auto
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
## --- checks ---
|
||||||
|
#
|
||||||
|
# These three mirror .gitea/workflows/ci.yaml step for step, so a green `make fmt
|
||||||
|
# lint test` here means the same thing CI means. The one deliberate difference is
|
||||||
|
# -race below. Both need a Postgres to test against; see test-db.
|
||||||
|
|
||||||
|
# The suite needs a Postgres, because the server does: there is no in-memory
|
||||||
|
# Postgres the way there was an in-memory SQLite. TERDUT_TEST_DSN says where, and
|
||||||
|
# the tests fail rather than skip without it — a suite that quietly tests nothing
|
||||||
|
# is worse than one that does not run. `make test-db` starts a local one;
|
||||||
|
# ci.yaml runs the same thing as a service container.
|
||||||
|
TEST_DB_CONTAINER ?= terdut-test-db
|
||||||
|
TEST_DB_PORT ?= 5433
|
||||||
|
TEST_DB_IMAGE ?= docker.io/library/postgres:17-alpine
|
||||||
|
export TERDUT_TEST_DSN ?= postgres://terdut:terdut@localhost:$(TEST_DB_PORT)/terdut_test?sslmode=disable
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run the test suite (needs TERDUT_TEST_DSN; see test-db)
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
# podman, with docker as the fallback: this is a dev convenience, not part of the
|
||||||
|
# pipeline, where the database arrives as a service container instead.
|
||||||
|
.PHONY: test-db
|
||||||
|
test-db: ## Start a local Postgres for the tests
|
||||||
|
@runtime=$$(command -v podman || command -v docker); \
|
||||||
|
if [ -z "$$runtime" ]; then echo "need podman or docker"; exit 1; fi; \
|
||||||
|
$$runtime run -d --rm --name $(TEST_DB_CONTAINER) \
|
||||||
|
-e POSTGRES_USER=terdut -e POSTGRES_PASSWORD=terdut -e POSTGRES_DB=terdut_test \
|
||||||
|
-p $(TEST_DB_PORT):5432 $(TEST_DB_IMAGE) >/dev/null; \
|
||||||
|
printf 'waiting for postgres'; \
|
||||||
|
for i in $$(seq 1 60); do \
|
||||||
|
if $$runtime exec $(TEST_DB_CONTAINER) pg_isready -U terdut -d terdut_test >/dev/null 2>&1; then \
|
||||||
|
echo " ready: $(TERDUT_TEST_DSN)"; exit 0; \
|
||||||
|
fi; \
|
||||||
|
printf '.'; sleep 1; \
|
||||||
|
done; \
|
||||||
|
echo " timed out"; exit 1
|
||||||
|
|
||||||
|
.PHONY: test-db-stop
|
||||||
|
test-db-stop: ## Stop the local test Postgres
|
||||||
|
@runtime=$$(command -v podman || command -v docker); \
|
||||||
|
$$runtime rm -f $(TEST_DB_CONTAINER) >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# CI runs a bare `go test ./...`. This is stricter on purpose: the sweeper, the
|
||||||
|
# notifier goroutine and the deadman sweep all touch the same single-connection
|
||||||
|
# database, and a race there would surface as a flaky production incident rather
|
||||||
|
# than a failed build. It passes today; if it ever costs more than it catches,
|
||||||
|
# the honest fix is to teach CI -race too, not to quietly drop it here.
|
||||||
|
.PHONY: lint
|
||||||
|
lint: ## go vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Copied from ci.yaml rather than simplified, because both of gofmt's failure
|
||||||
|
# modes need handling and they are not alike. A file that is merely misformatted
|
||||||
|
# is listed on stdout with exit 0 — so the failure has to be raised by hand. A
|
||||||
|
# file that does not parse is the opposite: nothing on stdout and exit 2, which a
|
||||||
|
# naive `[ -n "$$out" ]` reads as success. See 9046f6e.
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt: ## Report unformatted files
|
||||||
|
@if ! unformatted=$$(gofmt -l .); then \
|
||||||
|
echo "gofmt could not parse the tree:"; gofmt -l .; exit 1; \
|
||||||
|
fi; \
|
||||||
|
if [ -n "$$unformatted" ]; then \
|
||||||
|
echo "gofmt needed:"; echo "$$unformatted"; gofmt -d .; exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# database.dsn has no default and the deployment `required`s it: the chart
|
||||||
|
# provisions no database and cannot guess where it is, so a render without it is
|
||||||
|
# meant to fail. Setting it here keeps the lint honest about what a working
|
||||||
|
# install needs.
|
||||||
|
HELM_LINT_SET = --set image.tag=v0.0.0 \
|
||||||
|
--set 'database.dsn=postgres://terdut@terdut-postgres:5432/terdut?sslmode=require'
|
||||||
|
|
||||||
|
.PHONY: helm-lint
|
||||||
|
helm-lint: ## Lint and render the chart
|
||||||
|
helm lint $(HELM_CHART) $(HELM_LINT_SET)
|
||||||
|
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
|
||||||
|
$(HELM_LINT_SET) >/dev/null
|
||||||
|
@# networking.listener defaults to "", which attaches the route to every
|
||||||
|
@# matching listener including plaintext HTTP. Production sets it, so the
|
||||||
|
@# default render proves nothing about the path that actually ships.
|
||||||
|
helm template terdut-server $(HELM_CHART) --namespace terdut-server \
|
||||||
|
$(HELM_LINT_SET) --set networking.listener=https-terdut >/dev/null
|
||||||
|
|
||||||
|
## --- release ---
|
||||||
|
|
||||||
|
# The release process (~/.claude/skills/release) reads these rather than restating them.
|
||||||
|
# One definition, so the version that gets tagged, the image that gets pushed and the chart
|
||||||
|
# the wrapper pins cannot drift apart in a second copy.
|
||||||
|
.PHONY: release-vars
|
||||||
|
release-vars: ## Print the variables the release process reads
|
||||||
|
@printf 'IMAGE=%s\nHELM_CHART=%s\nHELM_REPO=%s\n' '$(IMAGE)' '$(HELM_CHART)' '$(HELM_REPO)'
|
||||||
|
|
||||||
|
# There is deliberately no build/push/helm-package/helm-push/release here, unlike
|
||||||
|
# riksdata and rd-web. .gitea/workflows/release.yaml owns publishing for this repo,
|
||||||
|
# and it does two things a local make cannot: it builds linux/amd64 and linux/arm64
|
||||||
|
# through buildx, and it stamps the chart's version and appVersion from the tag. A
|
||||||
|
# `docker build && docker push` target would push a single-architecture image over
|
||||||
|
# the multi-arch tag, which is both easy to do by accident and invisible afterwards
|
||||||
|
# — the tag would still resolve, just not on arm64. Publishing happens by pushing a
|
||||||
|
# tag; nothing else.
|
||||||
|
|
||||||
|
## --- publishing ---
|
||||||
|
#
|
||||||
|
# These exist so .gitea/workflows/release.yaml can call `make release` instead of
|
||||||
|
# restating the build in YAML, the way riksdata and rd-web already do. One definition
|
||||||
|
# of how this is built and published, runnable locally, reviewable in a diff.
|
||||||
|
#
|
||||||
|
# VERSION is the git tag, passed in by the workflow. The guard below is why a stray
|
||||||
|
# local `make release` cannot publish: dev is not a version anyone releases.
|
||||||
|
|
||||||
|
VERSION ?= dev
|
||||||
|
|
||||||
|
# Helm requires strict SemVer — strip a leading 'v' if present.
|
||||||
|
CHART_VERSION := $(shell echo "$(VERSION)" | sed 's/^v//')
|
||||||
|
|
||||||
|
PLATFORMS ?= linux/amd64,linux/arm64
|
||||||
|
BUILDX_BUILDER ?= terdut
|
||||||
|
|
||||||
|
TRIVY_VERSION := 0.73.0
|
||||||
|
GOVULNCHECK_VERSION := v1.1.4
|
||||||
|
GITLEAKS_VERSION := v8.30.0
|
||||||
|
|
||||||
|
# --pull, not --no-cache: refresh the base image without discarding the layer cache.
|
||||||
|
DOCKER_BUILD_FLAGS ?= --pull
|
||||||
|
|
||||||
|
# An isolated repo list. The machine-wide one is not this build's business, and one
|
||||||
|
# unreachable entry in it aborts otherwise-fine helm commands — there is a dead
|
||||||
|
# TrueCharts repo on this host that does exactly that. HELM_REPOSITORY_CACHE is
|
||||||
|
# deliberately NOT overridden alongside it: helm writes a refreshed index to the default
|
||||||
|
# cache and then looks for it in the overridden one.
|
||||||
|
HELM_ISOLATED = HELM_REPOSITORY_CONFIG=$(CURDIR)/.helm-repos.yaml
|
||||||
|
|
||||||
|
.PHONY: require-version
|
||||||
|
require-version:
|
||||||
|
@test "$(VERSION)" != "dev" || \
|
||||||
|
(echo "VERSION=dev names no release — pass VERSION=vX.Y.Z (the workflow passes the tag)" && exit 1)
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build the image for this host only, without pushing (local check / CI smoke)
|
||||||
|
docker build $(DOCKER_BUILD_FLAGS) \
|
||||||
|
--build-arg VERSION=$(VERSION) \
|
||||||
|
-t $(IMAGE):$(VERSION) .
|
||||||
|
|
||||||
|
# Multi-arch, so unlike riksdata and rd-web this cannot be a separate build then push:
|
||||||
|
# buildx cannot load a multi-platform result into the local image store, it can only
|
||||||
|
# push it. `build` above stays single-platform and local-only for that reason.
|
||||||
|
#
|
||||||
|
# No QEMU: the Dockerfile's builder stage runs on $$BUILDPLATFORM and cross-compiles from
|
||||||
|
# TARGETARCH, so both platforms build natively. The default "docker" driver cannot build
|
||||||
|
# more than one platform at a time; the docker-container driver can.
|
||||||
|
.PHONY: push
|
||||||
|
push: require-version ## Build and publish the multi-arch image
|
||||||
|
docker buildx create --name $(BUILDX_BUILDER) --use 2>/dev/null || docker buildx use $(BUILDX_BUILDER)
|
||||||
|
docker buildx build \
|
||||||
|
--platform $(PLATFORMS) \
|
||||||
|
--build-arg "VERSION=$(VERSION)" \
|
||||||
|
--tag "$(IMAGE):latest" \
|
||||||
|
--tag "$(IMAGE):$(VERSION)" \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
# --version and --app-version come from the tag, so Chart.yaml's own fields decide nothing
|
||||||
|
# about what is published. They used to be rewritten in place with sed before packaging;
|
||||||
|
# the flags do the same job without mutating the tree mid-build.
|
||||||
|
.PHONY: helm-package
|
||||||
|
helm-package: require-version ## Package the chart, versioned from the tag
|
||||||
|
$(HELM_ISOLATED) helm package $(HELM_CHART) \
|
||||||
|
--version $(CHART_VERSION) \
|
||||||
|
--app-version $(VERSION) \
|
||||||
|
--destination dist
|
||||||
|
|
||||||
|
.PHONY: helm-push
|
||||||
|
helm-push: require-version ## Push the packaged chart to the OCI registry
|
||||||
|
$(HELM_ISOLATED) helm push dist/terdut-server-$(CHART_VERSION).tgz $(HELM_REPO)
|
||||||
|
|
||||||
|
.PHONY: binaries
|
||||||
|
binaries: require-version ## Cross-compile the release binaries into dist/
|
||||||
|
@mkdir -p dist
|
||||||
|
@set -eu; for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \
|
||||||
|
GOOS="$${target%/*}"; GOARCH="$${target#*/}"; \
|
||||||
|
out="dist/terdut-$(VERSION)-$${GOOS}-$${GOARCH}"; \
|
||||||
|
echo "building $$out"; \
|
||||||
|
GOOS="$$GOOS" GOARCH="$$GOARCH" go build \
|
||||||
|
-ldflags "-w -s -X main.version=$(VERSION)" \
|
||||||
|
-o "$$out" ./cmd/terdut; \
|
||||||
|
done
|
||||||
|
|
||||||
|
.PHONY: release
|
||||||
|
release: push helm-package helm-push ## Publish image + chart (the workflow's one call)
|
||||||
|
|
||||||
|
## --- security ---
|
||||||
|
|
||||||
|
# Symbol-level, not dependency-level: govulncheck reports a vulnerability only when the
|
||||||
|
# code can actually reach it. As of 2026-09-02 this repo imports three chi advisories and
|
||||||
|
# reports none of them, because all three are middleware.RealIP and router.go uses Logger
|
||||||
|
# and Recoverer. That is the useful property rather than a loophole -- adding
|
||||||
|
# middleware.RealIP would turn this red, which is exactly when someone should look.
|
||||||
|
.PHONY: security-go
|
||||||
|
security-go: ## Scan Go deps for known CVEs (govulncheck)
|
||||||
|
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||||
|
|
||||||
|
# --no-git scans the working tree rather than the history, so this catches a secret on the
|
||||||
|
# way in. It is not a history audit and finding nothing here says nothing about what is
|
||||||
|
# already committed. --redact because the finding is printed into a CI log.
|
||||||
|
#
|
||||||
|
# Note when testing it that gitleaks allowlists well-known example credentials -- the AWS
|
||||||
|
# key from their own documentation does not trip it. A private key block does.
|
||||||
|
.PHONY: security-secrets
|
||||||
|
security-secrets: ## Scan the working tree for committed secrets (gitleaks)
|
||||||
|
go run github.com/zricethezav/gitleaks/v8@$(GITLEAKS_VERSION) detect --no-git \
|
||||||
|
--source . --redact --no-banner --exit-code 1
|
||||||
|
|
||||||
|
|
||||||
|
# Scans the pushed image, not a local one: trivy cannot read a locally built image on the
|
||||||
|
# runner -- Talos has no docker socket, and the dind sidecar shares no filesystem with the
|
||||||
|
# job -- so it pulls from the registry. Same reason riksdata and rd-web scan after pushing.
|
||||||
|
#
|
||||||
|
# It cannot gate a deploy, because this pipeline does not deploy. A red scan means: do not
|
||||||
|
# bump the wrapper chart in Ryuvia/charts to this version.
|
||||||
|
#
|
||||||
|
# The image is FROM scratch, so there are no OS packages to scan and trivy sees exactly one
|
||||||
|
# target -- the Go binary and its module graph. That also makes scanning a single platform
|
||||||
|
# sufficient here: linux/amd64 and linux/arm64 are the same modules built for a different
|
||||||
|
# GOARCH, so a CVE in one is a CVE in both. On an image with a base layer that would not
|
||||||
|
# hold and both platforms would need scanning.
|
||||||
|
#
|
||||||
|
# This is the last of the three scans and the only one that needs a published artifact;
|
||||||
|
# security-go and security-secrets above run on every push.
|
||||||
|
.PHONY: security-image
|
||||||
|
security-image: require-version ## Scan the pushed image for CVEs (needs VERSION)
|
||||||
|
@# The named volume persists trivy's vulnerability DB between runs; without it every
|
||||||
|
@# scan re-downloads the whole database.
|
||||||
|
docker run --rm -e TRIVY_USERNAME -e TRIVY_PASSWORD \
|
||||||
|
-v trivy-cache:/root/.cache/trivy \
|
||||||
|
docker.io/aquasec/trivy:$(TRIVY_VERSION) image --severity HIGH,CRITICAL \
|
||||||
|
--ignore-unfixed --exit-code 1 $(IMAGE):$(VERSION)
|
||||||
@@ -2,5 +2,18 @@ apiVersion: v2
|
|||||||
name: terdut-server
|
name: terdut-server
|
||||||
description: A Helm chart for Terminal Duty — on-call alert management server
|
description: A Helm chart for Terminal Duty — on-call alert management server
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.0
|
# These two are placeholders for a local `helm install ./charts/terdut-server`, not the
|
||||||
appVersion: "latest"
|
# released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
|
||||||
|
# publishes, so the chart version always equals the app version.
|
||||||
|
#
|
||||||
|
# They are kept in step with the tag anyway. Being read is the only thing these two lines
|
||||||
|
# do -- `helm package --version --app-version` sets the published values from the tag and
|
||||||
|
# never consults these -- and a tree heading for a numbered release that states an older
|
||||||
|
# number tells its reader something false. They said 0.9.0 and "latest" until 2026-09-01,
|
||||||
|
# through two releases.
|
||||||
|
#
|
||||||
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
|
# metadata and drives nothing.
|
||||||
|
version: 0.29.1
|
||||||
|
appVersion: "v0.29.1"
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|||||||
app.kubernetes.io/name: {{ include "terdut-server.name" . }}
|
app.kubernetes.io/name: {{ include "terdut-server.name" . }}
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "terdut-server.bootstrapSecretName" -}}
|
||||||
|
{{- .Values.bootstrap.secretName | default (printf "%s-admin-key" (include "terdut-server.fullname" .)) }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{{- if .Values.bootstrap.enabled }}
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "0"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
|
||||||
|
spec:
|
||||||
|
backoffLimit: 3
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: bootstrap
|
||||||
|
spec:
|
||||||
|
restartPolicy: OnFailure
|
||||||
|
serviceAccountName: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
containers:
|
||||||
|
- name: bootstrap
|
||||||
|
# alpine/curl, not alpine:3 + `apk add curl`. Installing the binary at run time
|
||||||
|
# writes it into the container's writable upper layer, which is exactly the
|
||||||
|
# signature Falco's `Drop and execute new binary in container` (MITRE TA0003)
|
||||||
|
# exists to catch -- this hook emitted two Critical events on every single
|
||||||
|
# upgrade. See Ryuvia/charts#100. It also made `helm upgrade` depend on the
|
||||||
|
# Alpine CDN answering, since this runs as a post-upgrade hook and a failed
|
||||||
|
# hook fails the release.
|
||||||
|
#
|
||||||
|
# Still a full Alpine underneath, so sh, cat, sleep, grep, cut, head and tail
|
||||||
|
# are all present (verified in-cluster 2026-09-04). The image declares
|
||||||
|
# ENTRYPOINT ["/entrypoint.sh"], which `command:` below overrides -- do not
|
||||||
|
# change `command:` to `args:`.
|
||||||
|
image: alpine/curl:8.21.0@sha256:a1c44bab54d88e18ea9a6a4ecefab7f2d230b968567b78960fcaff8d51b7f067
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
|
||||||
|
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
|
||||||
|
K8S_API="https://kubernetes.default.svc"
|
||||||
|
SA_TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
|
||||||
|
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||||
|
NAMESPACE="$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)"
|
||||||
|
|
||||||
|
echo "Waiting for terdut-server to be ready..."
|
||||||
|
RETRIES=60
|
||||||
|
while [ "$RETRIES" -gt 0 ]; do
|
||||||
|
curl -sf "$SERVICE_URL/healthz" > /dev/null 2>&1 && break
|
||||||
|
RETRIES=$((RETRIES - 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$RETRIES" -eq 0 ]; then
|
||||||
|
echo "Timed out waiting for server to be ready."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Server is ready."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$SERVICE_URL/api/bootstrap" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"{{ .Values.bootstrap.username }}","email":"{{ .Values.bootstrap.email }}"}')
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | head -1)
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "403" ]; then
|
||||||
|
echo "Server already bootstrapped, nothing to do."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Bootstrap failed (HTTP $HTTP_CODE): $BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_KEY=$(echo "$BODY" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "Failed to extract API key from response."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Bootstrap succeeded. Storing API key in secret '$SECRET_NAME'."
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST "$K8S_API/api/v1/namespaces/$NAMESPACE/secrets" \
|
||||||
|
--cacert "$CA_CERT" \
|
||||||
|
-H "Authorization: Bearer $SA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(printf '{"apiVersion":"v1","kind":"Secret","metadata":{"name":"%s"},"stringData":{"api-key":"%s"}}' "$SECRET_NAME" "$API_KEY")")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Failed to create secret (HTTP $HTTP_CODE)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Secret '$SECRET_NAME' created successfully."
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{{- if .Values.bootstrap.enabled }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "-1"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "-1"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["create"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
helm.sh/hook: post-install,post-upgrade
|
||||||
|
helm.sh/hook-weight: "-1"
|
||||||
|
helm.sh/hook-delete-policy: before-hook-creation
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: {{ include "terdut-server.fullname" . }}-bootstrap
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
{{- end }}
|
||||||
@@ -10,6 +10,11 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
||||||
|
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: the
|
||||||
|
# sweeper and the notifier are unsynchronised singletons, and two replicas
|
||||||
|
# overlapping during a rollout would both page for the same incident.
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -27,11 +32,86 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: TERDUT_ADDR
|
- name: TERDUT_ADDR
|
||||||
value: ":{{ .Values.service.port }}"
|
value: ":{{ .Values.service.port }}"
|
||||||
- name: TERDUT_DB_PATH
|
- name: TERDUT_DB_DSN
|
||||||
value: "/data/terdut.db"
|
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
||||||
volumeMounts:
|
{{- if .Values.database.passwordSecret.name }}
|
||||||
- name: data
|
# The password reaches pgx through libpq's environment variable
|
||||||
mountPath: /data
|
# rather than through the DSN, so it stays out of the rendered
|
||||||
|
# manifest. pgx fills in from PG* whatever the DSN leaves out.
|
||||||
|
- name: PGPASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.database.passwordSecret.name }}
|
||||||
|
key: {{ .Values.database.passwordSecret.key }}
|
||||||
|
{{- end }}
|
||||||
|
- name: TERDUT_STALE_AFTER
|
||||||
|
value: "{{ .Values.sweeper.staleAfter }}"
|
||||||
|
- name: TERDUT_ARCHIVE_AFTER
|
||||||
|
value: "{{ .Values.sweeper.archiveAfter }}"
|
||||||
|
- name: TERDUT_DEADMAN_MATCHERS
|
||||||
|
value: "{{ .Values.deadman.matchers }}"
|
||||||
|
- name: TERDUT_DEADMAN_TIMEOUT
|
||||||
|
value: "{{ .Values.deadman.timeout }}"
|
||||||
|
- name: TERDUT_DEADMAN_SEVERITY
|
||||||
|
value: "{{ .Values.deadman.severity }}"
|
||||||
|
{{- if .Values.notify.ntfyUrl }}
|
||||||
|
- name: TERDUT_NTFY_URL
|
||||||
|
value: "{{ .Values.notify.ntfyUrl }}"
|
||||||
|
- name: TERDUT_NTFY_FALLBACK_TOPIC
|
||||||
|
value: "{{ .Values.notify.fallbackTopic }}"
|
||||||
|
- name: TERDUT_NOTIFY_REPEAT
|
||||||
|
value: "{{ .Values.notify.repeatEvery }}"
|
||||||
|
{{- if .Values.notify.tokenSecret.name }}
|
||||||
|
- name: TERDUT_NTFY_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.notify.tokenSecret.name }}
|
||||||
|
key: {{ .Values.notify.tokenSecret.key }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
# Set whether or not ntfy is: single sign-on builds its redirect URI
|
||||||
|
# from it, and sessions use it to decide the cookie's Secure flag.
|
||||||
|
- name: TERDUT_PUBLIC_URL
|
||||||
|
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
|
||||||
|
- name: TERDUT_PASSWORD_LOGIN
|
||||||
|
value: {{ .Values.passwordLogin | quote }}
|
||||||
|
{{- if .Values.oidc.enabled }}
|
||||||
|
- name: TERDUT_OIDC_ISSUER
|
||||||
|
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
||||||
|
- name: TERDUT_OIDC_CLIENT_ID
|
||||||
|
value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }}
|
||||||
|
- name: TERDUT_OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }}
|
||||||
|
key: {{ .Values.oidc.clientSecret.key }}
|
||||||
|
- name: TERDUT_OIDC_NAME
|
||||||
|
value: {{ .Values.oidc.name | quote }}
|
||||||
|
- name: TERDUT_OIDC_SCOPES
|
||||||
|
value: {{ .Values.oidc.scopes | quote }}
|
||||||
|
- name: TERDUT_OIDC_USERNAME_CLAIM
|
||||||
|
value: {{ .Values.oidc.usernameClaim | quote }}
|
||||||
|
- name: TERDUT_OIDC_EMAIL_CLAIM
|
||||||
|
value: {{ .Values.oidc.emailClaim | quote }}
|
||||||
|
- name: TERDUT_OIDC_GROUPS_CLAIM
|
||||||
|
value: {{ .Values.oidc.groupsClaim | quote }}
|
||||||
|
- name: TERDUT_OIDC_TRUST_EMAIL
|
||||||
|
value: {{ .Values.oidc.trustEmail | quote }}
|
||||||
|
- name: TERDUT_OIDC_SESSION_MAX_AGE
|
||||||
|
value: {{ .Values.oidc.sessionMaxAge | quote }}
|
||||||
|
{{- if .Values.oidc.allowedGroups }}
|
||||||
|
- name: TERDUT_OIDC_ALLOWED_GROUPS
|
||||||
|
value: {{ join "," .Values.oidc.allowedGroups | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.oidc.adminGroup }}
|
||||||
|
- name: TERDUT_OIDC_ADMIN_GROUP
|
||||||
|
value: {{ .Values.oidc.adminGroup | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.oidc.groupMappings }}
|
||||||
|
- name: TERDUT_OIDC_GROUP_MAPPINGS
|
||||||
|
value: {{ .Values.oidc.groupMappings | toJson | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
@@ -42,7 +122,5 @@ spec:
|
|||||||
path: /healthz
|
path: /healthz
|
||||||
port: http
|
port: http
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: {{ .Release.Name }}-data
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ spec:
|
|||||||
parentRefs:
|
parentRefs:
|
||||||
- name: envoy-main
|
- name: envoy-main
|
||||||
namespace: envoy-gateway-system
|
namespace: envoy-gateway-system
|
||||||
|
{{- with .Values.networking.listener }}
|
||||||
|
sectionName: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
rules:
|
rules:
|
||||||
- backendRefs:
|
- backendRefs:
|
||||||
- name: {{ include "terdut-server.fullname" . }}
|
- name: {{ include "terdut-server.fullname" . }}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: {{ .Release.Name }}-data
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
spec:
|
|
||||||
storageClassName: {{ .Values.storage.storageClass | quote }}
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .Values.storage.size }}
|
|
||||||
@@ -1,16 +1,170 @@
|
|||||||
networking:
|
networking:
|
||||||
hostname: "terdut.example.com"
|
hostname: "terdut.example.com"
|
||||||
servicePort: 8080
|
servicePort: 8080
|
||||||
|
# Gateway listener to bind the HTTPRoute to. Empty attaches to every matching
|
||||||
|
# listener, including plaintext HTTP. Set this to the name of the HTTPS
|
||||||
|
# listener to serve the API over TLS only.
|
||||||
|
listener: ""
|
||||||
|
|
||||||
image:
|
image:
|
||||||
repository: ghcr.io/yeniklas/terdut-server
|
repository: git.ryuvia.com/niklas/terdut-server
|
||||||
tag: "latest"
|
tag: "latest"
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
storage:
|
# Postgres connection. The chart provisions no database; it expects one to exist.
|
||||||
size: 1Gi
|
database:
|
||||||
storageClass: synology-iscsi
|
# Required. A DSN with no password in it:
|
||||||
|
# postgres://terdut@terdut-postgres:5432/terdut?sslmode=require
|
||||||
|
#
|
||||||
|
# The password is deliberately a separate setting. pgx falls back to libpq's
|
||||||
|
# environment variables for anything the DSN omits, so PGPASSWORD supplies it
|
||||||
|
# without the credential appearing in values, in the rendered manifest, or in
|
||||||
|
# `kubectl describe pod`.
|
||||||
|
dsn: ""
|
||||||
|
# Where PGPASSWORD comes from. With the Zalando postgres operator this is the
|
||||||
|
# Secret it generates for the role — `<user>.<cluster>.credentials.postgresql.acid.zalan.do`,
|
||||||
|
# whose keys are `username` and `password` — so a from-scratch rebuild mints a
|
||||||
|
# new password and the server picks it up with nothing to keep in sync.
|
||||||
|
#
|
||||||
|
# Read at process start only: rotating the password needs a pod restart.
|
||||||
|
#
|
||||||
|
# Leave name empty only if the DSN carries its own password, which puts it in
|
||||||
|
# the manifest.
|
||||||
|
passwordSecret:
|
||||||
|
name: ""
|
||||||
|
key: password
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
|
sweeper:
|
||||||
|
# How long a firing alert may go without a refreshing webhook before it is
|
||||||
|
# treated as resolved. Must exceed your Alertmanager repeat_interval.
|
||||||
|
staleAfter: 6h
|
||||||
|
# How long a resolved alert stays in the default list before auto-archiving.
|
||||||
|
archiveAfter: 168h
|
||||||
|
|
||||||
|
# Alerts treated as dead man's switches: receiving one opens no incident, and
|
||||||
|
# the absence of one does. The Watchdog alert kube-prometheus-stack ships is
|
||||||
|
# exactly this — an always-firing alert whose only value is something noticing
|
||||||
|
# when it stops.
|
||||||
|
deadman:
|
||||||
|
# Which alerts to treat as heartbeats. ";" separates matchers, "," separates
|
||||||
|
# the label conditions within one, "=" is exact equality. Every matcher must
|
||||||
|
# name an alertname:
|
||||||
|
# alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
|
||||||
|
# Each distinct label set is watched independently, so two clusters sending
|
||||||
|
# the same alertname are two switches and a live one cannot mask a dead one.
|
||||||
|
matchers: "alertname=Watchdog"
|
||||||
|
# How long a heartbeat may go unheard before its switch is declared dead.
|
||||||
|
#
|
||||||
|
# This must be SHORTER than the Alertmanager repeat_interval of the route
|
||||||
|
# carrying the heartbeat — the opposite of sweeper.staleAfter. The default
|
||||||
|
# repeat_interval of 4h (12h in many setups) makes for a useless dead man's
|
||||||
|
# switch, so give the heartbeat a route of its own:
|
||||||
|
#
|
||||||
|
# - matchers: [ 'alertname = "Watchdog"' ]
|
||||||
|
# receiver: terdut
|
||||||
|
# group_wait: 0s
|
||||||
|
# group_interval: 1m
|
||||||
|
# repeat_interval: 1m
|
||||||
|
#
|
||||||
|
# That delivers every 2m rather than every 1m: a group is only reconsidered
|
||||||
|
# each group_interval, and at exactly one elapsed interval repeat_interval has
|
||||||
|
# not quite passed, so equal values give 2x. Fine against 15m; use
|
||||||
|
# group_interval: 30s if you want a true 1m.
|
||||||
|
#
|
||||||
|
# Set to 0 to disable dead man's switch handling entirely.
|
||||||
|
timeout: 15m
|
||||||
|
# Severity a dead man's switch incident opens at. These incidents have no
|
||||||
|
# member alerts to derive one from, and the heartbeat's own severity label is
|
||||||
|
# meaningless — Watchdog ships as "none". Only "critical" maps to the ntfy
|
||||||
|
# priority that overrides a phone's quiet hours.
|
||||||
|
severity: critical
|
||||||
|
|
||||||
|
notify:
|
||||||
|
# ntfy server that push notifications are published to, e.g.
|
||||||
|
# http://ntfy.ntfy.svc.cluster.local. Empty disables notifications entirely.
|
||||||
|
ntfyUrl: ""
|
||||||
|
# Topic used when nobody is on call today. Notifications sent here carry no
|
||||||
|
# Acknowledge button: the topic is shared, so there is no user to attribute an
|
||||||
|
# acknowledgement to. Leave empty to send nothing when the schedule is unset.
|
||||||
|
fallbackTopic: ""
|
||||||
|
# How long an incident may sit unacknowledged before it is paged again.
|
||||||
|
# Set to 0 to notify once and never repeat.
|
||||||
|
repeatEvery: 15m
|
||||||
|
# Base URL a phone uses to reach this server, for the link and the Acknowledge
|
||||||
|
# button inside a notification. Defaults to https://<networking.hostname>.
|
||||||
|
#
|
||||||
|
# The Acknowledge button is a POST to /api/notify/ack/{token} from the
|
||||||
|
# responder's phone, so that path has to stay publicly reachable — it is
|
||||||
|
# authorised by the scoped token in the URL, not by network placement.
|
||||||
|
publicUrl: ""
|
||||||
|
# Optional bearer token for an access-controlled ntfy, read from an existing
|
||||||
|
# Secret. Leave name empty for an open ntfy.
|
||||||
|
tokenSecret:
|
||||||
|
name: ""
|
||||||
|
key: token
|
||||||
|
|
||||||
|
# Whether a user may sign in, or sign up, with a password. Turn it off once
|
||||||
|
# single sign-on works, to make it the only way in; turn it back on (and
|
||||||
|
# redeploy) if the identity provider is down and somebody has to get in.
|
||||||
|
passwordLogin: true
|
||||||
|
|
||||||
|
# Single sign-on through an OpenID Connect provider such as Authentik.
|
||||||
|
#
|
||||||
|
# At the provider, create an OAuth2/OpenID application whose redirect URI is
|
||||||
|
# <notify.publicUrl>/api/oidc/callback
|
||||||
|
# (publicUrl defaults to https://<networking.hostname>), a confidential client, and
|
||||||
|
# put the client secret in an existing Secret named by clientSecret below.
|
||||||
|
#
|
||||||
|
# Groups from the provider decide what a person can do. Access it grants is
|
||||||
|
# marked as managed by single sign-on and is re-read at every sign-in; anything
|
||||||
|
# added by hand in terdut is left alone. Changes in the provider take effect at
|
||||||
|
# the person's next sign-in, at most sessionMaxAge later. API keys are NOT
|
||||||
|
# revoked when somebody is removed at the provider: disable the user in terdut too.
|
||||||
|
oidc:
|
||||||
|
enabled: false
|
||||||
|
# Issuer URL. For Authentik: https://<authentik>/application/o/<app-slug>/
|
||||||
|
issuer: ""
|
||||||
|
clientId: ""
|
||||||
|
clientSecret:
|
||||||
|
name: ""
|
||||||
|
key: client-secret
|
||||||
|
# What the sign-in button calls the provider.
|
||||||
|
name: SSO
|
||||||
|
# Authentik puts the groups claim behind the profile scope.
|
||||||
|
scopes: "openid profile email"
|
||||||
|
usernameClaim: preferred_username
|
||||||
|
emailClaim: email
|
||||||
|
groupsClaim: groups
|
||||||
|
# Link a first sign-in to an existing local user with the same email even when
|
||||||
|
# the provider does not mark the address verified. Authentik reports
|
||||||
|
# email_verified as false unless configured otherwise.
|
||||||
|
trustEmail: false
|
||||||
|
# Only people in one of these groups may sign in. Empty admits everybody the
|
||||||
|
# provider authenticates, and access control is left to the provider.
|
||||||
|
allowedGroups: []
|
||||||
|
# Members of this group are system administrators.
|
||||||
|
adminGroup: ""
|
||||||
|
# Team roles from groups. A team that does not exist is created. Where several
|
||||||
|
# groups grant the same team the highest role wins.
|
||||||
|
# - group: sre
|
||||||
|
# team: SRE
|
||||||
|
# role: member # member or owner
|
||||||
|
groupMappings: []
|
||||||
|
# Hard ceiling on a session made by a single sign-on login.
|
||||||
|
sessionMaxAge: 12h
|
||||||
|
|
||||||
|
# Backups are no longer this chart's business. The SQLite database lived on a PVC
|
||||||
|
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
|
||||||
|
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
|
||||||
|
# pg_dump annotation on the database pod itself.
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
enabled: true
|
||||||
|
username: admin
|
||||||
|
email: admin@example.com
|
||||||
|
# secretName overrides the default of <fullname>-admin-key
|
||||||
|
secretName: ""
|
||||||
|
|||||||
+34
-5
@@ -8,17 +8,20 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yeniklas/terdut-server/internal/api"
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
"github.com/yeniklas/terdut-server/internal/config"
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
"github.com/yeniklas/terdut-server/internal/db"
|
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
database, err := db.Open(cfg.DBPath)
|
database, err := db.Open(cfg.DSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open db: %v", err)
|
log.Fatalf("open db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,30 @@ func main() {
|
|||||||
log.Fatalf("migrate: %v", err)
|
log.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := api.NewRouter(database)
|
notify := api.NotifyConfig{
|
||||||
|
BaseURL: cfg.NtfyURL,
|
||||||
|
Token: cfg.NtfyToken,
|
||||||
|
FallbackTopic: cfg.NtfyFallbackTopic,
|
||||||
|
PublicURL: cfg.PublicURL,
|
||||||
|
RepeatEvery: cfg.NotifyRepeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead man's switches live per team now. The environment variables are the
|
||||||
|
// defaults a team starts from: every team without a configuration of its
|
||||||
|
// own gets one from them here, and an owner's later edit is never
|
||||||
|
// overwritten by a redeploy.
|
||||||
|
deadman := api.ParseDeadmanConfig(cfg.DeadmanMatchers, cfg.DeadmanTimeout, cfg.DeadmanSeverity)
|
||||||
|
if err := api.SeedDeadmanConfigs(context.Background(), database, deadman); err != nil {
|
||||||
|
log.Fatalf("seed dead man's switch defaults: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The behaviour knobs move into the database on first start, after which an
|
||||||
|
// administrator owns them and a redeploy leaves them alone.
|
||||||
|
if err := api.SeedSettings(context.Background(), database, cfg); err != nil {
|
||||||
|
log.Fatalf("seed settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := api.NewRouter(database, notify, cfg)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
@@ -41,6 +67,9 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
go api.StartArchiver(ctx, database, cfg.ArchiveAfter, cfg.StaleAfter, notify)
|
||||||
|
go api.StartNotifier(ctx, database, notify)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("terdut-server %s listening on %s", version, cfg.Addr)
|
log.Printf("terdut-server %s listening on %s", version, cfg.Addr)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
module github.com/yeniklas/terdut-server
|
module git.ryuvia.com/niklas/terdut-server
|
||||||
|
|
||||||
go 1.25.9
|
go 1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
modernc.org/sqlite v1.50.1
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,53 +1,38 @@
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
|
||||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bootstrap user is an administrator; everybody it creates afterwards is
|
||||||
|
// not. These tests are about the line between them.
|
||||||
|
|
||||||
|
// id64 spells an id into a path segment.
|
||||||
|
func id64(n int64) string { return strconv.FormatInt(n, 10) }
|
||||||
|
|
||||||
|
// member creates an ordinary user and an API key for it, and returns a caller
|
||||||
|
// that authenticates as them. Minting the key goes through the admin's own
|
||||||
|
// credentials, which is how a real install hands one out.
|
||||||
|
func member(t *testing.T, s *ts, username string) (id int64, call func(method, path string, body any) *http.Response) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": username, "email": username + "@test.com"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create %s: %d", username, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &user)
|
||||||
|
if user.IsAdmin {
|
||||||
|
t.Fatalf("a created user must not be an administrator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Into the default team as a plain member: being in a team is what lets
|
||||||
|
// somebody work its incidents, and is separate from administering accounts.
|
||||||
|
resp = s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("add %s to the team: %d", username, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||||
|
map[string]string{"name": "test"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("mint key for %s: %d", username, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var key struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &key)
|
||||||
|
|
||||||
|
return user.ID, func(method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the release: a user who is not an administrator cannot
|
||||||
|
// manage other people's accounts. Every one of these was open to any
|
||||||
|
// authenticated caller before.
|
||||||
|
func TestAdmin_MemberIsRefusedAdministration(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "member")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"create a user", http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "sneaky", "email": "sneaky@test.com"}},
|
||||||
|
{"delete the admin", http.MethodDelete, "/api/users/1", nil},
|
||||||
|
{"grant themselves admin", http.MethodPut, "/api/users/" + id64(memberID) + "/admin",
|
||||||
|
map[string]bool{"is_admin": true}},
|
||||||
|
{"set the admin's password", http.MethodPut, "/api/users/1/password",
|
||||||
|
map[string]string{"password": "hunter2-hunter2"}},
|
||||||
|
{"mint a key for the admin", http.MethodPost, "/api/users/1/api-keys",
|
||||||
|
map[string]string{"name": "borrowed"}},
|
||||||
|
{"retarget the admin's notifications", http.MethodPut, "/api/users/1/notify",
|
||||||
|
map[string]string{"ntfy_topic": "attacker-topic"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
resp := call(c.method, c.path, c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Being refused other people's accounts must not cost a user their own.
|
||||||
|
func TestAdmin_MemberKeepsTheirOwnAccount(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "member")
|
||||||
|
self := "/api/users/" + id64(memberID)
|
||||||
|
|
||||||
|
resp := call(http.MethodPut, self+"/notify", map[string]string{"ntfy_topic": "terdut-member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("own notify target: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = call(http.MethodPut, self+"/password", map[string]string{"password": "correct-horse-battery"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("own password: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An API key carries exactly the rights of its owner, so minting your own
|
||||||
|
// is no more than signing in again.
|
||||||
|
resp = call(http.MethodPost, self+"/api-keys", map[string]string{"name": "laptop"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Errorf("own API key: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the queue still has to be able to name people.
|
||||||
|
resp = call(http.MethodGet, "/api/users", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("list users: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incident work is everybody's job; none of it is administration.
|
||||||
|
func TestAdmin_MemberCanWorkIncidents(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "responder")
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-admin", "DiskFull", "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"list", http.MethodGet, "/api/incidents"},
|
||||||
|
{"acknowledge", http.MethodPost, "/api/incidents/1/acknowledge"},
|
||||||
|
{"resolve", http.MethodPost, "/api/incidents/1/resolve"},
|
||||||
|
} {
|
||||||
|
resp := call(c.method, c.path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("%s: expected 200, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An install must never be left with nobody who can administer it.
|
||||||
|
func TestAdmin_LastAdministratorIsProtected(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/users/1/admin", map[string]bool{"is_admin": false})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("self-demotion: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/users/1", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("deleting yourself: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a second administrator the first may stand down, but not while they
|
||||||
|
// are the only one — which is the same rule from the other side.
|
||||||
|
otherID, _ := member(t, s, "second")
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/users/"+id64(otherID)+"/admin", map[string]bool{"is_admin": true})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("granting admin: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/users/"+id64(otherID), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("deleting the second admin: expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A promoted user gets the powers with the flag, and loses them with it.
|
||||||
|
func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "promotee")
|
||||||
|
admin := "/api/users/" + id64(memberID) + "/admin"
|
||||||
|
|
||||||
|
resp := call(http.MethodPost, "/api/users", map[string]string{"username": "a", "email": "a@test.com"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("before the grant: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": true})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("grant: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "b", "email": "b@test.com"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Errorf("after the grant: expected 201, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPut, admin, map[string]bool{"is_admin": false})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = call(http.MethodPost, "/api/users", map[string]string{"username": "c", "email": "c@test.com"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("after the revoke: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An administrator passes every team-owner check without being in the team,
|
||||||
|
// which is what lets them repair a team whose owner has left. It has been true
|
||||||
|
// since teams landed and nothing pinned it, so a later reading of the epic's
|
||||||
|
// "an admin is not implicitly in every team" could quietly take it away.
|
||||||
|
//
|
||||||
|
// The line it draws: configuring a team, yes; reading what the team owns, no.
|
||||||
|
// The queue below is the half that stays shut.
|
||||||
|
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// A team the admin is deliberately not a member of. It is created by
|
||||||
|
// somebody else, so the admin's only claim on it is the flag.
|
||||||
|
_, call := member(t, s, "founder")
|
||||||
|
var team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||||
|
if team.ID == 0 {
|
||||||
|
t.Fatal("no team was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mine []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
|
||||||
|
for _, m := range mine {
|
||||||
|
if m.ID == team.ID {
|
||||||
|
t.Fatalf("the admin should not be a member of team %d", team.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/api/teams/" + id64(team.ID)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"rename it", http.MethodPut, path,
|
||||||
|
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
|
||||||
|
{"mint an invite", http.MethodPost, path + "/invites",
|
||||||
|
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
|
||||||
|
{"add a member", http.MethodPost, path + "/members",
|
||||||
|
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
|
||||||
|
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, c.method, c.path, c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != c.want {
|
||||||
|
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the rule. An incident in that team is not the admin's
|
||||||
|
// to read, because administration is about accounts — and the last case
|
||||||
|
// above has just taken the admin back out of the membership.
|
||||||
|
var integration struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, path+"/integrations",
|
||||||
|
map[string]string{"name": "theirs alertmanager"}), &integration)
|
||||||
|
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
|
||||||
|
|
||||||
|
var incidents []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
|
||||||
|
if len(incidents) != 0 {
|
||||||
|
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The team page at /admin/teams/{id} needs the one question the test above
|
||||||
|
// leaves shut: who is in a team the administrator is not in.
|
||||||
|
//
|
||||||
|
// It is answered by a separate endpoint under AdminOnly rather than by letting
|
||||||
|
// the admin flag through requireTeamMember, and the second half of this test is
|
||||||
|
// the reason — /api/teams/{id}/members must keep answering 404, so that "member
|
||||||
|
// means membership and nothing else" stays true of the endpoint it was said
|
||||||
|
// about. Reading a team's shape and reading a team's work are different things.
|
||||||
|
func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
founderID, call := member(t, s, "founder")
|
||||||
|
var team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||||
|
if team.ID == 0 {
|
||||||
|
t.Fatal("no team was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin reads it whole, without being in it.
|
||||||
|
var got struct {
|
||||||
|
Team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Members int64 `json:"members"`
|
||||||
|
OpenIncidents int64 `json:"open_incidents"`
|
||||||
|
} `json:"team"`
|
||||||
|
Members []struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"members"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
|
||||||
|
|
||||||
|
if got.Team.ID != team.ID || got.Team.Name != "theirs" {
|
||||||
|
t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name)
|
||||||
|
}
|
||||||
|
if got.Team.Members != 1 {
|
||||||
|
t.Errorf("expected a member count of 1, got %d", got.Team.Members)
|
||||||
|
}
|
||||||
|
if len(got.Members) != 1 {
|
||||||
|
t.Fatalf("expected one member, got %d", len(got.Members))
|
||||||
|
}
|
||||||
|
if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" {
|
||||||
|
t.Errorf("expected founder (%d), got %q (%d)",
|
||||||
|
founderID, got.Members[0].Username, got.Members[0].UserID)
|
||||||
|
}
|
||||||
|
// Whoever creates a team owns it, and the page's role toggle depends on
|
||||||
|
// that being reported rather than assumed.
|
||||||
|
if got.Members[0].Role != "owner" {
|
||||||
|
t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule this endpoint exists in order not to break. Same admin, same
|
||||||
|
// team, the member-only endpoint: still not found.
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the new one is administration, not membership: being in the team is
|
||||||
|
// not enough.
|
||||||
|
resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound},
|
||||||
|
{"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodGet, c.path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != c.want {
|
||||||
|
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A team name is trimmed when it is created, and renaming had not been, so " "
|
||||||
|
// was a legal name to rename to and an illegal one to start with.
|
||||||
|
func TestRenameTeam_TrimsTheName(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
var team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team)
|
||||||
|
|
||||||
|
path := "/api/teams/" + id64(team.ID)
|
||||||
|
resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("a blank name must be refused, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Team struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"team"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
|
||||||
|
if got.Team.Name != "padded" {
|
||||||
|
t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin page's per-user view asks what somebody is in. Self or admin, like
|
||||||
|
// the rest of the per-user endpoints.
|
||||||
|
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "joiner")
|
||||||
|
path := "/api/users/" + id64(memberID) + "/teams"
|
||||||
|
|
||||||
|
// member() puts them in the default team, so both readings agree on one.
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
do func() *http.Response
|
||||||
|
}{
|
||||||
|
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
|
||||||
|
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
|
||||||
|
} {
|
||||||
|
var teams []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
decode(t, c.do(), &teams)
|
||||||
|
if len(teams) != 1 {
|
||||||
|
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
|
||||||
|
}
|
||||||
|
if teams[0].Role != "member" {
|
||||||
|
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's is not theirs to read.
|
||||||
|
otherID, _ := member(t, s, "nosy")
|
||||||
|
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user who does not exist is a 404 rather than an empty list, which is
|
||||||
|
// how the page tells "no teams" from "no such person".
|
||||||
|
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||||
|
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/me", nil), &me)
|
||||||
|
if !me.User.IsAdmin {
|
||||||
|
t.Error("the bootstrap user should be an administrator")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
var theirs struct {
|
||||||
|
User struct {
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodGet, "/api/me", nil), &theirs)
|
||||||
|
if theirs.User.IsAdmin {
|
||||||
|
t.Error("a created user should not be an administrator")
|
||||||
|
}
|
||||||
|
}
|
||||||
+411
-43
@@ -1,18 +1,43 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Values for alerts.resolution_source, recording why an alert left the firing
|
||||||
|
// state: a real Alertmanager notification, or inference by the sweeper.
|
||||||
|
const (
|
||||||
|
resolutionAlertmanager = "alertmanager"
|
||||||
|
resolutionExpiry = "expiry"
|
||||||
|
|
||||||
|
// resolutionDeadman marks a heartbeat the dead man's switch sweeper declared
|
||||||
|
// dead. Distinct from expiry because it is load-bearing, not just
|
||||||
|
// descriptive: it is the one resolution the ingest upsert will let a
|
||||||
|
// same-instance re-fire undo, so a switch that comes back can be heard.
|
||||||
|
resolutionDeadman = "deadman"
|
||||||
)
|
)
|
||||||
|
|
||||||
// amPayload mirrors the Alertmanager webhook v4 payload.
|
// amPayload mirrors the Alertmanager webhook v4 payload.
|
||||||
type amPayload struct {
|
type amPayload struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Alerts []amAlert `json:"alerts"`
|
|
||||||
|
// GroupKey and GroupLabels are how alerts get correlated into incidents.
|
||||||
|
// Alertmanager has already done the grouping work according to the group_by
|
||||||
|
// routing tree the operator configured, so we adopt its answer instead of
|
||||||
|
// inventing a second grouping scheme here.
|
||||||
|
GroupKey string `json:"groupKey"`
|
||||||
|
GroupLabels map[string]string `json:"groupLabels"`
|
||||||
|
|
||||||
|
Alerts []amAlert `json:"alerts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type amAlert struct {
|
type amAlert struct {
|
||||||
@@ -25,48 +50,391 @@ type amAlert struct {
|
|||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAlertmanagerWebhook(db *sql.DB) http.HandlerFunc {
|
// ingested records what actually happened to one alert of a payload, which is
|
||||||
|
// what decides whether an incident opens.
|
||||||
|
type ingested struct {
|
||||||
|
id int64
|
||||||
|
name string
|
||||||
|
firing bool
|
||||||
|
|
||||||
|
// newOccurrence marks an alert that transitioned *into* firing: a
|
||||||
|
// fingerprint we had never seen, a newer startsAt, or a resolved alert that
|
||||||
|
// started again. A repeat_interval re-send of an already-firing alert is
|
||||||
|
// none of these, which is what keeps a manually resolved incident closed.
|
||||||
|
newOccurrence bool
|
||||||
|
|
||||||
|
// justResolved marks the firing → resolved edge, worth a timeline entry.
|
||||||
|
justResolved bool
|
||||||
|
|
||||||
|
// deadman marks a heartbeat: an alert whose arrival means everything is
|
||||||
|
// fine. It is stored like any other alert — received_at is the heartbeat —
|
||||||
|
// but it never reaches an incident. Its absence is what opens one, which
|
||||||
|
// sweepDeadman decides later and elsewhere.
|
||||||
|
deadman bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIntegrationWebhook receives alerts on a team's own integration key.
|
||||||
|
// The key in the path is both the credential and the routing: it says who may
|
||||||
|
// post, and which team the alerts belong to.
|
||||||
|
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var payload amPayload
|
src, err := sourceForKey(r.Context(), db, chi.URLParam(r, "key"))
|
||||||
if err := decodeJSON(r, &payload); err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
if errors.Is(err, errUnknownIntegration) {
|
||||||
|
// 401 and not 404: the path is real, the key is not, and a
|
||||||
|
// sender misconfigured this way should say so in its own logs
|
||||||
|
// rather than believe it is delivering.
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unknown integration key"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
receiveWebhook(w, r, db, notify, src)
|
||||||
now := time.Now().Unix()
|
|
||||||
for _, a := range payload.Alerts {
|
|
||||||
name := a.Labels["alertname"]
|
|
||||||
labelsJSON, _ := json.Marshal(a.Labels)
|
|
||||||
annotationsJSON, _ := json.Marshal(a.Annotations)
|
|
||||||
|
|
||||||
// Alertmanager uses zero time ("0001-01-01T00:00:00Z") to mean "still firing".
|
|
||||||
var endsAtUnix *int64
|
|
||||||
if a.EndsAt.Year() > 1 {
|
|
||||||
t := a.EndsAt.Unix()
|
|
||||||
endsAtUnix = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := db.ExecContext(r.Context(), `
|
|
||||||
INSERT INTO alerts
|
|
||||||
(fingerprint, name, status, labels, annotations, starts_at, ends_at, generator_url, received_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
||||||
status = excluded.status,
|
|
||||||
labels = excluded.labels,
|
|
||||||
annotations = excluded.annotations,
|
|
||||||
ends_at = excluded.ends_at,
|
|
||||||
generator_url = excluded.generator_url,
|
|
||||||
received_at = excluded.received_at`,
|
|
||||||
a.Fingerprint, name, a.Status,
|
|
||||||
string(labelsJSON), string(annotationsJSON),
|
|
||||||
a.StartsAt.Unix(), endsAtUnix,
|
|
||||||
a.GeneratorURL, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("upsert alert %s: %v", a.Fingerprint, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
|
||||||
|
teamID := src.teamID
|
||||||
|
var payload amPayload
|
||||||
|
if err := decodeJSON(r, &payload); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertmanager retries anything that is not 2xx, and a retry of a payload
|
||||||
|
// we failed to store is more useful than an error it cannot act on — so
|
||||||
|
// failures are logged, not surfaced.
|
||||||
|
if err := ingest(r.Context(), db, notify, src, payload); err != nil {
|
||||||
|
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingest stores a payload's alerts and reconciles the incident for its group.
|
||||||
|
// The whole payload is one transaction: an incident that opened but whose alerts
|
||||||
|
// failed to link would be a work item nobody could act on.
|
||||||
|
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, src alertSource, payload amPayload) error {
|
||||||
|
teamID := src.teamID
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
// Which arriving alerts are heartbeats is the team's own answer, read
|
||||||
|
// inside the transaction so an owner editing it mid-payload cannot split
|
||||||
|
// one webhook across two interpretations.
|
||||||
|
deadman, err := deadmanSetForTeam(ctx, tx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, err := upsertAlerts(ctx, tx, deadman, src, payload.Alerts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// touched collects every incident this payload affected, so severity and the
|
||||||
|
// resolution cascade are recomputed once per incident at the end.
|
||||||
|
touched := map[int64]bool{}
|
||||||
|
|
||||||
|
incidentID, err := incidentForGroup(ctx, tx, notify, teamID, payload, accepted)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if incidentID != 0 {
|
||||||
|
touched[incidentID] = true
|
||||||
|
for _, a := range accepted {
|
||||||
|
if !a.firing || a.deadman {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := linkAlert(ctx, tx, incidentID, a.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range accepted {
|
||||||
|
if !a.justResolved || a.deadman {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := openIncidentForAlert(ctx, tx, a.id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
touched[id] = true
|
||||||
|
alertID := a.id
|
||||||
|
if err := logEvent(ctx, tx, id, evAlertResolved, nil, &alertID, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range touched {
|
||||||
|
if err := refreshSeverity(ctx, tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := resolveIfSettled(ctx, tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
|
||||||
|
// the ordering guard rejected are left out entirely.
|
||||||
|
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, src alertSource, alerts []amAlert) ([]ingested, error) {
|
||||||
|
teamID := src.teamID
|
||||||
|
now := time.Now().Unix()
|
||||||
|
accepted := make([]ingested, 0, len(alerts))
|
||||||
|
|
||||||
|
for _, a := range alerts {
|
||||||
|
name := a.Labels["alertname"]
|
||||||
|
labelsJSON, _ := json.Marshal(a.Labels)
|
||||||
|
annotationsJSON, _ := json.Marshal(a.Annotations)
|
||||||
|
|
||||||
|
// The stored state has to be read before the upsert overwrites it: it is
|
||||||
|
// the only way to tell a genuine new occurrence from a re-send.
|
||||||
|
var prevStatus string
|
||||||
|
var prevStartsAt int64
|
||||||
|
existed := true
|
||||||
|
switch err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
|
||||||
|
teamID, a.Fingerprint,
|
||||||
|
).Scan(&prevStatus, &prevStartsAt); {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
existed = false
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero time ("0001-01-01T00:00:00Z") means "no end known" — that is the
|
||||||
|
// convention of Alertmanager's ingest API. Outgoing notifications
|
||||||
|
// normally carry a real future endsAt instead, which is the watermark
|
||||||
|
// the sweeper uses to expire alerts that stop being refreshed.
|
||||||
|
var endsAtUnix *int64
|
||||||
|
if a.EndsAt.Year() > 1 {
|
||||||
|
t := a.EndsAt.Unix()
|
||||||
|
endsAtUnix = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolutionSource *string
|
||||||
|
if a.Status == "resolved" {
|
||||||
|
s := resolutionAlertmanager
|
||||||
|
resolutionSource = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// The WHERE clause discards payloads that describe an alert instance
|
||||||
|
// older than the stored one. Alertmanager retries failed notifications,
|
||||||
|
// so a stale firing retry can arrive after the resolved one; it carries
|
||||||
|
// the same startsAt, whereas a genuine re-fire carries a newer one.
|
||||||
|
// Within a single instance, resolution is terminal — with one exception.
|
||||||
|
//
|
||||||
|
// A resolution this server synthesised for a dead man's switch is not
|
||||||
|
// Alertmanager's word that the instance ended; it is our inference from
|
||||||
|
// silence. The heartbeat that proves us wrong carries the unchanged
|
||||||
|
// startsAt of an alert that never stopped firing, so without the
|
||||||
|
// exemption a switch could go dead exactly once and never be heard from
|
||||||
|
// again. Scoped to 'deadman' so no resolution anybody else wrote can be
|
||||||
|
// undone by a stale retry.
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO alerts
|
||||||
|
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
|
||||||
|
generator_url, received_at, resolution_source, integration_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12)
|
||||||
|
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
labels = excluded.labels,
|
||||||
|
annotations = excluded.annotations,
|
||||||
|
starts_at = excluded.starts_at,
|
||||||
|
ends_at = excluded.ends_at,
|
||||||
|
generator_url = excluded.generator_url,
|
||||||
|
-- Load-bearing: advancing received_at on every accepted
|
||||||
|
-- payload, re-sends included, is the documented liveness
|
||||||
|
-- heartbeat clients and the sweeper both read. Removing it
|
||||||
|
-- is a breaking API change — see models.Alert.ReceivedAt.
|
||||||
|
received_at = excluded.received_at,
|
||||||
|
resolution_source = excluded.resolution_source,
|
||||||
|
-- Last sender wins; see migration 010.
|
||||||
|
integration_id = excluded.integration_id,
|
||||||
|
-- A re-fire makes the alert current again, so it leaves the archive.
|
||||||
|
archived_at = CASE WHEN excluded.status = 'firing'
|
||||||
|
THEN NULL ELSE alerts.archived_at END
|
||||||
|
WHERE excluded.starts_at > alerts.starts_at
|
||||||
|
OR (excluded.starts_at = alerts.starts_at
|
||||||
|
AND (alerts.resolution_source = '`+resolutionDeadman+`'
|
||||||
|
OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`,
|
||||||
|
teamID, a.Fingerprint, name, a.Status,
|
||||||
|
string(labelsJSON), string(annotationsJSON),
|
||||||
|
a.StartsAt.Unix(), endsAtUnix,
|
||||||
|
a.GeneratorURL, now, resolutionSource, src.integrationID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
var curStatus string
|
||||||
|
var curStartsAt int64
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT id, status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
|
||||||
|
teamID, a.Fingerprint,
|
||||||
|
).Scan(&id, &curStatus, &curStartsAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upsert copies status and starts_at straight from the payload, so a
|
||||||
|
// row that does not match it is one the ordering guard rejected. A
|
||||||
|
// discarded payload describes a past instance and must not touch the
|
||||||
|
// incident state either.
|
||||||
|
if existed && (curStatus != a.Status || curStartsAt != a.StartsAt.Unix()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
firing := a.Status == "firing"
|
||||||
|
accepted = append(accepted, ingested{
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
firing: firing,
|
||||||
|
newOccurrence: firing && (!existed || a.StartsAt.Unix() > prevStartsAt || prevStatus == "resolved"),
|
||||||
|
justResolved: !firing && existed && prevStatus == "firing",
|
||||||
|
deadman: deadman.isDeadman(a.Labels),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentForGroup returns the open incident that this payload's firing alerts
|
||||||
|
// belong to, opening one if the group has none. It returns 0 when the payload
|
||||||
|
// warrants no incident at all.
|
||||||
|
//
|
||||||
|
// The rule that matters: a group with no open incident gets a new one only if
|
||||||
|
// something actually started firing. Without that, a manually resolved incident
|
||||||
|
// would reappear on the next repeat_interval re-send of an alert that never
|
||||||
|
// stopped, and manual resolution would be meaningless.
|
||||||
|
//
|
||||||
|
// Heartbeats do not count as anything here. A group of nothing but dead man's
|
||||||
|
// switch alerts opens no incident at all, and a mixed group gets an incident for
|
||||||
|
// its real alerts only.
|
||||||
|
func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, teamID int64, payload amPayload, accepted []ingested) (int64, error) {
|
||||||
|
var firstName string
|
||||||
|
anyFiring, anyNew := false, false
|
||||||
|
for _, a := range accepted {
|
||||||
|
if a.deadman {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.firing {
|
||||||
|
if !anyFiring {
|
||||||
|
firstName = a.name
|
||||||
|
}
|
||||||
|
anyFiring = true
|
||||||
|
}
|
||||||
|
if a.newOccurrence {
|
||||||
|
anyNew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyFiring {
|
||||||
|
// A payload of nothing but resolutions never opens an incident.
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
groupKey := payload.GroupKey
|
||||||
|
if groupKey == "" {
|
||||||
|
// Alertmanager always sends groupKey; a sender that does not still gets
|
||||||
|
// one incident per alert name rather than one giant shared incident.
|
||||||
|
groupKey = "groupless:" + firstName
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
switch err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
|
||||||
|
teamID, groupKey,
|
||||||
|
).Scan(&id); {
|
||||||
|
case err == nil:
|
||||||
|
return id, nil
|
||||||
|
case err != sql.ErrNoRows:
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !anyNew {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return openIncident(ctx, tx, notify, teamID, groupKey,
|
||||||
|
incidentTitle(payload.GroupLabels, firstName), payload.GroupLabels, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openIncident creates an incident and assigns it to whoever is on call today,
|
||||||
|
// which is the point at which the schedule stops being decorative.
|
||||||
|
//
|
||||||
|
// The one place an incident is born, for both of the things that can raise one:
|
||||||
|
// the webhook, inside its transaction, and the dead man's switch sweeper, inside
|
||||||
|
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
|
||||||
|
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
||||||
|
// one because its incidents have no members to derive it from.
|
||||||
|
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
||||||
|
onCall, err := currentOnCall(ctx, q, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
labelsJSON, _ := json.Marshal(groupLabels)
|
||||||
|
if groupLabels == nil {
|
||||||
|
labelsJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err = q.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
||||||
|
time.Now().Unix(), onCall).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if onCall != nil {
|
||||||
|
// On an "assigned" event user_id is the assignee, not the actor.
|
||||||
|
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue the page, but do not send it here: this runs inside the webhook's
|
||||||
|
// transaction, and an HTTP call would hold a connection open across a
|
||||||
|
// network round trip. The notifier picks the row up within a tick.
|
||||||
|
if err := enqueueOpened(ctx, q, notify, id, onCall); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// And start the escalation clock, if the team keeps one. In the same
|
||||||
|
// transaction, so an incident is never briefly open with nobody counting.
|
||||||
|
if err := startEscalation(ctx, q, id, teamID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkAlert adds an alert to an incident, emitting a timeline entry only the
|
||||||
|
// first time. Re-sends of an already-linked alert are silent.
|
||||||
|
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
|
||||||
|
res, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO incident_alerts (incident_id, alert_id, added_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (incident_id, alert_id) DO NOTHING`, incidentID, alertID, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, &alertID, nil)
|
||||||
|
}
|
||||||
|
|||||||
+50
-85
@@ -10,45 +10,67 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/yeniklas/terdut-server/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
|
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
|
||||||
// It LEFT JOINs users so acknowledged_by username is always available.
|
// The subquery resolves the alert's most recent incident: membership is kept in
|
||||||
|
// incident_alerts rather than as a column here, because one alert row is reused
|
||||||
|
// across occurrences and belongs to a different incident each time.
|
||||||
const alertSelectFrom = `
|
const alertSelectFrom = `
|
||||||
SELECT a.id, a.fingerprint, a.name, a.status,
|
SELECT a.id, a.team_id, t.name, a.fingerprint, a.name, a.status,
|
||||||
a.labels, a.annotations,
|
a.labels, a.annotations,
|
||||||
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
||||||
a.acknowledged_by, a.acknowledged_at, u.username
|
(SELECT ia.incident_id
|
||||||
|
FROM incident_alerts ia
|
||||||
|
JOIN incidents i ON i.id = ia.incident_id
|
||||||
|
WHERE ia.alert_id = a.id
|
||||||
|
ORDER BY i.triggered_at DESC, i.id DESC
|
||||||
|
LIMIT 1),
|
||||||
|
a.resolution_source, a.archived_at
|
||||||
FROM alerts a
|
FROM alerts a
|
||||||
LEFT JOIN users u ON u.id = a.acknowledged_by`
|
JOIN teams t ON t.id = a.team_id`
|
||||||
|
|
||||||
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
|
|
||||||
where := []string{}
|
where := []string{}
|
||||||
args := []any{}
|
args := &sqlArgs{}
|
||||||
|
|
||||||
|
where = append(where, "a.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
|
||||||
|
if team := q.Get("team_id"); team != "" {
|
||||||
|
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
|
||||||
|
where = append(where, "a.team_id = "+args.add(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if status := q.Get("status"); status != "" {
|
if status := q.Get("status"); status != "" {
|
||||||
where = append(where, "a.status = ?")
|
where = append(where, "a.status = "+args.add(status))
|
||||||
args = append(args, status)
|
|
||||||
}
|
}
|
||||||
if name := q.Get("name"); name != "" {
|
if name := q.Get("name"); name != "" {
|
||||||
where = append(where, "a.name = ?")
|
where = append(where, "a.name = "+args.add(name))
|
||||||
args = append(args, name)
|
|
||||||
}
|
}
|
||||||
|
if archived := q.Get("archived"); archived == "true" {
|
||||||
|
where = append(where, "a.archived_at IS NOT NULL")
|
||||||
|
} else {
|
||||||
|
where = append(where, "a.archived_at IS NULL")
|
||||||
|
}
|
||||||
|
if incidentID := q.Get("incident_id"); incidentID != "" {
|
||||||
|
if n, err := strconv.ParseInt(incidentID, 10, 64); err == nil {
|
||||||
|
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = "+args.add(n)+")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if from := q.Get("from"); from != "" {
|
if from := q.Get("from"); from != "" {
|
||||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||||
where = append(where, "a.received_at >= ?")
|
where = append(where, "a.received_at >= "+args.add(t.UTC().Unix()))
|
||||||
args = append(args, t.UTC().Unix())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if to := q.Get("to"); to != "" {
|
if to := q.Get("to"); to != "" {
|
||||||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||||
where = append(where, "a.received_at < ?")
|
where = append(where, "a.received_at < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
|
||||||
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,11 +85,10 @@ func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
|||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
clause = strings.Join(where, " AND ")
|
clause = strings.Join(where, " AND ")
|
||||||
}
|
}
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(r.Context(),
|
rows, err := db.QueryContext(r.Context(),
|
||||||
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause),
|
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT %s", alertSelectFrom, clause, args.add(limit)),
|
||||||
args...)
|
args.all()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -94,7 +115,7 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a, err := fetchAlert(r.Context(), db, id)
|
a, err := fetchAlert(r.Context(), db, id, callerTeamIDs(r.Context()))
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
respond(w, http.StatusNotFound, errResp("alert not found"))
|
||||||
return
|
return
|
||||||
@@ -107,57 +128,10 @@ func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAcknowledge(db *sql.DB) http.HandlerFunc {
|
// fetchAlert loads a single alert by ID using the shared query.
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
func fetchAlert(ctx context.Context, db *sql.DB, id int64, teamIDs []int64) (models.Alert, error) {
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
return scanAlert(db.QueryRowContext(ctx,
|
||||||
if err != nil {
|
alertSelectFrom+" WHERE a.id = $1 AND a.team_id = ANY($2)", id, teamIDs))
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, _ := userFromContext(r.Context())
|
|
||||||
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
|
||||||
"UPDATE alerts SET acknowledged_by = ?, acknowledged_at = ? WHERE id = ?",
|
|
||||||
user.ID, time.Now().Unix(), id)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a, _ := fetchAlert(r.Context(), db, id)
|
|
||||||
respond(w, http.StatusOK, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUnacknowledge(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
|
||||||
"UPDATE alerts SET acknowledged_by = NULL, acknowledged_at = NULL WHERE id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAlert loads a single alert by ID using the shared JOIN query.
|
|
||||||
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
|
|
||||||
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = ?", id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||||
@@ -169,33 +143,24 @@ func scanAlert(s scanner) (models.Alert, error) {
|
|||||||
var a models.Alert
|
var a models.Alert
|
||||||
var labelsJSON, annotationsJSON string
|
var labelsJSON, annotationsJSON string
|
||||||
var startsAtUnix, receivedAtUnix int64
|
var startsAtUnix, receivedAtUnix int64
|
||||||
var endsAtUnix, ackAtUnix *int64
|
var endsAtUnix, archivedAtUnix *int64
|
||||||
var ackByID *int64
|
|
||||||
var ackByUser *string
|
|
||||||
|
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
&a.ID, &a.Fingerprint, &a.Name, &a.Status,
|
&a.ID, &a.TeamID, &a.TeamName, &a.Fingerprint, &a.Name, &a.Status,
|
||||||
&labelsJSON, &annotationsJSON,
|
&labelsJSON, &annotationsJSON,
|
||||||
&startsAtUnix, &endsAtUnix,
|
&startsAtUnix, &endsAtUnix,
|
||||||
&a.GeneratorURL, &receivedAtUnix,
|
&a.GeneratorURL, &receivedAtUnix,
|
||||||
&ackByID, &ackAtUnix, &ackByUser,
|
&a.IncidentID,
|
||||||
|
&a.ResolutionSource, &archivedAtUnix,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck
|
json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck
|
||||||
json.Unmarshal([]byte(annotationsJSON), &a.Annotations) //nolint:errcheck
|
json.Unmarshal([]byte(annotationsJSON), &a.Annotations) //nolint:errcheck
|
||||||
a.StartsAt = time.Unix(startsAtUnix, 0).UTC()
|
a.StartsAt = time.Unix(startsAtUnix, 0).UTC()
|
||||||
a.ReceivedAt = time.Unix(receivedAtUnix, 0).UTC()
|
a.ReceivedAt = time.Unix(receivedAtUnix, 0).UTC()
|
||||||
if endsAtUnix != nil {
|
a.EndsAt = unixPtr(endsAtUnix)
|
||||||
t := time.Unix(*endsAtUnix, 0).UTC()
|
a.ArchivedAt = unixPtr(archivedAtUnix)
|
||||||
a.EndsAt = &t
|
|
||||||
}
|
|
||||||
if ackByID != nil {
|
|
||||||
t := time.Unix(*ackAtUnix, 0).UTC()
|
|
||||||
a.AcknowledgedByID = ackByID
|
|
||||||
a.AcknowledgedByUser = ackByUser
|
|
||||||
a.AcknowledgedAt = &t
|
|
||||||
}
|
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+606
-97
@@ -2,34 +2,66 @@ package api_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yeniklas/terdut-server/internal/api"
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
"github.com/yeniklas/terdut-server/internal/db"
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ts wraps httptest.Server with a pre-bootstrapped API key.
|
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
|
||||||
|
// tests can age rows directly — the sweeper's inputs are wall-clock timestamps.
|
||||||
type ts struct {
|
type ts struct {
|
||||||
*httptest.Server
|
*httptest.Server
|
||||||
key string
|
key string
|
||||||
|
// ingestKey is an integration key for the default team: the only way in
|
||||||
|
// since the unauthenticated webhook was removed, so the tests exercise the
|
||||||
|
// same path production does.
|
||||||
|
ingestKey string
|
||||||
|
db *sql.DB
|
||||||
|
notify api.NotifyConfig
|
||||||
|
deadman api.DeadmanConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTS(t *testing.T) *ts {
|
// newTS builds a server over a fresh database. Notifications are off
|
||||||
|
// unless a NotifyConfig is passed, so tests that predate them are unaffected.
|
||||||
|
// Dead man's switches are off too — see newDeadmanTS.
|
||||||
|
func newTS(t *testing.T, notify ...api.NotifyConfig) *ts {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := db.Open(":memory:")
|
var cfg api.NotifyConfig
|
||||||
if err != nil {
|
if len(notify) > 0 {
|
||||||
t.Fatalf("open db: %v", err)
|
cfg = notify[0]
|
||||||
}
|
}
|
||||||
if err := db.Migrate(database); err != nil {
|
return newDeadmanTS(t, api.DeadmanConfig{}, cfg)
|
||||||
t.Fatalf("migrate: %v", err)
|
}
|
||||||
|
|
||||||
|
// newDeadmanTS is newTS with the default team's dead man's switches configured.
|
||||||
|
func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyConfig) *ts {
|
||||||
|
t.Helper()
|
||||||
|
var cfg api.NotifyConfig
|
||||||
|
if len(notify) > 0 {
|
||||||
|
cfg = notify[0]
|
||||||
}
|
}
|
||||||
srv := httptest.NewServer(api.NewRouter(database))
|
return newTSWith(t, deadman, cfg, testConfig())
|
||||||
t.Cleanup(func() { srv.Close(); database.Close() })
|
}
|
||||||
|
|
||||||
|
// newTSWith is newDeadmanTS with the server's own configuration supplied, for
|
||||||
|
// tests of behaviour that config switches on, such as single sign-on.
|
||||||
|
func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, conf config.Config) *ts {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
database := newTestDB(t)
|
||||||
|
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||||
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
|
||||||
@@ -44,7 +76,89 @@ func newTS(t *testing.T) *ts {
|
|||||||
json.NewDecoder(resp.Body).Decode(&result)
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
key := result["api_key"].(map[string]any)["key"].(string)
|
key := result["api_key"].(map[string]any)["key"].(string)
|
||||||
|
|
||||||
return &ts{Server: srv, key: key}
|
s := &ts{Server: srv, key: key, db: database, notify: cfg, deadman: deadman}
|
||||||
|
|
||||||
|
var integration struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/integrations",
|
||||||
|
map[string]string{"name": "test"}), &integration)
|
||||||
|
if integration.Key == "" {
|
||||||
|
t.Fatal("no integration key was returned")
|
||||||
|
}
|
||||||
|
s.ingestKey = integration.Key
|
||||||
|
|
||||||
|
// Dead man's switches belong to a team now, so a test that wants them
|
||||||
|
// configures the default team the way an owner would.
|
||||||
|
if deadman.Timeout > 0 {
|
||||||
|
setTeamDeadman(t, s, deadman)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTeamDeadman gives the default team one switch per configured matcher, over
|
||||||
|
// the API, the way an owner would add them.
|
||||||
|
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
|
||||||
|
t.Helper()
|
||||||
|
for _, m := range cfg.Matchers {
|
||||||
|
parts := []string{"alertname=" + m.Name}
|
||||||
|
for k, v := range m.Labels {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(parts[1:])
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
|
||||||
|
"matcher": strings.Join(parts, ","),
|
||||||
|
"timeout_seconds": int64(cfg.Timeout.Seconds()),
|
||||||
|
"severity": cfg.Severity,
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("add a dead man's switch: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec runs a statement against the test database.
|
||||||
|
func (s *ts) exec(t *testing.T, query string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.db.Exec(query, args...); err != nil {
|
||||||
|
t.Fatalf("exec %q: %v", query, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertRow reads the sweeper-relevant columns of one alert straight from the DB.
|
||||||
|
func (s *ts) alertRow(t *testing.T, fingerprint string) (status string, source *string, archivedAt *int64) {
|
||||||
|
t.Helper()
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT status, resolution_source, archived_at FROM alerts WHERE fingerprint = $1",
|
||||||
|
fingerprint).Scan(&status, &source, &archivedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read alert %s: %v", fingerprint, err)
|
||||||
|
}
|
||||||
|
return status, source, archivedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertTimes reads the timestamp columns that make up the received_at contract.
|
||||||
|
func (s *ts) alertTimes(t *testing.T, fingerprint string) (startsAt, receivedAt int64) {
|
||||||
|
t.Helper()
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT starts_at, received_at FROM alerts WHERE fingerprint = $1",
|
||||||
|
fingerprint).Scan(&startsAt, &receivedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read alert times %s: %v", fingerprint, err)
|
||||||
|
}
|
||||||
|
return startsAt, receivedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertEndsAt reads the nullable ends_at column of one alert.
|
||||||
|
func (s *ts) alertEndsAt(t *testing.T, fingerprint string) *int64 {
|
||||||
|
t.Helper()
|
||||||
|
var endsAt *int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
"SELECT ends_at FROM alerts WHERE fingerprint = $1", fingerprint).Scan(&endsAt); err != nil {
|
||||||
|
t.Fatalf("read ends_at %s: %v", fingerprint, err)
|
||||||
|
}
|
||||||
|
return endsAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// req sends an authenticated request, optionally with a JSON body.
|
// req sends an authenticated request, optionally with a JSON body.
|
||||||
@@ -123,11 +237,25 @@ func TestBootstrap_SecondCallForbidden(t *testing.T) {
|
|||||||
// Alert upsert by fingerprint
|
// Alert upsert by fingerprint
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func postWebhook(t *testing.T, s *ts, alerts []map[string]any) {
|
// postWebhook sends an Alertmanager v4 payload. groupKey is optional: omitting
|
||||||
|
// it exercises the fallback for senders that do not group, which is what most of
|
||||||
|
// these tests want.
|
||||||
|
func postWebhook(t *testing.T, s *ts, alerts []map[string]any, groupKey ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
payload := map[string]any{"version": "4", "status": "firing", "alerts": alerts}
|
payload := map[string]any{"version": "4", "status": "firing", "alerts": alerts}
|
||||||
|
if len(groupKey) > 0 {
|
||||||
|
payload["groupKey"] = groupKey[0]
|
||||||
|
// Alertmanager groups by alertname by default, so the group labels echo
|
||||||
|
// the first alert's name.
|
||||||
|
if len(alerts) > 0 {
|
||||||
|
if labels, ok := alerts[0]["labels"].(map[string]string); ok {
|
||||||
|
payload["groupLabels"] = map[string]string{"alertname": labels["alertname"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
data, _ := json.Marshal(payload)
|
data, _ := json.Marshal(payload)
|
||||||
resp, err := http.Post(s.URL+"/api/alertmanager/webhook", "application/json", bytes.NewReader(data))
|
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("post webhook: %v", err)
|
t.Fatalf("post webhook: %v", err)
|
||||||
}
|
}
|
||||||
@@ -195,84 +323,6 @@ func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Alert acknowledge
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestAcknowledge(t *testing.T) {
|
|
||||||
s := newTS(t)
|
|
||||||
postWebhook(t, s, []map[string]any{{
|
|
||||||
"status": "firing", "labels": map[string]string{"alertname": "X"},
|
|
||||||
"annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z",
|
|
||||||
"endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "fp-ack",
|
|
||||||
}})
|
|
||||||
|
|
||||||
resp := s.req(t, http.MethodPost, "/api/alerts/1/acknowledge", nil)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("acknowledge returned %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
var alert map[string]any
|
|
||||||
decode(t, resp, &alert)
|
|
||||||
if alert["acknowledged_by"] == nil {
|
|
||||||
t.Error("expected acknowledged_by to be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear it.
|
|
||||||
resp = s.req(t, http.MethodDelete, "/api/alerts/1/acknowledge", nil)
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
|
||||||
t.Errorf("unacknowledge returned %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = s.req(t, http.MethodGet, "/api/alerts/1", nil)
|
|
||||||
var alert2 map[string]any
|
|
||||||
decode(t, resp, &alert2)
|
|
||||||
if alert2["acknowledged_by"] != nil {
|
|
||||||
t.Error("expected acknowledged_by to be cleared")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Comments — own-only deletion
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestComment_DeleteOwnOnly(t *testing.T) {
|
|
||||||
s := newTS(t)
|
|
||||||
postWebhook(t, s, []map[string]any{{
|
|
||||||
"status": "firing", "labels": map[string]string{"alertname": "Y"},
|
|
||||||
"annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z",
|
|
||||||
"endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "fp-comment",
|
|
||||||
}})
|
|
||||||
|
|
||||||
// Create a second user and their own key.
|
|
||||||
s.req(t, http.MethodPost, "/api/users",
|
|
||||||
map[string]string{"username": "alice", "email": "alice@test.com"})
|
|
||||||
keyResp := s.req(t, http.MethodPost, "/api/users/2/api-keys",
|
|
||||||
map[string]string{"name": "alice-key"})
|
|
||||||
var keyData map[string]any
|
|
||||||
decode(t, keyResp, &keyData)
|
|
||||||
aliceKey := keyData["key"].(string)
|
|
||||||
|
|
||||||
// Admin posts a comment.
|
|
||||||
s.req(t, http.MethodPost, "/api/alerts/1/comments",
|
|
||||||
map[string]string{"content": "admin note"})
|
|
||||||
|
|
||||||
// Alice tries to delete admin's comment (should 404).
|
|
||||||
req, _ := http.NewRequest(http.MethodDelete, s.URL+"/api/alerts/1/comments/1", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+aliceKey)
|
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
|
||||||
t.Errorf("expected 404 when deleting another user's comment, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin deletes own comment (should 204).
|
|
||||||
resp = s.req(t, http.MethodDelete, "/api/alerts/1/comments/1", nil)
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
|
||||||
t.Errorf("expected 204 when deleting own comment, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schedule conflict
|
// Schedule conflict
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -280,14 +330,14 @@ func TestComment_DeleteOwnOnly(t *testing.T) {
|
|||||||
func TestSchedule_ConflictOnSameDate(t *testing.T) {
|
func TestSchedule_ConflictOnSameDate(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|
||||||
first := s.req(t, http.MethodPost, "/api/schedule",
|
first := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||||
if first.StatusCode != http.StatusCreated {
|
if first.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("first assignment returned %d", first.StatusCode)
|
t.Fatalf("first assignment returned %d", first.StatusCode)
|
||||||
}
|
}
|
||||||
first.Body.Close()
|
first.Body.Close()
|
||||||
|
|
||||||
second := s.req(t, http.MethodPost, "/api/schedule",
|
second := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||||
if second.StatusCode != http.StatusConflict {
|
if second.StatusCode != http.StatusConflict {
|
||||||
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
|
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
|
||||||
@@ -299,11 +349,11 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
|||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|
||||||
// Claim 2026-06-10 first.
|
// Claim 2026-06-10 first.
|
||||||
s.req(t, http.MethodPost, "/api/schedule",
|
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close()
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close()
|
||||||
|
|
||||||
// Try to assign two dates in one request where the second conflicts.
|
// Try to assign two dates in one request where the second conflicts.
|
||||||
resp := s.req(t, http.MethodPost, "/api/schedule",
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}})
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}})
|
||||||
if resp.StatusCode != http.StatusConflict {
|
if resp.StatusCode != http.StatusConflict {
|
||||||
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
||||||
@@ -311,7 +361,7 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
// 2026-06-09 must NOT have been committed (transaction rolled back).
|
// 2026-06-09 must NOT have been committed (transaction rolled back).
|
||||||
listResp := s.req(t, http.MethodGet, "/api/schedule?from=2026-06-09&to=2026-06-09", nil)
|
listResp := s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-09&to=2026-06-09", nil)
|
||||||
var entries []any
|
var entries []any
|
||||||
decode(t, listResp, &entries)
|
decode(t, listResp, &entries)
|
||||||
if len(entries) != 0 {
|
if len(entries) != 0 {
|
||||||
@@ -319,6 +369,139 @@ func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule reassignment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// addUser creates a second person to hand a shift to. The bootstrap user is
|
||||||
|
// admin, id 1.
|
||||||
|
// addUser creates a user and puts them in the default team, because a user who
|
||||||
|
// is in no team can be paged by nobody and take no shift — which is the rule
|
||||||
|
// these tests exercise around, not the one they are testing.
|
||||||
|
func addUser(t *testing.T, s *ts, username string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]any{"username": username, "email": username + "@test.com"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatalf("create user returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &user)
|
||||||
|
|
||||||
|
member := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"})
|
||||||
|
defer member.Body.Close()
|
||||||
|
if member.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("add %s to the team returned %d", username, member.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleHolder reports who is on call for one date, or "" for nobody.
|
||||||
|
func scheduleHolder(t *testing.T, s *ts, date string) string {
|
||||||
|
t.Helper()
|
||||||
|
var entries []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from="+date+"&to="+date, nil), &entries)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return entries[0]["username"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking a day somebody else holds is possible, but only by asking for it.
|
||||||
|
func TestSchedule_ReplaceTakesAnAssignedDate(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
addUser(t, s, "alex")
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close()
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}, "replace": true})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("expected replace to succeed, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if got := scheduleHolder(t, s, "2026-06-01"); got != "alex" {
|
||||||
|
t.Errorf("expected alex to hold the day, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row, not two: two entries for a date would mean two people believing
|
||||||
|
// they are on call for it.
|
||||||
|
var entries []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("expected exactly one entry for the date, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A week where only some days are taken is the case that was impossible before:
|
||||||
|
// the free days and the taken ones have to land together.
|
||||||
|
func TestSchedule_ReplaceMixedWeek(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
addUser(t, s, "alex")
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-02", "2026-06-04"}}).Body.Close()
|
||||||
|
|
||||||
|
week := []string{"2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"}
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 2, "dates": week, "replace": true})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("expected the mixed week to succeed, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
for _, d := range week {
|
||||||
|
if got := scheduleHolder(t, s, d); got != "alex" {
|
||||||
|
t.Errorf("%s: expected alex, got %q", d, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without replace the guard stands: nobody loses a shift by accident.
|
||||||
|
func TestSchedule_ReplaceDefaultsOff(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
addUser(t, s, "alex")
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}).Body.Close()
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 2, "dates": []string{"2026-06-01"}})
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409 without replace, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if got := scheduleHolder(t, s, "2026-06-01"); got != "admin" {
|
||||||
|
t.Errorf("expected the original holder untouched, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace makes a repeated date idempotent rather than a conflict: the second
|
||||||
|
// pass clears what the first wrote and rewrites it. Worth pinning down, because
|
||||||
|
// the same input without replace is a 409.
|
||||||
|
func TestSchedule_ReplaceCollapsesRepeatedDates(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01", "2026-06-01"}, "replace": true})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("expected a repeated date to be accepted under replace, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
var entries []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/schedule?from=2026-06-01&to=2026-06-01", nil), &entries)
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("expected one entry for the repeated date, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stats
|
// Stats
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -357,6 +540,332 @@ func TestStats_ByHourReturnsTwentyFourSlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Archive
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Alert archiving is sweeper-only housekeeping now — nobody archives an alert by
|
||||||
|
// hand — but the list filter it drives is still part of the API.
|
||||||
|
func TestArchive_AlertListFilter(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{{
|
||||||
|
"status": "resolved", "fingerprint": "arch1",
|
||||||
|
"labels": map[string]string{"alertname": "Archivable"},
|
||||||
|
"annotations": map[string]string{},
|
||||||
|
"startsAt": "2026-05-20T10:00:00Z",
|
||||||
|
"endsAt": "2026-05-20T11:00:00Z",
|
||||||
|
"generatorURL": "",
|
||||||
|
}})
|
||||||
|
|
||||||
|
// 1. Alert appears in the default list.
|
||||||
|
var alerts []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||||
|
if len(alerts) != 1 {
|
||||||
|
t.Fatalf("expected 1 alert in default list, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Let the sweeper archive it: ends_at is already well past archiveAfter.
|
||||||
|
api.Sweep(context.Background(), s.db, time.Hour, 6*time.Hour, s.notify)
|
||||||
|
|
||||||
|
// 3. Default list excludes it.
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||||
|
if len(alerts) != 0 {
|
||||||
|
t.Errorf("expected archived alert to be hidden, got %d results", len(alerts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. archived=true shows it.
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts?archived=true", nil), &alerts)
|
||||||
|
if len(alerts) != 1 {
|
||||||
|
t.Fatalf("expected 1 archived alert, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stale-alert expiry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// noArchive is long enough that archiving never interferes with expiry tests.
|
||||||
|
const noArchive = 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// zeroTime is Alertmanager's "no end known" sentinel, which stores ends_at NULL.
|
||||||
|
const zeroTime = "0001-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
// postAlert sends a single-alert webhook.
|
||||||
|
func postAlert(t *testing.T, s *ts, fingerprint, status, startsAt, endsAt string) {
|
||||||
|
t.Helper()
|
||||||
|
postWebhook(t, s, []map[string]any{{
|
||||||
|
"status": status,
|
||||||
|
"labels": map[string]string{"alertname": "Stale"},
|
||||||
|
"annotations": map[string]string{},
|
||||||
|
"startsAt": startsAt,
|
||||||
|
"endsAt": endsAt,
|
||||||
|
"generatorURL": "",
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sweep(t *testing.T, s *ts, staleAfter time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
api.Sweep(context.Background(), s.db, noArchive, staleAfter, s.notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A firing alert Alertmanager stopped refreshing is resolved via the
|
||||||
|
// received_at heartbeat, even with no ends_at watermark to go on.
|
||||||
|
func TestExpiry_StaleFiringAlert(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postAlert(t, s, "stale1", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
|
||||||
|
|
||||||
|
// Age the last-seen timestamp past the staleness window.
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'stale1'",
|
||||||
|
time.Now().Add(-10*time.Hour).Unix())
|
||||||
|
|
||||||
|
sweep(t, s, 6*time.Hour)
|
||||||
|
|
||||||
|
status, source, _ := s.alertRow(t, "stale1")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("expected status resolved, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "expiry" {
|
||||||
|
t.Errorf("expected resolution_source=expiry, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh webhook whose ends_at watermark has already passed is expired without
|
||||||
|
// waiting out the full staleness window.
|
||||||
|
func TestExpiry_PastEndsAt(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postAlert(t, s, "stale2", "firing",
|
||||||
|
time.Now().Add(-2*time.Hour).Format(time.RFC3339),
|
||||||
|
time.Now().Add(-30*time.Minute).Format(time.RFC3339))
|
||||||
|
|
||||||
|
sweep(t, s, 6*time.Hour) // received_at is fresh; only ends_at can trigger
|
||||||
|
|
||||||
|
status, source, _ := s.alertRow(t, "stale2")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("expected status resolved, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "expiry" {
|
||||||
|
t.Errorf("expected resolution_source=expiry, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regression that matters most: a genuinely firing alert must survive a
|
||||||
|
// sweep untouched.
|
||||||
|
func TestExpiry_LeavesFreshAlertsAlone(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postAlert(t, s, "fresh1", "firing",
|
||||||
|
time.Now().Add(-10*time.Minute).Format(time.RFC3339),
|
||||||
|
time.Now().Add(1*time.Hour).Format(time.RFC3339))
|
||||||
|
|
||||||
|
sweep(t, s, 6*time.Hour)
|
||||||
|
|
||||||
|
status, source, _ := s.alertRow(t, "fresh1")
|
||||||
|
if status != "firing" {
|
||||||
|
t.Errorf("expected fresh alert to stay firing, got %q", status)
|
||||||
|
}
|
||||||
|
if source != nil {
|
||||||
|
t.Errorf("expected no resolution_source, got %q", *source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ends_at only just past must not trip expiry — that grace absorbs clock skew.
|
||||||
|
func TestExpiry_RespectsGraceOnEndsAt(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postAlert(t, s, "grace1", "firing",
|
||||||
|
time.Now().Add(-time.Hour).Format(time.RFC3339),
|
||||||
|
time.Now().Add(-1*time.Minute).Format(time.RFC3339))
|
||||||
|
|
||||||
|
sweep(t, s, 6*time.Hour)
|
||||||
|
|
||||||
|
if status, _, _ := s.alertRow(t, "grace1"); status != "firing" {
|
||||||
|
t.Errorf("expected alert within grace period to stay firing, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhook resolution bookkeeping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWebhook_ResolvedSetsSource(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
start := time.Now().Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
postAlert(t, s, "src1", "firing", start, zeroTime)
|
||||||
|
|
||||||
|
if _, source, _ := s.alertRow(t, "src1"); source != nil {
|
||||||
|
t.Errorf("expected firing alert to have no resolution_source, got %q", *source)
|
||||||
|
}
|
||||||
|
|
||||||
|
postAlert(t, s, "src1", "resolved", start, time.Now().Format(time.RFC3339))
|
||||||
|
|
||||||
|
status, source, _ := s.alertRow(t, "src1")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("expected status resolved, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "alertmanager" {
|
||||||
|
t.Errorf("expected resolution_source=alertmanager, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A re-fire under the same fingerprint must leave the archive and clear the
|
||||||
|
// stale expiry marker, otherwise the alert stays invisible in the default list.
|
||||||
|
func TestWebhook_RefireUnarchivesAndClearsSource(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postAlert(t, s, "refire1", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
|
||||||
|
|
||||||
|
// Expire it, then archive it.
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'refire1'",
|
||||||
|
time.Now().Add(-10*time.Hour).Unix())
|
||||||
|
sweep(t, s, 6*time.Hour)
|
||||||
|
s.exec(t, "UPDATE alerts SET archived_at = FLOOR(EXTRACT(EPOCH FROM now()))::bigint WHERE fingerprint = 'refire1'")
|
||||||
|
|
||||||
|
var alerts []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||||
|
if len(alerts) != 0 {
|
||||||
|
t.Fatalf("expected archived alert to be hidden, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires again: a new alert instance, so a newer startsAt.
|
||||||
|
postAlert(t, s, "refire1", "firing", time.Now().Format(time.RFC3339), zeroTime)
|
||||||
|
|
||||||
|
status, source, archivedAt := s.alertRow(t, "refire1")
|
||||||
|
if status != "firing" {
|
||||||
|
t.Errorf("expected status firing after re-fire, got %q", status)
|
||||||
|
}
|
||||||
|
if source != nil {
|
||||||
|
t.Errorf("expected resolution_source cleared on re-fire, got %q", *source)
|
||||||
|
}
|
||||||
|
if archivedAt != nil {
|
||||||
|
t.Errorf("expected archived_at cleared on re-fire, got %d", *archivedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||||
|
if len(alerts) != 1 {
|
||||||
|
t.Errorf("expected re-fired alert back in default list, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertmanager retries failed notifications, so a firing payload for an
|
||||||
|
// already-resolved instance can arrive late. It must not resurrect the alert.
|
||||||
|
func TestWebhook_IgnoresOutOfOrderRetry(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
start := time.Now().Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
end := time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
postAlert(t, s, "ooo1", "firing", start, zeroTime)
|
||||||
|
postAlert(t, s, "ooo1", "resolved", start, end)
|
||||||
|
postAlert(t, s, "ooo1", "firing", start, zeroTime) // stale retry, same instance
|
||||||
|
|
||||||
|
status, source, _ := s.alertRow(t, "ooo1")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("expected alert to stay resolved after stale retry, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "alertmanager" {
|
||||||
|
t.Errorf("expected resolution_source=alertmanager, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expiry resolve writes ends_at as an upper bound, not an observed end: an
|
||||||
|
// Alertmanager watermark already on the row is preserved, and a row that never
|
||||||
|
// carried one is stamped at sweep time. Clients are told to read it that way —
|
||||||
|
// see "resolution_source says how much to trust ends_at" in the README.
|
||||||
|
func TestExpiry_EndsAtIsUpperBound(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// No watermark: expires on the received_at heartbeat, so the sweeper has
|
||||||
|
// nothing to go on but its own clock.
|
||||||
|
postAlert(t, s, "ub-none", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'ub-none'",
|
||||||
|
time.Now().Add(-10*time.Hour).Unix())
|
||||||
|
|
||||||
|
// Stale watermark: expires on the ends_at branch, and that reported time
|
||||||
|
// must survive the resolve rather than be overwritten with sweep time.
|
||||||
|
watermark := time.Now().Add(-90 * time.Minute).Truncate(time.Second)
|
||||||
|
postAlert(t, s, "ub-mark", "firing",
|
||||||
|
time.Now().Add(-3*time.Hour).Format(time.RFC3339), watermark.Format(time.RFC3339))
|
||||||
|
|
||||||
|
sweep(t, s, 6*time.Hour)
|
||||||
|
|
||||||
|
if _, source, _ := s.alertRow(t, "ub-none"); source == nil || *source != "expiry" {
|
||||||
|
t.Fatalf("expected resolution_source=expiry for heartbeat expiry, got %v", source)
|
||||||
|
}
|
||||||
|
stamped := s.alertEndsAt(t, "ub-none")
|
||||||
|
if stamped == nil {
|
||||||
|
t.Fatal("expected expiry to stamp ends_at when no watermark was known")
|
||||||
|
}
|
||||||
|
if skew := time.Now().Unix() - *stamped; skew < 0 || skew > 5 {
|
||||||
|
t.Errorf("expected stamped ends_at at sweep time, off by %ds", skew)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, source, _ := s.alertRow(t, "ub-mark"); source == nil || *source != "expiry" {
|
||||||
|
t.Fatalf("expected resolution_source=expiry for watermark expiry, got %v", source)
|
||||||
|
}
|
||||||
|
switch kept := s.alertEndsAt(t, "ub-mark"); {
|
||||||
|
case kept == nil:
|
||||||
|
t.Errorf("expected reported watermark %d preserved, got NULL", watermark.Unix())
|
||||||
|
case *kept != watermark.Unix():
|
||||||
|
t.Errorf("expected reported watermark %d preserved, got %d", watermark.Unix(), *kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// received_at heartbeat contract
|
||||||
|
//
|
||||||
|
// received_at is documented as a public liveness signal, so these lock the
|
||||||
|
// behaviour clients are told they may rely on. See "received_at is a liveness
|
||||||
|
// heartbeat" in the README and the comment on models.Alert.ReceivedAt.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The heartbeat itself: an unchanged firing notification — what Alertmanager
|
||||||
|
// re-sends every repeat_interval — must advance received_at, while leaving
|
||||||
|
// starts_at, which identifies the alert instance, untouched.
|
||||||
|
func TestWebhook_ResendBumpsReceivedAt(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
start := time.Now().Add(-24 * time.Hour).Format(time.RFC3339)
|
||||||
|
postAlert(t, s, "beat1", "firing", start, zeroTime)
|
||||||
|
|
||||||
|
startsBefore, _ := s.alertTimes(t, "beat1")
|
||||||
|
|
||||||
|
// received_at has one-second granularity, so back-date it to make the bump
|
||||||
|
// observable instead of sleeping out a second.
|
||||||
|
aged := time.Now().Add(-2 * time.Hour).Unix()
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'beat1'", aged)
|
||||||
|
|
||||||
|
// Identical re-send: same fingerprint, same startsAt, still firing.
|
||||||
|
postAlert(t, s, "beat1", "firing", start, zeroTime)
|
||||||
|
|
||||||
|
startsAfter, receivedAfter := s.alertTimes(t, "beat1")
|
||||||
|
if receivedAfter <= aged {
|
||||||
|
t.Errorf("expected re-send to advance received_at past %d, got %d", aged, receivedAfter)
|
||||||
|
}
|
||||||
|
if skew := time.Now().Unix() - receivedAfter; skew < 0 || skew > 5 {
|
||||||
|
t.Errorf("expected received_at to track the server clock, off by %ds", skew)
|
||||||
|
}
|
||||||
|
if startsAfter != startsBefore {
|
||||||
|
t.Errorf("expected starts_at unchanged by re-send, got %d want %d", startsAfter, startsBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// received_at tracks accepted payloads, not delivery attempts: a retry
|
||||||
|
// describing an already-resolved instance is discarded, so it must not register
|
||||||
|
// as a heartbeat and revive the alert's apparent liveness.
|
||||||
|
func TestWebhook_DiscardedRetryLeavesReceivedAtAlone(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
start := time.Now().Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
postAlert(t, s, "beat2", "firing", start, zeroTime)
|
||||||
|
postAlert(t, s, "beat2", "resolved", start, time.Now().Format(time.RFC3339))
|
||||||
|
|
||||||
|
aged := time.Now().Add(-2 * time.Hour).Unix()
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'beat2'", aged)
|
||||||
|
|
||||||
|
postAlert(t, s, "beat2", "firing", start, zeroTime) // stale retry, discarded
|
||||||
|
|
||||||
|
if _, receivedAfter := s.alertTimes(t, "beat2"); receivedAfter != aged {
|
||||||
|
t.Errorf("expected discarded retry to leave received_at at %d, got %d", aged, receivedAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStats_ByDayReturnsSevenSlots(t *testing.T) {
|
func TestStats_ByDayReturnsSevenSlots(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
resp := s.req(t, http.MethodGet, "/api/stats/alerts/by-day", nil)
|
resp := s.req(t, http.MethodGet, "/api/stats/alerts/by-day", nil)
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sweepInterval is how often the background sweeper runs.
|
||||||
|
sweepInterval = 15 * time.Minute
|
||||||
|
|
||||||
|
// expiryGrace absorbs clock skew and notification latency before an alert
|
||||||
|
// whose ends_at watermark has passed is treated as stale.
|
||||||
|
expiryGrace = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||||
|
// immediate pass so a restart reconciles state right away.
|
||||||
|
// archiveAfter and staleAfter are the values the server started with. They are
|
||||||
|
// the fallback, not the setting: each pass reads the current value from the
|
||||||
|
// settings table, so an administrator's change takes effect on the next tick
|
||||||
|
// instead of at the next restart.
|
||||||
|
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||||
|
ticker := time.NewTicker(sweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep runs a single pass, in dependency order: reconcile the dead man's
|
||||||
|
// switches, expire stale firing alerts, close the incidents that leaves with
|
||||||
|
// nothing firing, then archive whatever has been settled long enough. Running
|
||||||
|
// them in one pass means an alert can go stale and its incident can close and
|
||||||
|
// archive without waiting three ticks.
|
||||||
|
//
|
||||||
|
// The switches go first because they hand expireStale the alerts it must not
|
||||||
|
// touch: a heartbeat answers to its own, much tighter, timeout, and the generic
|
||||||
|
// staleness rules would otherwise resolve it as 'expiry' long before that.
|
||||||
|
// Exported so tests can drive a pass without waiting on the ticker.
|
||||||
|
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||||
|
settings := NewSettings(db)
|
||||||
|
staleAfter = settings.Duration(ctx, SettingStaleAfter, staleAfter)
|
||||||
|
archiveAfter = settings.Duration(ctx, SettingArchiveAfter, archiveAfter)
|
||||||
|
|
||||||
|
heartbeats := sweepDeadman(ctx, db, notify)
|
||||||
|
expireStale(ctx, db, staleAfter, heartbeats)
|
||||||
|
resolveSettledIncidents(ctx, db)
|
||||||
|
archiveResolved(ctx, db, archiveAfter)
|
||||||
|
archiveResolvedIncidents(ctx, db, archiveAfter)
|
||||||
|
purgeAckTokens(ctx, db)
|
||||||
|
purgeSessions(ctx, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireStale resolves firing alerts that Alertmanager has stopped refreshing.
|
||||||
|
//
|
||||||
|
// A resolved webhook is otherwise the only way out of the firing state, so a
|
||||||
|
// notification that is dropped, silenced, or lost to a restart would pin the
|
||||||
|
// alert as firing forever. Two independent signals mark an alert stale:
|
||||||
|
//
|
||||||
|
// - ends_at, the "valid until" watermark Alertmanager sets on outgoing firing
|
||||||
|
// notifications, has passed (plus expiryGrace for clock skew). Absent on
|
||||||
|
// rows whose payload carried no ends_at, hence the second signal.
|
||||||
|
// - received_at is older than staleAfter. Alertmanager re-sends firing
|
||||||
|
// notifications every repeat_interval, making received_at a liveness
|
||||||
|
// heartbeat — provided staleAfter exceeds that interval.
|
||||||
|
//
|
||||||
|
// Alerts in skip are left alone: they are dead man's switch heartbeats, whose
|
||||||
|
// liveness sweepDeadman has already judged against a timeout of its own.
|
||||||
|
//
|
||||||
|
// The matching rows are collected before the update rather than updated in bulk,
|
||||||
|
// because each one owes its incident a timeline entry.
|
||||||
|
func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip map[int64]bool) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
found, err := staleAlertIDs(ctx, db, now, staleAfter)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sweeper: find stale: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(found))
|
||||||
|
for _, id := range found {
|
||||||
|
if !skip[id] {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := &sqlArgs{}
|
||||||
|
source := args.add(resolutionExpiry)
|
||||||
|
idList := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
idList[i] = id
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE alerts
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolution_source = `+source+`,
|
||||||
|
ends_at = COALESCE(ends_at, `+nowEpoch+`)
|
||||||
|
WHERE id IN (`+args.addList(idList)+`)`, args.all()...); err != nil {
|
||||||
|
log.Printf("sweeper: expire stale: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("sweeper: expired %d stale firing alert(s)", len(ids))
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
incidentID, err := openIncidentForAlert(ctx, db, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sweeper: incident for alert %d: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if incidentID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alertID := id
|
||||||
|
if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, &alertID, nil); err != nil {
|
||||||
|
log.Printf("sweeper: log expiry event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleAlertIDs reads the ids in one go and closes the cursor before the caller
|
||||||
|
// writes. Under SQLite's single connection an open read would have blocked the
|
||||||
|
// update outright; with a pool it is no longer a deadlock, but reading the set
|
||||||
|
// first still keeps the write off a cursor the same transaction is walking.
|
||||||
|
func staleAlertIDs(ctx context.Context, db *sql.DB, now time.Time, staleAfter time.Duration) ([]int64, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id FROM alerts
|
||||||
|
WHERE status = 'firing'
|
||||||
|
AND archived_at IS NULL
|
||||||
|
AND ((ends_at IS NOT NULL AND ends_at < $1) OR received_at < $2)`,
|
||||||
|
now.Add(-expiryGrace).Unix(), now.Add(-staleAfter).Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSettledIncidents closes incidents whose alerts have all stopped firing.
|
||||||
|
// This is the cascade from alerts up to the work item, and it is what turns an
|
||||||
|
// expiry into a closed incident rather than one that sits open forever.
|
||||||
|
func resolveSettledIncidents(ctx context.Context, db *sql.DB) {
|
||||||
|
ids, err := settledIncidentIDs(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sweeper: find settled incidents: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
ok, err := resolveIfSettled(ctx, db, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sweeper: resolve incident %d: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
resolved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolved > 0 {
|
||||||
|
log.Printf("sweeper: resolved %d settled incident(s)", resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settledIncidentIDs(ctx context.Context, db *sql.DB) ([]int64, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT i.id
|
||||||
|
FROM incidents i
|
||||||
|
WHERE i.resolved_at IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM incident_alerts ia WHERE ia.incident_id = i.id)
|
||||||
|
AND NOT EXISTS (SELECT 1
|
||||||
|
FROM incident_alerts ia
|
||||||
|
JOIN alerts a ON a.id = ia.alert_id
|
||||||
|
WHERE ia.incident_id = i.id
|
||||||
|
AND a.status = 'firing')`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveResolved hides resolved alerts that have been settled for archiveAfter.
|
||||||
|
func archiveResolved(ctx context.Context, db *sql.DB, archiveAfter time.Duration) {
|
||||||
|
cutoff := time.Now().Add(-archiveAfter).Unix()
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE alerts SET archived_at = `+nowEpoch+`
|
||||||
|
WHERE status = 'resolved'
|
||||||
|
AND archived_at IS NULL
|
||||||
|
AND COALESCE(ends_at, received_at) < $1`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("archiver: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
log.Printf("archiver: archived %d resolved alert(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveResolvedIncidents does the same for the work items, on the same clock.
|
||||||
|
func archiveResolvedIncidents(ctx context.Context, db *sql.DB, archiveAfter time.Duration) {
|
||||||
|
cutoff := time.Now().Add(-archiveAfter).Unix()
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE incidents SET archived_at = `+nowEpoch+`
|
||||||
|
WHERE resolved_at IS NOT NULL
|
||||||
|
AND archived_at IS NULL
|
||||||
|
AND resolved_at < $1`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("archiver: incidents: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
log.Printf("archiver: archived %d resolved incident(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sessionCookie carries a web UI session. It is HttpOnly, so page script
|
||||||
|
// never sees the token; the page learns who it is from GET /api/me.
|
||||||
|
sessionCookie = "terdut_session"
|
||||||
|
|
||||||
|
// sessionTTL is how long a session lives without being used. It slides, so
|
||||||
|
// a phone that opens the UI now and then stays signed in indefinitely.
|
||||||
|
sessionTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// sessionTouchEvery bounds how often a request may slide the expiry.
|
||||||
|
sessionTouchEvery = time.Hour
|
||||||
|
|
||||||
|
minPasswordLen = 10
|
||||||
|
// maxPasswordLen is bcrypt's limit; it rejects longer input outright.
|
||||||
|
maxPasswordLen = 72
|
||||||
|
|
||||||
|
loginWindow = 15 * time.Minute
|
||||||
|
loginMaxPerUser = 10
|
||||||
|
loginMaxPerAddr = 30
|
||||||
|
passwordHashCost = bcrypt.DefaultCost
|
||||||
|
)
|
||||||
|
|
||||||
|
// dummyHash is compared against when the username is unknown or has no
|
||||||
|
// password, so a failed login takes as long whichever way it failed.
|
||||||
|
var dummyHash = sync.OnceValue(func() []byte {
|
||||||
|
h, _ := bcrypt.GenerateFromPassword([]byte("terdut-dummy-password"), passwordHashCost)
|
||||||
|
return h
|
||||||
|
})
|
||||||
|
|
||||||
|
// loginLimiter counts failed logins in a fixed window, per username and per
|
||||||
|
// client address. The username limit is what stops guessing one account; the
|
||||||
|
// address limit is looser because every user behind the same gateway or NAT
|
||||||
|
// shares it.
|
||||||
|
type loginLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
failures map[string]*loginWindowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginWindowCount struct {
|
||||||
|
start time.Time
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoginLimiter() *loginLimiter {
|
||||||
|
return &loginLimiter{failures: map[string]*loginWindowCount{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) blocked(key string, max int) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
c, ok := l.failures[key]
|
||||||
|
if !ok || time.Since(c.start) > loginWindow {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c.n >= max
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) fail(keys ...string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
for k, c := range l.failures {
|
||||||
|
if now.Sub(c.start) > loginWindow {
|
||||||
|
delete(l.failures, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
c, ok := l.failures[key]
|
||||||
|
if !ok {
|
||||||
|
c = &loginWindowCount{start: now}
|
||||||
|
l.failures[key] = c
|
||||||
|
}
|
||||||
|
c.n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loginLimiter) clear(key string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.failures, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientAddr is the address a login is counted against. Behind the gateway
|
||||||
|
// RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used
|
||||||
|
// when present. It can be forged, but only to dodge the address limit; the
|
||||||
|
// per-username limit does not depend on it.
|
||||||
|
func clientAddr(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
first, _, _ := strings.Cut(xff, ",")
|
||||||
|
return strings.TrimSpace(first)
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieSecure decides the cookie's Secure flag. TLS terminates at the gateway,
|
||||||
|
// so the server usually sees plain HTTP; the public URL is what says whether
|
||||||
|
// browsers reach it over HTTPS.
|
||||||
|
func cookieSecure(publicURL string, r *http.Request) bool {
|
||||||
|
return strings.HasPrefix(publicURL, "https://") ||
|
||||||
|
r.TLS != nil ||
|
||||||
|
r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePassword returns a message for the client, or "" when acceptable.
|
||||||
|
func validatePassword(pw string) string {
|
||||||
|
switch {
|
||||||
|
case len(pw) < minPasswordLen:
|
||||||
|
return "password must be at least " + strconv.Itoa(minPasswordLen) + " characters"
|
||||||
|
case len(pw) > maxPasswordLen:
|
||||||
|
return "password must be at most " + strconv.Itoa(maxPasswordLen) + " bytes"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(pw string) (string, error) {
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte(pw), passwordHashCost)
|
||||||
|
return string(h), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession mints a session and sets the cookie. Shared by login and
|
||||||
|
// sign-up: somebody who has just chosen a password is signed in, rather than
|
||||||
|
// being sent to a form to type the same credential again.
|
||||||
|
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
|
||||||
|
return startSessionCapped(w, r, db, userID, publicURL, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSessionCapped is startSession with a hard ceiling on the session's life,
|
||||||
|
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
|
||||||
|
// login uses it: the login is the only moment the provider's groups are read, so
|
||||||
|
// a session that could outlive it indefinitely would keep access the provider
|
||||||
|
// has since taken away.
|
||||||
|
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
|
||||||
|
raw, tokenHash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
life := sessionTTL
|
||||||
|
var ceiling *int64
|
||||||
|
if maxAge > 0 {
|
||||||
|
c := now.Add(maxAge).Unix()
|
||||||
|
ceiling = &c
|
||||||
|
life = min(life, maxAge)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: raw,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(life.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cookieSecure(publicURL, r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin exchanges a username and password for a session cookie.
|
||||||
|
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(req.Username)
|
||||||
|
userKey := "user:" + strings.ToLower(username)
|
||||||
|
addrKey := "addr:" + clientAddr(r)
|
||||||
|
|
||||||
|
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||||
|
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
var hash sql.NullString
|
||||||
|
err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT id, password_hash FROM users WHERE username = $1", username,
|
||||||
|
).Scan(&userID, &hash)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := dummyHash()
|
||||||
|
if hash.Valid {
|
||||||
|
stored = []byte(hash.String)
|
||||||
|
}
|
||||||
|
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
|
||||||
|
if !match || !hash.Valid {
|
||||||
|
limiter.fail(userKey, addrKey)
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limiter.clear(userKey)
|
||||||
|
|
||||||
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fetchUser(r.Context(), db, userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, meResponse{User: user, HasPassword: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout ends the browser's session. It sits outside AuthMiddleware so
|
||||||
|
// that a browser holding an already-expired cookie can still clear it.
|
||||||
|
func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||||
|
crossOrigin := http.NewCrossOriginProtection()
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := crossOrigin.Check(r); err != nil {
|
||||||
|
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||||
|
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cookieSecure(publicURL, r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type meResponse struct {
|
||||||
|
User any `json:"user"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
|
||||||
|
// OnboardingDismissed is whether this person has put the first-run
|
||||||
|
// checklist away. Per user rather than per browser: somebody who finishes
|
||||||
|
// setting up on a laptop should not be nagged again on their phone.
|
||||||
|
OnboardingDismissed bool `json:"onboarding_dismissed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMe says who the caller is. The web UI calls it on load to decide
|
||||||
|
// between the login form and the app, since it cannot read its own cookie.
|
||||||
|
func handleMe(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
user, err := fetchUser(r.Context(), db, caller.ID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var hash sql.NullString
|
||||||
|
var dismissed *int64
|
||||||
|
db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||||
|
caller.ID).Scan(&hash, &dismissed)
|
||||||
|
respond(w, http.StatusOK, meResponse{
|
||||||
|
User: user,
|
||||||
|
HasPassword: hash.Valid,
|
||||||
|
OnboardingDismissed: dismissed != nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetPassword sets a user's web UI password.
|
||||||
|
//
|
||||||
|
// Changing your own password takes the current one, when there is one, so an
|
||||||
|
// unattended signed-in browser cannot be used to take the account over. Setting
|
||||||
|
// somebody else's is how an admin gives a user their first password, and is
|
||||||
|
// restricted to administrators: it hands over an account outright, without
|
||||||
|
// knowing the password it replaces.
|
||||||
|
//
|
||||||
|
// Every other session of the target is ended: a password change is what you
|
||||||
|
// do when you think someone else is signed in.
|
||||||
|
func handleSetPassword(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing sql.NullString
|
||||||
|
err = db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT password_hash FROM users WHERE id = $1", id).Scan(&existing)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
if caller.ID == id && existing.Valid &&
|
||||||
|
bcrypt.CompareHashAndPassword([]byte(existing.String), []byte(req.CurrentPassword)) != nil {
|
||||||
|
respond(w, http.StatusForbidden, errResp("current password is incorrect"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET password_hash = $1 WHERE id = $2", hash, id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM sessions WHERE user_id = $1 AND id != $2", id, keep); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeSessions deletes sessions that have expired, from the sweeper.
|
||||||
|
func purgeSessions(ctx context.Context, db *sql.DB) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
"DELETE FROM sessions WHERE expires_at < $1", time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sweeper: purge sessions: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
log.Printf("sweeper: purged %d expired session(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminPassword = "correct horse battery"
|
||||||
|
|
||||||
|
// browser is an HTTP client with its own cookie jar, standing in for one
|
||||||
|
// signed-in browser.
|
||||||
|
type browser struct {
|
||||||
|
*http.Client
|
||||||
|
base string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBrowser(t *testing.T, base string) *browser {
|
||||||
|
t.Helper()
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
return &browser{Client: &http.Client{Jar: jar}, base: base}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do sends a request the way the web UI's own fetch would: same-origin, with
|
||||||
|
// the cookie from the jar.
|
||||||
|
func (b *browser) do(t *testing.T, method, path string, body any, header ...string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, b.base+path, r)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
for i := 0; i+1 < len(header); i += 2 {
|
||||||
|
req.Header.Set(header[i], header[i+1])
|
||||||
|
}
|
||||||
|
resp, err := b.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *browser) login(t *testing.T, username, password string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
return b.do(t, http.MethodPost, "/api/login", map[string]string{"username": username, "password": password})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAdminPassword gives the bootstrapped admin a password over its API key.
|
||||||
|
func setAdminPassword(t *testing.T, s *ts) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/users/1/password", map[string]string{"password": adminPassword})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("set password: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedIn(t *testing.T, s *ts) *browser {
|
||||||
|
t.Helper()
|
||||||
|
setAdminPassword(t, s)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
resp := b.login(t, "admin", adminPassword)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("login: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(t *testing.T, resp *http.Response) int {
|
||||||
|
t.Helper()
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_SetsSessionCookie(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
setAdminPassword(t, s)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
|
||||||
|
resp := b.login(t, "admin", adminPassword)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("login: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var cookie *http.Cookie
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "terdut_session" {
|
||||||
|
cookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Fatalf("expected an HttpOnly, SameSite=Lax session cookie, got %+v", cookie)
|
||||||
|
}
|
||||||
|
if cookie.Secure {
|
||||||
|
t.Error("cookie is Secure on a plain-HTTP server with no https public URL")
|
||||||
|
}
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"user"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.Username != "admin" || !me.HasPassword {
|
||||||
|
t.Errorf("unexpected login response %+v", me)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_CookieAuthenticatesAPI(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := signedIn(t, s)
|
||||||
|
|
||||||
|
if code := status(t, b.do(t, http.MethodGet, "/api/incidents", nil)); code != http.StatusOK {
|
||||||
|
t.Errorf("GET /api/incidents with cookie: %d", code)
|
||||||
|
}
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/me", nil)
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.ID != 1 {
|
||||||
|
t.Errorf("/api/me returned user %d", me.User.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_SecureCookieBehindHTTPSPublicURL(t *testing.T) {
|
||||||
|
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
setAdminPassword(t, s)
|
||||||
|
resp := newBrowser(t, s.URL).login(t, "admin", adminPassword)
|
||||||
|
resp.Body.Close()
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "terdut_session" && !c.Secure {
|
||||||
|
t.Error("cookie should be Secure when the public URL is https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_WrongPasswordAndUnknownUser(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
setAdminPassword(t, s)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
|
||||||
|
if code := status(t, b.login(t, "admin", "not the password")); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("wrong password: %d", code)
|
||||||
|
}
|
||||||
|
if code := status(t, b.login(t, "nobody", adminPassword)); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("unknown user: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_UserWithoutPasswordCannotSignIn(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
// The empty password must not match a user that has none.
|
||||||
|
if code := status(t, b.login(t, "admin", "")); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("login without a password set: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_RateLimitedPerUsername(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
setAdminPassword(t, s)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
if code := status(t, b.login(t, "admin", "wrong")); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d: %d", i+1, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Even the right password is refused once the limit is reached.
|
||||||
|
resp := b.login(t, "admin", adminPassword)
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("expected 429, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.Header.Get("Retry-After") == "" {
|
||||||
|
t.Error("429 without Retry-After")
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_CrossOriginWriteRejected(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := signedIn(t, s)
|
||||||
|
|
||||||
|
code := status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil,
|
||||||
|
"Sec-Fetch-Site", "cross-site", "Origin", "https://evil.example"))
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("cross-origin POST with cookie: %d, want 403", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same request from the page itself gets through to the handler.
|
||||||
|
code = status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil))
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Errorf("same-origin POST with cookie: %d, want 404 from the handler", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_BearerIgnoresOriginChecks(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/incidents/999/acknowledge", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.key)
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := status(t, resp); code != http.StatusNotFound {
|
||||||
|
t.Errorf("Bearer request: %d, want 404 from the handler", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogout_EndsSession(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := signedIn(t, s)
|
||||||
|
|
||||||
|
if code := status(t, b.do(t, http.MethodPost, "/api/logout", nil)); code != http.StatusNoContent {
|
||||||
|
t.Fatalf("logout: %d", code)
|
||||||
|
}
|
||||||
|
if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("after logout: %d", code)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("%d session(s) left after logout", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_ExpiredIsRejected(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := signedIn(t, s)
|
||||||
|
s.exec(t, "UPDATE sessions SET expires_at = 1")
|
||||||
|
|
||||||
|
if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expired session: %d", code)
|
||||||
|
}
|
||||||
|
api.Sweep(t.Context(), s.db, 0, 0, api.NotifyConfig{})
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("sweep left %d expired session(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPassword_OwnNeedsCurrent(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := signedIn(t, s)
|
||||||
|
|
||||||
|
code := status(t, b.do(t, http.MethodPut, "/api/users/1/password",
|
||||||
|
map[string]string{"password": "a brand new secret", "current_password": "wrong"}))
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("wrong current password: %d", code)
|
||||||
|
}
|
||||||
|
code = status(t, b.do(t, http.MethodPut, "/api/users/1/password",
|
||||||
|
map[string]string{"password": "short", "current_password": adminPassword}))
|
||||||
|
if code != http.StatusBadRequest {
|
||||||
|
t.Errorf("too-short password: %d", code)
|
||||||
|
}
|
||||||
|
code = status(t, b.do(t, http.MethodPut, "/api/users/1/password",
|
||||||
|
map[string]string{"password": "a brand new secret", "current_password": adminPassword}))
|
||||||
|
if code != http.StatusNoContent {
|
||||||
|
t.Fatalf("change password: %d", code)
|
||||||
|
}
|
||||||
|
if code := status(t, newBrowser(t, s.URL).login(t, "admin", "a brand new secret")); code != http.StatusOK {
|
||||||
|
t.Errorf("login with the new password: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
phone := signedIn(t, s)
|
||||||
|
laptop := newBrowser(t, s.URL)
|
||||||
|
status(t, laptop.login(t, "admin", adminPassword))
|
||||||
|
|
||||||
|
code := status(t, phone.do(t, http.MethodPut, "/api/users/1/password",
|
||||||
|
map[string]string{"password": "a brand new secret", "current_password": adminPassword}))
|
||||||
|
if code != http.StatusNoContent {
|
||||||
|
t.Fatalf("change password: %d", code)
|
||||||
|
}
|
||||||
|
if code := status(t, phone.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusOK {
|
||||||
|
t.Errorf("the session that changed the password: %d", code)
|
||||||
|
}
|
||||||
|
if code := status(t, laptop.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("the other session: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrap_WithPassword(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
||||||
|
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := status(t, resp); code != http.StatusCreated {
|
||||||
|
t.Fatalf("bootstrap: %d", code)
|
||||||
|
}
|
||||||
|
if code := status(t, newBrowser(t, srv.URL).login(t, "admin", adminPassword)); code != http.StatusOK {
|
||||||
|
t.Errorf("login after bootstrap: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_UnknownAPIPathIsJSON404(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
resp, err := http.Get(s.URL + "/api/nope")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||||
|
t.Errorf("GET /api/nope: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_DeepLinkServesWebUI(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
resp, err := http.Get(s.URL + "/incidents/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
||||||
|
t.Errorf("GET /incidents/1: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if resp.Header.Get("Content-Security-Policy") == "" {
|
||||||
|
t.Error("web UI served without a CSP")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2, err := http.Get(s.URL + "/js/missing.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := status(t, resp2); code != http.StatusNotFound {
|
||||||
|
t.Errorf("missing asset: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/yeniklas/terdut-server/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func handleListComments(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
alertID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the alert exists.
|
|
||||||
var exists int
|
|
||||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM alerts WHERE id = ?", alertID).Scan(&exists); err != nil {
|
|
||||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
|
||||||
SELECT c.id, c.alert_id, c.user_id, u.username, c.content, c.created_at
|
|
||||||
FROM alert_comments c
|
|
||||||
JOIN users u ON u.id = c.user_id
|
|
||||||
WHERE c.alert_id = ?
|
|
||||||
ORDER BY c.created_at ASC`, alertID)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
comments := []models.Comment{}
|
|
||||||
for rows.Next() {
|
|
||||||
var c models.Comment
|
|
||||||
var ts int64
|
|
||||||
if err := rows.Scan(&c.ID, &c.AlertID, &c.UserID, &c.Username, &c.Content, &ts); err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.CreatedAt = time.Unix(ts, 0).UTC()
|
|
||||||
comments = append(comments, c)
|
|
||||||
}
|
|
||||||
respond(w, http.StatusOK, comments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCreateComment(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
alertID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Content == "" {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("content is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the alert exists.
|
|
||||||
var exists int
|
|
||||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM alerts WHERE id = ?", alertID).Scan(&exists); err != nil {
|
|
||||||
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, _ := userFromContext(r.Context())
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
|
||||||
"INSERT INTO alert_comments (alert_id, user_id, content) VALUES (?, ?, ?)",
|
|
||||||
alertID, user.ID, req.Content)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commentID, _ := res.LastInsertId()
|
|
||||||
|
|
||||||
comment := models.Comment{
|
|
||||||
ID: commentID,
|
|
||||||
AlertID: alertID,
|
|
||||||
UserID: user.ID,
|
|
||||||
Username: user.Username,
|
|
||||||
Content: req.Content,
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
respond(w, http.StatusCreated, comment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleDeleteComment(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
alertID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commentID, err := strconv.ParseInt(chi.URLParam(r, "commentID"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid comment id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, _ := userFromContext(r.Context())
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
|
||||||
"DELETE FROM alert_comments WHERE id = ? AND alert_id = ? AND user_id = ?",
|
|
||||||
commentID, alertID, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
respond(w, http.StatusNotFound, errResp("comment not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deadmanGroupPrefix namespaces the incidents this file opens. Alertmanager
|
||||||
|
// group keys always contain braces, so this can never collide with one, and the
|
||||||
|
// partial unique index on open group_key (see 008_incidents.sql) gives one open
|
||||||
|
// incident per switch for free.
|
||||||
|
const deadmanGroupPrefix = "deadman:"
|
||||||
|
|
||||||
|
// DeadmanMatcher selects the alerts that are heartbeats rather than problems.
|
||||||
|
// Every condition has to match, and Name — the alertname label — is mandatory:
|
||||||
|
// it is what lets the sweeper find candidate rows through alerts_name_idx
|
||||||
|
// instead of JSON-extracting labels from every row in the table.
|
||||||
|
type DeadmanMatcher struct {
|
||||||
|
Name string
|
||||||
|
Labels map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the matcher the way it was configured, which is also how it
|
||||||
|
// reads in an incident title.
|
||||||
|
func (m DeadmanMatcher) String() string {
|
||||||
|
if len(m.Labels) == 0 {
|
||||||
|
return m.Name
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(m.Labels))
|
||||||
|
for k, v := range m.Labels {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return m.Name + " (" + strings.Join(parts, ", ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// config renders the matcher in the form parseDeadmanMatcher reads, which is
|
||||||
|
// what a switch row stores: `alertname=Watchdog,cluster=prod`.
|
||||||
|
func (m DeadmanMatcher) config() string {
|
||||||
|
parts := make([]string, 0, len(m.Labels))
|
||||||
|
for k, v := range m.Labels {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(append([]string{"alertname=" + m.Name}, parts...), ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// matches reports whether an alert's labels satisfy every condition.
|
||||||
|
func (m DeadmanMatcher) matches(labels map[string]string) bool {
|
||||||
|
if labels["alertname"] != m.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range m.Labels {
|
||||||
|
if labels[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadmanConfig is the server-wide default a team's switches are seeded from:
|
||||||
|
// the environment's matchers, timeout and severity. Switches themselves are rows
|
||||||
|
// of a team's own — see DeadmanSwitch — and this is only how a fresh install
|
||||||
|
// starts out.
|
||||||
|
type DeadmanConfig struct {
|
||||||
|
Matchers []DeadmanMatcher
|
||||||
|
|
||||||
|
// Timeout is how long a matched alert may go without a refreshing webhook
|
||||||
|
// before it is declared dead. It must be shorter than Alertmanager's
|
||||||
|
// repeat_interval for the heartbeat's route, which is what refreshes it.
|
||||||
|
// Zero disables dead man's switch handling entirely.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// Severity is the severity every dead man's switch incident opens at. These
|
||||||
|
// incidents have no member alerts to derive one from, and the heartbeat's
|
||||||
|
// own severity label is meaningless — Watchdog ships as "none".
|
||||||
|
Severity string
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabled reports whether there is anything to watch.
|
||||||
|
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
|
||||||
|
|
||||||
|
// DeadmanSwitch inverts the handling of the alerts it matches: receiving one
|
||||||
|
// opens nothing, and the absence of one opens an incident.
|
||||||
|
//
|
||||||
|
// The unit of monitoring is the fingerprint, not the switch — two clusters
|
||||||
|
// sending the same heartbeat alertname are two independent heartbeats under one
|
||||||
|
// switch, so one healthy cluster cannot mask a dead one.
|
||||||
|
type DeadmanSwitch struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Matcher DeadmanMatcher
|
||||||
|
|
||||||
|
// Timeout is how long a heartbeat may go unheard before it is declared dead.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// Severity is what the incident opens at.
|
||||||
|
Severity string
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSet is one team's switches.
|
||||||
|
type deadmanSet []DeadmanSwitch
|
||||||
|
|
||||||
|
// match returns the first switch an alert satisfies.
|
||||||
|
func (d deadmanSet) match(labels map[string]string) (DeadmanSwitch, bool) {
|
||||||
|
for _, sw := range d {
|
||||||
|
if sw.Matcher.matches(labels) {
|
||||||
|
return sw, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DeadmanSwitch{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDeadman is match without the switch, for the ingest path.
|
||||||
|
func (d deadmanSet) isDeadman(labels map[string]string) bool {
|
||||||
|
_, ok := d.match(labels)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// names lists the distinct alertnames worth loading from the database.
|
||||||
|
func (d deadmanSet) names() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(d))
|
||||||
|
for _, sw := range d {
|
||||||
|
if !seen[sw.Matcher.Name] {
|
||||||
|
seen[sw.Matcher.Name] = true
|
||||||
|
out = append(out, sw.Matcher.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeadmanMatcher reads one matcher from its configured form: "," separates
|
||||||
|
// the conditions and "=" is exact label equality — `alertname=Watchdog,cluster=prod`.
|
||||||
|
// The error says what is wrong with it, in words a form can show.
|
||||||
|
func parseDeadmanMatcher(entry string) (DeadmanMatcher, error) {
|
||||||
|
m := DeadmanMatcher{Labels: map[string]string{}}
|
||||||
|
for _, cond := range strings.Split(strings.TrimSpace(entry), ",") {
|
||||||
|
k, v, ok := strings.Cut(cond, "=")
|
||||||
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||||
|
if !ok || k == "" || v == "" {
|
||||||
|
return DeadmanMatcher{}, fmt.Errorf("%q is not label=value", strings.TrimSpace(cond))
|
||||||
|
}
|
||||||
|
if k == "alertname" {
|
||||||
|
m.Name = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Labels[k] = v
|
||||||
|
}
|
||||||
|
if m.Name == "" {
|
||||||
|
return DeadmanMatcher{}, errors.New("no alertname condition")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDeadmanConfig reads the matcher list from its configured form:
|
||||||
|
// ";" separates matchers, and each is parsed as parseDeadmanMatcher does.
|
||||||
|
//
|
||||||
|
// A malformed or alertname-less entry is dropped rather than fatal, following
|
||||||
|
// config.duration's rule that one bad tuning knob should not take the server
|
||||||
|
// down. Silence would be worse here than elsewhere, though — a typo that
|
||||||
|
// disarms the switch is exactly the failure this feature exists to catch — so
|
||||||
|
// the matchers that survived are logged.
|
||||||
|
func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string) DeadmanConfig {
|
||||||
|
cfg := DeadmanConfig{Timeout: timeout, Severity: severity}
|
||||||
|
|
||||||
|
for _, entry := range strings.Split(matchers, ";") {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, err := parseDeadmanMatcher(entry)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deadman: ignoring matcher %q: %v", entry, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg.Matchers = append(cfg.Matchers, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case timeout <= 0:
|
||||||
|
log.Print("deadman: disabled (timeout is zero)")
|
||||||
|
case len(cfg.Matchers) == 0:
|
||||||
|
log.Print("deadman: disabled (no usable matchers)")
|
||||||
|
default:
|
||||||
|
rendered := make([]string, 0, len(cfg.Matchers))
|
||||||
|
for _, m := range cfg.Matchers {
|
||||||
|
rendered = append(rendered, m.String())
|
||||||
|
}
|
||||||
|
log.Printf("deadman: default for new teams: %s, timeout %s, severity %s",
|
||||||
|
strings.Join(rendered, "; "), timeout, severity)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanAlert is one heartbeat: the alert row carrying its last sighting, and
|
||||||
|
// the switch that claimed it.
|
||||||
|
type deadmanAlert struct {
|
||||||
|
id int64
|
||||||
|
teamID int64
|
||||||
|
fingerprint string
|
||||||
|
labels map[string]string
|
||||||
|
sw DeadmanSwitch
|
||||||
|
resolved bool
|
||||||
|
receivedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// dead is the one rule for a silent heartbeat, shared by the sweeper that pages
|
||||||
|
// on it and the status the Switches page shows, so the page cannot disagree
|
||||||
|
// with the pager.
|
||||||
|
//
|
||||||
|
// An explicit resolved from Alertmanager is a stronger death signal than mere
|
||||||
|
// absence: the sender is telling us the heartbeat stopped, so there is nothing
|
||||||
|
// left to wait out.
|
||||||
|
func (a deadmanAlert) dead(now time.Time) bool {
|
||||||
|
return a.resolved || a.receivedAt < now.Add(-a.sw.Timeout).Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupKey is the switch's identity as an incident. Per fingerprint, so each
|
||||||
|
// source is tracked on its own.
|
||||||
|
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
|
||||||
|
|
||||||
|
// sweepDeadman is the whole point of the feature: it opens an incident for every
|
||||||
|
// switch that has stopped chirping, and closes one whose switch came back.
|
||||||
|
//
|
||||||
|
// It returns the ids of the alerts it owns, because the generic staleness
|
||||||
|
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
|
||||||
|
// a heartbeat long before its own, much tighter, timeout ever fired.
|
||||||
|
// Each team is swept against its own switches, each with its own matcher,
|
||||||
|
// timeout and severity. A team watching nothing is skipped entirely, which is
|
||||||
|
// most of them.
|
||||||
|
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
|
||||||
|
owned := map[int64]bool{}
|
||||||
|
|
||||||
|
configs, err := deadmanSets(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deadman: load configs: %v", err)
|
||||||
|
return owned
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for teamID, cfg := range configs {
|
||||||
|
heartbeats, err := deadmanAlerts(ctx, db, teamID, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deadman: load heartbeats for team %d: %v", teamID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hb := range heartbeats {
|
||||||
|
owned[hb.id] = true
|
||||||
|
|
||||||
|
if hb.dead(now) {
|
||||||
|
if err := deadmanDied(ctx, db, notify, hb, now); err != nil {
|
||||||
|
log.Printf("deadman: open incident for %s: %v", hb.sw.Matcher.Name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := deadmanRecovered(ctx, db, hb); err != nil {
|
||||||
|
log.Printf("deadman: resolve incident for %s: %v", hb.sw.Matcher.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owned
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanAlerts loads every alert row that one of a team's switches claims. The candidate query
|
||||||
|
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
|
||||||
|
// happens in Go, which keeps one implementation of the rules. The rows are read
|
||||||
|
// in full before the caller writes, so the writes do not run against an open
|
||||||
|
// cursor over the same table.
|
||||||
|
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet) ([]deadmanAlert, error) {
|
||||||
|
names := cfg.names()
|
||||||
|
args := &sqlArgs{}
|
||||||
|
nameList := make([]any, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
nameList[i] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, team_id, fingerprint, labels, status, received_at
|
||||||
|
FROM alerts
|
||||||
|
WHERE team_id = `+args.add(teamID)+`
|
||||||
|
AND name IN (`+args.addList(nameList)+`)
|
||||||
|
AND archived_at IS NULL`, args.all()...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []deadmanAlert
|
||||||
|
for rows.Next() {
|
||||||
|
var a deadmanAlert
|
||||||
|
var labelsJSON, status string
|
||||||
|
if err := rows.Scan(&a.id, &a.teamID, &a.fingerprint, &labelsJSON, &status, &a.receivedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
|
||||||
|
|
||||||
|
sw, ok := cfg.match(a.labels)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.sw = sw
|
||||||
|
a.resolved = status == "resolved"
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanDied raises the incident for a switch that has gone quiet.
|
||||||
|
//
|
||||||
|
// Two conditions gate it, and both matter. There must be no open incident for
|
||||||
|
// the switch already — the partial unique index enforces that anyway, but a
|
||||||
|
// second one would be a wasted page. And the heartbeat must have been seen since
|
||||||
|
// the last incident was raised, which is the re-arm rule: resolving a dead man's
|
||||||
|
// switch incident sticks, exactly as resolving an alert-backed one does (see
|
||||||
|
// incidentForGroup), and a source that is gone for good is a one-time page
|
||||||
|
// rather than a nag. Only a heartbeat that comes back and dies again earns a new
|
||||||
|
// incident.
|
||||||
|
func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadmanAlert, now time.Time) error {
|
||||||
|
var lastTriggered, open int64
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(MAX(triggered_at), 0),
|
||||||
|
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
||||||
|
FROM incidents WHERE team_id = $1 AND group_key = $2`,
|
||||||
|
hb.teamID, hb.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if open > 0 || hb.receivedAt <= lastTriggered {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
// A heartbeat nobody has heard from is not firing, and saying otherwise in
|
||||||
|
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its
|
||||||
|
// own source: it told us the truth first.
|
||||||
|
if !hb.resolved {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE alerts
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolution_source = $1,
|
||||||
|
ends_at = COALESCE(ends_at, `+nowEpoch+`)
|
||||||
|
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, hb.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
severity := hb.sw.Severity
|
||||||
|
var sev *string
|
||||||
|
if severity != "" {
|
||||||
|
sev = &severity
|
||||||
|
}
|
||||||
|
|
||||||
|
// The incident opens in the team whose integration received the heartbeat:
|
||||||
|
// the switch belongs to whoever is watching that source, not to the install.
|
||||||
|
incidentID, err := openIncident(ctx, tx, notify, hb.teamID, hb.groupKey(),
|
||||||
|
"No heartbeat from "+hb.sw.Matcher.String(), hb.labels, sev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
alertID := hb.id
|
||||||
|
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
|
||||||
|
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("deadman: %s went silent, opened incident %d", hb.sw.Matcher.String(), incidentID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanRecovered closes the incident for a switch that started chirping again.
|
||||||
|
//
|
||||||
|
// It cannot go through resolveIfSettled: a dead man's switch incident has no
|
||||||
|
// member alerts (linking the heartbeat would have the settled-incident cascade
|
||||||
|
// close it on the very same sweep that opened it), so the alert-driven cascade
|
||||||
|
// ignores it entirely and recovery is the only automatic way out.
|
||||||
|
func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
|
||||||
|
var incidentID int64
|
||||||
|
switch err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM incidents
|
||||||
|
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
|
||||||
|
hb.teamID, hb.groupKey()).Scan(&incidentID); {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE incidents
|
||||||
|
SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
||||||
|
WHERE id = $3 AND resolved_at IS NULL`,
|
||||||
|
time.Now().Unix(), incidentResolutionRecovered, incidentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The all-clear goes to whoever was paged, which enqueueResolved works out
|
||||||
|
// from the incident's own notification history.
|
||||||
|
if err := enqueueResolved(ctx, tx, incidentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("deadman: %s is back, resolved incident %d", hb.sw.Matcher.String(), incidentID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A team's switches
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const deadmanSwitchColumns = "id, team_id, name, matcher, timeout_seconds, severity"
|
||||||
|
|
||||||
|
// scanDeadmanSwitches reads switch rows into per-team sets. A row whose matcher
|
||||||
|
// no longer parses is skipped rather than fatal: the API refuses to store one,
|
||||||
|
// so it can only mean a hand edit, and one bad row must not stop the others
|
||||||
|
// from being watched.
|
||||||
|
func scanDeadmanSwitches(rows *sql.Rows) (map[int64]deadmanSet, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[int64]deadmanSet{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sw DeadmanSwitch
|
||||||
|
var teamID, timeout int64
|
||||||
|
var matcher string
|
||||||
|
if err := rows.Scan(&sw.ID, &teamID, &sw.Name, &matcher, &timeout, &sw.Severity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m, err := parseDeadmanMatcher(matcher)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("deadman: switch %d has an unusable matcher %q: %v", sw.ID, matcher, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sw.Matcher = m
|
||||||
|
sw.Timeout = time.Duration(timeout) * time.Second
|
||||||
|
out[teamID] = append(out[teamID], sw)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSetForTeam reads one team's switches. A team with none gets an empty
|
||||||
|
// set — which is the right answer rather than an error: most teams watch no
|
||||||
|
// heartbeat at all.
|
||||||
|
func deadmanSetForTeam(ctx context.Context, q querier, teamID int64) (deadmanSet, error) {
|
||||||
|
rows, err := q.QueryContext(ctx,
|
||||||
|
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches WHERE team_id = $1 ORDER BY id", teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sets, err := scanDeadmanSwitches(rows)
|
||||||
|
return sets[teamID], err
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSets reads every team's switches in one query, for the sweeper.
|
||||||
|
func deadmanSets(ctx context.Context, db *sql.DB) (map[int64]deadmanSet, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanDeadmanSwitches(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSeededKey is the settings row that records the environment defaults
|
||||||
|
// were handed out. Without it, a team that deleted its last switch would get
|
||||||
|
// the default back on the next restart.
|
||||||
|
const deadmanSeededKey = "deadman_seeded"
|
||||||
|
|
||||||
|
// SeedDeadmanConfigs gives every team the server's environment defaults as
|
||||||
|
// switches, exactly once per install, so a fresh install watches Watchdog
|
||||||
|
// without anybody setting it up.
|
||||||
|
//
|
||||||
|
// Once seeded it never runs again: a team's switches are its own, and a redeploy
|
||||||
|
// must not quietly put the environment's value back over an owner's edit or
|
||||||
|
// deletion. Installs that upgraded from per-team configuration were already
|
||||||
|
// seeded, which migration 009 records.
|
||||||
|
//
|
||||||
|
// A team created after that gets none and watches nothing until its owner says
|
||||||
|
// otherwise. That is deliberate: inheriting an install-wide heartbeat would page
|
||||||
|
// a new team about a source it has never heard of, and a switch nobody chose is
|
||||||
|
// the kind that gets muted rather than fixed.
|
||||||
|
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
|
||||||
|
if !cfg.enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO settings (key, value) VALUES ($1, '1') ON CONFLICT (key) DO NOTHING",
|
||||||
|
deadmanSeededKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range cfg.Matchers {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||||
|
SELECT id, $1, $1, $2, $3 FROM teams`,
|
||||||
|
m.config(), int64(cfg.Timeout.Seconds()), cfg.Severity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const (
|
||||||
|
switchHealthy = "healthy"
|
||||||
|
switchDead = "dead"
|
||||||
|
switchDormant = "dormant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deadmanSource is one heartbeat under a switch: a fingerprint that matched.
|
||||||
|
type deadmanSource struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
|
||||||
|
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||||
|
IncidentID *int64 `json:"incident_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSwitchStatus is a switch as the Switches page shows it.
|
||||||
|
type deadmanSwitchStatus struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
|
||||||
|
// Status is dead when any source is, dormant when none has ever been heard
|
||||||
|
// from, healthy otherwise — a live cluster must not hide a dead one.
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
||||||
|
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||||
|
OpenIncidentID *int64 `json:"open_incident_id"`
|
||||||
|
Sources []deadmanSource `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanStatuses reports every switch of a team with what its heartbeats are
|
||||||
|
// doing. The liveness verdict is deadmanAlert.dead, the sweeper's own.
|
||||||
|
func deadmanStatuses(ctx context.Context, db *sql.DB, teamID int64, set deadmanSet, now time.Time) ([]deadmanSwitchStatus, error) {
|
||||||
|
out := make([]deadmanSwitchStatus, 0, len(set))
|
||||||
|
if len(set) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeats, err := deadmanAlerts(ctx, db, teamID, set)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query for every switch's incident history, keyed the way the sweeper
|
||||||
|
// keys it.
|
||||||
|
type history struct {
|
||||||
|
triggeredAt int64
|
||||||
|
openID int64
|
||||||
|
}
|
||||||
|
incidents := map[string]history{}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT group_key, MAX(triggered_at), COALESCE(MAX(id) FILTER (WHERE resolved_at IS NULL), 0)
|
||||||
|
FROM incidents
|
||||||
|
WHERE team_id = $1 AND group_key LIKE $2
|
||||||
|
GROUP BY group_key`, teamID, deadmanGroupPrefix+"%")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var h history
|
||||||
|
if err := rows.Scan(&key, &h.triggeredAt, &h.openID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
incidents[key] = h
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bySwitch := map[int64][]deadmanAlert{}
|
||||||
|
for _, hb := range heartbeats {
|
||||||
|
bySwitch[hb.sw.ID] = append(bySwitch[hb.sw.ID], hb)
|
||||||
|
}
|
||||||
|
|
||||||
|
later := func(cur *time.Time, unix int64) *time.Time {
|
||||||
|
t := time.Unix(unix, 0).UTC()
|
||||||
|
if cur == nil || t.After(*cur) {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sw := range set {
|
||||||
|
st := deadmanSwitchStatus{
|
||||||
|
ID: sw.ID, Name: sw.Name, Matcher: sw.Matcher.config(),
|
||||||
|
TimeoutSeconds: int64(sw.Timeout.Seconds()), Severity: sw.Severity,
|
||||||
|
Status: switchDormant, Sources: []deadmanSource{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hb := range bySwitch[sw.ID] {
|
||||||
|
src := deadmanSource{
|
||||||
|
Fingerprint: hb.fingerprint,
|
||||||
|
Labels: hb.labels,
|
||||||
|
Status: switchHealthy,
|
||||||
|
LastHeartbeatAt: time.Unix(hb.receivedAt, 0).UTC(),
|
||||||
|
}
|
||||||
|
if hb.dead(now) {
|
||||||
|
src.Status = switchDead
|
||||||
|
}
|
||||||
|
if h, ok := incidents[hb.groupKey()]; ok {
|
||||||
|
t := time.Unix(h.triggeredAt, 0).UTC()
|
||||||
|
src.LastTriggeredAt = &t
|
||||||
|
st.LastTriggeredAt = later(st.LastTriggeredAt, h.triggeredAt)
|
||||||
|
if h.openID != 0 {
|
||||||
|
id := h.openID
|
||||||
|
src.IncidentID = &id
|
||||||
|
if st.OpenIncidentID == nil || id > *st.OpenIncidentID {
|
||||||
|
st.OpenIncidentID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.LastHeartbeatAt = later(st.LastHeartbeatAt, hb.receivedAt)
|
||||||
|
st.Sources = append(st.Sources, src)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case src.Status == switchDead:
|
||||||
|
st.Status = switchDead
|
||||||
|
case st.Status == switchDormant:
|
||||||
|
st.Status = switchHealthy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead ones first, then by fingerprint: what needs attention leads, and
|
||||||
|
// the order does not shuffle between refreshes.
|
||||||
|
sort.Slice(st.Sources, func(i, j int) bool {
|
||||||
|
a, b := st.Sources[i], st.Sources[j]
|
||||||
|
if (a.Status == switchDead) != (b.Status == switchDead) {
|
||||||
|
return a.Status == switchDead
|
||||||
|
}
|
||||||
|
return a.Fingerprint < b.Fingerprint
|
||||||
|
})
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// watchdogGroupKey is what Alertmanager sends for a Watchdog grouped by
|
||||||
|
// alertname, which is how the deployed route is configured.
|
||||||
|
const watchdogGroupKey = `{}:{alertname="Watchdog"}`
|
||||||
|
|
||||||
|
// deadmanCfg watches Watchdog with a timeout short enough to reason about and
|
||||||
|
// long enough that a fresh heartbeat is never accidentally stale.
|
||||||
|
func deadmanCfg() api.DeadmanConfig {
|
||||||
|
return api.ParseDeadmanConfig("alertname=Watchdog", time.Hour, "critical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanTS is notifyTS with dead man's switch handling on: notifications
|
||||||
|
// enabled against a fake ntfy, the admin on call today with a topic.
|
||||||
|
func deadmanTS(t *testing.T, cfg api.DeadmanConfig) (*ts, *fakeNtfy) {
|
||||||
|
t.Helper()
|
||||||
|
f := newFakeNtfy(t)
|
||||||
|
s := newDeadmanTS(t, cfg, api.NotifyConfig{
|
||||||
|
BaseURL: f.URL,
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
putOnCall(t, s, 1)
|
||||||
|
setTopic(t, s, 1, "terdut-admin")
|
||||||
|
return s, f
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeat posts one Watchdog webhook. Its startsAt never changes: a dead man's
|
||||||
|
// switch alert fires once and is re-sent unchanged forever, which is precisely
|
||||||
|
// what makes its absence meaningful.
|
||||||
|
func heartbeat(t *testing.T, s *ts, fingerprint string, labels map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert(fingerprint, "Watchdog", "firing", "2026-05-20T10:00:00Z", zeroTime, labels),
|
||||||
|
}, watchdogGroupKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence back-dates a heartbeat's received_at, which is the only clock the
|
||||||
|
// sweeper reads. There is no fake clock in this package.
|
||||||
|
func silence(t *testing.T, s *ts, fingerprint string, ago time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||||
|
time.Now().Add(-ago).Unix(), fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ageIncidents back-dates every incident. The re-arm rule compares a heartbeat
|
||||||
|
// against the last incident raised for its switch, so a test that wants a second
|
||||||
|
// episode has to put the first one in the past — there is no fake clock here.
|
||||||
|
func ageIncidents(t *testing.T, s *ts, ago time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
past := time.Now().Add(-ago).Unix()
|
||||||
|
// $2 is cast explicitly: with NULL in the other branch Postgres has nothing
|
||||||
|
// to infer the parameter's type from and defaults it to text, which the
|
||||||
|
// bigint column then refuses.
|
||||||
|
s.exec(t, `UPDATE incidents
|
||||||
|
SET triggered_at = $1,
|
||||||
|
resolved_at = CASE WHEN resolved_at IS NULL THEN NULL ELSE $2::bigint END`,
|
||||||
|
past, past)
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentByGroup reads the incident for a group key, resolved ones included.
|
||||||
|
func incidentByGroup(t *testing.T, s *ts, groupKey string) (id int64, status, severity string, source *string) {
|
||||||
|
t.Helper()
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT id, status, COALESCE(severity, ''), resolution_source
|
||||||
|
FROM incidents WHERE group_key = $1 ORDER BY id DESC LIMIT 1`,
|
||||||
|
groupKey).Scan(&id, &status, &severity, &source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read incident for group %s: %v", groupKey, err)
|
||||||
|
}
|
||||||
|
return id, status, severity, source
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Receiving a heartbeat
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The whole inversion: arrival of a dead man's switch alert is good news, and
|
||||||
|
// good news is not an incident.
|
||||||
|
func TestDeadman_HeartbeatOpensNoIncident(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 0 {
|
||||||
|
t.Fatalf("expected a heartbeat to open no incident, got %d", got)
|
||||||
|
}
|
||||||
|
if got := s.countNotifications(t, ""); got != 0 {
|
||||||
|
t.Errorf("expected no notification for a heartbeat, got %d", got)
|
||||||
|
}
|
||||||
|
if status, _, _ := s.alertRow(t, "fp-watchdog"); status != "firing" {
|
||||||
|
t.Errorf("expected the heartbeat to be stored firing, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A heartbeat routed into a group alongside real alerts must not join their
|
||||||
|
// incident: it is not a symptom of anything.
|
||||||
|
func TestDeadman_MixedGroupExcludesHeartbeat(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-mixed-wd", "Watchdog", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
amAlert("fp-mixed-disk", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime,
|
||||||
|
map[string]string{"severity": "critical"}),
|
||||||
|
}, `{}:{namespace="prod"}`)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected 1 incident for the real alert, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alerts []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents/1/alerts", nil), &alerts)
|
||||||
|
if len(alerts) != 1 {
|
||||||
|
t.Fatalf("expected 1 member alert, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
if name := alerts[0]["name"]; name != "DiskFull" {
|
||||||
|
t.Errorf("expected only the real alert linked, got %v", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A matcher scoped by label only claims the alerts it names, so a heartbeat from
|
||||||
|
// somewhere else stays an ordinary alert.
|
||||||
|
func TestDeadman_LabelScopedMatcherIgnoresOthers(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=Watchdog,cluster=prod", time.Hour, "critical"))
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-dev", map[string]string{"cluster": "dev"})
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected an unmatched Watchdog to behave like any other alert, got %d incidents", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Silence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDeadman_SilenceOpensIncident(t *testing.T) {
|
||||||
|
s, f := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected silence to open 1 incident, got %d", got)
|
||||||
|
}
|
||||||
|
id, status, severity, _ := incidentByGroup(t, s, "deadman:fp-watchdog")
|
||||||
|
if status != "triggered" {
|
||||||
|
t.Errorf("expected a triggered incident, got %q", status)
|
||||||
|
}
|
||||||
|
if severity != "critical" {
|
||||||
|
t.Errorf("expected the configured severity, got %q", severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert list must not keep claiming a dead heartbeat is firing.
|
||||||
|
alertStatus, source, _ := s.alertRow(t, "fp-watchdog")
|
||||||
|
if alertStatus != "resolved" || source == nil || *source != "deadman" {
|
||||||
|
t.Errorf("expected the heartbeat resolved as deadman, got %q / %v", alertStatus, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody was told anything by an alert here, so the page has to come from
|
||||||
|
// the switch itself.
|
||||||
|
s.sweepNotify(t)
|
||||||
|
msgs := f.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 page, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Topic != "terdut-admin" {
|
||||||
|
t.Errorf("expected the on-call topic, got %q", msgs[0].Topic)
|
||||||
|
}
|
||||||
|
if msgs[0].Priority != 5 {
|
||||||
|
t.Errorf("expected a critical page to override quiet hours (priority 5), got %d", msgs[0].Priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timeline says why, with the age of the last heartbeat.
|
||||||
|
types := eventTypes(timeline(t, s, int(id)))
|
||||||
|
found := false
|
||||||
|
for _, ty := range types {
|
||||||
|
if ty == "deadman_silent" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected a deadman_silent event, got %v", types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The generic staleness sweep must keep its hands off heartbeats: they answer to
|
||||||
|
// their own, much tighter, timeout, and an 'expiry' resolution here would be
|
||||||
|
// both wrong and unrecoverable.
|
||||||
|
func TestDeadman_GenericExpiryLeavesHeartbeatAlone(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 5*time.Minute)
|
||||||
|
|
||||||
|
// staleAfter far tighter than the dead man's switch timeout.
|
||||||
|
sweep(t, s, time.Minute)
|
||||||
|
|
||||||
|
status, source, _ := s.alertRow(t, "fp-watchdog")
|
||||||
|
if status != "firing" || source != nil {
|
||||||
|
t.Errorf("expected a live heartbeat left alone, got %q / %v", status, source)
|
||||||
|
}
|
||||||
|
if got := s.countIncidents(t); got != 0 {
|
||||||
|
t.Errorf("expected no incident for a heartbeat that is still fresh, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit resolved from Alertmanager is the sender telling us the heartbeat
|
||||||
|
// stopped. There is nothing left to wait out.
|
||||||
|
func TestDeadman_AlertmanagerResolvedIsImmediateDeath(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-watchdog", "Watchdog", "resolved", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, watchdogGroupKey)
|
||||||
|
|
||||||
|
// No ageing: received_at is seconds old, well inside the timeout.
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected a resolved heartbeat to open an incident at once, got %d", got)
|
||||||
|
}
|
||||||
|
// Alertmanager told the truth first, so its resolution source stands.
|
||||||
|
if _, source, _ := s.alertRow(t, "fp-watchdog"); source == nil || *source != "alertmanager" {
|
||||||
|
t.Errorf("expected the Alertmanager resolution source kept, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each label set is its own switch, so one healthy source cannot mask a dead one.
|
||||||
|
func TestDeadman_TracksEachFingerprintSeparately(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-a", map[string]string{"cluster": "a"})
|
||||||
|
heartbeat(t, s, "fp-b", map[string]string{"cluster": "b"})
|
||||||
|
silence(t, s, "fp-b", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected only the silent switch to page, got %d incidents", got)
|
||||||
|
}
|
||||||
|
if _, status, _, _ := incidentByGroup(t, s, "deadman:fp-b"); status != "triggered" {
|
||||||
|
t.Errorf("expected the incident to belong to the silent switch, got %q", status)
|
||||||
|
}
|
||||||
|
if status, _, _ := s.alertRow(t, "fp-a"); status != "firing" {
|
||||||
|
t.Errorf("expected the live switch untouched, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A switch nothing has ever been heard from is dormant. A fresh deploy, a
|
||||||
|
// restored database or a typo'd alertname must not page.
|
||||||
|
func TestDeadman_UnheardOfSwitchIsDormant(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=NeverSent", time.Hour, "critical"))
|
||||||
|
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 0 {
|
||||||
|
t.Fatalf("expected a switch that never chirped to be dormant, got %d incidents", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recovery and re-arming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The returning heartbeat carries the unchanged startsAt of an alert that never
|
||||||
|
// stopped firing, so this also covers the ingest guard exemption: without it the
|
||||||
|
// upsert would discard the payload and the switch could die exactly once.
|
||||||
|
func TestDeadman_RecoveryResolvesIncident(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
if status, source, _ := s.alertRow(t, "fp-watchdog"); status != "firing" || source != nil {
|
||||||
|
t.Fatalf("expected the returning heartbeat to be accepted, got %q / %v", status, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
_, status, _, source := incidentByGroup(t, s, "deadman:fp-watchdog")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("expected recovery to close the incident, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "recovered" {
|
||||||
|
t.Errorf("expected resolution_source recovered, got %v", source)
|
||||||
|
}
|
||||||
|
if got := s.countNotifications(t, "resolved"); got != 1 {
|
||||||
|
t.Errorf("expected 1 all-clear, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolving a dead man's switch incident sticks, exactly as it does for an
|
||||||
|
// alert-backed one. A source that is gone for good is a one-time page.
|
||||||
|
func TestDeadman_ManualResolveSticksWhileSilent(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
// Still silent, several sweeps later.
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected a manually resolved incident to stay closed, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...but the switch re-arms, so a heartbeat that comes back and dies again is a
|
||||||
|
// new incident rather than silence forever.
|
||||||
|
func TestDeadman_ReArmsAfterHeartbeatReturns(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
// That episode is yesterday's news; the heartbeat now returns after it.
|
||||||
|
ageIncidents(t, s, 10*time.Hour)
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected the live switch to open nothing, got %d incidents", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if got := s.countIncidents(t); got != 2 {
|
||||||
|
t.Fatalf("expected a second death to open a second incident, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead man's switch incident has no member alerts — linking the heartbeat
|
||||||
|
// would have the settled-incident cascade close it on the very sweep that opened
|
||||||
|
// it — so the cascade has to leave it alone.
|
||||||
|
func TestDeadman_SettledCascadeLeavesIncidentOpen(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
if _, status, _, _ := incidentByGroup(t, s, "deadman:fp-watchdog"); status != "triggered" {
|
||||||
|
t.Fatalf("expected the incident to stay open until the switch recovers, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestParseDeadmanConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
matchers string
|
||||||
|
timeout time.Duration
|
||||||
|
want []api.DeadmanMatcher
|
||||||
|
enabled bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single alertname",
|
||||||
|
matchers: "alertname=Watchdog",
|
||||||
|
timeout: time.Hour,
|
||||||
|
want: []api.DeadmanMatcher{{Name: "Watchdog", Labels: map[string]string{}}},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "several matchers with extra labels and whitespace",
|
||||||
|
matchers: " alertname=Watchdog, cluster=prod ; alertname=EdgeHeartbeat ",
|
||||||
|
timeout: time.Hour,
|
||||||
|
want: []api.DeadmanMatcher{
|
||||||
|
{Name: "Watchdog", Labels: map[string]string{"cluster": "prod"}},
|
||||||
|
{Name: "EdgeHeartbeat", Labels: map[string]string{}},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mandatory: it is what keeps the sweeper's candidate query on an index.
|
||||||
|
name: "matcher without alertname is dropped",
|
||||||
|
matchers: "cluster=prod; alertname=Watchdog",
|
||||||
|
timeout: time.Hour,
|
||||||
|
want: []api.DeadmanMatcher{{Name: "Watchdog", Labels: map[string]string{}}},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed condition drops only its matcher",
|
||||||
|
matchers: "alertname=Watchdog,garbage; alertname=Other",
|
||||||
|
timeout: time.Hour,
|
||||||
|
want: []api.DeadmanMatcher{{Name: "Other", Labels: map[string]string{}}},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero timeout disables",
|
||||||
|
matchers: "alertname=Watchdog",
|
||||||
|
timeout: 0,
|
||||||
|
want: []api.DeadmanMatcher{{Name: "Watchdog", Labels: map[string]string{}}},
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no usable matchers disables",
|
||||||
|
matchers: "",
|
||||||
|
timeout: time.Hour,
|
||||||
|
want: nil,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := api.ParseDeadmanConfig(tc.matchers, tc.timeout, "critical")
|
||||||
|
if len(got.Matchers) != len(tc.want) {
|
||||||
|
t.Fatalf("got %d matchers %v, want %d", len(got.Matchers), got.Matchers, len(tc.want))
|
||||||
|
}
|
||||||
|
for i, w := range tc.want {
|
||||||
|
if got.Matchers[i].Name != w.Name {
|
||||||
|
t.Errorf("matcher %d: name %q, want %q", i, got.Matchers[i].Name, w.Name)
|
||||||
|
}
|
||||||
|
if len(got.Matchers[i].Labels) != len(w.Labels) {
|
||||||
|
t.Errorf("matcher %d: labels %v, want %v", i, got.Matchers[i].Labels, w.Labels)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range w.Labels {
|
||||||
|
if got.Matchers[i].Labels[k] != v {
|
||||||
|
t.Errorf("matcher %d: label %s=%q, want %q", i, k, got.Matchers[i].Labels[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero config is off, which is what keeps the feature opt-in for anything
|
||||||
|
// building a router without one.
|
||||||
|
func TestDeadman_DisabledConfigIsInert(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, api.DeadmanConfig{})
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
silence(t, s, "fp-watchdog", 48*time.Hour)
|
||||||
|
sweep(t, s, time.Hour)
|
||||||
|
|
||||||
|
// Ordinary alert handling: an incident from the arrival, not the absence.
|
||||||
|
if got := s.countIncidents(t); got != 1 {
|
||||||
|
t.Fatalf("expected plain alert handling with deadman off, got %d incidents", got)
|
||||||
|
}
|
||||||
|
if _, source, _ := s.alertRow(t, "fp-watchdog"); source == nil || *source != "expiry" {
|
||||||
|
t.Errorf("expected the generic sweeper to own the alert, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-team configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Each team decides for itself what a heartbeat is. The same alert is a
|
||||||
|
// heartbeat in one team and an ordinary problem in another.
|
||||||
|
func TestDeadman_ConfigurationIsPerTeam(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
watched := newTeam(t, s, "watched")
|
||||||
|
unwatched := newTeam(t, s, "unwatched")
|
||||||
|
|
||||||
|
// Only the first team calls Watchdog a heartbeat.
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(watched.id)+"/deadman/switches", map[string]any{
|
||||||
|
"matcher": "alertname=Watchdog",
|
||||||
|
"timeout_seconds": 3600,
|
||||||
|
"severity": "critical",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("configure the watched team: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postToIntegration(t, s, watched.key, "fp-watched", "Watchdog")
|
||||||
|
postToIntegration(t, s, unwatched.key, "fp-unwatched", "Watchdog")
|
||||||
|
|
||||||
|
// A heartbeat opens nothing where it is one; an ordinary alert opens an
|
||||||
|
// incident where it is not.
|
||||||
|
if got := len(list(t, watched.call(http.MethodGet, "/api/incidents", nil))); got != 0 {
|
||||||
|
t.Errorf("the watched team's heartbeat opened %d incident(s), want 0", got)
|
||||||
|
}
|
||||||
|
if got := len(list(t, unwatched.call(http.MethodGet, "/api/incidents", nil))); got != 1 {
|
||||||
|
t.Errorf("the unwatched team's Watchdog opened %d incident(s), want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence pages only the team that is watching.
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||||
|
time.Now().Add(-2*time.Hour).Unix(), "fp-watched")
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||||
|
time.Now().Add(-2*time.Hour).Unix(), "fp-unwatched")
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
|
||||||
|
watchedIncidents := list(t, watched.call(http.MethodGet, "/api/incidents", nil))
|
||||||
|
if len(watchedIncidents) != 1 {
|
||||||
|
t.Fatalf("silence opened %d incident(s) for the watching team, want 1", len(watchedIncidents))
|
||||||
|
}
|
||||||
|
if title := watchedIncidents[0]["title"].(string); title != "No heartbeat from Watchdog" {
|
||||||
|
t.Errorf("unexpected incident title %q", title)
|
||||||
|
}
|
||||||
|
if teamID := int64(watchedIncidents[0]["team_id"].(float64)); teamID != watched.id {
|
||||||
|
t.Errorf("the incident opened in team %d, want %d", teamID, watched.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unwatched team's alert went stale the ordinary way, so it has the one
|
||||||
|
// incident it always had — not a second, dead man's switch one.
|
||||||
|
if got := len(list(t, unwatched.call(http.MethodGet, "/api/incidents", nil))); got != 1 {
|
||||||
|
t.Errorf("the unwatched team ended with %d incident(s), want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration is an owner's to change and a member's to read, like the rest of
|
||||||
|
// a team's settings.
|
||||||
|
func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
team := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
// A plain member of that team.
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
|
||||||
|
s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"}).Body.Close()
|
||||||
|
var key struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||||
|
map[string]string{"name": "test"}), &key)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost,
|
||||||
|
s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches",
|
||||||
|
strings.NewReader(`{"matcher":"alertname=Watchdog","timeout_seconds":60}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("put: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches", nil)
|
||||||
|
read.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
got, err := http.DefaultClient.Do(read)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
got.Body.Close()
|
||||||
|
if got.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("a member reading the switches: expected 200, got %d", got.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A matcher with no alertname watches nothing, silently, which is the failure
|
||||||
|
// this feature exists to prevent — so it is refused at the door, along with the
|
||||||
|
// other things that would make a switch unable to fire.
|
||||||
|
func TestDeadman_UnusableSwitchesAreRejected(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
for name, body := range map[string]map[string]any{
|
||||||
|
"no alertname": {"matcher": "cluster=prod", "timeout_seconds": 900},
|
||||||
|
"malformed": {"matcher": "alertname=Watchdog,garbage", "timeout_seconds": 900},
|
||||||
|
"several": {"matcher": "alertname=A; alertname=B", "timeout_seconds": 900},
|
||||||
|
"zero timeout": {"matcher": "alertname=Watchdog", "timeout_seconds": 0},
|
||||||
|
"bad severity": {"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "loud"},
|
||||||
|
"empty matcher": {"matcher": "", "timeout_seconds": 900},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: expected 400, got %d", name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The switch list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// listSwitches reads the default team's switches as the Switches page does.
|
||||||
|
func listSwitches(t *testing.T, s *ts) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
return list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A switch is healthy while its heartbeat is fresh, dead once it is silent, and
|
||||||
|
// dormant until the first one arrives.
|
||||||
|
func TestDeadman_ListReportsStatus(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=Watchdog; alertname=NeverSent", time.Hour, "critical"))
|
||||||
|
|
||||||
|
got := listSwitches(t, s)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 switches, got %d", len(got))
|
||||||
|
}
|
||||||
|
for _, sw := range got {
|
||||||
|
if sw["status"] != "dormant" || sw["last_heartbeat_at"] != nil || sw["last_triggered_at"] != nil {
|
||||||
|
t.Errorf("a switch nobody has heard from should be dormant and blank, got %v", sw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
got = listSwitches(t, s)
|
||||||
|
if got[0]["status"] != "healthy" || got[0]["last_heartbeat_at"] == nil {
|
||||||
|
t.Errorf("a fresh heartbeat should be healthy with a timestamp, got %v", got[0])
|
||||||
|
}
|
||||||
|
if got[1]["status"] != "dormant" {
|
||||||
|
t.Errorf("the other switch is still dormant, got %v", got[1]["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
got = listSwitches(t, s)
|
||||||
|
if got[0]["status"] != "dead" {
|
||||||
|
t.Fatalf("a silent heartbeat should be dead, got %v", got[0]["status"])
|
||||||
|
}
|
||||||
|
if got[0]["last_triggered_at"] == nil || got[0]["open_incident_id"] == nil {
|
||||||
|
t.Errorf("a dead switch should show when it triggered and its open incident, got %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One matcher, several clusters: the switch is as bad as its worst heartbeat and
|
||||||
|
// each heartbeat is listed on its own.
|
||||||
|
func TestDeadman_ListBreaksDownByFingerprint(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-a", map[string]string{"cluster": "a"})
|
||||||
|
heartbeat(t, s, "fp-b", map[string]string{"cluster": "b"})
|
||||||
|
silence(t, s, "fp-b", 2*time.Hour)
|
||||||
|
|
||||||
|
sw := listSwitches(t, s)[0]
|
||||||
|
if sw["status"] != "dead" {
|
||||||
|
t.Errorf("one dead cluster makes the switch dead, got %v", sw["status"])
|
||||||
|
}
|
||||||
|
sources := sw["sources"].([]any)
|
||||||
|
if len(sources) != 2 {
|
||||||
|
t.Fatalf("expected 2 sources, got %d", len(sources))
|
||||||
|
}
|
||||||
|
first, second := sources[0].(map[string]any), sources[1].(map[string]any)
|
||||||
|
if first["fingerprint"] != "fp-b" || first["status"] != "dead" || second["status"] != "healthy" {
|
||||||
|
t.Errorf("the dead source should lead, got %v then %v", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every switch keeps its own deadline.
|
||||||
|
func TestDeadman_TimeoutsArePerSwitch(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
|
||||||
|
"matcher": "alertname=Edge", "timeout_seconds": 300,
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-edge", "Edge", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, `{}:{alertname="Edge"}`)
|
||||||
|
|
||||||
|
// Ten minutes of silence: past the Edge switch's five, inside Watchdog's hour.
|
||||||
|
silence(t, s, "fp-watchdog", 10*time.Minute)
|
||||||
|
silence(t, s, "fp-edge", 10*time.Minute)
|
||||||
|
|
||||||
|
got := listSwitches(t, s)
|
||||||
|
if got[0]["status"] != "healthy" || got[1]["status"] != "dead" {
|
||||||
|
t.Errorf("want Watchdog healthy and Edge dead, got %v and %v", got[0]["status"], got[1]["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting is an owner's, is scoped to the team, and leaves what the switch
|
||||||
|
// already opened alone.
|
||||||
|
func TestDeadman_DeleteIsScopedToTheTeam(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
other := newTeam(t, s, "other")
|
||||||
|
|
||||||
|
id := int64(listSwitches(t, s)[0]["id"].(float64))
|
||||||
|
|
||||||
|
// Another team's owner cannot reach it.
|
||||||
|
resp := other.call(http.MethodDelete, "/api/teams/"+id64(other.id)+"/deadman/switches/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("deleting another team's switch: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 1 {
|
||||||
|
t.Fatalf("the switch should have survived, %d left", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("deleting: expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 0 {
|
||||||
|
t.Errorf("expected no switches, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The environment's defaults are handed out once and then belong to the teams.
|
||||||
|
func TestDeadman_SeedRunsOnce(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
cfg := api.ParseDeadmanConfig("alertname=Watchdog", time.Hour, "critical")
|
||||||
|
|
||||||
|
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 1 {
|
||||||
|
t.Fatalf("the first seed should add the default, got %d switches", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner deletes it; a restart must not put it back.
|
||||||
|
id := int64(listSwitches(t, s)[0]["id"].(float64))
|
||||||
|
s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil).Body.Close()
|
||||||
|
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
|
||||||
|
t.Fatalf("seed again: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 0 {
|
||||||
|
t.Errorf("a second seed resurrected %d switch(es)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The device login flow lets a client that cannot open a browser sign in: it
|
||||||
|
// shows a code, the person approves it in a browser they are signed in to, and
|
||||||
|
// the client is handed an ordinary session. See migration 012.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// deviceTTL is how long a person has to get from the terminal's prompt to an
|
||||||
|
// approval.
|
||||||
|
deviceTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// deviceInterval is how often the client is told to poll. The server holds it
|
||||||
|
// to that, with a second of slack for clocks and scheduling.
|
||||||
|
deviceInterval = 5 * time.Second
|
||||||
|
|
||||||
|
// deviceStartMaxPerAddr bounds unauthenticated device logins started per
|
||||||
|
// address, since each writes a row.
|
||||||
|
deviceStartMaxPerAddr = 30
|
||||||
|
|
||||||
|
// userCodeAlphabet has no vowels, so a code cannot spell a word, and none of
|
||||||
|
// the characters that read alike (0/O, 1/I/L).
|
||||||
|
userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789"
|
||||||
|
userCodeLen = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
// newUserCode returns a code for a person to read, as XXXX-XXXX.
|
||||||
|
func newUserCode() (string, error) {
|
||||||
|
max := big.NewInt(int64(len(userCodeAlphabet)))
|
||||||
|
b := make([]byte, userCodeLen)
|
||||||
|
for i := range b {
|
||||||
|
n, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b[i] = userCodeAlphabet[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(b[:4]) + "-" + string(b[4:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUserCode reduces whatever a person typed or pasted to the stored
|
||||||
|
// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for
|
||||||
|
// anything that cannot be a code.
|
||||||
|
func normalizeUserCode(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(s) {
|
||||||
|
if strings.ContainsRune(userCodeAlphabet, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code := b.String()
|
||||||
|
if len(code) != userCodeLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return code[:4] + "-" + code[4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeviceStart begins a device login: it returns the device code the
|
||||||
|
// client polls with, and the user code and URL the person is shown.
|
||||||
|
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
addrKey := "device:" + clientAddr(r)
|
||||||
|
if limiter.blocked(addrKey, deviceStartMaxPerAddr) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||||
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limiter.fail(addrKey)
|
||||||
|
|
||||||
|
deviceCode, deviceHash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix())
|
||||||
|
|
||||||
|
// A collision on the user code is one in 27^8; retrying a few times makes
|
||||||
|
// it a non-event rather than a 500.
|
||||||
|
var userCode string
|
||||||
|
for range 5 {
|
||||||
|
userCode, err = newUserCode()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_, err = db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`,
|
||||||
|
deviceHash, userCode, now.Add(deviceTTL).Unix())
|
||||||
|
if err == nil || !isUniqueViolation(err) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("device login: start: %v", err)
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(w, http.StatusOK, map[string]any{
|
||||||
|
"device_code": deviceCode,
|
||||||
|
"user_code": userCode,
|
||||||
|
// The code is in the URL so nobody has to type it; it is shown anyway,
|
||||||
|
// for the person to check against the terminal before approving.
|
||||||
|
"verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode),
|
||||||
|
"interval": int(deviceInterval.Seconds()),
|
||||||
|
"expires_in": int(deviceTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeviceDecision approves or denies a pending device login on behalf of
|
||||||
|
// the signed-in caller.
|
||||||
|
//
|
||||||
|
// It takes a session, not an API key. Approving hands a terminal the caller's
|
||||||
|
// identity, and the approval must come from a browser the person is looking at:
|
||||||
|
// the page shows the code and asks. A script with a key has no business
|
||||||
|
// approving one, and the check keeps it from being a way to mint sessions out of
|
||||||
|
// keys.
|
||||||
|
func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
|
||||||
|
status := "denied"
|
||||||
|
if approve {
|
||||||
|
status = "approved"
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, viaSession := sessionFromContext(r.Context()); !viaSession {
|
||||||
|
respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := normalizeUserCode(req.UserCode)
|
||||||
|
if code == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("that is not a sign-in code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
// Only a pending login can be decided, and only once: an approval cannot
|
||||||
|
// be overwritten, so a second browser cannot take a login over.
|
||||||
|
res, err := db.ExecContext(r.Context(), `
|
||||||
|
UPDATE device_logins SET status = $1, user_id = $2
|
||||||
|
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
|
||||||
|
status, caller.ID, code, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeviceToken is what the client polls. Pending answers 202; an approval
|
||||||
|
// answers 200 with the session cookie, once; anything else is 410.
|
||||||
|
func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc {
|
||||||
|
gone := func(w http.ResponseWriter, why string) {
|
||||||
|
respond(w, http.StatusGone, map[string]string{"error": why})
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("device_code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := hashToken(req.DeviceCode)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
var status string
|
||||||
|
var userID sql.NullInt64
|
||||||
|
var expires, lastPolled int64
|
||||||
|
err = tx.QueryRowContext(r.Context(), `
|
||||||
|
SELECT status, user_id, expires_at, last_polled_at FROM device_logins
|
||||||
|
WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) {
|
||||||
|
gone(w, "expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "denied":
|
||||||
|
tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash)
|
||||||
|
tx.Commit() //nolint:errcheck
|
||||||
|
gone(w, "denied")
|
||||||
|
return
|
||||||
|
|
||||||
|
case "pending":
|
||||||
|
// Held to the interval it was given, less a second of slack.
|
||||||
|
if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds())))
|
||||||
|
respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approved. Single use: the row goes before the session is made, so two
|
||||||
|
// racing polls cannot both be given one.
|
||||||
|
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var disabled, sso bool
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `
|
||||||
|
SELECT disabled_at IS NOT NULL,
|
||||||
|
EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1)
|
||||||
|
FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil {
|
||||||
|
gone(w, "denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
gone(w, "denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session for somebody who signs in through the provider carries the
|
||||||
|
// same ceiling as their browser's would, so the terminal is not a way
|
||||||
|
// round it. Password users have none.
|
||||||
|
var maxAge time.Duration
|
||||||
|
if sso {
|
||||||
|
maxAge = ssoMaxAge
|
||||||
|
}
|
||||||
|
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
|
||||||
|
log.Printf("device login: start session: %v", err)
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := fetchUser(r.Context(), db, userID.Int64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceStart struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURL string `json:"verification_url"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDevice is the terminal asking for a login.
|
||||||
|
func startDevice(t *testing.T, s *ts) deviceStart {
|
||||||
|
t.Helper()
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||||
|
var d deviceStart
|
||||||
|
decode(t, resp, &d)
|
||||||
|
if d.DeviceCode == "" || d.UserCode == "" {
|
||||||
|
t.Fatalf("device start returned %+v", d)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollDevice is the terminal polling. It returns the status, and the session
|
||||||
|
// cookie the response set, if any.
|
||||||
|
func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
var cookie *http.Cookie
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "terdut_session" {
|
||||||
|
cookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg, _ := body["error"].(string)
|
||||||
|
if msg == "" {
|
||||||
|
msg, _ = body["status"].(string)
|
||||||
|
}
|
||||||
|
return resp.StatusCode, cookie, msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyToPoll lets the next poll through: the server holds a client to the
|
||||||
|
// interval it was given, which a test has no wish to wait out.
|
||||||
|
func (s *ts) readyToPoll(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
s.exec(t, "UPDATE device_logins SET last_polled_at = 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decide(t *testing.T, b *browser, what, code string) int {
|
||||||
|
t.Helper()
|
||||||
|
resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code})
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_FullFlow(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") ||
|
||||||
|
!strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) {
|
||||||
|
t.Errorf("verification url %q", d.VerificationURL)
|
||||||
|
}
|
||||||
|
if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 {
|
||||||
|
t.Errorf("start: %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" {
|
||||||
|
t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The person signs in through the provider in some browser and approves.
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("approve: %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.readyToPoll(t)
|
||||||
|
status, cookie, _ := pollDevice(t, s, d.DeviceCode)
|
||||||
|
if status != http.StatusOK || cookie == nil {
|
||||||
|
t.Fatalf("poll after approval: %d, cookie %v", status, cookie)
|
||||||
|
}
|
||||||
|
// The cookie is a working session for the person who approved.
|
||||||
|
term := newBrowser(t, s.URL)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
resp, err := term.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.Username != "alice" {
|
||||||
|
t.Errorf("session belongs to %q, want alice", me.User.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single use.
|
||||||
|
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" {
|
||||||
|
t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg)
|
||||||
|
}
|
||||||
|
// The session was made for an SSO user, so it carries the ceiling.
|
||||||
|
var ceiling *int64
|
||||||
|
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
|
||||||
|
if ceiling == nil {
|
||||||
|
t.Error("a device session for an SSO user must carry the SSO session ceiling")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
admin := signedIn(t, s) // password sign-in as the bootstrap admin
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("approve: %d", got)
|
||||||
|
}
|
||||||
|
s.readyToPoll(t)
|
||||||
|
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil {
|
||||||
|
t.Fatalf("poll: %d %v", status, cookie)
|
||||||
|
}
|
||||||
|
var ceiling *int64
|
||||||
|
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
|
||||||
|
if ceiling != nil {
|
||||||
|
t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_Denied(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("deny: %d", got)
|
||||||
|
}
|
||||||
|
s.readyToPoll(t)
|
||||||
|
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" {
|
||||||
|
t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_DecisionNeedsABrowserSession(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
d := startDevice(t, s)
|
||||||
|
|
||||||
|
// Nobody signed in.
|
||||||
|
if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous approve: %d, want 401", got)
|
||||||
|
}
|
||||||
|
// An API key is a credential for scripts, not for approving a terminal.
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("approve with an API key: %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" {
|
||||||
|
t.Errorf("the login must still be pending: %d %q", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_ApprovalIsFinal(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
first, second := ssoBrowser(t, s), ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, first, alice)
|
||||||
|
signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}})
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("approve: %d", got)
|
||||||
|
}
|
||||||
|
// A second browser cannot take the login over, nor refuse it.
|
||||||
|
for _, what := range []string{"approve", "deny"} {
|
||||||
|
if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound {
|
||||||
|
t.Errorf("%s after approval: %d, want 404", what, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.readyToPoll(t)
|
||||||
|
_, cookie, _ := pollDevice(t, s, d.DeviceCode)
|
||||||
|
if cookie == nil {
|
||||||
|
t.Fatal("no session")
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name)
|
||||||
|
if name != "alice" {
|
||||||
|
t.Errorf("session for %q, want alice", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " "))
|
||||||
|
if got := decide(t, person, "approve", typed); got != http.StatusNoContent {
|
||||||
|
t.Errorf("approve %q: %d, want 204", typed, got)
|
||||||
|
}
|
||||||
|
if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest {
|
||||||
|
t.Errorf("approve nonsense: %d, want 400", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_ExpiredAndUnknown(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
s.exec(t, "UPDATE device_logins SET expires_at = 1")
|
||||||
|
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound {
|
||||||
|
t.Errorf("approve expired: %d, want 404", got)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" {
|
||||||
|
t.Errorf("poll expired: %d %q, want 410 expired", status, msg)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" {
|
||||||
|
t.Errorf("poll unknown: %d %q, want 410 expired", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_PollingTooFastIsRefused(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted {
|
||||||
|
t.Fatalf("first poll: %d", status)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" {
|
||||||
|
t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_DisabledUserGetsNoSession(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
decide(t, person, "approve", d.UserCode)
|
||||||
|
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
|
||||||
|
s.readyToPoll(t)
|
||||||
|
var before int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before)
|
||||||
|
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil {
|
||||||
|
t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie)
|
||||||
|
}
|
||||||
|
var after int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after)
|
||||||
|
if after != before {
|
||||||
|
t.Error("a session was created for a disabled user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) {
|
||||||
|
s := newTS(t) // no SSO
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
s *ts
|
||||||
|
want bool
|
||||||
|
}{{"off", s, false}, {"on", newSSOTS(t, idp), true}} {
|
||||||
|
var cfg struct {
|
||||||
|
DeviceLogin bool `json:"device_login"`
|
||||||
|
}
|
||||||
|
decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg)
|
||||||
|
if cfg.DeviceLogin != c.want {
|
||||||
|
t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_StartIsRateLimited(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
var last int
|
||||||
|
for range 32 {
|
||||||
|
resp := b.do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
last = resp.StatusCode
|
||||||
|
}
|
||||||
|
if last != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("32nd start: %d, want 429", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After signing in the browser is sent on to where the person was going, which
|
||||||
|
// is how somebody without a session gets from /device?code=... through the
|
||||||
|
// provider and back to it. Only paths on this server are honoured.
|
||||||
|
func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
for _, c := range []struct{ next, want string }{
|
||||||
|
{"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"},
|
||||||
|
{"/team/members", "/team/members"},
|
||||||
|
{"", "/"},
|
||||||
|
{"//evil.example/x", "/"},
|
||||||
|
{"/\\evil.example", "/"},
|
||||||
|
{"https://evil.example/", "/"},
|
||||||
|
{"evil.example", "/"},
|
||||||
|
{"/api/users", "/"},
|
||||||
|
{"/ok\r\nSet-Cookie: x=y", "/"},
|
||||||
|
{"/" + strings.Repeat("a", 600), "/"},
|
||||||
|
} {
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
loc, _ := url.Parse(resp.Header.Get("Location"))
|
||||||
|
q := loc.Query()
|
||||||
|
got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state"))
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evEscalated records a rung of the ladder on the incident's timeline: which
|
||||||
|
// level, and who it woke.
|
||||||
|
const evEscalated = "escalated"
|
||||||
|
|
||||||
|
// escalationPolicy is a team's ladder, loaded whole. It is small — a handful of
|
||||||
|
// levels with a few targets each — and every use needs all of it, so there is
|
||||||
|
// no point reading it a level at a time.
|
||||||
|
type escalationPolicy struct {
|
||||||
|
teamID int64
|
||||||
|
repeatCount int64
|
||||||
|
fallbackTopic string
|
||||||
|
levels []escalationLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationLevel struct {
|
||||||
|
id int64
|
||||||
|
position int64
|
||||||
|
timeout time.Duration
|
||||||
|
targets []escalationTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationTarget struct {
|
||||||
|
kind string // "user" or "oncall"
|
||||||
|
userID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// configured reports whether this team has anything to escalate through. A
|
||||||
|
// policy row with no levels is the same as no policy: the team gets the
|
||||||
|
// pre-escalation behaviour, which is reminders on the assignee's topic.
|
||||||
|
func (p *escalationPolicy) configured() bool { return p != nil && len(p.levels) > 0 }
|
||||||
|
|
||||||
|
// level returns the level at a 1-based position.
|
||||||
|
func (p *escalationPolicy) level(pos int64) (escalationLevel, bool) {
|
||||||
|
for _, l := range p.levels {
|
||||||
|
if l.position == pos {
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escalationLevel{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEscalationPolicy reads one team's ladder. A team with no policy row
|
||||||
|
// returns nil, which every caller treats as "not configured" rather than as an
|
||||||
|
// error: most teams will never set one up.
|
||||||
|
func loadEscalationPolicy(ctx context.Context, q querier, teamID int64) (*escalationPolicy, error) {
|
||||||
|
p := &escalationPolicy{teamID: teamID}
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
"SELECT repeat_count, fallback_topic FROM escalation_policies WHERE team_id = $1",
|
||||||
|
teamID).Scan(&p.repeatCount, &p.fallbackTopic)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT l.id, l.position, l.timeout_seconds, t.kind, t.user_id
|
||||||
|
FROM escalation_levels l
|
||||||
|
LEFT JOIN escalation_targets t ON t.level_id = l.id
|
||||||
|
WHERE l.team_id = $1
|
||||||
|
ORDER BY l.position, t.id`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
byPosition := map[int64]int{} // position -> index in p.levels
|
||||||
|
for rows.Next() {
|
||||||
|
var id, position, timeout int64
|
||||||
|
var kind *string
|
||||||
|
var userID *int64
|
||||||
|
if err := rows.Scan(&id, &position, &timeout, &kind, &userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idx, seen := byPosition[position]
|
||||||
|
if !seen {
|
||||||
|
p.levels = append(p.levels, escalationLevel{
|
||||||
|
id: id,
|
||||||
|
position: position,
|
||||||
|
timeout: time.Duration(timeout) * time.Second,
|
||||||
|
})
|
||||||
|
idx = len(p.levels) - 1
|
||||||
|
byPosition[position] = idx
|
||||||
|
}
|
||||||
|
// LEFT JOIN: a level with no targets yet still produces a row, with a
|
||||||
|
// NULL kind. It is a rung that pages nobody, which the API refuses to
|
||||||
|
// store but an older row could still hold.
|
||||||
|
if kind != nil {
|
||||||
|
p.levels[idx].targets = append(p.levels[idx].targets,
|
||||||
|
escalationTarget{kind: *kind, userID: userID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalate advances every incident whose current level has run out of time.
|
||||||
|
//
|
||||||
|
// Runs on the notifier's tick, beside the reminder pass, because it is the same
|
||||||
|
// question asked differently: reminders ask "has this been ignored long
|
||||||
|
// enough to say it again", escalation asks "long enough to say it to somebody
|
||||||
|
// else". Sharing the tick means one query cadence and one outbox.
|
||||||
|
func escalate(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT i.id, i.team_id, i.escalation_level, i.escalation_level_at, i.escalation_round
|
||||||
|
FROM incidents i
|
||||||
|
JOIN escalation_policies p ON p.team_id = i.team_id
|
||||||
|
WHERE i.resolved_at IS NULL
|
||||||
|
AND i.archived_at IS NULL
|
||||||
|
AND i.status = 'triggered'
|
||||||
|
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $1)
|
||||||
|
AND i.escalation_level > 0`, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("escalation: find due: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type pending struct {
|
||||||
|
incidentID, teamID, level, round int64
|
||||||
|
levelAt int64
|
||||||
|
}
|
||||||
|
var due []pending
|
||||||
|
for rows.Next() {
|
||||||
|
var p pending
|
||||||
|
var levelAt *int64
|
||||||
|
if err := rows.Scan(&p.incidentID, &p.teamID, &p.level, &levelAt, &p.round); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Printf("escalation: scan: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if levelAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.levelAt = *levelAt
|
||||||
|
due = append(due, p)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
log.Printf("escalation: iterate: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, d := range due {
|
||||||
|
policy, err := loadEscalationPolicy(ctx, db, d.teamID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("escalation: load policy for team %d: %v", d.teamID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !policy.configured() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current, ok := policy.level(d.level)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(time.Unix(d.levelAt, 0)) < current.timeout {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := advanceEscalation(ctx, db, cfg, policy, d.incidentID, d.level, d.round, now); err != nil {
|
||||||
|
log.Printf("escalation: advance incident %d: %v", d.incidentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceEscalation moves one incident to its next rung, or off the end of the
|
||||||
|
// ladder.
|
||||||
|
//
|
||||||
|
// The whole move is one transaction: the level, the page and the timeline entry
|
||||||
|
// are one event, and an incident recorded as being at level 3 that nobody at
|
||||||
|
// level 3 was told about is the worst of the possible half-states.
|
||||||
|
func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy *escalationPolicy, incidentID, level, round int64, now time.Time) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
next := level + 1
|
||||||
|
nextRound := round
|
||||||
|
if _, ok := policy.level(next); !ok {
|
||||||
|
// Off the end. Either start the chain again, or make the last call.
|
||||||
|
if round < policy.repeatCount {
|
||||||
|
next, nextRound = 1, round+1
|
||||||
|
} else {
|
||||||
|
if err := escalationExhausted(ctx, tx, policy, incidentID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target, ok := policy.level(next)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
paged, err := pageLevel(ctx, tx, cfg, policy, incidentID, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE incidents
|
||||||
|
SET escalation_level = $1, escalation_level_at = $2, escalation_round = $3
|
||||||
|
WHERE id = $4`, next, now.Unix(), nextRound, incidentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := "level " + strconv.FormatInt(next, 10)
|
||||||
|
if nextRound > round {
|
||||||
|
detail += " (round " + strconv.FormatInt(nextRound+1, 10) + ")"
|
||||||
|
}
|
||||||
|
if len(paged) > 0 {
|
||||||
|
detail += ": " + strings.Join(paged, ", ")
|
||||||
|
} else {
|
||||||
|
// Worth recording loudly: the rung exists, its turn came, and it woke
|
||||||
|
// nobody. That is a policy that looks configured and is not.
|
||||||
|
detail += ": nobody reachable"
|
||||||
|
}
|
||||||
|
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalationExhausted is the end of the line: the fallback topic, once, and a
|
||||||
|
// timeline entry saying the ladder is finished. The incident stays triggered —
|
||||||
|
// escalation running out is not the same as somebody answering.
|
||||||
|
func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPolicy, incidentID int64, now time.Time) error {
|
||||||
|
detail := "escalation exhausted"
|
||||||
|
if policy.fallbackTopic != "" {
|
||||||
|
if err := enqueueNotification(ctx, tx, incidentID, nil, policy.fallbackTopic, notifyEscalated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
detail += ": paged " + policy.fallbackTopic
|
||||||
|
} else {
|
||||||
|
detail += ": no fallback topic configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 0 again, so the sweep stops considering it. The round counter is
|
||||||
|
// left where it is, as the record of how far it got.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE incidents SET escalation_level = 0, escalation_level_at = NULL WHERE id = $1",
|
||||||
|
incidentID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageLevel notifies every target of one level and reports who was woken.
|
||||||
|
//
|
||||||
|
// Each target gets its own outbox row, so each gets its own Acknowledge token:
|
||||||
|
// the button in a notification must acknowledge as the person holding the
|
||||||
|
// phone, not as whoever was paged first.
|
||||||
|
func pageLevel(ctx context.Context, tx *sql.Tx, cfg NotifyConfig, policy *escalationPolicy, incidentID int64, level escalationLevel) ([]string, error) {
|
||||||
|
var paged []string
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
|
||||||
|
for _, t := range level.targets {
|
||||||
|
userID := t.userID
|
||||||
|
if t.kind == "oncall" {
|
||||||
|
onCall, err := currentOnCall(ctx, tx, policy.teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if onCall == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID = onCall
|
||||||
|
}
|
||||||
|
if userID == nil || seen[*userID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[*userID] = true
|
||||||
|
|
||||||
|
var topic *string
|
||||||
|
var username string
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT ntfy_topic, username FROM users WHERE id = $1 AND disabled_at IS NULL",
|
||||||
|
*userID).Scan(&topic, &username); err != nil {
|
||||||
|
// A disabled or deleted account is not an error in the middle of an
|
||||||
|
// escalation: it is a target that cannot be woken, and the next
|
||||||
|
// level is the answer to that.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if topic == nil || *topic == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := enqueueNotification(ctx, tx, incidentID, userID, *topic, notifyEscalated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paged = append(paged, username)
|
||||||
|
}
|
||||||
|
return paged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startEscalation puts a newly opened incident on the first rung, when its team
|
||||||
|
// has a ladder. Called from openIncident, inside the same transaction, so an
|
||||||
|
// incident is never briefly open with no escalation clock running.
|
||||||
|
func startEscalation(ctx context.Context, q querier, incidentID, teamID int64) error {
|
||||||
|
policy, err := loadEscalationPolicy(ctx, q, teamID)
|
||||||
|
if err != nil || !policy.configured() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = q.ExecContext(ctx,
|
||||||
|
"UPDATE incidents SET escalation_level = 1, escalation_level_at = $1 WHERE id = $2",
|
||||||
|
time.Now().Unix(), incidentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopEscalation takes an incident off the ladder. Acknowledging or resolving
|
||||||
|
// is somebody saying "I have this", and continuing to wake people after that is
|
||||||
|
// the behaviour that teaches people to ignore the tool.
|
||||||
|
func stopEscalation(ctx context.Context, q querier, incidentID int64) error {
|
||||||
|
_, err := q.ExecContext(ctx,
|
||||||
|
"UPDATE incidents SET escalation_level = 0, escalation_level_at = NULL WHERE id = $1",
|
||||||
|
incidentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetEscalation returns a team's ladder.
|
||||||
|
func handleGetEscalation(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := escalationStatus(r.Context(), db, teamID, policy)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level statuses, as the Escalation page colours them.
|
||||||
|
const (
|
||||||
|
levelReady = "ready"
|
||||||
|
levelEscalating = "escalating"
|
||||||
|
levelUnreachable = "unreachable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// escalationTargetView is a target with who it means today and whether that
|
||||||
|
// person can actually be woken. The extra fields are output only: the PUT body
|
||||||
|
// is the plain escalationTargetJSON, and anything else in it is ignored.
|
||||||
|
type escalationTargetView struct {
|
||||||
|
escalationTargetJSON
|
||||||
|
|
||||||
|
// Username is who the target resolves to right now: the named person, or
|
||||||
|
// whoever the rota says is on call today. Empty when nobody is.
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
|
||||||
|
// Reachable is whether a page to this target would go anywhere, and Problem
|
||||||
|
// says why not when it would not — the same conditions pageLevel skips on.
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationLevelView struct {
|
||||||
|
Position int64 `json:"position"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Targets []escalationTargetView `json:"targets"`
|
||||||
|
|
||||||
|
// Status is unreachable when no target of the level could be woken — a rung
|
||||||
|
// that looks configured and pages nobody, which is worth seeing before an
|
||||||
|
// incident finds it — escalating when an unanswered incident has climbed to
|
||||||
|
// it, and ready otherwise.
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// Waiting lists the open, unacknowledged incidents currently on this level.
|
||||||
|
Waiting []int64 `json:"waiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationView struct {
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
RepeatCount int64 `json:"repeat_count"`
|
||||||
|
FallbackTopic string `json:"fallback_topic"`
|
||||||
|
Levels []escalationLevelView `json:"levels"`
|
||||||
|
|
||||||
|
// LastEscalatedAt is when an incident of this team last moved up the ladder,
|
||||||
|
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
|
||||||
|
// when nothing ever has: a ladder nobody has needed yet.
|
||||||
|
LastEscalatedAt *time.Time `json:"last_escalated_at,omitempty"`
|
||||||
|
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalationStatus is a team's ladder together with what it would do right now
|
||||||
|
// and what it has been doing. The resolution follows pageLevel's rules, so the
|
||||||
|
// page cannot promise a page that the notifier would skip.
|
||||||
|
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
|
||||||
|
base := escalationResponse(policy, teamID)
|
||||||
|
out := escalationView{
|
||||||
|
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
|
||||||
|
Levels: []escalationLevelView{},
|
||||||
|
}
|
||||||
|
if !policy.configured() {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
onCall, err := currentOnCall(ctx, db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
username string
|
||||||
|
topic bool
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
accounts := map[int64]account{}
|
||||||
|
lookup := func(id int64) (account, error) {
|
||||||
|
if a, ok := accounts[id]; ok {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
var a account
|
||||||
|
var topic *string
|
||||||
|
var disabledAt *int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
|
||||||
|
Scan(&a.username, &topic, &disabledAt); err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
a.topic = topic != nil && *topic != ""
|
||||||
|
a.disabled = disabledAt != nil
|
||||||
|
accounts[id] = a
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := map[int64][]int64{}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, escalation_level FROM incidents
|
||||||
|
WHERE team_id = $1 AND resolved_at IS NULL AND archived_at IS NULL
|
||||||
|
AND status = 'triggered' AND escalation_level > 0
|
||||||
|
ORDER BY id`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, level int64
|
||||||
|
if err := rows.Scan(&id, &level); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
waiting[level] = append(waiting[level], id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range base.Levels {
|
||||||
|
level := escalationLevelView{
|
||||||
|
Position: l.Position, TimeoutSeconds: l.TimeoutSeconds,
|
||||||
|
Targets: []escalationTargetView{}, Waiting: []int64{},
|
||||||
|
}
|
||||||
|
if w := waiting[l.Position]; w != nil {
|
||||||
|
level.Waiting = w
|
||||||
|
}
|
||||||
|
|
||||||
|
anyReachable := false
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
view := escalationTargetView{escalationTargetJSON: t}
|
||||||
|
userID := t.UserID
|
||||||
|
if t.Kind == "oncall" {
|
||||||
|
userID = onCall
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case userID == nil:
|
||||||
|
view.Problem = "nobody is on call today"
|
||||||
|
default:
|
||||||
|
a, err := lookup(*userID)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
view.Problem = "account not found"
|
||||||
|
case a.disabled:
|
||||||
|
view.Username, view.Problem = a.username, "account is disabled"
|
||||||
|
case !a.topic:
|
||||||
|
view.Username, view.Problem = a.username, "has no ntfy topic"
|
||||||
|
default:
|
||||||
|
view.Username, view.Reachable = a.username, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyReachable = anyReachable || view.Reachable
|
||||||
|
level.Targets = append(level.Targets, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !anyReachable:
|
||||||
|
level.Status = levelUnreachable
|
||||||
|
case l.Position >= 2 && len(level.Waiting) > 0:
|
||||||
|
level.Status = levelEscalating
|
||||||
|
default:
|
||||||
|
level.Status = levelReady
|
||||||
|
}
|
||||||
|
out.Levels = append(out.Levels, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
var incidentID, at int64
|
||||||
|
switch err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT e.incident_id, e.created_at
|
||||||
|
FROM incident_events e JOIN incidents i ON i.id = e.incident_id
|
||||||
|
WHERE i.team_id = $1 AND e.type = $2
|
||||||
|
ORDER BY e.created_at DESC, e.id DESC LIMIT 1`, teamID, evEscalated).
|
||||||
|
Scan(&incidentID, &at); {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
case err != nil:
|
||||||
|
return out, err
|
||||||
|
default:
|
||||||
|
t := time.Unix(at, 0).UTC()
|
||||||
|
out.LastEscalatedAt, out.LastEscalatedIncidentID = &t, &incidentID
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationLevelJSON struct {
|
||||||
|
Position int64 `json:"position"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Targets []escalationTargetJSON `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationTargetJSON struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
UserID *int64 `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationJSON struct {
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
RepeatCount int64 `json:"repeat_count"`
|
||||||
|
FallbackTopic string `json:"fallback_topic"`
|
||||||
|
Levels []escalationLevelJSON `json:"levels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func escalationResponse(p *escalationPolicy, teamID int64) escalationJSON {
|
||||||
|
out := escalationJSON{TeamID: teamID, Levels: []escalationLevelJSON{}}
|
||||||
|
if p == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.RepeatCount = p.repeatCount
|
||||||
|
out.FallbackTopic = p.fallbackTopic
|
||||||
|
for _, l := range p.levels {
|
||||||
|
level := escalationLevelJSON{
|
||||||
|
Position: l.position,
|
||||||
|
TimeoutSeconds: int64(l.timeout.Seconds()),
|
||||||
|
Targets: []escalationTargetJSON{},
|
||||||
|
}
|
||||||
|
for _, t := range l.targets {
|
||||||
|
level.Targets = append(level.Targets, escalationTargetJSON{Kind: t.kind, UserID: t.userID})
|
||||||
|
}
|
||||||
|
out.Levels = append(out.Levels, level)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetEscalation replaces a team's ladder wholesale.
|
||||||
|
//
|
||||||
|
// Replace rather than patch: the levels are an order, and an API that edits one
|
||||||
|
// rung has to answer what happens to the numbering of the others. Sending the
|
||||||
|
// whole ladder makes the order the client's to decide and the server's to
|
||||||
|
// store, and makes an edit atomic — there is no moment where level 2 exists
|
||||||
|
// twice.
|
||||||
|
func handleSetEscalation(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req escalationJSON
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RepeatCount < 0 || req.RepeatCount > 10 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("repeat_count must be between 0 and 10"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, l := range req.Levels {
|
||||||
|
if l.TimeoutSeconds <= 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("every level needs a timeout"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(l.Targets) == 0 {
|
||||||
|
// A rung that pages nobody is not a delay, it is a silence with
|
||||||
|
// a number on it.
|
||||||
|
respond(w, http.StatusBadRequest,
|
||||||
|
errResp("level "+strconv.FormatInt(int64(i+1), 10)+" has no targets"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
switch t.Kind {
|
||||||
|
case "oncall":
|
||||||
|
if t.UserID != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("an oncall target takes no user_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "user":
|
||||||
|
if t.UserID == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("a user target needs a user_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
respond(w, http.StatusBadRequest, errResp("target kind must be user or oncall"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO escalation_policies (team_id, repeat_count, fallback_topic, updated_at)
|
||||||
|
VALUES ($1, $2, $3, `+nowEpoch+`)
|
||||||
|
ON CONFLICT (team_id) DO UPDATE SET
|
||||||
|
repeat_count = excluded.repeat_count,
|
||||||
|
fallback_topic = excluded.fallback_topic,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
teamID, req.RepeatCount, req.FallbackTopic); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The levels are replaced, not merged; the cascade takes the targets.
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM escalation_levels WHERE team_id = $1", teamID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, l := range req.Levels {
|
||||||
|
var levelID int64
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO escalation_levels (team_id, position, timeout_seconds)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
teamID, int64(i+1), l.TimeoutSeconds).Scan(&levelID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO escalation_targets (level_id, kind, user_id)
|
||||||
|
VALUES ($1, $2, $3)`, levelID, t.Kind, t.UserID); err != nil {
|
||||||
|
// The only foreign key here is the user.
|
||||||
|
respond(w, http.StatusBadRequest, errResp("unknown user in targets"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := loadEscalationPolicy(r.Context(), db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamUser creates a user in the default team with an ntfy topic, so they can
|
||||||
|
// actually be paged.
|
||||||
|
func teamUser(t *testing.T, s *ts, username, topic string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": username, "email": username + "@test.com"}), &user)
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
setTopic(t, s, int(user.ID), topic)
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalation is all timeouts, and there is no fake clock in this package. The
|
||||||
|
// tests back-date escalation_level_at instead, which is the same trick the dead
|
||||||
|
// man's switch tests use on received_at: the sweeper reads a stored timestamp,
|
||||||
|
// so moving the timestamp is moving the clock.
|
||||||
|
|
||||||
|
// ladder configures the default team with two levels: the rota first, then a
|
||||||
|
// named person, then the fallback topic.
|
||||||
|
func ladder(t *testing.T, s *ts, secondUserID int64, repeat int64, fallback string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||||
|
"repeat_count": repeat,
|
||||||
|
"fallback_topic": fallback,
|
||||||
|
"levels": []map[string]any{
|
||||||
|
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}},
|
||||||
|
{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user", "user_id": secondUserID}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("configure the ladder: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overdue back-dates an incident's current level so its timeout has passed.
|
||||||
|
func overdue(t *testing.T, s *ts, incidentID int64) {
|
||||||
|
t.Helper()
|
||||||
|
s.exec(t, "UPDATE incidents SET escalation_level_at = $1 WHERE id = $2",
|
||||||
|
time.Now().Add(-time.Hour).Unix(), incidentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func escalationLevel(t *testing.T, s *ts, incidentID int64) (level, round int64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
"SELECT escalation_level, escalation_round FROM incidents WHERE id = $1",
|
||||||
|
incidentID).Scan(&level, &round); err != nil {
|
||||||
|
t.Fatalf("read escalation state: %v", err)
|
||||||
|
}
|
||||||
|
return level, round
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: nobody answers, so somebody else is woken.
|
||||||
|
func TestEscalation_PagesTheNextLevel(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-esc", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// Level 1 is the rota, so the first page went to the admin.
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 1 {
|
||||||
|
t.Fatalf("a new incident should start at level 1, got %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) == 0 || got[0] != "terdut-admin" {
|
||||||
|
t.Fatalf("the first page should go to the on-call user, went to %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time passes with no acknowledgement.
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||||
|
t.Errorf("expected level 2, got %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-second" {
|
||||||
|
t.Errorf("level 2 should page the named user, paged %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the timeline says so, which is what somebody reads afterwards to
|
||||||
|
// understand why their phone rang at 04:00.
|
||||||
|
timeline := list(t, s.req(t, http.MethodGet, "/api/incidents/1/timeline", nil))
|
||||||
|
found := ""
|
||||||
|
for _, e := range timeline {
|
||||||
|
if e["type"] == "escalated" {
|
||||||
|
found, _ = e["detail"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == "" {
|
||||||
|
t.Error("the timeline should record the escalation")
|
||||||
|
} else if !strings.HasPrefix(found, "level 2") || !strings.Contains(found, "second") {
|
||||||
|
t.Errorf("the escalation entry should say which level and who: %q", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledging is somebody saying "I have this". Nobody else should be woken.
|
||||||
|
func TestEscalation_AcknowledgementStopsIt(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-ack", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||||
|
t.Errorf("acknowledging should take the incident off the ladder, level is %d", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1) // no-op: level is 0, so there is nothing due
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("an acknowledged incident should page nobody, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolving stops it too, and by the same mechanism.
|
||||||
|
func TestEscalation_ResolutionStopsIt(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-res", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||||
|
t.Errorf("a resolved incident should be off the ladder, level is %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snoozing is a deliberate "not now", so the ladder waits rather than carrying
|
||||||
|
// on without the person who asked for quiet.
|
||||||
|
func TestEscalation_SnoozePausesIt(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-snooze", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/snooze", map[string]any{"duration": "1h"})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 1 {
|
||||||
|
t.Errorf("a snoozed incident should stay where it is, level is %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("a snoozed incident should page nobody, paged %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the snooze ends, the ladder picks up where it left off.
|
||||||
|
s.exec(t, "UPDATE incidents SET snoozed_until = $1 WHERE id = 1", time.Now().Add(-time.Minute).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||||
|
t.Errorf("after the snooze the ladder should resume, level is %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running out of ladder pages the team's fallback topic once, and says so.
|
||||||
|
func TestEscalation_ExhaustionPagesTheFallback(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-end", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // level 2
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // off the end
|
||||||
|
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-fallback" {
|
||||||
|
t.Errorf("exhaustion should page the fallback topic once, paged %v", got)
|
||||||
|
}
|
||||||
|
level, _ := escalationLevel(t, s, 1)
|
||||||
|
if level != 0 {
|
||||||
|
t.Errorf("an exhausted ladder should stop asking, level is %d", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The incident is still open: running out of people is not an answer.
|
||||||
|
var status string
|
||||||
|
if err := s.db.QueryRow("SELECT status FROM incidents WHERE id = 1").Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "triggered" {
|
||||||
|
t.Errorf("exhaustion must not resolve the incident, status is %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat_count walks the whole ladder again before giving up.
|
||||||
|
func TestEscalation_RepeatsTheChain(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 1, "terdut-fallback") // one extra round
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-repeat", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // level 2
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t) // back to level 1, round 2
|
||||||
|
|
||||||
|
level, round := escalationLevel(t, s, 1)
|
||||||
|
if level != 1 || round != 1 {
|
||||||
|
t.Errorf("expected level 1 round 1, got level %d round %d", level, round)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-admin" {
|
||||||
|
t.Errorf("the second round should start at the top again, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A team without a ladder keeps exactly the behaviour it had, and never gets
|
||||||
|
// both a reminder and an escalation for the same silence.
|
||||||
|
func TestEscalation_WithoutAPolicyRemindersStillRun(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-noesc", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// Age the first notification past the repeat interval.
|
||||||
|
f.forget()
|
||||||
|
s.exec(t, "UPDATE notifications SET created_at = $1, sent_at = $1",
|
||||||
|
time.Now().Add(-time.Hour).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := f.topicsSince(t); len(got) != 1 || got[0] != "terdut-admin" {
|
||||||
|
t.Errorf("without a ladder the reminder should still fire, paged %v", got)
|
||||||
|
}
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 0 {
|
||||||
|
t.Errorf("an incident in a team with no ladder should not be on one, level is %d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a ladder, reminders stop: two pages for one silence is how people learn
|
||||||
|
// to mute the tool.
|
||||||
|
func TestEscalation_WithAPolicyRemindersDoNotAlsoFire(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-both", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
// Old enough for a reminder, but not yet due for escalation.
|
||||||
|
s.exec(t, "UPDATE notifications SET created_at = $1, sent_at = $1",
|
||||||
|
time.Now().Add(-time.Hour).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("a team with a ladder should not also get reminders, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API refuses a ladder that cannot page anybody.
|
||||||
|
func TestEscalation_RejectsAnUnusablePolicy(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"a level with no targets", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{}}},
|
||||||
|
}},
|
||||||
|
{"a level with no timeout", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 0, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||||
|
}},
|
||||||
|
{"a user target with no user", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "user"}}}},
|
||||||
|
}},
|
||||||
|
{"an unknown target kind", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "everybody"}}}},
|
||||||
|
}},
|
||||||
|
{"an absurd repeat count", map[string]any{
|
||||||
|
"repeat_count": 99,
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing the ladder is an owner's job; reading it is any member's.
|
||||||
|
func TestEscalation_OwnerOnlyToEdit(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPut, "/api/teams/"+defaultTeam+"/escalation", map[string]any{
|
||||||
|
"levels": []map[string]any{{"timeout_seconds": 300, "targets": []map[string]any{{"kind": "oncall"}}}},
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member editing the ladder: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = call(http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("a member reading the ladder: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A target who cannot be woken is not a reason to stop: the next level is the
|
||||||
|
// answer to an unreachable one.
|
||||||
|
func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
// Second user has no ntfy topic at all.
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "silent", "email": "silent@test.com"}), &user)
|
||||||
|
s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"}).Body.Close()
|
||||||
|
|
||||||
|
ladder(t, s, user.ID, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-silent", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
f.forget()
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// Level 2 was entered even though it woke nobody, so the ladder keeps
|
||||||
|
// moving toward the fallback rather than stalling on a silent rung.
|
||||||
|
if level, _ := escalationLevel(t, s, 1); level != 2 {
|
||||||
|
t.Errorf("expected the ladder to advance past an unreachable target, level is %d", level)
|
||||||
|
}
|
||||||
|
if got := f.topicsSince(t); len(got) != 0 {
|
||||||
|
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The ladder as the Escalation page reads it
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ladderLevel struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Waiting []int64 `json:"waiting"`
|
||||||
|
Targets []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
} `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ladderView struct {
|
||||||
|
Levels []ladderLevel `json:"levels"`
|
||||||
|
LastEscalatedAt *string `json:"last_escalated_at"`
|
||||||
|
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLadder(t *testing.T, s *ts) ladderView {
|
||||||
|
t.Helper()
|
||||||
|
var v ladderView
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil), &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targets say who they mean today, so "whoever is on call" is a name and not a
|
||||||
|
// promise.
|
||||||
|
func TestEscalation_StatusResolvesTargets(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if len(v.Levels) != 2 {
|
||||||
|
t.Fatalf("expected 2 levels, got %d", len(v.Levels))
|
||||||
|
}
|
||||||
|
if got := v.Levels[0].Targets[0]; got.Kind != "oncall" || got.Username != "admin" || !got.Reachable {
|
||||||
|
t.Errorf("the rota target should resolve to the person on call, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := v.Levels[1].Targets[0]; got.Username != "second" || !got.Reachable {
|
||||||
|
t.Errorf("the named target should be reachable, got %+v", got)
|
||||||
|
}
|
||||||
|
if v.Levels[0].Status != "ready" || v.Levels[1].Status != "ready" || v.LastEscalatedAt != nil {
|
||||||
|
t.Errorf("an idle, healthy ladder is ready and has never escalated, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rung that would page nobody is called out before an incident finds it.
|
||||||
|
func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
silent := teamUser(t, s, "silent", "terdut-silent")
|
||||||
|
ladder(t, s, silent, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
// Nobody on call today, and the named person loses their topic.
|
||||||
|
s.exec(t, "DELETE FROM schedule_entries")
|
||||||
|
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
|
||||||
|
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
|
||||||
|
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
|
||||||
|
}
|
||||||
|
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
|
||||||
|
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
|
||||||
|
if p := readLadder(t, s).Levels[1].Targets[0].Problem; p != "account is disabled" {
|
||||||
|
t.Errorf("a disabled account should say so, got %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where unanswered incidents are right now, and when the ladder last did its
|
||||||
|
// job.
|
||||||
|
func TestEscalation_StatusShowsWhoIsWaitingAndLastEscalation(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-wait", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// On level 1 it is waiting, which is normal and not yet an escalation.
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if len(v.Levels[0].Waiting) != 1 || v.Levels[0].Status != "ready" || v.LastEscalatedAt != nil {
|
||||||
|
t.Fatalf("a fresh incident waits on level 1 quietly, got %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
v = readLadder(t, s)
|
||||||
|
if v.Levels[1].Status != "escalating" || len(v.Levels[1].Waiting) != 1 || v.Levels[1].Waiting[0] != 1 {
|
||||||
|
t.Errorf("level 2 should be escalating with the incident on it, got %+v", v.Levels[1])
|
||||||
|
}
|
||||||
|
if v.LastEscalatedAt == nil || v.LastEscalatedIncidentID == nil || *v.LastEscalatedIncidentID != 1 {
|
||||||
|
t.Errorf("the escalation should be recorded, got %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody answers: nothing is waiting, but the history stays.
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||||
|
v = readLadder(t, s)
|
||||||
|
if v.Levels[1].Status != "ready" || len(v.Levels[1].Waiting) != 0 || v.LastEscalatedAt == nil {
|
||||||
|
t.Errorf("an acknowledged incident stops waiting but stays in the history, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ladder is a real answer, not an error.
|
||||||
|
func TestEscalation_StatusWithoutALadder(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if len(v.Levels) != 0 || v.LastEscalatedAt != nil {
|
||||||
|
t.Errorf("a team with no ladder should read as empty, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,67 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgerrcode"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sqlArgs accumulates query arguments and hands back the placeholder for each.
|
||||||
|
//
|
||||||
|
// Postgres numbers its placeholders, so a dynamically assembled WHERE clause has
|
||||||
|
// to keep its $1, $2, … in step with the order of the values — which SQLite's
|
||||||
|
// positional `?` did for free. Handing out the placeholder and storing the value
|
||||||
|
// in one call is what keeps them in step: a filter can be added, removed or
|
||||||
|
// reordered without renumbering anything by hand.
|
||||||
|
type sqlArgs struct{ vals []any }
|
||||||
|
|
||||||
|
// add stores v and returns the placeholder that refers to it.
|
||||||
|
func (a *sqlArgs) add(v any) string {
|
||||||
|
a.vals = append(a.vals, v)
|
||||||
|
return "$" + strconv.Itoa(len(a.vals))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addList stores every value and returns their placeholders as "$1, $2, …",
|
||||||
|
// ready to drop into an IN (…) clause. Returns an empty string for no values,
|
||||||
|
// which no caller should reach: `IN ()` is a syntax error in Postgres as it was
|
||||||
|
// in SQLite, so callers check for an empty set before building the query.
|
||||||
|
func (a *sqlArgs) addList(vs []any) string {
|
||||||
|
parts := make([]string, len(vs))
|
||||||
|
for i, v := range vs {
|
||||||
|
parts[i] = a.add(v)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// all returns the accumulated values, to be passed straight to Query or Exec.
|
||||||
|
func (a *sqlArgs) all() []any { return a.vals }
|
||||||
|
|
||||||
|
// nowEpoch is the SQL expression for "now, as unix seconds", matching how every
|
||||||
|
// timestamp in this schema is stored. SQLite spelled it unixepoch().
|
||||||
|
//
|
||||||
|
// FLOOR, not a bare cast: EXTRACT returns fractional seconds and casting to
|
||||||
|
// bigint rounds half up, so a row written at .6 of a second would claim a
|
||||||
|
// timestamp one second in the future — off by one against the time.Now().Unix()
|
||||||
|
// the Go side stamps, which is what the expiry tests measure.
|
||||||
|
const nowEpoch = "FLOOR(EXTRACT(EPOCH FROM now()))::bigint"
|
||||||
|
|
||||||
|
// isUniqueViolation reports whether err is a broken unique constraint, which
|
||||||
|
// callers turn into 409 Conflict rather than 500.
|
||||||
|
//
|
||||||
|
// Postgres reports it as SQLSTATE 23505 on a typed error; the SQLite driver this
|
||||||
|
// replaced only put "UNIQUE constraint failed" in the message, which is why the
|
||||||
|
// check used to be a substring match. Matching the code means a renamed
|
||||||
|
// constraint or a translated message cannot quietly turn a conflict back into a
|
||||||
|
// 500.
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
|
||||||
|
}
|
||||||
|
|
||||||
func respond(w http.ResponseWriter, status int, v any) {
|
func respond(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Values for incidents.resolution_source, recording who closed the incident:
|
||||||
|
// every member alert stopped firing, or a person decided it was done.
|
||||||
|
const (
|
||||||
|
incidentResolutionAlerts = "alerts"
|
||||||
|
incidentResolutionManual = "manual"
|
||||||
|
|
||||||
|
// incidentResolutionRecovered closes a dead man's switch incident whose
|
||||||
|
// heartbeat started arriving again. It cannot be "alerts": these incidents
|
||||||
|
// have no member alerts for the cascade to work from.
|
||||||
|
incidentResolutionRecovered = "recovered"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Incident timeline event types. Stored as free text so adding one later is not
|
||||||
|
// a migration, but these are the ones the server writes.
|
||||||
|
const (
|
||||||
|
evTriggered = "triggered"
|
||||||
|
evAlertAdded = "alert_added"
|
||||||
|
evAlertResolved = "alert_resolved"
|
||||||
|
evAcknowledged = "acknowledged"
|
||||||
|
evUnacknowledged = "unacknowledged"
|
||||||
|
evAssigned = "assigned"
|
||||||
|
evSnoozed = "snoozed"
|
||||||
|
evUnsnoozed = "unsnoozed"
|
||||||
|
evResolved = "resolved"
|
||||||
|
evNote = "note"
|
||||||
|
// evResolutionNote is the note worth finding again: what fixed it. The
|
||||||
|
// similar-incidents lookup and the page lead with these; plain notes are
|
||||||
|
// the working chatter and stay one click away.
|
||||||
|
evResolutionNote = "resolution_note"
|
||||||
|
evDeadmanSilent = "deadman_silent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// severityLabel is the Alertmanager label an incident's severity is derived from.
|
||||||
|
const severityLabel = "severity"
|
||||||
|
|
||||||
|
// querier is satisfied by both *sql.DB and *sql.Tx, so the helpers below work
|
||||||
|
// inside the webhook's transaction and standalone from handlers and the sweeper.
|
||||||
|
type querier interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
const incidentSelectFrom = `
|
||||||
|
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
||||||
|
i.escalation_level,
|
||||||
|
-- When this level runs out. Computed here rather than in Go because
|
||||||
|
-- the timeout lives beside the level in the policy, and one join is
|
||||||
|
-- cheaper than a second query per incident in a list.
|
||||||
|
(SELECT i.escalation_level_at + el.timeout_seconds
|
||||||
|
FROM escalation_levels el
|
||||||
|
WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
|
||||||
|
i.triggered_at,
|
||||||
|
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||||
|
i.assigned_to, asg.username, i.snoozed_until,
|
||||||
|
i.resolved_at, i.resolution_source, i.archived_at
|
||||||
|
FROM incidents i
|
||||||
|
JOIN teams t ON t.id = i.team_id
|
||||||
|
LEFT JOIN users ack ON ack.id = i.acknowledged_by
|
||||||
|
LEFT JOIN users asg ON asg.id = i.assigned_to`
|
||||||
|
|
||||||
|
func scanIncident(s scanner) (models.Incident, error) {
|
||||||
|
var i models.Incident
|
||||||
|
var groupLabelsJSON string
|
||||||
|
var triggeredAt int64
|
||||||
|
var ackAt, snoozedUntil, resolvedAt, archivedAt, escalationDue *int64
|
||||||
|
|
||||||
|
if err := s.Scan(
|
||||||
|
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
||||||
|
&i.EscalationLevel, &escalationDue,
|
||||||
|
&triggeredAt,
|
||||||
|
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||||
|
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||||
|
&resolvedAt, &i.ResolutionSource, &archivedAt,
|
||||||
|
); err != nil {
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal([]byte(groupLabelsJSON), &i.GroupLabels) //nolint:errcheck
|
||||||
|
i.TriggeredAt = time.Unix(triggeredAt, 0).UTC()
|
||||||
|
i.AcknowledgedAt = unixPtr(ackAt)
|
||||||
|
i.SnoozedUntil = unixPtr(snoozedUntil)
|
||||||
|
i.ResolvedAt = unixPtr(resolvedAt)
|
||||||
|
i.ArchivedAt = unixPtr(archivedAt)
|
||||||
|
i.EscalationDueAt = unixPtr(escalationDue)
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unixPtr converts a nullable Unix-second column to a nullable UTC time.
|
||||||
|
func unixPtr(sec *int64) *time.Time {
|
||||||
|
if sec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.Unix(*sec, 0).UTC()
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, error) {
|
||||||
|
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = $1", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// logEvent appends one entry to an incident's timeline. A nil userID means the
|
||||||
|
// server acted rather than a person.
|
||||||
|
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error {
|
||||||
|
_, err := q.ExecContext(ctx, `
|
||||||
|
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
incidentID, evType, userID, alertID, detail, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// todayUTC is the schedule's day key. The schedule's smallest unit is one UTC day.
|
||||||
|
func todayUTC() string {
|
||||||
|
return time.Now().UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentOnCall returns a team's on-call user for today, or nil when nobody is
|
||||||
|
// scheduled. A missing schedule entry is not an error — incidents just open
|
||||||
|
// unassigned.
|
||||||
|
//
|
||||||
|
// Per team: each team keeps its own rota, so two teams can have two different
|
||||||
|
// people on call on the same day, which was the point of scoping the schedule.
|
||||||
|
func currentOnCall(ctx context.Context, q querier, teamID int64) (*int64, error) {
|
||||||
|
var userID int64
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
"SELECT user_id FROM schedule_entries WHERE team_id = $1 AND date = $2",
|
||||||
|
teamID, todayUTC()).Scan(&userID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// severityRank orders the conventional Alertmanager severity label values.
|
||||||
|
// Anything unrecognised sorts below all of them rather than being dropped.
|
||||||
|
func severityRank(s string) int {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "critical":
|
||||||
|
return 4
|
||||||
|
case "error":
|
||||||
|
return 3
|
||||||
|
case "warning":
|
||||||
|
return 2
|
||||||
|
case "info":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshSeverity raises an incident's severity to the highest `severity` label
|
||||||
|
// seen across its alerts.
|
||||||
|
//
|
||||||
|
// It is a high-water mark, never lowered: an incident that hit critical was a
|
||||||
|
// critical incident, even after the critical alert clears and a warning is all
|
||||||
|
// that is left firing. Downgrading a live incident would also quietly demote it
|
||||||
|
// in the queue while the work is still open.
|
||||||
|
func refreshSeverity(ctx context.Context, q querier, incidentID int64) error {
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT a.labels ->> $1
|
||||||
|
FROM incident_alerts ia
|
||||||
|
JOIN alerts a ON a.id = ia.alert_id
|
||||||
|
WHERE ia.incident_id = $2`, severityLabel, incidentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
best := ""
|
||||||
|
for rows.Next() {
|
||||||
|
var sev *string
|
||||||
|
if err := rows.Scan(&sev); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sev != nil && severityRank(*sev) > severityRank(best) {
|
||||||
|
best = *sev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if best == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The comparison lives in SQL so an unrelated concurrent update cannot be
|
||||||
|
// clobbered by a stale read.
|
||||||
|
_, err = q.ExecContext(ctx, `
|
||||||
|
UPDATE incidents SET severity = $1
|
||||||
|
WHERE id = $2
|
||||||
|
AND (severity IS NULL OR `+severityRankSQL("severity")+` < $3)`,
|
||||||
|
best, incidentID, severityRank(best))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// severityRankSQL mirrors severityRank for use inside a statement. SQL cannot
|
||||||
|
// order these strings meaningfully on its own.
|
||||||
|
func severityRankSQL(col string) string {
|
||||||
|
return `CASE lower(COALESCE(` + col + `, ''))
|
||||||
|
WHEN 'critical' THEN 4
|
||||||
|
WHEN 'error' THEN 3
|
||||||
|
WHEN 'warning' THEN 2
|
||||||
|
WHEN 'info' THEN 1
|
||||||
|
ELSE 0 END`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveIfSettled closes an incident once every alert under it has stopped
|
||||||
|
// firing — PagerDuty's cascade, and the only automatic route out of the open
|
||||||
|
// state. Reports whether it actually resolved anything.
|
||||||
|
func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, error) {
|
||||||
|
res, err := q.ExecContext(ctx, `
|
||||||
|
UPDATE incidents
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolved_at = $1,
|
||||||
|
resolution_source = $2
|
||||||
|
WHERE id = $3
|
||||||
|
AND resolved_at IS NULL
|
||||||
|
-- An incident with no members yet is mid-creation, not settled.
|
||||||
|
AND EXISTS (SELECT 1 FROM incident_alerts ia WHERE ia.incident_id = incidents.id)
|
||||||
|
AND NOT EXISTS (SELECT 1
|
||||||
|
FROM incident_alerts ia
|
||||||
|
JOIN alerts a ON a.id = ia.alert_id
|
||||||
|
WHERE ia.incident_id = incidents.id
|
||||||
|
AND a.status = 'firing')`,
|
||||||
|
time.Now().Unix(), incidentResolutionAlerts, incidentID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// The all-clear goes only to whoever was paged in the first place, which
|
||||||
|
// enqueueResolved works out from the incident's own notification history.
|
||||||
|
// Manual resolution sends nothing: the person who closed it already knows.
|
||||||
|
return true, enqueueResolved(ctx, q, incidentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acknowledgeIncident records that userID has picked an incident up, and reports
|
||||||
|
// whether it changed anything — an already-resolved incident is left alone.
|
||||||
|
// Shared by the authenticated handler and the Acknowledge button in a push
|
||||||
|
// notification, so both write the same state and the same timeline entry.
|
||||||
|
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
||||||
|
res, err := q.ExecContext(ctx, `
|
||||||
|
UPDATE incidents
|
||||||
|
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
|
||||||
|
WHERE id = $3 AND resolved_at IS NULL`,
|
||||||
|
userID, time.Now().Unix(), incidentID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Somebody has it: stop waking anybody else.
|
||||||
|
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openIncidentForAlert returns the open incident an alert currently belongs to,
|
||||||
|
// or 0 when it has none. Used when an alert resolves or expires so the event
|
||||||
|
// lands on the right timeline.
|
||||||
|
func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := q.QueryRowContext(ctx, `
|
||||||
|
SELECT i.id
|
||||||
|
FROM incident_alerts ia
|
||||||
|
JOIN incidents i ON i.id = ia.incident_id
|
||||||
|
WHERE ia.alert_id = $1 AND i.resolved_at IS NULL`, alertID).Scan(&id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// volatileLabels say where a problem ran this time, not what the problem is, so
|
||||||
|
// they stay out of the signature. Migration 008's backfill lists the same set.
|
||||||
|
var volatileLabels = map[string]bool{
|
||||||
|
"instance": true, "pod": true, "pod_name": true, "pod_ip": true,
|
||||||
|
"container": true, "container_name": true, "endpoint": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentSignature identifies "the same problem" across incidents: the alert
|
||||||
|
// name plus the stable group labels, sorted. Incidents in one team with equal
|
||||||
|
// signatures are what the similar-incidents lookup returns. title stands in for
|
||||||
|
// the name when the payload carried no alertname (groupless and dead man's
|
||||||
|
// switch incidents).
|
||||||
|
func incidentSignature(groupLabels map[string]string, title string) string {
|
||||||
|
name := groupLabels["alertname"]
|
||||||
|
if name == "" {
|
||||||
|
name = title
|
||||||
|
}
|
||||||
|
rest := make([]string, 0, len(groupLabels))
|
||||||
|
for k, v := range groupLabels {
|
||||||
|
if k == "alertname" || volatileLabels[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(rest)
|
||||||
|
return name + "|" + strings.Join(rest, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
|
||||||
|
// leading with the alert name and appending whatever else the operator grouped
|
||||||
|
// by. Falls back to the alert's own name when the payload carried no groupLabels.
|
||||||
|
func incidentTitle(groupLabels map[string]string, fallback string) string {
|
||||||
|
name := groupLabels["alertname"]
|
||||||
|
if name == "" {
|
||||||
|
name = fallback
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Incident"
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := make([]string, 0, len(groupLabels))
|
||||||
|
for k, v := range groupLabels {
|
||||||
|
if k == "alertname" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, k+"="+v)
|
||||||
|
}
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
sort.Strings(rest)
|
||||||
|
return name + " (" + strings.Join(rest, ", ") + ")"
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
where := []string{}
|
||||||
|
args := &sqlArgs{}
|
||||||
|
|
||||||
|
// The combined queue: every team the caller belongs to, in one list. A
|
||||||
|
// caller in no team sees an empty queue rather than everybody's.
|
||||||
|
where = append(where, "i.team_id = ANY("+args.add(callerTeamIDs(r.Context()))+")")
|
||||||
|
if team := q.Get("team_id"); team != "" {
|
||||||
|
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
|
||||||
|
where = append(where, "i.team_id = "+args.add(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without an explicit status the queue shows open work, which is what an
|
||||||
|
// on-call person opens the tool to see.
|
||||||
|
if status := q.Get("status"); status != "" {
|
||||||
|
where = append(where, "i.status = "+args.add(status))
|
||||||
|
} else {
|
||||||
|
where = append(where, "i.resolved_at IS NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.Get("archived") == "true" {
|
||||||
|
where = append(where, "i.archived_at IS NOT NULL")
|
||||||
|
} else {
|
||||||
|
where = append(where, "i.archived_at IS NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snooze expires by simply falling into the past; nothing sweeps it.
|
||||||
|
if q.Get("snoozed") == "true" {
|
||||||
|
where = append(where, "i.snoozed_until > "+args.add(time.Now().Unix()))
|
||||||
|
} else {
|
||||||
|
where = append(where, "(i.snoozed_until IS NULL OR i.snoozed_until <= "+args.add(time.Now().Unix())+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
if severity := q.Get("severity"); severity != "" {
|
||||||
|
where = append(where, "i.severity = "+args.add(severity))
|
||||||
|
}
|
||||||
|
if assignee := q.Get("assigned_to"); assignee != "" {
|
||||||
|
if n, err := strconv.ParseInt(assignee, 10, 64); err == nil {
|
||||||
|
where = append(where, "i.assigned_to = "+args.add(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if from := q.Get("from"); from != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||||
|
where = append(where, "i.triggered_at >= "+args.add(t.UTC().Unix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to := q.Get("to"); to != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||||
|
where = append(where, "i.triggered_at < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 50
|
||||||
|
if l := q.Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 500 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
order := "i.triggered_at DESC"
|
||||||
|
if q.Get("sort") == "severity" {
|
||||||
|
order = severityRankSQL("i.severity") + " DESC, i.triggered_at DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(),
|
||||||
|
fmt.Sprintf("%s WHERE %s ORDER BY %s LIMIT %s",
|
||||||
|
incidentSelectFrom, strings.Join(where, " AND "), order, args.add(limit)),
|
||||||
|
args.all()...)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
incidents := []models.Incident{}
|
||||||
|
for rows.Next() {
|
||||||
|
i, err := scanIncident(rows)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
incidents = append(incidents, i)
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, incidents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetIncident(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inc, err := fetchIncident(r.Context(), db, id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if inc.Alerts, err = incidentAlerts(r, db, id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, inc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentAlerts(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alerts, err := incidentAlerts(r, db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
|
||||||
|
e.alert_id, e.detail, e.created_at
|
||||||
|
FROM incident_events e
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
WHERE e.incident_id = $1
|
||||||
|
ORDER BY e.created_at ASC, e.id ASC`, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
events := []models.IncidentEvent{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.IncidentEvent
|
||||||
|
var ts int64
|
||||||
|
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
|
||||||
|
&e.AlertID, &e.Detail, &ts); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
acked, err := acknowledgeIncident(r.Context(), db, id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !acked {
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusConflict, errResp("incident is resolved"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondIncident(w, r, db, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
if !updateOpenIncident(w, r, db, id,
|
||||||
|
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL
|
||||||
|
WHERE id = $1 AND resolved_at IS NULL`, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIncidentResolve closes an incident by hand. This is terminal: a later
|
||||||
|
// occurrence opens a new incident rather than reopening this one, which is what
|
||||||
|
// stops a resolved incident from reappearing on the next repeat_interval
|
||||||
|
// re-send of an alert that never stopped firing. Use snooze for "not now".
|
||||||
|
func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
// The body is optional: clients that predate resolution notes send none.
|
||||||
|
var req struct {
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil && err != io.EOF {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Resolution = strings.TrimSpace(req.Resolution)
|
||||||
|
if !updateOpenIncident(w, r, db, id,
|
||||||
|
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
||||||
|
WHERE id = $3 AND resolved_at IS NULL`,
|
||||||
|
time.Now().Unix(), incidentResolutionManual, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A person closing an incident is the clearest possible "I have this".
|
||||||
|
if err := stopEscalation(r.Context(), db, id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Resolution != "" {
|
||||||
|
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respondIncident(w, r, db, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UserID == 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("user_id is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var exists int
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT 1 FROM users WHERE id = $1", req.UserID).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !updateOpenIncident(w, r, db, id,
|
||||||
|
"UPDATE incidents SET assigned_to = $1 WHERE id = $2 AND resolved_at IS NULL",
|
||||||
|
req.UserID, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// On an "assigned" event user_id is the assignee, not the actor.
|
||||||
|
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondIncident(w, r, db, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIncidentSnooze hides an incident from the default queue without closing
|
||||||
|
// it. Accepts either an absolute {"until": RFC3339} or a relative
|
||||||
|
// {"duration": "2h"}.
|
||||||
|
func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Until string `json:"until"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var until time.Time
|
||||||
|
switch {
|
||||||
|
case req.Until != "":
|
||||||
|
t, err := time.Parse(time.RFC3339, req.Until)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid until (expected RFC3339)"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until = t
|
||||||
|
case req.Duration != "":
|
||||||
|
d, err := time.ParseDuration(req.Duration)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid duration"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until = time.Now().Add(d)
|
||||||
|
default:
|
||||||
|
respond(w, http.StatusBadRequest, errResp("until or duration is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !until.After(time.Now()) {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("snooze must end in the future"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
if !updateOpenIncident(w, r, db, id,
|
||||||
|
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
|
||||||
|
until.Unix(), id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail := until.UTC().Format(time.RFC3339)
|
||||||
|
if err := logEvent(r.Context(), db, id, evSnoozed, &user.ID, nil, &detail); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondIncident(w, r, db, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
if !updateOpenIncident(w, r, db, id,
|
||||||
|
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE incidents SET archived_at = "+nowEpoch+" WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondIncident(w, r, db, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE incidents SET archived_at = NULL WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateNote adds a note to the timeline. Notes are ordinary events, so a
|
||||||
|
// single query renders the whole story of an incident in order.
|
||||||
|
func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
// Pinned files the note as the resolution note: what fixed it.
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteType := evNote
|
||||||
|
if req.Pinned {
|
||||||
|
noteType = evResolutionNote
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("content is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
now := time.Now()
|
||||||
|
var eventID int64
|
||||||
|
err := db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(w, http.StatusCreated, models.IncidentEvent{
|
||||||
|
ID: eventID,
|
||||||
|
IncidentID: id,
|
||||||
|
Type: noteType,
|
||||||
|
UserID: &user.ID,
|
||||||
|
Username: &user.Username,
|
||||||
|
Detail: &req.Content,
|
||||||
|
CreatedAt: now.UTC().Truncate(time.Second),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteNote removes one of your own notes. Only notes are deletable — the
|
||||||
|
// rest of the timeline is what actually happened, and is not editable.
|
||||||
|
func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventID, err := strconv.ParseInt(chi.URLParam(r, "eventID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid note id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := userFromContext(r.Context())
|
||||||
|
res, err := db.ExecContext(r.Context(), `
|
||||||
|
DELETE FROM incident_events
|
||||||
|
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`,
|
||||||
|
eventID, id, evNote, evResolutionNote, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("note not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared handler plumbing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// incidentIDParam reads {id} from the path AND confirms the incident belongs to
|
||||||
|
// a team the caller is in. Both in one place, deliberately: every incident route
|
||||||
|
// goes through here, so scoping cannot be forgotten by writing a new handler
|
||||||
|
// that only remembers the first half.
|
||||||
|
//
|
||||||
|
// An incident in somebody else's team is reported as not found rather than
|
||||||
|
// forbidden, because "there is an incident 41 you may not see" is itself
|
||||||
|
// something only that team should know.
|
||||||
|
func incidentIDParam(w http.ResponseWriter, r *http.Request, db *sql.DB) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid incident id"))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentExists reports whether the incident is one the caller may see at all.
|
||||||
|
func incidentExists(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) bool {
|
||||||
|
var exists int
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT 1 FROM incidents WHERE id = $1 AND team_id = ANY($2)",
|
||||||
|
id, callerTeamIDs(r.Context())).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOpenIncident runs a mutation that is only valid while an incident is
|
||||||
|
// open. The query must be constrained to `resolved_at IS NULL`, so no rows means
|
||||||
|
// either the incident does not exist or it is already closed — two different
|
||||||
|
// answers the caller should not have to distinguish itself.
|
||||||
|
func updateOpenIncident(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64, query string, args ...any) bool {
|
||||||
|
res, err := db.ExecContext(r.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !incidentExists(w, r, db, id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
respond(w, http.StatusConflict, errResp("incident is resolved"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondIncident(w http.ResponseWriter, r *http.Request, db *sql.DB, id int64) {
|
||||||
|
inc, err := fetchIncident(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, inc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentAlerts loads the alerts under an incident, newest signal first.
|
||||||
|
func incidentAlerts(r *http.Request, db *sql.DB, id int64) ([]models.Alert, error) {
|
||||||
|
rows, err := db.QueryContext(r.Context(), alertSelectFrom+`
|
||||||
|
JOIN incident_alerts m ON m.alert_id = a.id
|
||||||
|
WHERE m.incident_id = $1
|
||||||
|
ORDER BY a.received_at DESC`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
alerts := []models.Alert{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAlert(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
alerts = append(alerts, a)
|
||||||
|
}
|
||||||
|
return alerts, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// amAlert builds one alert of a webhook payload.
|
||||||
|
func amAlert(fingerprint, name, status, startsAt, endsAt string, labels map[string]string) map[string]any {
|
||||||
|
l := map[string]string{"alertname": name}
|
||||||
|
for k, v := range labels {
|
||||||
|
l[k] = v
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"labels": l,
|
||||||
|
"annotations": map[string]string{},
|
||||||
|
"startsAt": startsAt,
|
||||||
|
"endsAt": endsAt,
|
||||||
|
"generatorURL": "",
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listIncidents(t *testing.T, s *ts, query string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents"+query, nil), &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIncident(t *testing.T, s *ts, id int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/incidents/%d", id), nil), &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(t *testing.T, s *ts, id int) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/incidents/%d/timeline", id), nil), &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventTypes flattens a timeline to its event types, which is what the ordering
|
||||||
|
// assertions actually care about.
|
||||||
|
func eventTypes(events []map[string]any) []string {
|
||||||
|
types := make([]string, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
types[i] = e["type"].(string)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// countIncidents counts rows directly, including resolved and archived ones that
|
||||||
|
// no list view returns.
|
||||||
|
func (s *ts) countIncidents(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM incidents").Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count incidents: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ingest: alerts becoming incidents
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWebhook_FiringOpensIncident(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-1", "HighCPU", "firing", "2026-05-20T10:00:00Z", zeroTime,
|
||||||
|
map[string]string{"severity": "critical"}),
|
||||||
|
}, "{}:{alertname=\"HighCPU\"}")
|
||||||
|
|
||||||
|
incidents := listIncidents(t, s, "")
|
||||||
|
if len(incidents) != 1 {
|
||||||
|
t.Fatalf("expected 1 incident, got %d", len(incidents))
|
||||||
|
}
|
||||||
|
inc := incidents[0]
|
||||||
|
if inc["status"] != "triggered" {
|
||||||
|
t.Errorf("expected status triggered, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
if inc["severity"] != "critical" {
|
||||||
|
t.Errorf("expected severity critical, got %v", inc["severity"])
|
||||||
|
}
|
||||||
|
if inc["title"] != "HighCPU" {
|
||||||
|
t.Errorf("expected title from groupLabels, got %v", inc["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert points back at the incident it opened.
|
||||||
|
var alerts []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||||
|
if len(alerts) != 1 || alerts[0]["incident_id"] == nil {
|
||||||
|
t.Fatalf("expected the alert to carry an incident_id, got %v", alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertmanager already grouped these; we adopt its answer rather than
|
||||||
|
// correlating again.
|
||||||
|
func TestWebhook_SameGroupKeyJoinsOneIncident(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
const groupKey = "{}:{alertname=\"DiskFull\"}"
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-a", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
amAlert("fp-b", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, groupKey)
|
||||||
|
|
||||||
|
incidents := listIncidents(t, s, "")
|
||||||
|
if len(incidents) != 1 {
|
||||||
|
t.Fatalf("expected 1 incident for one groupKey, got %d", len(incidents))
|
||||||
|
}
|
||||||
|
id := int(incidents[0]["id"].(float64))
|
||||||
|
|
||||||
|
inc := getIncident(t, s, id)
|
||||||
|
members, _ := inc["alerts"].([]any)
|
||||||
|
if len(members) != 2 {
|
||||||
|
t.Fatalf("expected 2 alerts under the incident, got %d", len(members))
|
||||||
|
}
|
||||||
|
|
||||||
|
added := 0
|
||||||
|
for _, ty := range eventTypes(timeline(t, s, id)) {
|
||||||
|
if ty == "alert_added" {
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if added != 2 {
|
||||||
|
t.Errorf("expected 2 alert_added events, got %d", added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The load-bearing rule. Alertmanager re-sends firing notifications every
|
||||||
|
// repeat_interval; if those re-sends reopened incidents, resolving one by hand
|
||||||
|
// would mean nothing.
|
||||||
|
func TestWebhook_HeartbeatDoesNotReopenResolvedIncident(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
const groupKey = "{}:{alertname=\"Flapper\"}"
|
||||||
|
alert := amAlert("fp-hb", "Flapper", "firing", "2026-05-20T10:00:00Z", zeroTime, nil)
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{alert}, groupKey)
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("resolve returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Same startsAt, same fingerprint: a re-send, not a new occurrence.
|
||||||
|
postWebhook(t, s, []map[string]any{alert}, groupKey)
|
||||||
|
|
||||||
|
if n := s.countIncidents(t); n != 1 {
|
||||||
|
t.Fatalf("expected the heartbeat to open no incident, got %d total", n)
|
||||||
|
}
|
||||||
|
if inc := getIncident(t, s, 1); inc["resolved_at"] == nil {
|
||||||
|
t.Error("expected incident 1 to stay resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert itself is still firing and still being tracked — only the work
|
||||||
|
// item is closed.
|
||||||
|
status, _, _ := s.alertRow(t, "fp-hb")
|
||||||
|
if status != "firing" {
|
||||||
|
t.Errorf("expected the alert to still be firing, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhook_NewOccurrenceOpensNewIncident(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
const groupKey = "{}:{alertname=\"Recurring\"}"
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-new", "Recurring", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, groupKey)
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
// A newer startsAt is a genuinely new occurrence, not a re-send.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-new", "Recurring", "firing", "2026-05-21T09:00:00Z", zeroTime, nil),
|
||||||
|
}, groupKey)
|
||||||
|
|
||||||
|
if n := s.countIncidents(t); n != 2 {
|
||||||
|
t.Fatalf("expected a second incident for the new occurrence, got %d total", n)
|
||||||
|
}
|
||||||
|
open := listIncidents(t, s, "")
|
||||||
|
if len(open) != 1 || int(open[0]["id"].(float64)) != 2 {
|
||||||
|
t.Fatalf("expected incident 2 to be the open one, got %v", open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new incident starts unacknowledged: that is the point of the split.
|
||||||
|
if open[0]["acknowledged_by"] != nil {
|
||||||
|
t.Error("expected a fresh occurrence to start unacknowledged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhook_ResolvedOnlyPayloadOpensNothing(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-res", "AlreadyOver", "resolved", "2026-05-20T10:00:00Z", "2026-05-20T11:00:00Z", nil),
|
||||||
|
}, "{}:{alertname=\"AlreadyOver\"}")
|
||||||
|
|
||||||
|
if n := s.countIncidents(t); n != 0 {
|
||||||
|
t.Errorf("expected no incident from a resolved-only payload, got %d", n)
|
||||||
|
}
|
||||||
|
if status, _, _ := s.alertRow(t, "fp-res"); status != "resolved" {
|
||||||
|
t.Errorf("expected the alert itself to be stored, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An incident that hit critical was a critical incident, even once the critical
|
||||||
|
// alert clears and only a warning is left.
|
||||||
|
func TestIncident_SeverityIsHighWaterMark(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
const groupKey = "{}:{alertname=\"Mixed\"}"
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-warn", "Mixed", "firing", "2026-05-20T10:00:00Z", zeroTime,
|
||||||
|
map[string]string{"severity": "warning"}),
|
||||||
|
amAlert("fp-crit", "Mixed", "firing", "2026-05-20T10:00:00Z", zeroTime,
|
||||||
|
map[string]string{"severity": "critical"}),
|
||||||
|
}, groupKey)
|
||||||
|
|
||||||
|
if inc := getIncident(t, s, 1); inc["severity"] != "critical" {
|
||||||
|
t.Fatalf("expected severity critical, got %v", inc["severity"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The critical alert clears; the warning keeps the incident open.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-crit", "Mixed", "resolved", "2026-05-20T10:00:00Z", "2026-05-20T11:00:00Z",
|
||||||
|
map[string]string{"severity": "critical"}),
|
||||||
|
}, groupKey)
|
||||||
|
|
||||||
|
inc := getIncident(t, s, 1)
|
||||||
|
if inc["resolved_at"] != nil {
|
||||||
|
t.Fatal("expected the incident to stay open")
|
||||||
|
}
|
||||||
|
if inc["severity"] != "critical" {
|
||||||
|
t.Errorf("expected severity to stay critical, got %v", inc["severity"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolution cascade
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIncident_AllAlertsResolvedAutoResolves(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
const groupKey = "{}:{alertname=\"Pair\"}"
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-p1", "Pair", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
amAlert("fp-p2", "Pair", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, groupKey)
|
||||||
|
|
||||||
|
// One down, one still firing: the work is not done.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-p1", "Pair", "resolved", "2026-05-20T10:00:00Z", "2026-05-20T11:00:00Z", nil),
|
||||||
|
}, groupKey)
|
||||||
|
if inc := getIncident(t, s, 1); inc["resolved_at"] != nil {
|
||||||
|
t.Fatal("expected the incident to stay open while an alert is firing")
|
||||||
|
}
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-p2", "Pair", "resolved", "2026-05-20T10:00:00Z", "2026-05-20T11:30:00Z", nil),
|
||||||
|
}, groupKey)
|
||||||
|
|
||||||
|
inc := getIncident(t, s, 1)
|
||||||
|
if inc["status"] != "resolved" {
|
||||||
|
t.Errorf("expected status resolved, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
if inc["resolution_source"] != "alerts" {
|
||||||
|
t.Errorf("expected resolution_source alerts, got %v", inc["resolution_source"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry is inference, not observation, but it still has to close the work item
|
||||||
|
// — otherwise a lost resolved notification leaves an incident open forever.
|
||||||
|
func TestExpiry_CascadesToIncidentResolution(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postAlert(t, s, "fp-exp", "firing", time.Now().Add(-24*time.Hour).Format(time.RFC3339), zeroTime)
|
||||||
|
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-exp'",
|
||||||
|
time.Now().Add(-10*time.Hour).Unix())
|
||||||
|
sweep(t, s, 6*time.Hour)
|
||||||
|
|
||||||
|
inc := getIncident(t, s, 1)
|
||||||
|
if inc["status"] != "resolved" {
|
||||||
|
t.Errorf("expected the incident to resolve after expiry, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
if inc["resolution_source"] != "alerts" {
|
||||||
|
t.Errorf("expected resolution_source alerts, got %v", inc["resolution_source"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expiry is recorded against the alert, not the incident.
|
||||||
|
if _, source, _ := s.alertRow(t, "fp-exp"); source == nil || *source != "expiry" {
|
||||||
|
t.Errorf("expected the alert's resolution_source to stay expiry, got %v", source)
|
||||||
|
}
|
||||||
|
if types := eventTypes(timeline(t, s, 1)); !contains(types, "alert_resolved") {
|
||||||
|
t.Errorf("expected an alert_resolved event on the timeline, got %v", types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workflow actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIncident_Acknowledge(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-ack", "X", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("acknowledge returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var inc map[string]any
|
||||||
|
decode(t, resp, &inc)
|
||||||
|
if inc["acknowledged_by"] == nil {
|
||||||
|
t.Error("expected acknowledged_by to be set")
|
||||||
|
}
|
||||||
|
if inc["status"] != "acknowledged" {
|
||||||
|
t.Errorf("expected status acknowledged, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/incidents/1/acknowledge", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("unacknowledge returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if inc := getIncident(t, s, 1); inc["status"] != "triggered" {
|
||||||
|
t.Errorf("expected status back to triggered, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncident_ManualResolveIsTerminal(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-term", "Terminal", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("first resolve returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var inc map[string]any
|
||||||
|
decode(t, resp, &inc)
|
||||||
|
if inc["resolution_source"] != "manual" {
|
||||||
|
t.Errorf("expected resolution_source manual, got %v", inc["resolution_source"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("expected 409 on re-resolve, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acknowledging a closed incident is equally meaningless.
|
||||||
|
resp = s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("expected 409 acknowledging a resolved incident, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncident_SnoozeHiddenFromDefaultList(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-snz", "Noisy", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/snooze", map[string]string{"duration": "2h"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("snooze returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var inc map[string]any
|
||||||
|
decode(t, resp, &inc)
|
||||||
|
if inc["snoozed_until"] == nil {
|
||||||
|
t.Error("expected snoozed_until to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := listIncidents(t, s, ""); len(got) != 0 {
|
||||||
|
t.Errorf("expected the snoozed incident to be hidden, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got := listIncidents(t, s, "?snoozed=true"); len(got) != 1 {
|
||||||
|
t.Errorf("expected snoozed=true to show it, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snooze is not a resolution: the incident is still open work.
|
||||||
|
if inc := getIncident(t, s, 1); inc["resolved_at"] != nil {
|
||||||
|
t.Error("expected a snoozed incident to stay open")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/incidents/1/snooze", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("unsnooze returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := listIncidents(t, s, ""); len(got) != 1 {
|
||||||
|
t.Errorf("expected the incident back in the default list, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncident_SnoozeRejectsPastDeadline(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-past", "Past", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/snooze",
|
||||||
|
map[string]string{"until": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 for a snooze in the past, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The schedule stops being decorative here: it is read at trigger time.
|
||||||
|
func TestIncident_AutoAssignedToCurrentOnCall(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{today}})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("schedule assignment returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-oncall", "PageMe", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
inc := getIncident(t, s, 1)
|
||||||
|
if inc["assigned_to"] != "admin" {
|
||||||
|
t.Errorf("expected the incident assigned to today's on-call, got %v", inc["assigned_to"])
|
||||||
|
}
|
||||||
|
if types := eventTypes(timeline(t, s, 1)); !contains(types, "assigned") {
|
||||||
|
t.Errorf("expected an assigned event, got %v", types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncident_AssignToUser(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-asg", "Assignable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "alice", "email": "alice@test.com"}).Body.Close()
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 2})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("assign returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var inc map[string]any
|
||||||
|
decode(t, resp, &inc)
|
||||||
|
if inc["assigned_to"] != "alice" {
|
||||||
|
t.Errorf("expected assigned_to alice, got %v", inc["assigned_to"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 99})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 assigning an unknown user, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Timeline and notes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIncident_TimelineOrdering(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-tl", "Storyline", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/notes",
|
||||||
|
map[string]string{"content": "looking into it"}).Body.Close()
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
events := timeline(t, s, 1)
|
||||||
|
want := []string{"triggered", "alert_added", "acknowledged", "note", "resolved"}
|
||||||
|
got := eventTypes(events)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("expected timeline %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("expected timeline %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The note carries its author; system events do not.
|
||||||
|
for _, e := range events {
|
||||||
|
if e["type"] == "note" {
|
||||||
|
if e["username"] != "admin" || e["detail"] != "looking into it" {
|
||||||
|
t.Errorf("unexpected note event: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e["type"] == "triggered" && e["username"] != nil {
|
||||||
|
t.Errorf("expected the triggered event to have no author, got %v", e["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncident_NoteDeleteOwnOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-note", "Y", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "alice", "email": "alice@test.com"}).Body.Close()
|
||||||
|
var keyData map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users/2/api-keys",
|
||||||
|
map[string]string{"name": "alice-key"}), &keyData)
|
||||||
|
aliceKey := keyData["key"].(string)
|
||||||
|
|
||||||
|
var note map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/incidents/1/notes",
|
||||||
|
map[string]string{"content": "admin note"}), ¬e)
|
||||||
|
noteID := int(note["id"].(float64))
|
||||||
|
|
||||||
|
// Alice cannot delete admin's note.
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete,
|
||||||
|
fmt.Sprintf("%s/api/incidents/1/notes/%d", s.URL, noteID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+aliceKey)
|
||||||
|
resp, _ := http.DefaultClient.Do(req)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 deleting another user's note, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, fmt.Sprintf("/api/incidents/1/notes/%d", noteID), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("expected 204 deleting own note, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only notes are deletable — the rest of the timeline is what happened.
|
||||||
|
func TestIncident_CannotDeleteSystemEvent(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-sys", "System", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
events := timeline(t, s, 1)
|
||||||
|
id := int(events[0]["id"].(float64))
|
||||||
|
resp := s.req(t, http.MethodDelete, fmt.Sprintf("/api/incidents/1/notes/%d", id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 deleting a system event, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Archive
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIncident_ArchiveRoundTrip(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-arc", "Archivable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/archive", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("archive returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var inc map[string]any
|
||||||
|
decode(t, resp, &inc)
|
||||||
|
if inc["archived_at"] == nil {
|
||||||
|
t.Error("expected archived_at to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := listIncidents(t, s, "?status=resolved"); len(got) != 0 {
|
||||||
|
t.Errorf("expected the archived incident to be hidden, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got := listIncidents(t, s, "?status=resolved&archived=true"); len(got) != 1 {
|
||||||
|
t.Errorf("expected archived=true to show it, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/incidents/1/archive", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("unarchive returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := listIncidents(t, s, "?status=resolved"); len(got) != 1 {
|
||||||
|
t.Errorf("expected the incident back, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-swp", "Old", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
s.exec(t, "UPDATE incidents SET resolved_at = $1 WHERE id = 1",
|
||||||
|
time.Now().Add(-30*24*time.Hour).Unix())
|
||||||
|
api.Sweep(context.Background(), s.db, 7*24*time.Hour, 6*time.Hour, s.notify)
|
||||||
|
|
||||||
|
if inc := getIncident(t, s, 1); inc["archived_at"] == nil {
|
||||||
|
t.Error("expected the sweeper to archive a long-resolved incident")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestStats_Incidents(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-s1", "One", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, "g1")
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-s2", "Two", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, "g2")
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
var stats map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/stats/incidents", nil), &stats)
|
||||||
|
|
||||||
|
if stats["total"].(float64) != 2 {
|
||||||
|
t.Errorf("expected total 2, got %v", stats["total"])
|
||||||
|
}
|
||||||
|
if stats["resolved"].(float64) != 1 {
|
||||||
|
t.Errorf("expected resolved 1, got %v", stats["resolved"])
|
||||||
|
}
|
||||||
|
if stats["triggered"].(float64) != 1 {
|
||||||
|
t.Errorf("expected triggered 1, got %v", stats["triggered"])
|
||||||
|
}
|
||||||
|
// One incident has been acknowledged and resolved, so both averages exist.
|
||||||
|
if stats["mtta_seconds"] == nil || stats["mttr_seconds"] == nil {
|
||||||
|
t.Errorf("expected mtta and mttr to be computable, got %v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty window is a report of zero, not a failure. SUM over no rows is NULL
|
||||||
|
// in Postgres as it was in SQLite, and that used to come back as a 500 the
|
||||||
|
// moment every incident was archived — the state a quiet installation settles
|
||||||
|
// into.
|
||||||
|
func TestStats_IncidentsEmptyWindowIsZeroNotAnError(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// No incidents at all.
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/stats/incidents", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var stats map[string]any
|
||||||
|
decode(t, resp, &stats)
|
||||||
|
for _, k := range []string{"total", "triggered", "acknowledged", "resolved"} {
|
||||||
|
if stats[k].(float64) != 0 {
|
||||||
|
t.Errorf("expected %s 0, got %v", k, stats[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And with every incident archived out of the window.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-s4", "Gone", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodGet, "/api/stats/incidents", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatalf("expected 200 when every incident is archived, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
stats = nil
|
||||||
|
decode(t, resp, &stats)
|
||||||
|
if stats["total"].(float64) != 0 {
|
||||||
|
t.Errorf("expected total 0, got %v", stats["total"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert stats share the same aggregate, and the same empty-window trap.
|
||||||
|
func TestStats_AlertsEmptyWindowIsZeroNotAnError(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/stats/alerts", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var stats map[string]any
|
||||||
|
decode(t, resp, &stats)
|
||||||
|
for _, k := range []string{"total", "firing", "resolved"} {
|
||||||
|
if stats[k].(float64) != 0 {
|
||||||
|
t.Errorf("expected %s 0, got %v", k, stats[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing acknowledged yet means "no data", which is not the same claim as zero.
|
||||||
|
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-s3", "Untouched", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
var stats map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/stats/incidents", nil), &stats)
|
||||||
|
if stats["mtta_seconds"] != nil {
|
||||||
|
t.Errorf("expected mtta_seconds null, got %v", stats["mtta_seconds"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack []string, needle string) bool {
|
||||||
|
for _, s := range haystack {
|
||||||
|
if s == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testNotify() api.NotifyConfig {
|
||||||
|
return api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memberView struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OnCall bool `json:"on_call"`
|
||||||
|
NextShift *string `json:"next_shift"`
|
||||||
|
Pageable bool `json:"pageable"`
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
LastActiveAt *string `json:"last_active_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMembers(t *testing.T, s *ts) map[string]memberView {
|
||||||
|
t.Helper()
|
||||||
|
var list []memberView
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/members", nil), &list)
|
||||||
|
out := map[string]memberView{}
|
||||||
|
for _, m := range list {
|
||||||
|
out[m.Username] = m
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list says who is on call, who could not be woken, and who is merely
|
||||||
|
// there — and an on-call person who cannot be paged is the red one.
|
||||||
|
func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic
|
||||||
|
teamUser(t, s, "reachable", "terdut-reachable")
|
||||||
|
silent := teamUser(t, s, "silent", "terdut-silent")
|
||||||
|
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
|
||||||
|
|
||||||
|
got := readMembers(t, s)
|
||||||
|
if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable {
|
||||||
|
t.Errorf("the person on call should read on call, got %+v", m)
|
||||||
|
}
|
||||||
|
if m := got["reachable"]; m.Status != "reachable" || m.OnCall {
|
||||||
|
t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m)
|
||||||
|
}
|
||||||
|
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" {
|
||||||
|
t.Errorf("no topic means they cannot be paged, got %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Being on call does not rescue an account that cannot be woken.
|
||||||
|
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'")
|
||||||
|
if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall {
|
||||||
|
t.Errorf("an on-call person with no topic is the red case, got %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
|
||||||
|
if m := readMembers(t, s)["silent"]; m.Problem != "account is disabled" {
|
||||||
|
t.Errorf("a disabled account should say so, got %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next shift is the next day after today, not today itself.
|
||||||
|
func TestMembers_NextShiftIsAfterToday(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, testNotify())
|
||||||
|
tomorrow := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02")
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{tomorrow}})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
m := readMembers(t, s)["admin"]
|
||||||
|
if !m.OnCall || m.NextShift == nil || *m.NextShift != tomorrow {
|
||||||
|
t.Errorf("want on call today with the next shift on %s, got %+v", tomorrow, m)
|
||||||
|
}
|
||||||
|
teamUser(t, s, "idle", "terdut-idle")
|
||||||
|
if m := readMembers(t, s)["idle"]; m.NextShift != nil {
|
||||||
|
t.Errorf("somebody not on the rota has no next shift, got %v", *m.NextShift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last active is the newer of a session and an API key, and absent when neither
|
||||||
|
// has ever been used.
|
||||||
|
func TestMembers_LastActive(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, testNotify())
|
||||||
|
idle := teamUser(t, s, "idle", "terdut-idle")
|
||||||
|
|
||||||
|
if m := readMembers(t, s)["idle"]; m.LastActiveAt != nil {
|
||||||
|
t.Errorf("nobody has used idle's account, got %v", *m.LastActiveAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
old := time.Now().Add(-48 * time.Hour).Unix()
|
||||||
|
s.exec(t, `INSERT INTO api_keys (user_id, key_hash, name, last_used_at) VALUES ($1, 'h1', 'k', $2)`, idle, old)
|
||||||
|
s.exec(t, `INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at)
|
||||||
|
VALUES ('h2', $1, $2, $3, $4)`, idle, old, old+3600, time.Now().Add(time.Hour).Unix())
|
||||||
|
|
||||||
|
m := readMembers(t, s)["idle"]
|
||||||
|
if m.LastActiveAt == nil {
|
||||||
|
t.Fatal("expected a last active time")
|
||||||
|
}
|
||||||
|
got, _ := time.Parse(time.RFC3339, *m.LastActiveAt)
|
||||||
|
if got.Unix() != old+3600 {
|
||||||
|
t.Errorf("last active should be the newer session (%d), got %d", old+3600, got.Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last owner can be neither removed nor demoted; with another owner in
|
||||||
|
// place, both are fine.
|
||||||
|
func TestMembers_LastOwnerIsProtected(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, testNotify())
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
base := "/api/teams/" + id64(tm.id) + "/members"
|
||||||
|
|
||||||
|
// Creating a team makes the creator an owner too; step the admin out so
|
||||||
|
// "red-user" is the only one left.
|
||||||
|
resp := s.req(t, http.MethodDelete, base+"/1", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("removing the creator: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var members []map[string]any
|
||||||
|
decode(t, tm.call(http.MethodGet, base, nil), &members)
|
||||||
|
var owner int64
|
||||||
|
for _, m := range members {
|
||||||
|
if m["username"] == "red-user" {
|
||||||
|
owner = int64(m["user_id"].(float64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("demoting the last owner: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = tm.call(http.MethodDelete, base+"/"+id64(owner), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("removing the last owner: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second owner frees the first to step down.
|
||||||
|
resp = s.req(t, http.MethodPost, base, map[string]any{"user_id": 1, "role": "owner"})
|
||||||
|
resp.Body.Close()
|
||||||
|
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("demoting one of two owners: expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
-27
@@ -9,55 +9,272 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yeniklas/terdut-server/internal/models"
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const ctxUser contextKey = "user"
|
const (
|
||||||
|
ctxUser contextKey = "user"
|
||||||
|
ctxSession contextKey = "session"
|
||||||
|
ctxTeams contextKey = "teams"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware accepts either of the two credentials the server issues: an
|
||||||
|
// API key in an Authorization header (the TUI, scripts) or a session cookie
|
||||||
|
// (the web UI). A request carrying a Bearer header is judged on that alone and
|
||||||
|
// never falls back to the cookie.
|
||||||
|
//
|
||||||
|
// Only the cookie needs a CSRF guard. A browser attaches it to requests other
|
||||||
|
// sites make, whereas an Authorization header is only ever set by the client
|
||||||
|
// that holds the key.
|
||||||
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||||
|
crossOrigin := http.NewCrossOriginProtection()
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
if header := r.Header.Get("Authorization"); header != "" {
|
||||||
if !ok || token == "" {
|
token, ok := strings.CutPrefix(header, "Bearer ")
|
||||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
if !ok || token == "" {
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := apiKeyUser(r.Context(), db, token)
|
||||||
|
if !ok {
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveAs(w, r, next, db, userID, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h := sha256.Sum256([]byte(token))
|
c, err := r.Cookie(sessionCookie)
|
||||||
hash := hex.EncodeToString(h[:])
|
if err != nil || c.Value == "" {
|
||||||
|
|
||||||
var keyID, userID int64
|
|
||||||
err := db.QueryRowContext(r.Context(),
|
|
||||||
"SELECT id, user_id FROM api_keys WHERE key_hash = ?", hash,
|
|
||||||
).Scan(&keyID, &userID)
|
|
||||||
if err != nil {
|
|
||||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
sessionID, userID, ok := sessionUser(r.Context(), db, c.Value)
|
||||||
// best-effort; don't fail the request if this update fails
|
if !ok {
|
||||||
db.ExecContext(r.Context(),
|
|
||||||
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
|
|
||||||
time.Now().Unix(), keyID)
|
|
||||||
|
|
||||||
var u models.User
|
|
||||||
var createdUnix int64
|
|
||||||
if err := db.QueryRowContext(r.Context(),
|
|
||||||
"SELECT id, username, email, created_at FROM users WHERE id = ?", userID,
|
|
||||||
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil {
|
|
||||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
|
if err := crossOrigin.Check(r); err != nil {
|
||||||
|
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u)))
|
return
|
||||||
|
}
|
||||||
|
serveAs(w, r, next, db, userID, sessionID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
||||||
|
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
||||||
|
//
|
||||||
|
// 403 and not 404: the route exists and the caller is authenticated, they are
|
||||||
|
// simply not allowed. Hiding the endpoint would buy nothing — every one of them
|
||||||
|
// is in the README.
|
||||||
|
func AdminOnly(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := userFromContext(r.Context())
|
||||||
|
if !ok || !caller.IsAdmin {
|
||||||
|
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSelfOrAdmin guards the endpoints that are self-service for your own
|
||||||
|
// account and administration for anybody else's: your password, your ntfy
|
||||||
|
// topic, your API keys. Reports whether the request may proceed, and answers it
|
||||||
|
// if not.
|
||||||
|
//
|
||||||
|
// An API key is not an escalation: it carries exactly the rights of the user it
|
||||||
|
// belongs to, so minting your own is no more than signing in again.
|
||||||
|
func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64) bool {
|
||||||
|
caller, ok := userFromContext(r.Context())
|
||||||
|
if !ok || (caller.ID != targetID && !caller.IsAdmin) {
|
||||||
|
respond(w, http.StatusForbidden, errResp("administrator access required"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeyUser resolves an API key to its user and stamps its last use.
|
||||||
|
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||||
|
var keyID, userID int64
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
|
||||||
|
).Scan(&keyID, &userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// best-effort; don't fail the request if this update fails
|
||||||
|
db.ExecContext(ctx,
|
||||||
|
"UPDATE api_keys SET last_used_at = $1 WHERE id = $2",
|
||||||
|
time.Now().Unix(), keyID)
|
||||||
|
return userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionUser resolves a session token to its session and user. The expiry
|
||||||
|
// slides forward with use, but at most once per sessionTouchEvery, so a page
|
||||||
|
// that polls does not write to the database on every request.
|
||||||
|
func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, userID int64, ok bool) {
|
||||||
|
now := time.Now()
|
||||||
|
var lastSeen int64
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, user_id, last_seen_at FROM sessions
|
||||||
|
WHERE token_hash = $1 AND expires_at > $2`,
|
||||||
|
hashToken(token), now.Unix()).Scan(&sessionID, &userID, &lastSeen)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
|
||||||
|
// LEAST keeps a capped session (a single sign-on login) from sliding
|
||||||
|
// past its ceiling; with no ceiling COALESCE makes it the plain slide.
|
||||||
|
db.ExecContext(ctx, `
|
||||||
|
UPDATE sessions
|
||||||
|
SET last_seen_at = $1,
|
||||||
|
expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint))
|
||||||
|
WHERE id = $3`,
|
||||||
|
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
|
||||||
|
}
|
||||||
|
return sessionID, userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveAs loads the user and hands the request on with it in the context.
|
||||||
|
// sessionID is zero for API-key requests.
|
||||||
|
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
|
||||||
|
var u models.User
|
||||||
|
var createdUnix int64
|
||||||
|
// disabled_at IS NULL is part of the lookup rather than a check afterwards:
|
||||||
|
// a disabled account is one that cannot authenticate, by either credential,
|
||||||
|
// and the way to be sure of that is for there to be no path where the row
|
||||||
|
// is loaded and the flag is then forgotten.
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
|
||||||
|
|
||||||
|
// Every scoped query needs the caller's teams, so they are loaded once here
|
||||||
|
// rather than per handler. One extra round trip per request, against a
|
||||||
|
// table with one row per membership.
|
||||||
|
teams, err := callerMemberships(r.Context(), db, userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), ctxTeams, teams)
|
||||||
|
ctx = context.WithValue(ctx, ctxUser, u)
|
||||||
|
if sessionID != 0 {
|
||||||
|
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
func userFromContext(ctx context.Context) (models.User, bool) {
|
func userFromContext(ctx context.Context) (models.User, bool) {
|
||||||
u, ok := ctx.Value(ctxUser).(models.User)
|
u, ok := ctx.Value(ctxUser).(models.User)
|
||||||
return u, ok
|
return u, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// membership is the caller's role in one team.
|
||||||
|
type membership struct {
|
||||||
|
teamID int64
|
||||||
|
role string
|
||||||
|
}
|
||||||
|
|
||||||
|
func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]membership, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
"SELECT team_id, role FROM team_members WHERE user_id = $1 ORDER BY team_id", userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []membership
|
||||||
|
for rows.Next() {
|
||||||
|
var m membership
|
||||||
|
if err := rows.Scan(&m.teamID, &m.role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerTeamIDs lists the teams the caller belongs to, for the `team_id = ANY`
|
||||||
|
// filter every list query carries. An admin is NOT implicitly in every team:
|
||||||
|
// administration is about accounts, not about reading other people's incidents,
|
||||||
|
// and an admin who needs to see a team's queue can add themselves to it.
|
||||||
|
func callerTeamIDs(ctx context.Context) []int64 {
|
||||||
|
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||||
|
ids := make([]int64, 0, len(ms))
|
||||||
|
for _, m := range ms {
|
||||||
|
ids = append(ids, m.teamID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerRole reports the caller's role in one team, and whether they are in it
|
||||||
|
// at all.
|
||||||
|
func callerRole(ctx context.Context, teamID int64) (string, bool) {
|
||||||
|
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||||
|
for _, m := range ms {
|
||||||
|
if m.teamID == teamID {
|
||||||
|
return m.role, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireTeamMember answers the request and reports false unless the caller
|
||||||
|
// belongs to teamID.
|
||||||
|
//
|
||||||
|
// 404, not 403: whether a team exists is itself something only its members
|
||||||
|
// should learn, and the same reasoning applies to every incident and alert
|
||||||
|
// under it.
|
||||||
|
func requireTeamMember(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
||||||
|
if _, ok := callerRole(r.Context(), teamID); !ok {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireTeamOwner is requireTeamMember for the things only an owner may change:
|
||||||
|
// the schedule, the integrations and who is in the team. A system administrator
|
||||||
|
// passes without being a member, because somebody has to be able to repair a
|
||||||
|
// team whose owner has left.
|
||||||
|
func requireTeamOwner(w http.ResponseWriter, r *http.Request, teamID int64) bool {
|
||||||
|
role, ok := callerRole(r.Context(), teamID)
|
||||||
|
if ok && role == models.RoleOwner {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if caller, _ := userFromContext(r.Context()); caller.IsAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
respond(w, http.StatusForbidden, errResp("team owner access required"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionFromContext returns the id of the session a request was authenticated
|
||||||
|
// with, or false for an API-key request.
|
||||||
|
func sessionFromContext(ctx context.Context) (int64, bool) {
|
||||||
|
id, ok := ctx.Value(ctxSession).(int64)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// notifyInterval is how often the notifier looks for work. The archiver's
|
||||||
|
// 15 minute tick is far too coarse for something that has to wake a person.
|
||||||
|
notifyInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// notifyRetryBase and notifyRetryMax bound the delivery backoff. ntfy being
|
||||||
|
// briefly unreachable should not lose the page.
|
||||||
|
notifyRetryBase = 30 * time.Second
|
||||||
|
notifyRetryMax = 15 * time.Minute
|
||||||
|
|
||||||
|
// notifyMaxAttempts stops a permanently undeliverable row from being retried
|
||||||
|
// forever. It keeps last_error so the reason survives.
|
||||||
|
notifyMaxAttempts = 8
|
||||||
|
|
||||||
|
// notifyBatch caps one delivery pass, so a large backlog cannot hold the
|
||||||
|
// single database connection for an unbounded stretch.
|
||||||
|
notifyBatch = 100
|
||||||
|
|
||||||
|
// ackTokenTTL is how long the Acknowledge button in a notification keeps
|
||||||
|
// working. Past this the notification is stale enough that the responder
|
||||||
|
// should look at the incident rather than blind-acknowledge it.
|
||||||
|
ackTokenTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification kinds, recording why a push was sent.
|
||||||
|
const (
|
||||||
|
notifyTriggered = "triggered"
|
||||||
|
notifyReminder = "reminder"
|
||||||
|
notifyResolved = "resolved"
|
||||||
|
|
||||||
|
// notifyEscalated is a page that went out because nobody answered the last
|
||||||
|
// one. Told apart from a reminder because it goes to somebody else.
|
||||||
|
notifyEscalated = "escalated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timeline event types the notifier writes, so an incident's history says who
|
||||||
|
// was paged and whether the page landed. Written from the delivery result
|
||||||
|
// rather than at enqueue: a queued notification is an intention, and claiming
|
||||||
|
// somebody was told before ntfy accepted it would be a lie the timeline keeps.
|
||||||
|
//
|
||||||
|
// The topic is deliberately absent from both. It is a shared secret with the
|
||||||
|
// ntfy server — anyone holding it can publish to it — and the timeline is
|
||||||
|
// readable by every API key.
|
||||||
|
const (
|
||||||
|
eventNotified = "notified"
|
||||||
|
eventNotifyFailed = "notify_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifyConfig is everything the notifier needs to reach ntfy and to build URLs
|
||||||
|
// a phone can follow back to this server.
|
||||||
|
type NotifyConfig struct {
|
||||||
|
// BaseURL is the ntfy server. Empty disables notifications entirely: no
|
||||||
|
// goroutine, and nothing is ever enqueued.
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
// Token is an optional bearer token for an access-controlled ntfy.
|
||||||
|
Token string
|
||||||
|
|
||||||
|
// FallbackTopic receives incidents that open with nobody on call. Those
|
||||||
|
// notifications carry no Acknowledge button — there is no user to attribute
|
||||||
|
// the acknowledgement to, and putting one on a shared topic would let any
|
||||||
|
// subscriber acknowledge as somebody else.
|
||||||
|
FallbackTopic string
|
||||||
|
|
||||||
|
// PublicURL is the base URL a phone uses to reach this server, for the
|
||||||
|
// notification's click target and its Acknowledge action. Without it a
|
||||||
|
// notification is informational only.
|
||||||
|
PublicURL string
|
||||||
|
|
||||||
|
// RepeatEvery is how long an incident may sit unacknowledged before it is
|
||||||
|
// notified again. Zero disables reminders.
|
||||||
|
RepeatEvery time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabled reports whether notifications are configured at all.
|
||||||
|
func (c NotifyConfig) enabled() bool { return c.BaseURL != "" }
|
||||||
|
|
||||||
|
// notifyClient is shared: a page is small and infrequent, and the timeout is
|
||||||
|
// what keeps a hung ntfy from stalling the delivery pass.
|
||||||
|
var notifyClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
// StartNotifier delivers queued notifications until ctx is cancelled, starting
|
||||||
|
// with an immediate pass so a restart flushes whatever the last one left behind.
|
||||||
|
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
|
if !cfg.enabled() {
|
||||||
|
log.Print("notifier: disabled (no ntfy URL configured)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("notifier: publishing to %s", cfg.BaseURL)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(notifyInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
NotifySweep(ctx, db, cfg)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
NotifySweep(ctx, db, cfg)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifySweep runs a single pass: queue reminders for incidents nobody has
|
||||||
|
// picked up, then deliver everything that is due. Reminders are queued first so
|
||||||
|
// a freshly due one goes out in the same pass rather than a tick later.
|
||||||
|
// Exported so tests can drive a pass without waiting on the ticker.
|
||||||
|
func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
|
enqueueReminders(ctx, db, cfg)
|
||||||
|
// Escalation before delivery, so a level that comes due on this tick is
|
||||||
|
// paged on this tick rather than waiting for the next one.
|
||||||
|
escalate(ctx, db, cfg)
|
||||||
|
deliverPending(ctx, db, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueReminders re-notifies incidents that are still sitting untouched.
|
||||||
|
//
|
||||||
|
// The stop conditions are the incident states that already mean "somebody has
|
||||||
|
// this": acknowledged, snoozed, resolved, archived. Snooze in particular is the
|
||||||
|
// mute button — a deliberate "not now" that should not keep buzzing — which is
|
||||||
|
// why there is no separate reminder cap.
|
||||||
|
//
|
||||||
|
// The previous notification must have actually been sent before another is
|
||||||
|
// queued, so an ntfy outage produces a retry backlog rather than a reminder
|
||||||
|
// backlog that all lands at once when it comes back.
|
||||||
|
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
|
// cfg.RepeatEvery is what the server started with; the settings table is
|
||||||
|
// what it runs on. Read per tick, so an administrator lengthening the
|
||||||
|
// interval at 02:00 is obeyed at 02:00 and not at the next restart.
|
||||||
|
repeat := NewSettings(db).Duration(ctx, SettingNotifyRepeat, cfg.RepeatEvery)
|
||||||
|
if repeat <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
type due struct {
|
||||||
|
incidentID int64
|
||||||
|
userID *int64
|
||||||
|
topic string
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT n.incident_id, n.user_id, n.topic
|
||||||
|
FROM notifications n
|
||||||
|
JOIN incidents i ON i.id = n.incident_id
|
||||||
|
WHERE n.id = (SELECT MAX(id) FROM notifications WHERE incident_id = n.incident_id)
|
||||||
|
AND n.sent_at IS NOT NULL
|
||||||
|
AND n.created_at <= $1
|
||||||
|
AND i.resolved_at IS NULL
|
||||||
|
AND i.archived_at IS NULL
|
||||||
|
AND i.status = 'triggered'
|
||||||
|
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)
|
||||||
|
-- A team with an escalation ladder gets escalation instead. Both
|
||||||
|
-- would mean two pages for one silence, which is how people learn to
|
||||||
|
-- mute a tool.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM escalation_levels el WHERE el.team_id = i.team_id)`,
|
||||||
|
now.Add(-repeat).Unix(), now.Unix())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("notifier: find reminders: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collected before inserting, rather than written while walking the cursor:
|
||||||
|
// the inserts below are what this query selects on, and a cursor reading its
|
||||||
|
// own writes is a hazard whatever the pool size.
|
||||||
|
var pending []due
|
||||||
|
for rows.Next() {
|
||||||
|
var d due
|
||||||
|
if err := rows.Scan(&d.incidentID, &d.userID, &d.topic); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Printf("notifier: scan reminder: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending = append(pending, d)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Printf("notifier: find reminders: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, d := range pending {
|
||||||
|
if err := enqueueNotification(ctx, db, d.incidentID, d.userID, d.topic, notifyReminder); err != nil {
|
||||||
|
log.Printf("notifier: queue reminder for incident %d: %v", d.incidentID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pending) > 0 {
|
||||||
|
log.Printf("notifier: queued %d reminder(s)", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// outboxRow is one queued notification, read before any HTTP happens.
|
||||||
|
type outboxRow struct {
|
||||||
|
id int64
|
||||||
|
incidentID int64
|
||||||
|
userID *int64
|
||||||
|
topic string
|
||||||
|
kind string
|
||||||
|
attempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverPending sends everything that is due and records the outcome.
|
||||||
|
func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
|
batch, err := pendingNotifications(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("notifier: find pending: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := 0
|
||||||
|
for _, n := range batch {
|
||||||
|
if err := deliver(ctx, db, cfg, n); err != nil {
|
||||||
|
log.Printf("notifier: deliver %d (incident %d): %v", n.id, n.incidentID, err)
|
||||||
|
markFailed(ctx, db, n, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
"UPDATE notifications SET sent_at = $1, attempts = attempts + 1, last_error = NULL WHERE id = $2",
|
||||||
|
time.Now().Unix(), n.id); err != nil {
|
||||||
|
log.Printf("notifier: mark sent %d: %v", n.id, err)
|
||||||
|
}
|
||||||
|
// Logged, not returned: the page has already gone out, and treating a
|
||||||
|
// failed timeline write as a failed delivery would send it again.
|
||||||
|
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, &n.kind); err != nil {
|
||||||
|
log.Printf("notifier: log delivery of %d: %v", n.id, err)
|
||||||
|
}
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
if sent > 0 {
|
||||||
|
log.Printf("notifier: delivered %d notification(s)", sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingNotifications reads the due rows and closes the cursor before the
|
||||||
|
// caller writes, for the same single-connection reason as staleAlertIDs.
|
||||||
|
func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, incident_id, user_id, topic, kind, attempts
|
||||||
|
FROM notifications
|
||||||
|
WHERE sent_at IS NULL
|
||||||
|
AND send_after <= $1
|
||||||
|
AND attempts < $2
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT $3`, time.Now().Unix(), notifyMaxAttempts, notifyBatch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var batch []outboxRow
|
||||||
|
for rows.Next() {
|
||||||
|
var n outboxRow
|
||||||
|
if err := rows.Scan(&n.id, &n.incidentID, &n.userID, &n.topic, &n.kind, &n.attempts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
batch = append(batch, n)
|
||||||
|
}
|
||||||
|
return batch, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// markFailed bumps the attempt count and pushes the row out to its next retry.
|
||||||
|
//
|
||||||
|
// The attempt that exhausts the budget also writes a timeline event. Without it
|
||||||
|
// a page that never landed leaves the incident's history identical to one that
|
||||||
|
// did, which is the failure most worth seeing: nobody was told, and nothing
|
||||||
|
// says so.
|
||||||
|
func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
|
||||||
|
next := time.Now().Add(retryDelay(n.attempts)).Unix()
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
"UPDATE notifications SET attempts = attempts + 1, send_after = $1, last_error = $2 WHERE id = $3",
|
||||||
|
next, cause.Error(), n.id); err != nil {
|
||||||
|
log.Printf("notifier: mark failed %d: %v", n.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.attempts+1 < notifyMaxAttempts {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("%s: %s", n.kind, cause)
|
||||||
|
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, &detail); err != nil {
|
||||||
|
log.Printf("notifier: log failure of %d: %v", n.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryDelay doubles the wait per attempt, up to notifyRetryMax.
|
||||||
|
func retryDelay(attempts int) time.Duration {
|
||||||
|
d := notifyRetryBase << attempts
|
||||||
|
if d > notifyRetryMax || d <= 0 {
|
||||||
|
return notifyRetryMax
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver renders one notification against the incident's *current* state and
|
||||||
|
// publishes it. Rendering happens here rather than at enqueue time so a message
|
||||||
|
// that waited in the queue while its incident escalated goes out at the
|
||||||
|
// severity the incident has now.
|
||||||
|
func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) error {
|
||||||
|
inc, err := fetchIncident(ctx, db, n.incidentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load incident: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var firing int
|
||||||
|
if err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM incident_alerts ia
|
||||||
|
JOIN alerts a ON a.id = ia.alert_id
|
||||||
|
WHERE ia.incident_id = $1 AND a.status = 'firing'`, n.incidentID).Scan(&firing); err != nil {
|
||||||
|
return fmt.Errorf("count firing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := renderNotification(inc, n, firing, cfg)
|
||||||
|
|
||||||
|
// The page that opens an incident carries what fixed it last time, so the
|
||||||
|
// person woken up starts from that. Best effort: a failed lookup must not
|
||||||
|
// hold back the page itself.
|
||||||
|
if n.kind == notifyTriggered {
|
||||||
|
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
|
||||||
|
notes := sim[0].ResolutionNotes
|
||||||
|
msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Acknowledge button needs both a user to attribute the acknowledgement
|
||||||
|
// to and a URL the phone can reach. Minted per delivery, so every push
|
||||||
|
// carries its own short-lived token rather than reusing one.
|
||||||
|
if n.kind != notifyResolved && n.userID != nil && cfg.PublicURL != "" {
|
||||||
|
raw, err := issueAckToken(ctx, db, n.incidentID, *n.userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("issue ack token: %w", err)
|
||||||
|
}
|
||||||
|
msg.Actions = append(msg.Actions, ntfyAction{
|
||||||
|
Action: "http",
|
||||||
|
Label: "Acknowledge",
|
||||||
|
URL: strings.TrimSuffix(cfg.PublicURL, "/") + "/api/notify/ack/" + raw,
|
||||||
|
Method: "POST",
|
||||||
|
Clear: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return publish(ctx, cfg, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ntfyMessage is ntfy's JSON publish format. Using it rather than the X-Actions
|
||||||
|
// header avoids that header's comma and quote escaping rules, which are easy to
|
||||||
|
// break with a title that happens to contain a comma.
|
||||||
|
type ntfyMessage struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Click string `json:"click,omitempty"`
|
||||||
|
Actions []ntfyAction `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ntfyAction struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Clear bool `json:"clear,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNotification builds the message body for one queued notification.
|
||||||
|
func renderNotification(inc models.Incident, n outboxRow, firing int, cfg NotifyConfig) ntfyMessage {
|
||||||
|
msg := ntfyMessage{Topic: n.topic}
|
||||||
|
|
||||||
|
if cfg.PublicURL != "" {
|
||||||
|
// The web UI's page for the incident, so tapping the notification
|
||||||
|
// opens something a browser can use.
|
||||||
|
msg.Click = fmt.Sprintf("%s/incidents/%d",
|
||||||
|
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch n.kind {
|
||||||
|
case notifyResolved:
|
||||||
|
msg.Title = "Resolved: " + inc.Title
|
||||||
|
msg.Message = "All alerts stopped firing after " +
|
||||||
|
humanDuration(time.Since(inc.TriggeredAt))
|
||||||
|
msg.Priority = ntfyPriorityLow
|
||||||
|
msg.Tags = []string{"white_check_mark"}
|
||||||
|
return msg
|
||||||
|
|
||||||
|
case notifyReminder:
|
||||||
|
msg.Title = "Still unacknowledged: " + inc.Title
|
||||||
|
default:
|
||||||
|
msg.Title = inc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
severity := derefString(inc.Severity)
|
||||||
|
|
||||||
|
parts := []string{fmt.Sprintf("%d alert%s firing", firing, plural(firing))}
|
||||||
|
if severity != "" {
|
||||||
|
parts = append(parts, "severity "+severity)
|
||||||
|
}
|
||||||
|
if assignee := derefString(inc.AssignedToUser); assignee != "" {
|
||||||
|
parts = append(parts, "on call: "+assignee)
|
||||||
|
}
|
||||||
|
if n.kind == notifyReminder {
|
||||||
|
parts = append(parts, "open "+humanDuration(time.Since(inc.TriggeredAt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Message = strings.Join(parts, " · ")
|
||||||
|
msg.Priority = ntfyPriority(severity)
|
||||||
|
msg.Tags = []string{severityTag(severity)}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ntfy's priority scale. Max is the one that overrides the phone's quiet
|
||||||
|
// settings, which is the whole point of paging on critical.
|
||||||
|
const (
|
||||||
|
ntfyPriorityLow = 2
|
||||||
|
ntfyPriorityDefault = 3
|
||||||
|
ntfyPriorityHigh = 4
|
||||||
|
ntfyPriorityMax = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// ntfyPriority maps an incident's severity onto ntfy's scale, following the
|
||||||
|
// same ordering severityRank uses. An unrecognised severity gets the default
|
||||||
|
// rather than being silenced.
|
||||||
|
func ntfyPriority(severity string) int {
|
||||||
|
switch severityRank(severity) {
|
||||||
|
case 4:
|
||||||
|
return ntfyPriorityMax
|
||||||
|
case 3:
|
||||||
|
return ntfyPriorityHigh
|
||||||
|
case 2:
|
||||||
|
return ntfyPriorityDefault
|
||||||
|
case 1:
|
||||||
|
return ntfyPriorityLow
|
||||||
|
default:
|
||||||
|
return ntfyPriorityDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityTag(severity string) string {
|
||||||
|
switch severityRank(severity) {
|
||||||
|
case 4:
|
||||||
|
return "rotating_light"
|
||||||
|
case 3:
|
||||||
|
return "red_circle"
|
||||||
|
case 2:
|
||||||
|
return "warning"
|
||||||
|
case 1:
|
||||||
|
return "information_source"
|
||||||
|
default:
|
||||||
|
return "bell"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish POSTs one message to ntfy.
|
||||||
|
func publish(ctx context.Context, cfg NotifyConfig, msg ntfyMessage) error {
|
||||||
|
body, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
strings.TrimSuffix(cfg.BaseURL, "/"), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if cfg.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := notifyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("ntfy returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueNotification adds one row to the outbox, due immediately.
|
||||||
|
func enqueueNotification(ctx context.Context, q querier, incidentID int64, userID *int64, topic, kind string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := q.ExecContext(ctx, `
|
||||||
|
INSERT INTO notifications (incident_id, user_id, topic, kind, created_at, send_after)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`, incidentID, userID, topic, kind, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyTarget decides where a newly opened incident's notification goes.
|
||||||
|
//
|
||||||
|
// The on-call user's own topic when they have one, otherwise the fallback
|
||||||
|
// topic with no user attached. Deliberately not "the fallback topic, attributed
|
||||||
|
// to the on-call user": the fallback is shared, and an Acknowledge button on a
|
||||||
|
// shared topic would let any subscriber acknowledge as somebody else.
|
||||||
|
func notifyTarget(ctx context.Context, q querier, cfg NotifyConfig, onCall *int64) (topic string, userID *int64) {
|
||||||
|
if onCall != nil {
|
||||||
|
var t *string
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
"SELECT ntfy_topic FROM users WHERE id = $1", *onCall).Scan(&t)
|
||||||
|
if err == nil && t != nil && *t != "" {
|
||||||
|
return *t, onCall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg.FallbackTopic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueOpened queues the notification for a freshly opened incident. It is the
|
||||||
|
// only enqueue point that has to resolve a topic from scratch; every later
|
||||||
|
// notification for the incident reuses what this one chose.
|
||||||
|
func enqueueOpened(ctx context.Context, q querier, cfg NotifyConfig, incidentID int64, onCall *int64) error {
|
||||||
|
if !cfg.enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
topic, userID := notifyTarget(ctx, q, cfg, onCall)
|
||||||
|
if topic == "" {
|
||||||
|
// Nobody on call has a topic and there is no fallback: there is nowhere
|
||||||
|
// to send this, and queueing it would only accumulate undeliverable rows.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return enqueueNotification(ctx, q, incidentID, userID, topic, notifyTriggered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueResolved queues the all-clear, reusing the topic the incident's last
|
||||||
|
// notification went to. That needs no configuration to reach this function, and
|
||||||
|
// it gives the right rule for free: you only hear that something resolved if you
|
||||||
|
// were told it started.
|
||||||
|
func enqueueResolved(ctx context.Context, q querier, incidentID int64) error {
|
||||||
|
var topic string
|
||||||
|
var userID *int64
|
||||||
|
err := q.QueryRowContext(ctx, `
|
||||||
|
SELECT topic, user_id FROM notifications
|
||||||
|
WHERE incident_id = $1 ORDER BY id DESC LIMIT 1`, incidentID).Scan(&topic, &userID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return enqueueNotification(ctx, q, incidentID, userID, topic, notifyResolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanDuration renders an age the way a person reads it at 3am: coarse, and
|
||||||
|
// never more than two units.
|
||||||
|
func humanDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return "less than a minute"
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||||
|
}
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) - h*60
|
||||||
|
if m == 0 {
|
||||||
|
return fmt.Sprintf("%dh", h)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh%dm", h, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// shorten cuts s to at most n runes, marking the cut, and flattens newlines so
|
||||||
|
// a multi-line note stays one line in a push.
|
||||||
|
func shorten(s string, n int) string {
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// derefString reads a nullable text column as a plain string.
|
||||||
|
func derefString(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// issueAckToken mints the secret behind one notification's Acknowledge button
|
||||||
|
// and returns the raw value to embed in its URL. Only the hash is stored, the
|
||||||
|
// same way api_keys works.
|
||||||
|
//
|
||||||
|
// A fresh token per delivery rather than one per incident: the raw value only
|
||||||
|
// exists for as long as it takes to build the message, so there is nothing to
|
||||||
|
// look up and reuse later, and a reminder that supersedes an earlier page
|
||||||
|
// carries its own credential.
|
||||||
|
func issueAckToken(ctx context.Context, q querier, incidentID, userID int64) (string, error) {
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := q.ExecContext(ctx, `
|
||||||
|
INSERT INTO incident_ack_tokens (token_hash, incident_id, user_id, created_at, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
hash, incidentID, userID, now.Unix(), now.Add(ackTokenTTL).Unix()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNotifyAck acknowledges an incident from the Acknowledge button in a
|
||||||
|
// push notification.
|
||||||
|
//
|
||||||
|
// It is deliberately outside AuthMiddleware: the caller is a phone acting on a
|
||||||
|
// notification, not a client holding an API key. What stands in for the key is
|
||||||
|
// the token in the path — 256 bits of entropy, valid for one incident, one
|
||||||
|
// action, and one day. It must stay publicly reachable for the button to work
|
||||||
|
// when the responder is off the cluster network.
|
||||||
|
func handleNotifyAck(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := sha256.Sum256([]byte(chi.URLParam(r, "token")))
|
||||||
|
hash := hex.EncodeToString(h[:])
|
||||||
|
|
||||||
|
var incidentID, userID int64
|
||||||
|
err := db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT incident_id, user_id FROM incident_ack_tokens
|
||||||
|
WHERE token_hash = $1 AND expires_at > $2`,
|
||||||
|
hash, time.Now().Unix()).Scan(&incidentID, &userID)
|
||||||
|
if err != nil {
|
||||||
|
// Unknown and expired get the same answer, so the endpoint cannot be
|
||||||
|
// used to probe which tokens once existed.
|
||||||
|
respond(w, http.StatusNotFound, errResp("invalid or expired token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acked, err := acknowledgeIncident(r.Context(), db, incidentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !acked {
|
||||||
|
// The incident closed between the page and the tap. Nothing to do,
|
||||||
|
// and nothing the responder did wrong — report the state, not an error,
|
||||||
|
// so ntfy shows a success toast rather than a failure.
|
||||||
|
respond(w, http.StatusOK, map[string]any{
|
||||||
|
"incident_id": incidentID,
|
||||||
|
"status": "resolved",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, map[string]any{
|
||||||
|
"incident_id": incidentID,
|
||||||
|
"status": "acknowledged",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeAckTokens drops tokens whose notifications are long past. Nothing else
|
||||||
|
// deletes them: incidents are archived rather than removed, so the cascade never
|
||||||
|
// fires in practice.
|
||||||
|
func purgeAckTokens(ctx context.Context, db *sql.DB) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
"DELETE FROM incident_ack_tokens WHERE expires_at < $1", time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sweeper: purge ack tokens: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
log.Printf("sweeper: purged %d expired ack token(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake ntfy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// pushed is one message the fake ntfy received, in ntfy's JSON publish shape.
|
||||||
|
type pushed struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Click string `json:"click"`
|
||||||
|
Actions []struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Clear bool `json:"clear"`
|
||||||
|
} `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeNtfy records what the notifier published. status controls the reply, so a
|
||||||
|
// test can make delivery fail.
|
||||||
|
type fakeNtfy struct {
|
||||||
|
*httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
got []pushed
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeNtfy(t *testing.T) *fakeNtfy {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakeNtfy{status: http.StatusOK}
|
||||||
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var msg pushed
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.got = append(f.got, msg)
|
||||||
|
status := f.status
|
||||||
|
f.mu.Unlock()
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNtfy) messages() []pushed {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]pushed(nil), f.got...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicsSince lists the topics published to since the last forget, which is how
|
||||||
|
// the escalation tests ask "who did this tick wake".
|
||||||
|
func (f *fakeNtfy) topicsSince(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]string, 0, len(f.got))
|
||||||
|
for _, m := range f.got {
|
||||||
|
out = append(out, m.Topic)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// forget drops what has been published so far, so the next assertion is about
|
||||||
|
// this tick rather than the whole test.
|
||||||
|
func (f *fakeNtfy) forget() {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.got = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNtfy) failWith(status int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Harness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// notifyTS builds a server with notifications enabled, the admin on call today,
|
||||||
|
// and a topic on the admin — the setup every delivery test needs.
|
||||||
|
func notifyTS(t *testing.T, cfg api.NotifyConfig) (*ts, *fakeNtfy) {
|
||||||
|
t.Helper()
|
||||||
|
f := newFakeNtfy(t)
|
||||||
|
cfg.BaseURL = f.URL
|
||||||
|
s := newTS(t, cfg)
|
||||||
|
|
||||||
|
putOnCall(t, s, 1)
|
||||||
|
setTopic(t, s, 1, "terdut-admin")
|
||||||
|
return s, f
|
||||||
|
}
|
||||||
|
|
||||||
|
func putOnCall(t *testing.T, s *ts, userID int) {
|
||||||
|
t.Helper()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||||
|
map[string]any{"user_id": userID, "dates": []string{today}})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("schedule assignment returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTopic(t *testing.T, s *ts, userID int, topic string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/users/%d/notify", userID), map[string]any{"ntfy_topic": topic})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("set notify topic returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ts) sweepNotify(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
api.NotifySweep(context.Background(), s.db, s.notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countNotifications reports how many outbox rows exist, optionally of one kind.
|
||||||
|
func (s *ts) countNotifications(t *testing.T, kind string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
query := "SELECT COUNT(*) FROM notifications"
|
||||||
|
args := []any{}
|
||||||
|
if kind != "" {
|
||||||
|
query += " WHERE kind = $1"
|
||||||
|
args = append(args, kind)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRow(query, args...).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count notifications: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireCritical posts a single critical alert, which opens one incident.
|
||||||
|
func fireCritical(t *testing.T, s *ts) {
|
||||||
|
t.Helper()
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-notify", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime,
|
||||||
|
map[string]string{"severity": "critical"}),
|
||||||
|
}, "{}:{alertname=\"DiskFull\"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delivery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
|
||||||
|
if got := s.countNotifications(t, "triggered"); got != 1 {
|
||||||
|
t.Fatalf("expected 1 queued notification, got %d", got)
|
||||||
|
}
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
msgs := f.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 push, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
m := msgs[0]
|
||||||
|
|
||||||
|
if m.Topic != "terdut-admin" {
|
||||||
|
t.Errorf("expected the on-call user's topic, got %q", m.Topic)
|
||||||
|
}
|
||||||
|
if m.Priority != 5 {
|
||||||
|
t.Errorf("expected max priority for a critical incident, got %d", m.Priority)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.Title, "DiskFull") {
|
||||||
|
t.Errorf("expected the incident title in %q", m.Title)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.Message, "severity critical") {
|
||||||
|
t.Errorf("expected the severity in %q", m.Message)
|
||||||
|
}
|
||||||
|
if m.Click != "https://terdut.example.com/incidents/1" {
|
||||||
|
t.Errorf("unexpected click target %q", m.Click)
|
||||||
|
}
|
||||||
|
if len(m.Actions) != 1 || m.Actions[0].Label != "Acknowledge" {
|
||||||
|
t.Fatalf("expected an Acknowledge action, got %+v", m.Actions)
|
||||||
|
}
|
||||||
|
if m.Actions[0].Method != http.MethodPost {
|
||||||
|
t.Errorf("expected the action to POST, got %q", m.Actions[0].Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delivered row must not be delivered again on the next pass.
|
||||||
|
func TestNotify_DeliveredOnlyOnce(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := len(f.messages()); got != 1 {
|
||||||
|
t.Errorf("expected 1 push across two passes, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delivery on the timeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// notifyEvents picks the notifier's entries out of an incident's timeline.
|
||||||
|
// Asserted through the API rather than the table: the timeline is what the
|
||||||
|
// clients read, so its shape is the contract worth covering.
|
||||||
|
func notifyEvents(t *testing.T, s *ts, id int) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
for _, e := range timeline(t, s, id) {
|
||||||
|
if e["type"] == "notified" || e["type"] == "notify_failed" {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_DeliveryIsRecordedOnTheTimeline(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
// Queued is not notified: nothing is on the timeline until ntfy accepts it.
|
||||||
|
if got := notifyEvents(t, s, 1); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no event before delivery, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
events := notifyEvents(t, s, 1)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 notification event, got %v", events)
|
||||||
|
}
|
||||||
|
e := events[0]
|
||||||
|
if e["type"] != "notified" {
|
||||||
|
t.Errorf("expected a notified event, got %v", e["type"])
|
||||||
|
}
|
||||||
|
if e["detail"] != "triggered" {
|
||||||
|
t.Errorf("expected the kind in detail, got %v", e["detail"])
|
||||||
|
}
|
||||||
|
if e["username"] != "admin" {
|
||||||
|
t.Errorf("expected the paged user attached, got %v", e["username"])
|
||||||
|
}
|
||||||
|
// The topic is a shared secret with ntfy; the timeline is not the place for it.
|
||||||
|
for _, v := range e {
|
||||||
|
if s, ok := v.(string); ok && strings.Contains(s, "terdut-admin") {
|
||||||
|
t.Errorf("expected the topic kept out of the timeline, found it in %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redelivery-free pass must not double-log either.
|
||||||
|
func TestNotify_TimelineRecordsOneEventPerDelivery(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
RepeatEvery: 15 * time.Minute,
|
||||||
|
})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.ageNotifications(t, 20*time.Minute)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
events := notifyEvents(t, s, 1)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("expected one event per delivery, got %v", events)
|
||||||
|
}
|
||||||
|
if events[0]["detail"] != "triggered" || events[1]["detail"] != "reminder" {
|
||||||
|
t.Errorf("expected triggered then reminder, got %v and %v",
|
||||||
|
events[0]["detail"], events[1]["detail"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_AllClearIsRecordedOnTheTimeline(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-notify", "DiskFull", "resolved", "2026-05-20T10:00:00Z",
|
||||||
|
"2026-05-20T11:00:00Z", map[string]string{"severity": "critical"}),
|
||||||
|
}, "{}:{alertname=\"DiskFull\"}")
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
events := notifyEvents(t, s, 1)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("expected the all-clear recorded, got %v", events)
|
||||||
|
}
|
||||||
|
if events[1]["detail"] != "resolved" {
|
||||||
|
t.Errorf("expected a resolved event, got %v", events[1]["detail"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page to the shared fallback belongs to nobody, and the timeline has to say
|
||||||
|
// so rather than attributing it to whoever happens to be on call now.
|
||||||
|
func TestNotify_FallbackDeliveryHasNoUser(t *testing.T) {
|
||||||
|
f := newFakeNtfy(t)
|
||||||
|
s := newTS(t, api.NotifyConfig{
|
||||||
|
BaseURL: f.URL,
|
||||||
|
FallbackTopic: "terdut-oncall",
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
events := notifyEvents(t, s, 1)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 notification event, got %v", events)
|
||||||
|
}
|
||||||
|
if got, ok := events[0]["username"]; ok && got != nil && got != "" {
|
||||||
|
t.Errorf("expected no user on a fallback-topic page, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure worth seeing: nobody was paged, and the timeline says so instead
|
||||||
|
// of looking exactly like a delivery that worked.
|
||||||
|
func TestNotify_ExhaustedRetriesAreRecordedOnce(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
f.failWith(http.StatusInternalServerError)
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
// One pass per attempt, each made due by clearing the backoff the last one set.
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.exec(t, "UPDATE notifications SET send_after = $1 WHERE sent_at IS NULL",
|
||||||
|
time.Now().Add(-time.Second).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
events := notifyEvents(t, s, 1)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected exactly one failure event, got %v", events)
|
||||||
|
}
|
||||||
|
if events[0]["type"] != "notify_failed" {
|
||||||
|
t.Errorf("expected notify_failed, got %v", events[0]["type"])
|
||||||
|
}
|
||||||
|
detail, _ := events[0]["detail"].(string)
|
||||||
|
if !strings.HasPrefix(detail, "triggered: ") || !strings.Contains(detail, "500") {
|
||||||
|
t.Errorf("expected the kind and the reason in %q", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acknowledging from the notification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNotify_AckButtonAcknowledgesIncident(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
ackURL := f.messages()[0].Actions[0].URL
|
||||||
|
// The action URL is built for the public hostname; point it at the test
|
||||||
|
// server, which is the same handler.
|
||||||
|
path := ackURL[strings.Index(ackURL, "/api/notify/ack/"):]
|
||||||
|
|
||||||
|
resp, err := http.Post(s.URL+path, "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ack: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 from the ack button, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
inc := getIncident(t, s, 1)
|
||||||
|
if inc["status"] != "acknowledged" {
|
||||||
|
t.Errorf("expected the incident acknowledged, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
if inc["acknowledged_by"] != "admin" {
|
||||||
|
t.Errorf("expected the ack attributed to the token's user, got %v", inc["acknowledged_by"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timeline must record it the same way the authenticated route would.
|
||||||
|
events := timeline(t, s, 1)
|
||||||
|
if !contains(eventTypes(events), "acknowledged") {
|
||||||
|
t.Errorf("expected an acknowledged event, got %v", eventTypes(events))
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
if e["type"] == "acknowledged" && e["username"] != "admin" {
|
||||||
|
t.Errorf("expected the acknowledged event attributed to admin, got %v", e["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_AckRejectsUnknownToken(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
fireCritical(t, s)
|
||||||
|
|
||||||
|
resp, err := http.Post(s.URL+"/api/notify/ack/deadbeef", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ack: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 for an unknown token, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if inc := getIncident(t, s, 1); inc["status"] != "triggered" {
|
||||||
|
t.Errorf("expected the incident untouched, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_AckRejectsExpiredToken(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
ackURL := f.messages()[0].Actions[0].URL
|
||||||
|
path := ackURL[strings.Index(ackURL, "/api/notify/ack/"):]
|
||||||
|
|
||||||
|
// Age the token past its TTL. The token's inputs are wall-clock timestamps,
|
||||||
|
// so this is the same trick the sweeper tests use.
|
||||||
|
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = $1", time.Now().Add(-time.Minute).Unix())
|
||||||
|
|
||||||
|
resp, err := http.Post(s.URL+path, "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ack: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 for an expired token, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if inc := getIncident(t, s, 1); inc["status"] != "triggered" {
|
||||||
|
t.Errorf("expected the incident untouched, got %v", inc["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sweeper is what stops expired tokens accumulating forever.
|
||||||
|
func TestNotify_SweepPurgesExpiredAckTokens(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.exec(t, "UPDATE incident_ack_tokens SET expires_at = $1", time.Now().Add(-time.Minute).Unix())
|
||||||
|
|
||||||
|
api.Sweep(context.Background(), s.db, 168*time.Hour, 6*time.Hour, s.notify)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM incident_ack_tokens").Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count tokens: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected expired tokens purged, %d left", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ageNotifications backdates every sent notification so the next pass sees the
|
||||||
|
// reminder as due.
|
||||||
|
func (s *ts) ageNotifications(t *testing.T, by time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
s.exec(t, "UPDATE notifications SET created_at = $1 WHERE sent_at IS NOT NULL",
|
||||||
|
time.Now().Add(-by).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_UnacknowledgedIncidentIsRenotified(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
RepeatEvery: 15 * time.Minute,
|
||||||
|
})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.ageNotifications(t, 20*time.Minute)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
msgs := f.messages()
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("expected a reminder push, got %d message(s)", len(msgs))
|
||||||
|
}
|
||||||
|
if !strings.Contains(msgs[1].Title, "Still unacknowledged") {
|
||||||
|
t.Errorf("expected the reminder to say so, got %q", msgs[1].Title)
|
||||||
|
}
|
||||||
|
if msgs[1].Topic != "terdut-admin" {
|
||||||
|
t.Errorf("expected the reminder on the same topic, got %q", msgs[1].Topic)
|
||||||
|
}
|
||||||
|
// Each page carries its own credential.
|
||||||
|
if len(msgs[1].Actions) != 1 || msgs[1].Actions[0].URL == msgs[0].Actions[0].URL {
|
||||||
|
t.Errorf("expected the reminder to carry a fresh ack token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_AcknowledgedIncidentStopsReminders(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
RepeatEvery: 15 * time.Minute,
|
||||||
|
})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
s.ageNotifications(t, 20*time.Minute)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := len(f.messages()); got != 1 {
|
||||||
|
t.Errorf("expected no reminder once acknowledged, got %d message(s)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snooze is the deliberate "not now", and it is what mutes the pager.
|
||||||
|
func TestNotify_SnoozedIncidentStopsReminders(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
RepeatEvery: 15 * time.Minute,
|
||||||
|
})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/snooze", map[string]any{"duration": "1h"})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("snooze returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
s.ageNotifications(t, 20*time.Minute)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := len(f.messages()); got != 1 {
|
||||||
|
t.Errorf("expected no reminder while snoozed, got %d message(s)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_ZeroRepeatDisablesReminders(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.ageNotifications(t, 24*time.Hour)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := len(f.messages()); got != 1 {
|
||||||
|
t.Errorf("expected reminders off, got %d message(s)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNotify_ResolvedIncidentSendsAllClear(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-notify", "DiskFull", "resolved", "2026-05-20T10:00:00Z",
|
||||||
|
"2026-05-20T11:00:00Z", map[string]string{"severity": "critical"}),
|
||||||
|
}, "{}:{alertname=\"DiskFull\"}")
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
msgs := f.messages()
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("expected an all-clear push, got %d message(s)", len(msgs))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(msgs[1].Title, "Resolved:") {
|
||||||
|
t.Errorf("expected a resolved title, got %q", msgs[1].Title)
|
||||||
|
}
|
||||||
|
if msgs[1].Priority != 2 {
|
||||||
|
t.Errorf("expected the all-clear at low priority, got %d", msgs[1].Priority)
|
||||||
|
}
|
||||||
|
// Nothing to acknowledge on a closed incident.
|
||||||
|
if len(msgs[1].Actions) != 0 {
|
||||||
|
t.Errorf("expected no actions on the all-clear, got %+v", msgs[1].Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing an incident by hand sends nothing: the person who did it knows.
|
||||||
|
func TestNotify_ManualResolveSendsNothing(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := len(f.messages()); got != 1 {
|
||||||
|
t.Errorf("expected no push for a manual resolve, got %d message(s)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Routing and configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// With nobody on call the page goes to the shared fallback, and carries no
|
||||||
|
// Acknowledge button — there is no user to attribute the acknowledgement to.
|
||||||
|
func TestNotify_FallbackTopicHasNoAckButton(t *testing.T) {
|
||||||
|
f := newFakeNtfy(t)
|
||||||
|
s := newTS(t, api.NotifyConfig{
|
||||||
|
BaseURL: f.URL,
|
||||||
|
FallbackTopic: "terdut-oncall",
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
msgs := f.messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 push, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Topic != "terdut-oncall" {
|
||||||
|
t.Errorf("expected the fallback topic, got %q", msgs[0].Topic)
|
||||||
|
}
|
||||||
|
if len(msgs[0].Actions) != 0 {
|
||||||
|
t.Errorf("expected no ack button on a shared topic, got %+v", msgs[0].Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody on call and no fallback means there is nowhere to send: queueing would
|
||||||
|
// only pile up rows that can never be delivered.
|
||||||
|
func TestNotify_NoTargetQueuesNothing(t *testing.T) {
|
||||||
|
f := newFakeNtfy(t)
|
||||||
|
s := newTS(t, api.NotifyConfig{BaseURL: f.URL})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
|
||||||
|
if got := s.countNotifications(t, ""); got != 0 {
|
||||||
|
t.Errorf("expected nothing queued without a target, got %d", got)
|
||||||
|
}
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if got := len(f.messages()); got != 0 {
|
||||||
|
t.Errorf("expected no push, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zero NotifyConfig is what every pre-existing test runs under.
|
||||||
|
func TestNotify_DisabledQueuesNothing(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
putOnCall(t, s, 1)
|
||||||
|
setTopic(t, s, 1, "terdut-admin")
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
|
||||||
|
if got := s.countNotifications(t, ""); got != 0 {
|
||||||
|
t.Errorf("expected nothing queued with notifications off, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Retries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNotify_FailedDeliveryRetriesWithBackoff(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
f.failWith(http.StatusInternalServerError)
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
var attempts int
|
||||||
|
var sentAt *int64
|
||||||
|
var sendAfter int64
|
||||||
|
var lastError *string
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
"SELECT attempts, sent_at, send_after, last_error FROM notifications WHERE id = 1").
|
||||||
|
Scan(&attempts, &sentAt, &sendAfter, &lastError); err != nil {
|
||||||
|
t.Fatalf("read notification: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("expected 1 attempt recorded, got %d", attempts)
|
||||||
|
}
|
||||||
|
if sentAt != nil {
|
||||||
|
t.Errorf("expected the row unsent, got sent_at %v", *sentAt)
|
||||||
|
}
|
||||||
|
if sendAfter <= time.Now().Unix() {
|
||||||
|
t.Errorf("expected the retry pushed into the future, got %d", sendAfter)
|
||||||
|
}
|
||||||
|
if lastError == nil || !strings.Contains(*lastError, "500") {
|
||||||
|
t.Errorf("expected the failure recorded, got %v", lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backing off means the next pass leaves it alone until it is due.
|
||||||
|
s.sweepNotify(t)
|
||||||
|
if got := len(f.messages()); got != 1 {
|
||||||
|
t.Errorf("expected no immediate retry, got %d attempt(s)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once due and once ntfy recovers, it goes out.
|
||||||
|
f.failWith(http.StatusOK)
|
||||||
|
s.exec(t, "UPDATE notifications SET send_after = $1 WHERE id = 1", time.Now().Add(-time.Second).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if err := s.db.QueryRow("SELECT sent_at FROM notifications WHERE id = 1").Scan(&sentAt); err != nil {
|
||||||
|
t.Fatalf("read notification: %v", err)
|
||||||
|
}
|
||||||
|
if sentAt == nil {
|
||||||
|
t.Error("expected the retry to succeed once ntfy recovered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ntfy outage must not produce a reminder backlog that all lands at once
|
||||||
|
// when it comes back: the previous page has to have been sent first.
|
||||||
|
func TestNotify_UnsentNotificationBlocksReminders(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{
|
||||||
|
PublicURL: "https://terdut.example.com",
|
||||||
|
RepeatEvery: 15 * time.Minute,
|
||||||
|
})
|
||||||
|
f.failWith(http.StatusInternalServerError)
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
s.exec(t, "UPDATE notifications SET created_at = $1", time.Now().Add(-time.Hour).Unix())
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
if got := s.countNotifications(t, "reminder"); got != 0 {
|
||||||
|
t.Errorf("expected no reminders queued behind an undelivered page, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// oidcStateCookie ties an in-flight login to the browser that started it.
|
||||||
|
// Without it anybody could start a login, and send the callback URL that
|
||||||
|
// results to somebody else, who would be signed in as the attacker.
|
||||||
|
oidcStateCookie = "terdut_oidc_state"
|
||||||
|
|
||||||
|
// oidcLoginTTL is how long a login may take between the redirect to the
|
||||||
|
// provider and the callback, which includes the person typing a password
|
||||||
|
// and a second factor.
|
||||||
|
oidcLoginTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// oidcStartMaxPerAddr bounds unauthenticated logins started per address.
|
||||||
|
// Each writes a row, so an unbounded endpoint is a way to grow the table.
|
||||||
|
oidcStartMaxPerAddr = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// ssoError is a sign-in refusal the person can be told about. Its value is the
|
||||||
|
// code the web UI is sent back with, as ?sso_error=<code>; the detail stays in
|
||||||
|
// the server log, since it can name accounts.
|
||||||
|
type ssoError string
|
||||||
|
|
||||||
|
func (e ssoError) Error() string { return "sso: " + string(e) }
|
||||||
|
|
||||||
|
const (
|
||||||
|
ssoDenied ssoError = "denied" // the provider reported an error, or the person declined
|
||||||
|
ssoExpired ssoError = "expired" // unknown, used or expired state; start again
|
||||||
|
ssoFailed ssoError = "failed" // the token exchange or its verification failed
|
||||||
|
ssoUnavailable ssoError = "unavailable" // the provider could not be reached
|
||||||
|
ssoNotAllowed ssoError = "not_allowed" // authenticated, but in none of the allowed groups
|
||||||
|
ssoNoEmail ssoError = "no_email" // the provider sent no email address
|
||||||
|
ssoEmailConflict ssoError = "email_conflict" // a local account has this email and cannot be linked
|
||||||
|
ssoDisabled ssoError = "disabled" // the linked account is disabled
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleAuthConfig says how this server can be signed in to, so the login form
|
||||||
|
// and the TUI can offer the right choices before anybody types anything. It is
|
||||||
|
// unauthenticated by necessity, and reveals nothing beyond what the login page
|
||||||
|
// shows anyway.
|
||||||
|
func handleAuthConfig(cfg config.Config) http.HandlerFunc {
|
||||||
|
type oidcInfo struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
type response struct {
|
||||||
|
PasswordLogin bool `json:"password_login"`
|
||||||
|
OIDC oidcInfo `json:"oidc"`
|
||||||
|
|
||||||
|
// DeviceLogin is whether a client that cannot open a browser (the TUI)
|
||||||
|
// can sign in by showing a code, through /api/oidc/device.
|
||||||
|
DeviceLogin bool `json:"device_login"`
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
|
||||||
|
if cfg.OIDC.Enabled() {
|
||||||
|
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
|
||||||
|
resp.DeviceLogin = true
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordLoginOnly refuses a route when password login is switched off.
|
||||||
|
func passwordLoginOnly(enabled bool) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
if enabled {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
respond(w, http.StatusForbidden, errResp("password login is disabled on this server"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoRedirect sends the browser back to the web UI with the reason a sign-in
|
||||||
|
// failed. It is a redirect and not a JSON error because the browser arrived
|
||||||
|
// here by navigating from the provider: there is no page script to read one.
|
||||||
|
func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
|
||||||
|
http.Redirect(w, r, "/?sso_error="+url.QueryEscape(string(code)), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOIDCLogin starts a sign-in: it records the state, nonce and PKCE
|
||||||
|
// verifier the callback will need and sends the browser to the provider.
|
||||||
|
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
addrKey := "oidc:" + clientAddr(r)
|
||||||
|
if limiter.blocked(addrKey, oidcStartMaxPerAddr) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||||
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limiter.fail(addrKey)
|
||||||
|
|
||||||
|
state, stateHash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nonce, _, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifier := oidc.NewVerifier()
|
||||||
|
next := safeNext(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
|
// Abandoned logins are swept here rather than by the sweeper: this is
|
||||||
|
// the only place they are made, so the table cannot outgrow its writers.
|
||||||
|
now := time.Now()
|
||||||
|
db.ExecContext(r.Context(), "DELETE FROM oidc_logins WHERE expires_at < $1", now.Unix())
|
||||||
|
if _, err := db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO oidc_logins (state_hash, nonce, pkce_verifier, next, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
stateHash, nonce, verifier, next, now.Add(oidcLoginTTL).Unix()); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL, err := prov.AuthURL(r.Context(), state, nonce, verifier)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("oidc: start login: %v", err)
|
||||||
|
ssoRedirect(w, r, ssoUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: oidcStateCookie,
|
||||||
|
Value: state,
|
||||||
|
Path: "/api/oidc",
|
||||||
|
MaxAge: int(oidcLoginTTL.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: cookieSecure(publicURL, r),
|
||||||
|
// Lax, not Strict: the callback is a top-level navigation from the
|
||||||
|
// provider's site, which Strict would not send the cookie on.
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOIDCCallback finishes a sign-in: it verifies the provider's answer,
|
||||||
|
// finds or creates the user, applies their groups and starts a session.
|
||||||
|
func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.HandlerFunc {
|
||||||
|
cfg := prov.Config()
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// The state cookie has done its job once the callback arrives, whatever
|
||||||
|
// the outcome.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
if e := q.Get("error"); e != "" {
|
||||||
|
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description"))
|
||||||
|
ssoRedirect(w, r, ssoDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := q.Get("state")
|
||||||
|
cookie, err := r.Cookie(oidcStateCookie)
|
||||||
|
if state == "" || q.Get("code") == "" || err != nil || cookie.Value != state {
|
||||||
|
ssoRedirect(w, r, ssoExpired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE ... RETURNING makes the state single-use: a replayed callback
|
||||||
|
// finds nothing.
|
||||||
|
var nonce, verifier, next string
|
||||||
|
err = db.QueryRowContext(r.Context(), `
|
||||||
|
DELETE FROM oidc_logins WHERE state_hash = $1 AND expires_at > $2
|
||||||
|
RETURNING nonce, pkce_verifier, next`,
|
||||||
|
hashToken(state), time.Now().Unix()).Scan(&nonce, &verifier, &next)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
ssoRedirect(w, r, ssoExpired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("oidc: load login state: %v", err)
|
||||||
|
ssoRedirect(w, r, ssoFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := prov.Exchange(r.Context(), q.Get("code"), verifier, nonce)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("oidc: %v", err)
|
||||||
|
ssoRedirect(w, r, ssoFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grants := oidc.ComputeGrants(cfg, identity.Groups)
|
||||||
|
if !grants.Admitted {
|
||||||
|
log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject)
|
||||||
|
ssoRedirect(w, r, ssoNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
|
||||||
|
if err != nil {
|
||||||
|
var se ssoError
|
||||||
|
if errors.As(err, &se) {
|
||||||
|
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se)
|
||||||
|
ssoRedirect(w, r, se)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("oidc: sign in %q: %v", identity.Username, err)
|
||||||
|
ssoRedirect(w, r, ssoFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := startSessionCapped(w, r, db, userID, publicURL, cfg.SessionMaxAge); err != nil {
|
||||||
|
log.Printf("oidc: start session: %v", err)
|
||||||
|
ssoRedirect(w, r, ssoFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, safeNext(next), http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeNext returns where to send the browser after a sign-in: the path asked
|
||||||
|
// for, if it is one on this server, and the front page otherwise. It is the
|
||||||
|
// only thing standing between a login link and an open redirect, so it accepts
|
||||||
|
// a single leading slash and nothing that a browser could read as another host
|
||||||
|
// ("//evil.example", "/\evil.example"), and never an API path, which would
|
||||||
|
// land somebody on raw JSON.
|
||||||
|
func safeNext(next string) string {
|
||||||
|
switch {
|
||||||
|
case next == "", len(next) > 512,
|
||||||
|
!strings.HasPrefix(next, "/"),
|
||||||
|
strings.HasPrefix(next, "//"),
|
||||||
|
strings.HasPrefix(next, "/api/"),
|
||||||
|
strings.ContainsAny(next, "\\\r\n"):
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// signInSSO resolves the identity to a user and applies its grants, in one
|
||||||
|
// transaction: a login that fails half way must not leave memberships changed.
|
||||||
|
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants) (int64, error) {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
userID, err := resolveSSOUser(ctx, tx, cfg, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var disabled bool
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT disabled_at IS NOT NULL FROM users WHERE id = $1", userID).Scan(&disabled); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
return 0, ssoDisabled
|
||||||
|
}
|
||||||
|
if err := syncGrants(ctx, tx, userID, g); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return userID, tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSSOUser finds the user an identity belongs to, linking or creating one
|
||||||
|
// when this is its first sign-in.
|
||||||
|
//
|
||||||
|
// The order matters. The (issuer, subject) pair is the identity; email is only
|
||||||
|
// a way to recognise an existing local account the first time. Once linked, a
|
||||||
|
// changed email at the provider must not move the account to somebody else.
|
||||||
|
func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.Identity) (int64, error) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT user_id FROM user_identities WHERE issuer = $1 AND subject = $2",
|
||||||
|
id.Issuer, id.Subject).Scan(&userID)
|
||||||
|
if err == nil {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE user_identities SET last_login_at = $1 WHERE issuer = $2 AND subject = $3",
|
||||||
|
now, id.Issuer, id.Subject); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return userID, refreshProfile(ctx, tx, userID, id)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// First sign-in with this identity.
|
||||||
|
if id.Email == "" {
|
||||||
|
return 0, ssoNoEmail
|
||||||
|
}
|
||||||
|
err = tx.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM users WHERE lower(email) = lower($1)", id.Email).Scan(&userID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if !id.EmailVerified && !cfg.TrustEmail {
|
||||||
|
return 0, ssoEmailConflict
|
||||||
|
}
|
||||||
|
// A local account that already has an identity from this issuer is a
|
||||||
|
// different person at the provider using a recycled address. Linking
|
||||||
|
// them would hand one person's account to another.
|
||||||
|
var linked bool
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1 AND issuer = $2)",
|
||||||
|
userID, id.Issuer).Scan(&linked); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if linked {
|
||||||
|
return 0, ssoEmailConflict
|
||||||
|
}
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
userID, err = createSSOUser(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO user_identities (user_id, issuer, subject) VALUES ($1, $2, $3)",
|
||||||
|
userID, id.Issuer, id.Subject); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSSOUser inserts a user with no password. The username is the provider's,
|
||||||
|
// made unique with a numeric suffix when somebody local already has it.
|
||||||
|
func createSSOUser(ctx context.Context, tx *sql.Tx, id *oidc.Identity) (int64, error) {
|
||||||
|
base := strings.TrimSpace(id.Username)
|
||||||
|
if base == "" {
|
||||||
|
base, _, _ = strings.Cut(id.Email, "@")
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
base = "user"
|
||||||
|
}
|
||||||
|
for n := 1; n <= 100; n++ {
|
||||||
|
name := base
|
||||||
|
if n > 1 {
|
||||||
|
name = base + "-" + strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
var userID int64
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO users (username, email) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (username) DO NOTHING RETURNING id`,
|
||||||
|
name, id.Email).Scan(&userID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
continue // taken; try the next suffix
|
||||||
|
}
|
||||||
|
return userID, err
|
||||||
|
}
|
||||||
|
return 0, errors.New("no free username for " + base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshProfile brings a linked user's username and email in line with the
|
||||||
|
// provider. Each update is skipped, not failed, when another user already holds
|
||||||
|
// the value: both columns are unique, and a sign-in must not break over a name.
|
||||||
|
func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Identity) error {
|
||||||
|
if id.Username != "" {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE users SET username = $1
|
||||||
|
WHERE id = $2 AND username <> $1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE username = $1)`,
|
||||||
|
id.Username, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id.Email != "" {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE users SET email = $1
|
||||||
|
WHERE id = $2 AND email <> $1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))`,
|
||||||
|
id.Email, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncGrants makes the user's OIDC-sourced access match what their groups grant
|
||||||
|
// now, and touches nothing else.
|
||||||
|
//
|
||||||
|
// Rows the sync owns are marked source 'oidc'. It adds them, changes their role
|
||||||
|
// and removes them. The last-owner and last-administrator guards do not apply:
|
||||||
|
// they exist to stop a person's mistake, and the provider is the source of truth
|
||||||
|
// for the access it grants, so a team or an install can be left without an
|
||||||
|
// SSO-granted owner. Administrators can always repair a team, and the bootstrap
|
||||||
|
// administrator is a manual one. Rows added by hand are 'manual', and the sync
|
||||||
|
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
|
||||||
|
// them.
|
||||||
|
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) error {
|
||||||
|
// Administrator. A manual administrator stays one whatever the groups say.
|
||||||
|
if g.Admin {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE users SET is_admin = true, admin_source = 'oidc' WHERE id = $1 AND NOT is_admin",
|
||||||
|
userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if _, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE users SET is_admin = false, admin_source = 'manual' WHERE id = $1 AND admin_source = 'oidc'",
|
||||||
|
userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams. The result of the loop is the set of teams the groups grant.
|
||||||
|
granted := make([]int64, 0, len(g.Teams))
|
||||||
|
for name, role := range g.Teams {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var teamID int64
|
||||||
|
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
granted = append(granted, teamID)
|
||||||
|
|
||||||
|
// A row the sync owns follows the groups in both directions. One added by
|
||||||
|
// hand is only raised: a member the owner made an owner by hand is not
|
||||||
|
// demoted because the mapping says member.
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_members (team_id, user_id, role, source)
|
||||||
|
VALUES ($1, $2, $3, 'oidc')
|
||||||
|
ON CONFLICT (team_id, user_id) DO UPDATE
|
||||||
|
SET role = excluded.role, source = 'oidc'
|
||||||
|
WHERE team_members.source = 'oidc'
|
||||||
|
OR (excluded.role = $4 AND team_members.role = $5)`,
|
||||||
|
teamID, userID, role, models.RoleOwner, models.RoleMember); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access the groups no longer grant. granted is never nil, or the ALL
|
||||||
|
// comparison would be against NULL and delete nothing.
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
"DELETE FROM team_members WHERE user_id = $1 AND source = 'oidc' AND team_id <> ALL($2)",
|
||||||
|
userID, granted)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeIdP is just enough of an OpenID Connect provider for terdut to sign
|
||||||
|
// somebody in against: discovery, a key set and a token endpoint that checks the
|
||||||
|
// PKCE verifier. There is no authorize endpoint; the tests read the URL terdut
|
||||||
|
// redirects to and play the part of the browser and the person themselves.
|
||||||
|
type fakeIdP struct {
|
||||||
|
*httptest.Server
|
||||||
|
key *rsa.PrivateKey
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
codes map[string]pendingCode
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingCode struct {
|
||||||
|
claims map[string]any
|
||||||
|
challenge string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
idpClientID = "terdut"
|
||||||
|
idpClientSecret = "s3cret"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newFakeIdP(t *testing.T) *fakeIdP {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := &fakeIdP{key: key, codes: map[string]pendingCode{}}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": f.URL,
|
||||||
|
"authorization_endpoint": f.URL + "/authorize",
|
||||||
|
"token_endpoint": f.URL + "/token",
|
||||||
|
"jwks_uri": f.URL + "/jwks",
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b64 := base64.RawURLEncoding.EncodeToString
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
|
||||||
|
"kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256",
|
||||||
|
"n": b64(key.N.Bytes()),
|
||||||
|
"e": b64(big.NewInt(int64(key.E)).Bytes()),
|
||||||
|
}}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.ParseForm()
|
||||||
|
user, pass, basic := r.BasicAuth()
|
||||||
|
if !basic {
|
||||||
|
user, pass = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
|
||||||
|
}
|
||||||
|
if user != idpClientID || pass != idpClientSecret {
|
||||||
|
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
p, ok := f.codes[r.PostForm.Get("code")]
|
||||||
|
delete(f.codes, r.PostForm.Get("code")) // single use, like a real provider
|
||||||
|
f.mu.Unlock()
|
||||||
|
sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
|
||||||
|
if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != p.challenge {
|
||||||
|
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// oauth2 picks the parser from the content type; without this it reads
|
||||||
|
// the body as a form, finds no token and retries, spending the code.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "unused", "token_type": "Bearer", "expires_in": 300,
|
||||||
|
"id_token": f.sign(t, p.claims),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
f.Server = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(f.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign returns claims as an RS256 JWT.
|
||||||
|
func (f *fakeIdP) sign(t *testing.T, claims map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
enc := func(v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
signing := enc(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + "." + enc(claims)
|
||||||
|
sum := sha256.Sum256([]byte(signing))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// idpUser is who signs in, as the provider describes them.
|
||||||
|
type idpUser struct {
|
||||||
|
sub, username, email string
|
||||||
|
unverified bool
|
||||||
|
groups []string
|
||||||
|
badNonce bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoConfig is a terdut configuration wired to idp, with the mapping the tests
|
||||||
|
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
|
||||||
|
// grant roles in the SRE team.
|
||||||
|
func ssoConfig(idp *fakeIdP) config.Config {
|
||||||
|
c := testConfig()
|
||||||
|
c.OIDC = config.OIDC{
|
||||||
|
Issuer: idp.URL,
|
||||||
|
ClientID: idpClientID,
|
||||||
|
ClientSecret: idpClientSecret,
|
||||||
|
Name: "Authentik",
|
||||||
|
Scopes: []string{"openid", "profile", "email"},
|
||||||
|
UsernameClaim: "preferred_username",
|
||||||
|
EmailClaim: "email",
|
||||||
|
GroupsClaim: "groups",
|
||||||
|
AllowedGroups: []string{"terdut-users"},
|
||||||
|
AdminGroup: "terdut-admins",
|
||||||
|
GroupMappings: []config.GroupMapping{
|
||||||
|
{Group: "sre", Team: "SRE", Role: "member"},
|
||||||
|
{Group: "sre-leads", Team: "SRE", Role: "owner"},
|
||||||
|
{Group: "platform", Team: "Platform", Role: "member"},
|
||||||
|
},
|
||||||
|
SessionMaxAge: 12 * time.Hour,
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts {
|
||||||
|
t.Helper()
|
||||||
|
c := ssoConfig(idp)
|
||||||
|
for _, f := range tweak {
|
||||||
|
f(&c)
|
||||||
|
}
|
||||||
|
return newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{PublicURL: "http://terdut.test"}, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoBrowser is a browser that does not follow redirects, so a test can read
|
||||||
|
// where each step sends it.
|
||||||
|
func ssoBrowser(t *testing.T, s *ts) *browser {
|
||||||
|
t.Helper()
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
b.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// startLogin visits /api/oidc/login and returns what terdut asked the provider
|
||||||
|
// for: the state, nonce and PKCE challenge.
|
||||||
|
func startLogin(t *testing.T, idp *fakeIdP, b *browser) (state, nonce, challenge string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("login start: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||||
|
if err != nil || !strings.HasPrefix(loc.String(), idp.URL+"/authorize") {
|
||||||
|
t.Fatalf("login redirected to %q, want the provider", resp.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
q := loc.Query()
|
||||||
|
if q.Get("code_challenge_method") != "S256" || q.Get("client_id") != idpClientID ||
|
||||||
|
q.Get("redirect_uri") != "http://terdut.test/api/oidc/callback" || q.Get("response_type") != "code" {
|
||||||
|
t.Fatalf("unexpected authorization request: %v", q)
|
||||||
|
}
|
||||||
|
return q.Get("state"), q.Get("nonce"), q.Get("code_challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueCode has the provider authenticate u and hand back an authorization code.
|
||||||
|
func (f *fakeIdP) issueCode(u idpUser, nonce, challenge string) string {
|
||||||
|
if u.badNonce {
|
||||||
|
nonce = "not-the-nonce"
|
||||||
|
}
|
||||||
|
claims := map[string]any{
|
||||||
|
"iss": f.URL, "sub": u.sub, "aud": idpClientID,
|
||||||
|
"iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(),
|
||||||
|
"nonce": nonce,
|
||||||
|
"preferred_username": u.username,
|
||||||
|
"email": u.email,
|
||||||
|
"email_verified": !u.unverified,
|
||||||
|
"groups": u.groups,
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
code := fmt.Sprintf("code-%d", len(f.codes)+int(time.Now().UnixNano()%1e6))
|
||||||
|
f.codes[code] = pendingCode{claims: claims, challenge: challenge}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// callback delivers the provider's answer to terdut and returns where terdut
|
||||||
|
// sends the browser next.
|
||||||
|
func callback(t *testing.T, b *browser, code, state string) string {
|
||||||
|
t.Helper()
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/oidc/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("callback: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return resp.Header.Get("Location")
|
||||||
|
}
|
||||||
|
|
||||||
|
// signInSSO runs a whole sign-in and returns the Location the callback ended on.
|
||||||
|
func signInSSO(t *testing.T, idp *fakeIdP, b *browser, u idpUser) string {
|
||||||
|
t.Helper()
|
||||||
|
state, nonce, challenge := startLogin(t, idp, b)
|
||||||
|
return callback(t, b, idp.issueCode(u, nonce, challenge), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alice = idpUser{sub: "sub-alice", username: "alice", email: "alice@example.com", groups: []string{"terdut-users", "sre"}}
|
||||||
|
|
||||||
|
func withGroups(u idpUser, groups ...string) idpUser {
|
||||||
|
u.groups = groups
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// meOf reads /api/me over the browser's session.
|
||||||
|
func meOf(t *testing.T, b *browser) (status int, username string, isAdmin, hasPassword bool) {
|
||||||
|
t.Helper()
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/me", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&me)
|
||||||
|
return resp.StatusCode, me.User.Username, me.User.IsAdmin, me.HasPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberships lists a user's teams as name -> "role/source".
|
||||||
|
func (s *ts) memberships(t *testing.T, username string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT t.name, m.role, m.source FROM team_members m
|
||||||
|
JOIN teams t ON t.id = m.team_id JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE u.username = $1`, username)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name, role, source string
|
||||||
|
rows.Scan(&name, &role, &source)
|
||||||
|
out[name] = role + "/" + source
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameMap(a, b map[string]string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range a {
|
||||||
|
if b[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
|
||||||
|
if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" {
|
||||||
|
t.Fatalf("signed in and was sent to %q, want /", loc)
|
||||||
|
}
|
||||||
|
status, name, isAdmin, hasPassword := meOf(t, b)
|
||||||
|
if status != http.StatusOK || name != "alice" || isAdmin || hasPassword {
|
||||||
|
t.Fatalf("me: status %d user %q admin %v has_password %v", status, name, isAdmin, hasPassword)
|
||||||
|
}
|
||||||
|
want := map[string]string{"SRE": "member/oidc", "Platform": "member/oidc"}
|
||||||
|
if got := s.memberships(t, "alice"); !sameMap(got, want) {
|
||||||
|
t.Errorf("memberships %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
|
||||||
|
loc := signInSSO(t, idp, b, withGroups(alice, "sre", "terdut-admins"))
|
||||||
|
if loc != "/?sso_error=not_allowed" {
|
||||||
|
t.Fatalf("sent to %q, want the not_allowed error", loc)
|
||||||
|
}
|
||||||
|
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a refused sign-in must not leave a session: /api/me %d", status)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'alice'").Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Error("a refused sign-in must not create the user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_AdminFollowsTheAdminGroup(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "terdut-admins"))
|
||||||
|
var isAdmin bool
|
||||||
|
var source string
|
||||||
|
read := func() {
|
||||||
|
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'alice'").Scan(&isAdmin, &source)
|
||||||
|
}
|
||||||
|
if read(); !isAdmin || source != "oidc" {
|
||||||
|
t.Fatalf("after admin sign-in: admin %v source %q", isAdmin, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
|
||||||
|
if read(); isAdmin || source != "manual" {
|
||||||
|
t.Errorf("after losing the group: admin %v source %q, want revoked and manual", isAdmin, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
|
||||||
|
|
||||||
|
// The bootstrap administrator is a manual one. Signing in through the
|
||||||
|
// provider without the admin group must not take that away.
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), idpUser{sub: "sub-admin", username: "admin", email: "admin@test.com", groups: []string{"terdut-users"}})
|
||||||
|
var isAdmin bool
|
||||||
|
var source string
|
||||||
|
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'admin'").Scan(&isAdmin, &source)
|
||||||
|
if !isAdmin || source != "manual" {
|
||||||
|
t.Errorf("admin %v source %q, want still a manual admin", isAdmin, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||||
|
// Somebody adds alice to another team by hand.
|
||||||
|
s.exec(t, "INSERT INTO teams (name) VALUES ('Hand')")
|
||||||
|
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
|
||||||
|
SELECT (SELECT id FROM teams WHERE name = 'Hand'), id, 'member' FROM users WHERE username = 'alice'`)
|
||||||
|
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
|
||||||
|
want := map[string]string{"Hand": "member/manual"}
|
||||||
|
if got := s.memberships(t, "alice"); !sameMap(got, want) {
|
||||||
|
t.Errorf("memberships %v, want %v: the SRE row is the sync's to remove, Hand is not", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads"))
|
||||||
|
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) {
|
||||||
|
t.Errorf("both groups: %v, want owner", got)
|
||||||
|
}
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre"))
|
||||||
|
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
|
||||||
|
t.Errorf("lead group dropped: %v, want member", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
// alice exists locally, is a manual owner of SRE, and is linked by email.
|
||||||
|
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')")
|
||||||
|
s.exec(t, "INSERT INTO teams (name) VALUES ('SRE')")
|
||||||
|
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
|
||||||
|
VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`)
|
||||||
|
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), alice) // the mapping only says member
|
||||||
|
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) {
|
||||||
|
t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_LinksExistingUserByVerifiedEmail(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'Alice@Example.com')")
|
||||||
|
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, b, alice)
|
||||||
|
if _, name, _, _ := meOf(t, b); name != "alice-local" {
|
||||||
|
t.Errorf("signed in as %q, want the existing local user", name)
|
||||||
|
}
|
||||||
|
var users, identities int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users)
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM user_identities").Scan(&identities)
|
||||||
|
if users != 2 || identities != 1 { // admin + alice-local
|
||||||
|
t.Errorf("%d users, %d identities: linking must not create a second user", users, identities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_UnverifiedEmailIsNotLinkedUnlessTrusted(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
unverified := alice
|
||||||
|
unverified.unverified = true
|
||||||
|
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
|
||||||
|
if loc := signInSSO(t, idp, ssoBrowser(t, s), unverified); loc != "/?sso_error=email_conflict" {
|
||||||
|
t.Errorf("unverified email: sent to %q, want email_conflict", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
trusting := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
|
||||||
|
trusting.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
|
||||||
|
b := ssoBrowser(t, trusting)
|
||||||
|
if loc := signInSSO(t, idp, b, unverified); loc != "/" {
|
||||||
|
t.Fatalf("trusted email: sent to %q, want /", loc)
|
||||||
|
}
|
||||||
|
if _, name, _, _ := meOf(t, b); name != "alice-local" {
|
||||||
|
t.Errorf("signed in as %q, want the existing local user", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_RecycledEmailDoesNotTakeOverALinkedAccount(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||||
|
|
||||||
|
// A different person at the provider, same address.
|
||||||
|
other := alice
|
||||||
|
other.sub = "sub-someone-else"
|
||||||
|
if loc := signInSSO(t, idp, ssoBrowser(t, s), other); loc != "/?sso_error=email_conflict" {
|
||||||
|
t.Errorf("sent to %q, want email_conflict", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_UsernameCollisionGetsASuffix(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'someone-else@example.com')")
|
||||||
|
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, b, alice)
|
||||||
|
if _, name, _, _ := meOf(t, b); name != "alice-2" {
|
||||||
|
t.Errorf("username %q, want alice-2", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ProfileFollowsTheProvider(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||||
|
|
||||||
|
renamed := alice
|
||||||
|
renamed.username, renamed.email = "alice.smith", "alice.smith@example.com"
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, b, renamed)
|
||||||
|
if _, name, _, _ := meOf(t, b); name != "alice.smith" {
|
||||||
|
t.Errorf("username %q, want the provider's new one", name)
|
||||||
|
}
|
||||||
|
var email string
|
||||||
|
s.db.QueryRow("SELECT email FROM users WHERE username = 'alice.smith'").Scan(&email)
|
||||||
|
if email != "alice.smith@example.com" {
|
||||||
|
t.Errorf("email %q", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_DisabledUserIsRefused(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||||
|
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
|
||||||
|
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
if loc := signInSSO(t, idp, b, alice); loc != "/?sso_error=disabled" {
|
||||||
|
t.Errorf("sent to %q, want disabled", loc)
|
||||||
|
}
|
||||||
|
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("/api/me %d, want 401", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_NoEmailIsRefused(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
noEmail := alice
|
||||||
|
noEmail.email = ""
|
||||||
|
if loc := signInSSO(t, idp, ssoBrowser(t, s), noEmail); loc != "/?sso_error=no_email" {
|
||||||
|
t.Errorf("sent to %q, want no_email", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_SessionIsCappedAndDoesNotSlidePastTheCap(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, b, alice)
|
||||||
|
|
||||||
|
var expires, ceiling int64
|
||||||
|
s.db.QueryRow(`SELECT expires_at, max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&expires, &ceiling)
|
||||||
|
inTwelveHours := time.Now().Add(12 * time.Hour).Unix()
|
||||||
|
if ceiling < inTwelveHours-60 || ceiling > inTwelveHours+60 || expires != ceiling {
|
||||||
|
t.Fatalf("expires %d ceiling %d, want both about %d", expires, ceiling, inTwelveHours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age the session so the next request would slide it, with a ceiling well
|
||||||
|
// inside the ordinary 30 days.
|
||||||
|
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200")
|
||||||
|
if status, _, _, _ := meOf(t, b); status != http.StatusOK {
|
||||||
|
t.Fatalf("/api/me %d", status)
|
||||||
|
}
|
||||||
|
var after int64
|
||||||
|
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
|
||||||
|
if after > ceiling {
|
||||||
|
t.Errorf("expiry slid to %d, past the ceiling %d", after, ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_PasswordSessionsStillSlideWithoutACeiling(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
b := signedIn(t, s)
|
||||||
|
var ceiling *int64
|
||||||
|
s.db.QueryRow(`SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&ceiling)
|
||||||
|
if ceiling != nil {
|
||||||
|
t.Errorf("a password session has a ceiling %d, want none", *ceiling)
|
||||||
|
}
|
||||||
|
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200, expires_at = expires_at - 7200")
|
||||||
|
var before, after int64
|
||||||
|
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&before)
|
||||||
|
meOf(t, b)
|
||||||
|
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
|
||||||
|
if after <= before {
|
||||||
|
t.Errorf("expiry %d -> %d, want it to slide forward", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_StateIsSingleUseAndBoundToTheBrowser(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
// Replaying a callback finds no state.
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
state, nonce, challenge := startLogin(t, idp, b)
|
||||||
|
code := idp.issueCode(alice, nonce, challenge)
|
||||||
|
if loc := callback(t, b, code, state); loc != "/" {
|
||||||
|
t.Fatalf("first callback sent to %q", loc)
|
||||||
|
}
|
||||||
|
if loc := callback(t, b, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
|
||||||
|
t.Errorf("replayed state: sent to %q, want expired", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callback from a browser that did not start the login is refused, which
|
||||||
|
// is what stops a login being planted on somebody else.
|
||||||
|
victim := ssoBrowser(t, s)
|
||||||
|
state, nonce, challenge = startLogin(t, idp, ssoBrowser(t, s)) // the attacker's
|
||||||
|
if loc := callback(t, victim, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
|
||||||
|
t.Errorf("foreign browser: sent to %q, want expired", loc)
|
||||||
|
}
|
||||||
|
if status, _, _, _ := meOf(t, victim); status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("the victim has a session: /api/me %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_WrongNonceIsRefused(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
bad := alice
|
||||||
|
bad.badNonce = true
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
if loc := signInSSO(t, idp, b, bad); loc != "/?sso_error=failed" {
|
||||||
|
t.Errorf("sent to %q, want failed", loc)
|
||||||
|
}
|
||||||
|
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("/api/me %d, want 401", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/oidc/callback?error=access_denied", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=denied" {
|
||||||
|
t.Errorf("%d to %q, want a redirect to denied", resp.StatusCode, loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
|
||||||
|
|
||||||
|
var aliceID, sreID int64
|
||||||
|
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
|
||||||
|
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
|
||||||
|
teamPath := fmt.Sprintf("/api/teams/%d/members", sreID)
|
||||||
|
|
||||||
|
// The bootstrap admin is a system administrator, so may manage SRE.
|
||||||
|
for _, c := range []struct {
|
||||||
|
name, method, path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"role change", http.MethodPost, teamPath, map[string]any{"user_id": aliceID, "role": "owner"}},
|
||||||
|
{"removal", http.MethodDelete, fmt.Sprintf("%s/%d", teamPath, aliceID), nil},
|
||||||
|
{"admin revoke", http.MethodPut, fmt.Sprintf("/api/users/%d/admin", aliceID), map[string]any{"is_admin": false}},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, c.method, c.path, c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("%s: %d, want 409", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
|
||||||
|
t.Errorf("memberships changed by a refused edit: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_PasswordLoginCanBeSwitchedOff(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp, func(c *config.Config) { c.DisablePasswordLogin = true })
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
|
||||||
|
resp := b.login(t, "admin", "whatever-password")
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("login: %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = b.do(t, http.MethodPost, "/api/signup", map[string]string{"username": "x", "email": "x@example.com", "password": "correct horse battery"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("signup: %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg struct {
|
||||||
|
PasswordLogin bool `json:"password_login"`
|
||||||
|
OIDC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"oidc"`
|
||||||
|
}
|
||||||
|
resp = b.do(t, http.MethodGet, "/api/auth/config", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||||
|
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" {
|
||||||
|
t.Errorf("auth config: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthConfig_DefaultsToPasswordOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
var cfg struct {
|
||||||
|
PasswordLogin bool `json:"password_login"`
|
||||||
|
OIDC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
} `json:"oidc"`
|
||||||
|
}
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodGet, "/api/auth/config", nil)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||||
|
if !cfg.PasswordLogin || cfg.OIDC.Enabled {
|
||||||
|
t.Errorf("auth config: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With SSO off the routes do not exist, rather than answering with an error
|
||||||
|
// page a person could land on.
|
||||||
|
resp = newBrowser(t, s.URL).do(t, http.MethodGet, "/api/oidc/login", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("/api/oidc/login with SSO off: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
idp.Close() // the provider goes down after terdut has started
|
||||||
|
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=unavailable" {
|
||||||
|
t.Errorf("%d to %q, want a redirect to unavailable", resp.StatusCode, loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))
|
||||||
|
|
||||||
|
var aliceID, sreID int64
|
||||||
|
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
|
||||||
|
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
|
||||||
|
|
||||||
|
// Users: alice's administrator flag is the groups', the bootstrap admin's is not.
|
||||||
|
var users []struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
AdminSource string `json:"admin_source"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &users)
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, u := range users {
|
||||||
|
got[u.Username] = u.AdminSource
|
||||||
|
}
|
||||||
|
if got["alice"] != "oidc" || got["admin"] != "manual" {
|
||||||
|
t.Errorf("admin_source by user: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The team's own member list, as a member sees it.
|
||||||
|
var members []struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
resp := b.do(t, http.MethodGet, fmt.Sprintf("/api/teams/%d/members", sreID), nil)
|
||||||
|
decode(t, resp, &members)
|
||||||
|
if len(members) != 1 || members[0].Username != "alice" || members[0].Source != "oidc" {
|
||||||
|
t.Errorf("team members: %+v", members)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The administrator's view of the same team, and of alice's teams.
|
||||||
|
var adminTeam struct {
|
||||||
|
Members []struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"members"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/admin/teams/%d", sreID), nil), &adminTeam)
|
||||||
|
if len(adminTeam.Members) != 1 || adminTeam.Members[0].Source != "oidc" {
|
||||||
|
t.Errorf("admin team members: %+v", adminTeam.Members)
|
||||||
|
}
|
||||||
|
var teams []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/users/%d/teams", aliceID), nil), &teams)
|
||||||
|
if len(teams) != 1 || teams[0].Name != "SRE" || teams[0].Source != "oidc" {
|
||||||
|
t.Errorf("user teams: %+v", teams)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bootstrap admin's own membership is manual.
|
||||||
|
var mine []struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/users/1/teams", nil), &mine)
|
||||||
|
if len(mine) == 0 || mine[0].Source != "manual" {
|
||||||
|
t.Errorf("bootstrap admin's teams: %+v", mine)
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
-14
@@ -4,11 +4,25 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(db *sql.DB) http.Handler {
|
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
|
||||||
|
// the only handler that has to decide where a new incident's page goes; a zero
|
||||||
|
// notify disables notifications. Dead man's switches are per team and read from
|
||||||
|
// the database, so nothing about them is wired in here.
|
||||||
|
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
||||||
|
// One limiter each, both process-wide for the life of the router: login
|
||||||
|
// counts failed passwords, sign-up counts account creation, and mixing the
|
||||||
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||||
|
loginLimit := newLoginLimiter()
|
||||||
|
signupLimiter := newLoginLimiter()
|
||||||
|
oidcLimit := newLoginLimiter()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -17,38 +31,183 @@ func NewRouter(db *sql.DB) http.Handler {
|
|||||||
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unauthenticated: bootstrap and Alertmanager webhook receiver.
|
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
|
||||||
|
// Acknowledge button in a push notification. The last one is authorised by
|
||||||
|
// the scoped token in its path rather than an API key, and has to stay
|
||||||
|
// reachable from outside the cluster for the button to work.
|
||||||
r.Post("/api/bootstrap", handleBootstrap(db))
|
r.Post("/api/bootstrap", handleBootstrap(db))
|
||||||
r.Post("/api/alertmanager/webhook", handleAlertmanagerWebhook(db))
|
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
|
||||||
|
|
||||||
|
// Alert ingestion. The key in the path says both that the sender may post
|
||||||
|
// and which team the alerts belong to, which is why it needs no session.
|
||||||
|
//
|
||||||
|
// This is the only way in. The pre-teams /api/alertmanager/webhook, which
|
||||||
|
// took no credential at all, was removed in v0.13.0 once the cluster's
|
||||||
|
// Alertmanager had moved onto a key; a sender still posting there gets the
|
||||||
|
// JSON 404 every unknown /api path gets.
|
||||||
|
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
||||||
|
|
||||||
|
// Signing up. Both are unauthenticated by necessity: the caller has no
|
||||||
|
// account yet. The info endpoint says whether the door is open and whether
|
||||||
|
// an invite link is good, so the form can say so before somebody picks a
|
||||||
|
// password.
|
||||||
|
r.Get("/api/signup", handleSignupInfo(db))
|
||||||
|
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
||||||
|
Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
||||||
|
|
||||||
|
// How to sign in: what the login form and the TUI offer before anybody types.
|
||||||
|
r.Get("/api/auth/config", handleAuthConfig(cfg))
|
||||||
|
|
||||||
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
||||||
|
// which AuthMiddleware accepts in place of an API key.
|
||||||
|
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
||||||
|
Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
||||||
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
||||||
|
|
||||||
|
// Single sign-on. Both routes are navigations the browser makes, to and from
|
||||||
|
// the provider, so they answer with redirects rather than JSON.
|
||||||
|
if cfg.OIDC.Enabled() {
|
||||||
|
prov := oidc.New(cfg.OIDC, notify.PublicURL)
|
||||||
|
r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL))
|
||||||
|
r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL))
|
||||||
|
|
||||||
|
// Device login, for a client with no browser of its own. Both are
|
||||||
|
// unauthenticated: the device code in the body is the credential.
|
||||||
|
r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL))
|
||||||
|
r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL))
|
||||||
|
}
|
||||||
|
|
||||||
// All other /api routes require a valid API key.
|
// All other /api routes require a valid API key.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(AuthMiddleware(db))
|
r.Use(AuthMiddleware(db))
|
||||||
|
|
||||||
|
r.Get("/api/me", handleMe(db))
|
||||||
|
|
||||||
|
// Approving or refusing a device login is done by somebody signed in
|
||||||
|
// to a browser, and needs the same SSO configuration the flow does.
|
||||||
|
if cfg.OIDC.Enabled() {
|
||||||
|
r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true))
|
||||||
|
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
|
||||||
|
}
|
||||||
|
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
||||||
|
// Proves the topic works, which is the only part of "notifications are
|
||||||
|
// set up" that the person holding the phone can confirm.
|
||||||
|
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
|
||||||
|
|
||||||
|
// Readable by anyone signed in: the queue's assignment control and the
|
||||||
|
// on-call schedule both need to name people.
|
||||||
r.Get("/api/users", handleListUsers(db))
|
r.Get("/api/users", handleListUsers(db))
|
||||||
r.Post("/api/users", handleCreateUser(db))
|
|
||||||
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
// Your own account, or anybody's if you are an admin. The handlers call
|
||||||
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||||
|
// which rule applies depends on the {id} in the path.
|
||||||
|
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||||
|
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||||
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||||
|
|
||||||
|
// Administration: who exists, and who is an administrator. Until #3
|
||||||
|
// these were open to any authenticated caller, which meant every user
|
||||||
|
// could delete every other one.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(AdminOnly)
|
||||||
|
|
||||||
|
r.Post("/api/users", handleCreateUser(db))
|
||||||
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
||||||
|
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
||||||
|
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
|
||||||
|
|
||||||
|
// What exists on this server, and how it behaves. /api/teams
|
||||||
|
// answers "what am I in"; this one answers "what is there".
|
||||||
|
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
||||||
|
// One team and who is in it. The member list under
|
||||||
|
// /api/teams/{id}/members stays member-only and still 404s
|
||||||
|
// an administrator from outside; this is a different
|
||||||
|
// question, so it is a different endpoint.
|
||||||
|
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
|
||||||
|
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
||||||
|
r.Put("/api/admin/settings", handleSetSettings(db))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Alerts are read-only: they are Alertmanager's record, not a work
|
||||||
|
// queue. Everything a person does happens on the incident instead.
|
||||||
r.Get("/api/alerts", handleListAlerts(db))
|
r.Get("/api/alerts", handleListAlerts(db))
|
||||||
r.Get("/api/alerts/{id}", handleGetAlert(db))
|
r.Get("/api/alerts/{id}", handleGetAlert(db))
|
||||||
r.Post("/api/alerts/{id}/acknowledge", handleAcknowledge(db))
|
|
||||||
r.Delete("/api/alerts/{id}/acknowledge", handleUnacknowledge(db))
|
|
||||||
r.Get("/api/alerts/{id}/comments", handleListComments(db))
|
|
||||||
r.Post("/api/alerts/{id}/comments", handleCreateComment(db))
|
|
||||||
r.Delete("/api/alerts/{id}/comments/{commentID}", handleDeleteComment(db))
|
|
||||||
|
|
||||||
r.Post("/api/schedule", handleCreateSchedule(db))
|
r.Get("/api/incidents", handleListIncidents(db))
|
||||||
r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id}
|
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
||||||
r.Get("/api/schedule", handleListSchedule(db))
|
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
||||||
r.Delete("/api/schedule/{id}", handleDeleteSchedule(db))
|
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
||||||
|
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
||||||
|
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
||||||
|
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
||||||
|
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
||||||
|
r.Post("/api/incidents/{id}/assign", handleIncidentAssign(db))
|
||||||
|
r.Post("/api/incidents/{id}/snooze", handleIncidentSnooze(db))
|
||||||
|
r.Delete("/api/incidents/{id}/snooze", handleIncidentUnsnooze(db))
|
||||||
|
r.Post("/api/incidents/{id}/archive", handleIncidentArchive(db))
|
||||||
|
r.Delete("/api/incidents/{id}/archive", handleIncidentUnarchive(db))
|
||||||
|
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
||||||
|
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
||||||
|
|
||||||
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
||||||
|
r.Get("/api/teams", handleListTeams(db))
|
||||||
|
r.Post("/api/teams", handleCreateTeam(db))
|
||||||
|
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
||||||
|
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
||||||
|
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
||||||
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||||
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||||
|
|
||||||
|
// Invite links into this team.
|
||||||
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||||
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||||
|
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
||||||
|
|
||||||
|
// A team's escalation ladder: who is paged when nobody answers.
|
||||||
|
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
||||||
|
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
||||||
|
|
||||||
|
// A team's own dead man's switches: which of its alerts are heartbeats,
|
||||||
|
// and how long a silence has to last before somebody is paged.
|
||||||
|
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
|
||||||
|
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
|
||||||
|
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
|
||||||
|
|
||||||
|
// Integrations: where a team's alerts come in, and the key that says so.
|
||||||
|
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
||||||
|
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
||||||
|
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
|
||||||
|
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
||||||
|
|
||||||
|
// The rota is per team. /api/schedule/current is the exception: it
|
||||||
|
// answers across every team the caller is in, which is what somebody on
|
||||||
|
// two rotas wants to see.
|
||||||
|
r.Get("/api/schedule/current", handleCurrentSchedule(db))
|
||||||
|
r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db))
|
||||||
|
r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db))
|
||||||
|
r.Delete("/api/teams/{teamID}/schedule/{id}", handleDeleteSchedule(db))
|
||||||
|
|
||||||
|
r.Get("/api/stats/incidents", handleStatsIncidents(db))
|
||||||
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
||||||
r.Get("/api/stats/alerts/top", handleStatsTop(db))
|
r.Get("/api/stats/alerts/top", handleStatsTop(db))
|
||||||
r.Get("/api/stats/alerts/by-hour", handleStatsByHour(db))
|
r.Get("/api/stats/alerts/by-hour", handleStatsByHour(db))
|
||||||
r.Get("/api/stats/alerts/by-day", handleStatsByDay(db))
|
r.Get("/api/stats/alerts/by-day", handleStatsByDay(db))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Anything else under /api is a mistake in a client, and should say so in
|
||||||
|
// JSON rather than get the web UI's HTML.
|
||||||
|
r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Everything outside /api is the web UI.
|
||||||
|
site, err := web.Handler()
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // the site is embedded at build time; this cannot fail at runtime
|
||||||
|
}
|
||||||
|
r.Handle("/*", site)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-36
@@ -8,15 +8,31 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/yeniklas/terdut-server/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The schedule is per team: each team keeps its own rota, so two teams can have
|
||||||
|
// two different people on call on the same day. Editing it is an owner's job,
|
||||||
|
// like the rest of a team's configuration; reading it is any member's.
|
||||||
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Dates []string `json:"dates"`
|
Dates []string `json:"dates"`
|
||||||
|
|
||||||
|
// Replace takes dates that somebody else already holds. It defaults
|
||||||
|
// to off so that the plain call cannot quietly move a shift off the
|
||||||
|
// person expecting to be paged for it — reassigning has to be asked
|
||||||
|
// for.
|
||||||
|
Replace bool `json:"replace"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
@@ -37,14 +53,20 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the user exists.
|
// The person taking the shift has to be in the team: paging somebody
|
||||||
|
// who cannot open the incident is worse than paging nobody.
|
||||||
var exists int
|
var exists int
|
||||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil {
|
if err := db.QueryRowContext(r.Context(),
|
||||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
"SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2",
|
||||||
|
teamID, req.UserID).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user is not a member of this team"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// All-or-nothing: if any date already has an assignment, reject the whole request.
|
// All-or-nothing, in both directions: without replace, one taken date
|
||||||
|
// rejects the whole request; with it, either every date moves or none
|
||||||
|
// does. The rota must never be left with a hole where a shift used to
|
||||||
|
// be, so the delete and the insert share one transaction.
|
||||||
tx, err := db.BeginTx(r.Context(), nil)
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
@@ -53,10 +75,20 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
for _, d := range req.Dates {
|
for _, d := range req.Dates {
|
||||||
|
if req.Replace {
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM schedule_entries WHERE team_id = $1 AND date = $2",
|
||||||
|
teamID, d); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(r.Context(),
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
"INSERT INTO schedule_entries (user_id, date) VALUES (?, ?)", req.UserID, d); err != nil {
|
"INSERT INTO schedule_entries (team_id, user_id, date) VALUES ($1, $2, $3)",
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
teamID, req.UserID, d); err != nil {
|
||||||
respond(w, http.StatusConflict, errResp("date already assigned: "+d))
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict,
|
||||||
|
errResp("date already assigned: "+d+" (pass replace to take it)"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
@@ -73,7 +105,7 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
for _, d := range req.Dates {
|
for _, d := range req.Dates {
|
||||||
dateSet[d] = true
|
dateSet[d] = true
|
||||||
}
|
}
|
||||||
all, err := scheduleRange(r.Context(), db, req.Dates[0], req.Dates[len(req.Dates)-1])
|
all, err := scheduleRange(r.Context(), db, teamID, req.Dates[0], req.Dates[len(req.Dates)-1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -90,6 +122,13 @@ func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
from, to := q.Get("from"), q.Get("to")
|
from, to := q.Get("from"), q.Get("to")
|
||||||
|
|
||||||
@@ -106,7 +145,7 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := scheduleRange(r.Context(), db, from, to)
|
entries, err := scheduleRange(r.Context(), db, teamID, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -117,12 +156,20 @@ func handleListSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
|
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := db.ExecContext(r.Context(), "DELETE FROM schedule_entries WHERE id = ?", id)
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM schedule_entries WHERE id = $1 AND team_id = $2", id, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -135,55 +182,66 @@ func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCurrentSchedule answers "who is on call right now" for every team the
|
||||||
|
// caller belongs to — one entry per team, so somebody on two rotas sees both.
|
||||||
|
// A team with nobody scheduled today simply does not appear.
|
||||||
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
|
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
var e models.ScheduleEntry
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
var ts int64
|
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||||
err := db.QueryRowContext(r.Context(), `
|
|
||||||
SELECT s.id, s.user_id, u.username, s.date, s.created_at
|
|
||||||
FROM schedule_entries s
|
FROM schedule_entries s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.date = ?`, today).Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts)
|
JOIN teams t ON t.id = s.team_id
|
||||||
if err == sql.ErrNoRows {
|
WHERE s.date = $1 AND s.team_id = ANY($2)
|
||||||
respond(w, http.StatusNotFound, errResp("no one is on call today"))
|
ORDER BY t.name`, today, callerTeamIDs(r.Context()))
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
defer rows.Close()
|
||||||
respond(w, http.StatusOK, e)
|
|
||||||
|
entries := []models.ScheduleEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.ScheduleEntry
|
||||||
|
var ts int64
|
||||||
|
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleRange returns schedule entries ordered by date.
|
// scheduleRange returns schedule entries ordered by date.
|
||||||
// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side.
|
// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side.
|
||||||
func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.ScheduleEntry, error) {
|
func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to string) ([]models.ScheduleEntry, error) {
|
||||||
where := []string{}
|
args := &sqlArgs{}
|
||||||
args := []any{}
|
where := []string{"s.team_id = " + args.add(teamID)}
|
||||||
if from != "" {
|
if from != "" {
|
||||||
where = append(where, "s.date >= ?")
|
where = append(where, "s.date >= "+args.add(from))
|
||||||
args = append(args, from)
|
|
||||||
}
|
}
|
||||||
if to != "" {
|
if to != "" {
|
||||||
where = append(where, "s.date <= ?")
|
where = append(where, "s.date <= "+args.add(to))
|
||||||
args = append(args, to)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clause := "1=1"
|
clause := strings.Join(where, " AND ")
|
||||||
if len(where) > 0 {
|
|
||||||
clause = strings.Join(where, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT s.id, s.user_id, u.username, s.date, s.created_at
|
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||||
FROM schedule_entries s
|
FROM schedule_entries s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
|
JOIN teams t ON t.id = s.team_id
|
||||||
WHERE `+clause+`
|
WHERE `+clause+`
|
||||||
ORDER BY s.date ASC`, args...)
|
ORDER BY s.date ASC`, args.all()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -193,7 +251,7 @@ func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.S
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e models.ScheduleEntry
|
var e models.ScheduleEntry
|
||||||
var ts int64
|
var ts int64
|
||||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settings an administrator can change at runtime. Each is behaviour rather
|
||||||
|
// than infrastructure: what the server does, not where it is plugged in.
|
||||||
|
//
|
||||||
|
// The values are seconds, stored as text. A duration string would be friendlier
|
||||||
|
// to read in psql and worse everywhere else — it can be stored unparseable, and
|
||||||
|
// then the question is what a background loop should do at 02:00 with a
|
||||||
|
// tuning knob it cannot understand.
|
||||||
|
const (
|
||||||
|
SettingNotifyRepeat = "notify_repeat_seconds"
|
||||||
|
SettingStaleAfter = "stale_after_seconds"
|
||||||
|
SettingArchiveAfter = "archive_after_seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingBounds keeps an edit from producing a server that cannot work. The
|
||||||
|
// ceilings are loose — they exist to catch a slipped decimal point, not to have
|
||||||
|
// an opinion about anybody's rota.
|
||||||
|
var settingBounds = map[string]struct {
|
||||||
|
min, max time.Duration
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
|
||||||
|
SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
|
||||||
|
SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings reads the runtime configuration. It holds no cache: the readers are
|
||||||
|
// two background loops that tick every 30 seconds and 15 minutes, and handlers
|
||||||
|
// that run once per request, so a query each time costs nothing measurable and
|
||||||
|
// means an administrator's change takes effect on the next tick rather than at
|
||||||
|
// the next restart.
|
||||||
|
type Settings struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NewSettings returns a reader over db.
|
||||||
|
func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
|
||||||
|
|
||||||
|
// Duration reads one setting, falling back to def when the row is missing or
|
||||||
|
// unreadable. A tuning knob is never worth failing a sweep over: the fallback
|
||||||
|
// is the value the server started with.
|
||||||
|
func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
|
||||||
|
var raw string
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedSettings writes each key from the server's environment configuration,
|
||||||
|
// once. Never overwrites: after the first start the database owns these, and a
|
||||||
|
// redeploy must not put a chart's default back over an administrator's edit —
|
||||||
|
// the same rule as the per-team dead man's switches.
|
||||||
|
func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
||||||
|
seeds := map[string]time.Duration{
|
||||||
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
||||||
|
SettingStaleAfter: cfg.StaleAfter,
|
||||||
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
||||||
|
}
|
||||||
|
for key, d := range seeds {
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO settings (key, value) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO NOTHING`,
|
||||||
|
key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsResponse is what the admin page renders. The environment half is
|
||||||
|
// included and marked read-only, so somebody looking for the ntfy URL finds out
|
||||||
|
// where it lives rather than concluding the server does not have one.
|
||||||
|
type settingsResponse struct {
|
||||||
|
Editable map[string]settingValue `json:"editable"`
|
||||||
|
FromEnv map[string]string `json:"from_env"`
|
||||||
|
|
||||||
|
// Choices are settings that are a word from a fixed list rather than a
|
||||||
|
// duration. One so far: who may create an account.
|
||||||
|
Choices map[string]choiceValue `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type choiceValue struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingValue struct {
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MinSeconds int64 `json:"min_seconds"`
|
||||||
|
MaxSeconds int64 `json:"max_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
||||||
|
settings := NewSettings(db)
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := settingsResponse{
|
||||||
|
Editable: map[string]settingValue{},
|
||||||
|
Choices: map[string]choiceValue{
|
||||||
|
SettingSignupMode: {
|
||||||
|
Value: signupMode(r.Context(), db),
|
||||||
|
Options: []string{SignupInviteOnly, SignupOpen},
|
||||||
|
Description: "who may create an account: invite_only means a link from a team owner, " +
|
||||||
|
"open means anybody who can reach this server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
FromEnv: map[string]string{
|
||||||
|
// Never the ntfy token or the DSN: both are credentials, and an
|
||||||
|
// admin page that renders them turns a browser tab into a place
|
||||||
|
// they leak from.
|
||||||
|
"ntfy_url": cfg.NtfyURL,
|
||||||
|
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
|
||||||
|
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
|
||||||
|
"public_url": cfg.PublicURL,
|
||||||
|
"listen_address": cfg.Addr,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for key, b := range settingBounds {
|
||||||
|
def := map[string]time.Duration{
|
||||||
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
||||||
|
SettingStaleAfter: cfg.StaleAfter,
|
||||||
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
||||||
|
}[key]
|
||||||
|
out.Editable[key] = settingValue{
|
||||||
|
Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
|
||||||
|
Description: b.label,
|
||||||
|
MinSeconds: int64(b.min.Seconds()),
|
||||||
|
MaxSeconds: int64(b.max.Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetSettings changes one or more settings. Unknown keys are refused
|
||||||
|
// rather than stored: a typo that writes notify_repeat_second would otherwise
|
||||||
|
// sit in the table looking like configuration and doing nothing.
|
||||||
|
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]any
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req) == 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("no settings given"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate everything before writing anything: a request that sets two
|
||||||
|
// settings and gets one wrong should change neither.
|
||||||
|
values := map[string]string{}
|
||||||
|
for key, raw := range req {
|
||||||
|
switch key {
|
||||||
|
case SettingSignupMode:
|
||||||
|
mode, _ := raw.(string)
|
||||||
|
if mode != SignupOpen && mode != SignupInviteOnly {
|
||||||
|
respond(w, http.StatusBadRequest,
|
||||||
|
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[key] = mode
|
||||||
|
default:
|
||||||
|
b, known := settingBounds[key]
|
||||||
|
if !known {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secs, ok := raw.(float64) // JSON numbers decode as float64
|
||||||
|
if !ok {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := time.Duration(int64(secs)) * time.Second
|
||||||
|
if d < b.min || d > b.max {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(
|
||||||
|
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values[key] = strconv.FormatInt(int64(secs), 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
for key, value := range values {
|
||||||
|
if _, err := tx.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES ($1, $2, `+nowEpoch+`)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
key, value); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminTeam is a team as an administrator sees it: what it is, plus how big it
|
||||||
|
// is and how much is on fire in it. One definition, so a team in the list and a
|
||||||
|
// team on its own page cannot describe themselves differently.
|
||||||
|
type adminTeam struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Members int64 `json:"members"`
|
||||||
|
OpenIncidents int64 `json:"open_incidents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminListTeams lists every team on the server, with its size. The
|
||||||
|
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
||||||
|
// which only an administrator may ask.
|
||||||
|
func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT t.id, t.name, t.created_at,
|
||||||
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||||
|
(SELECT COUNT(*) FROM incidents i
|
||||||
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||||
|
FROM teams t
|
||||||
|
ORDER BY t.name`)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []adminTeam{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t adminTeam
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
teams = append(teams, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminGetTeam answers "what is this team, and who is in it" for any team
|
||||||
|
// on the server, which is the one question an administrator could not ask.
|
||||||
|
//
|
||||||
|
// GET /api/teams/{id}/members is requireTeamMember and answers 404 to somebody
|
||||||
|
// outside the team, administrator or not, and that stays exactly as it is:
|
||||||
|
// member means membership and nothing else. Reading a team's shape is a
|
||||||
|
// different thing from reading its work, so it gets an endpoint of its own
|
||||||
|
// under AdminOnly rather than an exception carved into that rule. An
|
||||||
|
// administrator still sees none of the team's incidents, alerts or rota.
|
||||||
|
func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var t adminTeam
|
||||||
|
var created int64
|
||||||
|
err := db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT t.id, t.name, t.created_at,
|
||||||
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||||
|
(SELECT COUNT(*) FROM incidents i
|
||||||
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||||
|
FROM teams t
|
||||||
|
WHERE t.id = $1`, teamID).
|
||||||
|
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
|
||||||
|
// Same query and same ordering as handleListTeamMembers, so the two
|
||||||
|
// answers to "who is in this team" cannot disagree about the answer.
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source
|
||||||
|
FROM team_members m
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.team_id = $1
|
||||||
|
ORDER BY u.username`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
members := []models.TeamMember{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m models.TeamMember
|
||||||
|
var joined int64
|
||||||
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrapper rather than a team with the members hung off it: "members"
|
||||||
|
// already means a count on the list endpoint, and one name must not be
|
||||||
|
// a number in one answer and an array in the next.
|
||||||
|
respond(w, http.StatusOK, map[string]any{"team": t, "members": members})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
||||||
|
// a team has nobody left to do it.
|
||||||
|
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
// Trimmed, as handleCreateTeam trims: without it " " is a team name
|
||||||
|
// here but not at creation, which is one rule stated twice and only
|
||||||
|
// half applied.
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetUserDisabled takes an account out of use, or puts it back.
|
||||||
|
//
|
||||||
|
// Not a delete: the person's acknowledgements, assignments and timeline entries
|
||||||
|
// stay attached to them. Deleting a user nulls those columns, which rewrites
|
||||||
|
// what happened during an incident months after the fact.
|
||||||
|
func handleSetUserDisabled(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Disabled *bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("disabled is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *req.Disabled {
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
if caller.ID == id {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot disable your own account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last, err := isLastAdmin(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var res sql.Result
|
||||||
|
if *req.Disabled {
|
||||||
|
res, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
|
||||||
|
} else {
|
||||||
|
res, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET disabled_at = NULL WHERE id = $1", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
// Either no such user, or already in the state asked for. The
|
||||||
|
// second is not a failure, so check which before answering.
|
||||||
|
var exists int
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signing back in is the only way to use a re-enabled account, and a
|
||||||
|
// disabled one must not keep a live session.
|
||||||
|
if *req.Disabled {
|
||||||
|
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fetchUser(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settings an administrator can change, and the ones they cannot.
|
||||||
|
func TestSettings_EditableAndReadOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Editable map[string]struct {
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MinSeconds int64 `json:"min_seconds"`
|
||||||
|
MaxSeconds int64 `json:"max_seconds"`
|
||||||
|
} `json:"editable"`
|
||||||
|
FromEnv map[string]string `json:"from_env"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
|
||||||
|
|
||||||
|
// Seeded from the environment the server started with, not from zero.
|
||||||
|
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 900 {
|
||||||
|
t.Errorf("notify_repeat_seconds seeded as %d, want 900", v)
|
||||||
|
}
|
||||||
|
if v := got.Editable["stale_after_seconds"].Seconds; v != 21600 {
|
||||||
|
t.Errorf("stale_after_seconds seeded as %d, want 21600", v)
|
||||||
|
}
|
||||||
|
if got.Editable["archive_after_seconds"].Description == "" {
|
||||||
|
t.Error("a setting without a description is a number nobody can act on")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The environment half is visible so somebody can see where it lives, but
|
||||||
|
// never the credentials themselves.
|
||||||
|
if _, ok := got.FromEnv["public_url"]; !ok {
|
||||||
|
t.Error("public_url should be reported as environment-configured")
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"ntfy_token", "dsn", "database_dsn", "password"} {
|
||||||
|
if v, ok := got.FromEnv[leak]; ok {
|
||||||
|
t.Errorf("%s must not be in the settings response (got %q)", leak, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a setting takes effect on the next tick, without a restart. This is
|
||||||
|
// the whole point of moving them out of the environment.
|
||||||
|
func TestSettings_ChangeTakesEffectOnTheNextSweep(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// An alert whose last webhook was two hours ago. Under the seeded
|
||||||
|
// stale_after of six hours the sweeper leaves it alone.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-settings", "Stale", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
|
||||||
|
time.Now().Add(-2*time.Hour).Unix(), "fp-settings")
|
||||||
|
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
if status, _, _ := s.alertRow(t, "fp-settings"); status != "firing" {
|
||||||
|
t.Fatalf("before the change the alert should still be firing, got %q", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorten it to an hour. Nothing restarts.
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings",
|
||||||
|
map[string]int64{"stale_after_seconds": 3600})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("change setting: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
status, source, _ := s.alertRow(t, "fp-settings")
|
||||||
|
if status != "resolved" {
|
||||||
|
t.Errorf("after the change the alert should have expired, got %q", status)
|
||||||
|
}
|
||||||
|
if source == nil || *source != "expiry" {
|
||||||
|
t.Errorf("expected resolution_source expiry, got %v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typo must not look like configuration, and a slipped decimal point must not
|
||||||
|
// produce a server that sweeps every second.
|
||||||
|
func TestSettings_RejectsUnknownKeysAndSillyValues(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
body map[string]int64
|
||||||
|
}{
|
||||||
|
{"unknown key", map[string]int64{"notify_repeat_second": 60}},
|
||||||
|
{"below the floor", map[string]int64{"stale_after_seconds": 30}},
|
||||||
|
{"above the ceiling", map[string]int64{"archive_after_seconds": 400 * 24 * 3600}},
|
||||||
|
{"nothing at all", map[string]int64{}},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings", c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings are the server's behaviour, so only an administrator may change
|
||||||
|
// them — or see where the rest of the configuration comes from.
|
||||||
|
func TestSettings_AreAdminOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "member")
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
method string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{http.MethodGet, nil},
|
||||||
|
{http.MethodPut, map[string]int64{"notify_repeat_seconds": 60}},
|
||||||
|
} {
|
||||||
|
resp := call(c.method, "/api/admin/settings", c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s /api/admin/settings: expected 403, got %d", c.method, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := call(http.MethodGet, "/api/admin/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("GET /api/admin/teams: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An administrator sees every team, including ones they are not in — which is
|
||||||
|
// exactly what /api/teams must not show them.
|
||||||
|
func TestSettings_AdminSeesEveryTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
newTeam(t, s, "red")
|
||||||
|
newTeam(t, s, "blue")
|
||||||
|
|
||||||
|
all := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
|
||||||
|
if len(all) != 3 { // Default, red, blue
|
||||||
|
t.Fatalf("admin should see all 3 teams, saw %d", len(all))
|
||||||
|
}
|
||||||
|
for _, team := range all {
|
||||||
|
if _, ok := team["members"]; !ok {
|
||||||
|
t.Error("the admin listing should say how big each team is")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin created them, so they own them — but they are not a member of
|
||||||
|
// a team somebody else makes, and /api/teams still answers "what am I in".
|
||||||
|
mine := list(t, s.req(t, http.MethodGet, "/api/teams", nil))
|
||||||
|
if len(mine) != 3 {
|
||||||
|
t.Errorf("the creator is an owner of what they created, saw %d", len(mine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabling is not deleting: the account stops working and the history stays.
|
||||||
|
func TestSettings_DisablingAnAccountKeepsItsHistory(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "leaver")
|
||||||
|
|
||||||
|
// They acknowledge an incident, so there is history to preserve.
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-leaver", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
resp := call(http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("acknowledge: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
|
||||||
|
map[string]bool{"disabled": true})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("disable: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Their API key stops working.
|
||||||
|
resp = call(http.MethodGet, "/api/incidents", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("a disabled user's key: expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The acknowledgement still names them.
|
||||||
|
var incident map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents/1", nil), &incident)
|
||||||
|
if incident["acknowledged_by"] != "leaver" {
|
||||||
|
t.Errorf("the acknowledgement should still name leaver, got %v", incident["acknowledged_by"])
|
||||||
|
}
|
||||||
|
if incident["status"] != "acknowledged" {
|
||||||
|
t.Errorf("the incident should still be acknowledged, got %v", incident["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// And re-enabling gives the account back.
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
|
||||||
|
map[string]bool{"disabled": false})
|
||||||
|
resp.Body.Close()
|
||||||
|
resp = call(http.MethodGet, "/api/incidents", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("after re-enabling: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same two guards as deleting and demoting: an install must keep somebody
|
||||||
|
// who can administer it.
|
||||||
|
func TestSettings_CannotDisableYourselfOrTheLastAdmin(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/users/1/disabled", map[string]bool{"disabled": true})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("disabling yourself: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming a team is an owner's job, and the name stays unique.
|
||||||
|
func TestSettings_TeamRename(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
team := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Platform"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("rename: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
teams := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
|
||||||
|
found := false
|
||||||
|
for _, x := range teams {
|
||||||
|
if x["name"] == "Platform" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the renamed team should be listed under its new name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taking a name that exists is a conflict, not a silent second team with
|
||||||
|
// the same label.
|
||||||
|
resp = s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Default"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("renaming onto an existing name: expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seed runs once. A redeploy must not put the chart's default back over an
|
||||||
|
// administrator's edit — the rule the dead man's switches already follow.
|
||||||
|
func TestSettings_SeedDoesNotOverwrite(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings",
|
||||||
|
map[string]int64{"notify_repeat_seconds": 60})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// A second start, with the environment still saying 15 minutes.
|
||||||
|
if err := api.SeedSettings(t.Context(), s.db, testConfig()); err != nil {
|
||||||
|
t.Fatalf("re-seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Editable map[string]struct {
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
} `json:"editable"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
|
||||||
|
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 60 {
|
||||||
|
t.Errorf("the edit should survive a restart, got %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingSignupMode says who may create an account. It lives in the settings
|
||||||
|
// table with the other behaviour settings, so an administrator changes it in
|
||||||
|
// the admin page rather than in a chart.
|
||||||
|
//
|
||||||
|
// Two modes, not three. A domain-restricted mode was considered and dropped:
|
||||||
|
// with no email in this server there is nothing to verify an address against,
|
||||||
|
// so it would check the domain of a string somebody typed — a speed bump
|
||||||
|
// dressed as a control.
|
||||||
|
const (
|
||||||
|
SettingSignupMode = "signup_mode"
|
||||||
|
|
||||||
|
SignupInviteOnly = "invite_only"
|
||||||
|
SignupOpen = "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultSignupMode is invite-only. An install that gets a public hostname
|
||||||
|
// before anybody has thought about sign-up should not be collecting accounts
|
||||||
|
// from the internet by default.
|
||||||
|
const defaultSignupMode = SignupInviteOnly
|
||||||
|
|
||||||
|
// inviteTTL is how long a new invite link lives. Long enough to send it and be
|
||||||
|
// read tomorrow, short enough that a link in an old chat log stops working.
|
||||||
|
const inviteTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// signupMode reads the current mode, falling back to invite-only for a missing
|
||||||
|
// or unrecognised value: the failure mode of a typo in this setting should be
|
||||||
|
// the closed door, not the open one.
|
||||||
|
func signupMode(ctx context.Context, db *sql.DB) string {
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
|
||||||
|
return defaultSignupMode
|
||||||
|
}
|
||||||
|
if raw != SignupOpen && raw != SignupInviteOnly {
|
||||||
|
return defaultSignupMode
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSignupInfo tells the sign-up page what it may offer, without requiring
|
||||||
|
// a session: whether open sign-up is on, and whether the invite in the URL is
|
||||||
|
// any good. A bad invite is better reported before somebody picks a password.
|
||||||
|
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out := map[string]any{"mode": signupMode(r.Context(), db)}
|
||||||
|
|
||||||
|
if token := r.URL.Query().Get("invite"); token != "" {
|
||||||
|
inv, err := loadInvite(r.Context(), db, token)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
out["invite_valid"] = true
|
||||||
|
out["invite_team"] = inv.teamName
|
||||||
|
default:
|
||||||
|
// Deliberately one answer for expired, revoked, used up and
|
||||||
|
// never existed. Telling a stranger which it was tells them
|
||||||
|
// something about links they do not hold.
|
||||||
|
out["invite_valid"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type invite struct {
|
||||||
|
id int64
|
||||||
|
teamID int64
|
||||||
|
teamName string
|
||||||
|
role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
|
||||||
|
// it exists, has not been revoked, has not expired and has uses left.
|
||||||
|
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
|
||||||
|
var inv invite
|
||||||
|
err := q.QueryRowContext(ctx, `
|
||||||
|
SELECT i.id, i.team_id, t.name, i.role
|
||||||
|
FROM invites i
|
||||||
|
JOIN teams t ON t.id = i.team_id
|
||||||
|
WHERE i.token_hash = $1
|
||||||
|
AND i.revoked_at IS NULL
|
||||||
|
AND i.expires_at > `+nowEpoch+`
|
||||||
|
AND i.uses < i.max_uses`, hashToken(token)).
|
||||||
|
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return invite{}, errInviteUnusable
|
||||||
|
}
|
||||||
|
return inv, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInviteUnusable = errors.New("invite is not usable")
|
||||||
|
|
||||||
|
// handleSignup creates an account, and puts it somewhere.
|
||||||
|
//
|
||||||
|
// Rate-limited on the same limiter as login, by address: sign-up is the other
|
||||||
|
// unauthenticated endpoint that writes, and an open install without this is a
|
||||||
|
// way to fill somebody's user table.
|
||||||
|
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
addr := clientAddr(r)
|
||||||
|
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
|
||||||
|
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Invite string `json:"invite"`
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
req.TeamName = strings.TrimSpace(req.TeamName)
|
||||||
|
|
||||||
|
if req.Username == "" || req.Email == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := signupMode(r.Context(), db)
|
||||||
|
var inv invite
|
||||||
|
hasInvite := false
|
||||||
|
if req.Invite != "" {
|
||||||
|
var err error
|
||||||
|
inv, err = loadInvite(r.Context(), db, req.Invite)
|
||||||
|
if err != nil {
|
||||||
|
limiter.fail("signup:" + addr)
|
||||||
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasInvite = true
|
||||||
|
}
|
||||||
|
if !hasInvite && mode != SignupOpen {
|
||||||
|
// No invite and the door is shut. Not 404: the endpoint exists and
|
||||||
|
// saying so is how somebody knows to ask for a link.
|
||||||
|
respond(w, http.StatusForbidden,
|
||||||
|
errResp("sign-up is invite-only on this server"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasInvite && req.TeamName == "" {
|
||||||
|
// Open sign-up with no team would create an account that sees an
|
||||||
|
// empty queue and can be paged by nobody.
|
||||||
|
respond(w, http.StatusBadRequest, errResp("team_name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
var invitedVia *int64
|
||||||
|
if hasInvite {
|
||||||
|
invitedVia = &inv.id
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO users (username, email, password_hash, invited_via)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
teamID, role := inv.teamID, inv.role
|
||||||
|
if !hasInvite {
|
||||||
|
// Open sign-up makes a team, and its creator owns it.
|
||||||
|
if err := tx.QueryRowContext(r.Context(),
|
||||||
|
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role = models.RoleOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||||
|
teamID, userID, role); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasInvite {
|
||||||
|
// Counted inside the transaction, so two people redeeming the last
|
||||||
|
// use of a link at once cannot both get in.
|
||||||
|
res, err := tx.ExecContext(r.Context(),
|
||||||
|
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in immediately: the alternative is a form that says "now go
|
||||||
|
// and log in", which is the same credential typed twice.
|
||||||
|
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := fetchUser(r.Context(), db, userID)
|
||||||
|
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSignupsPerAddr is looser than the login limit: several people joining from
|
||||||
|
// one office share an address, and the thing being limited is account creation
|
||||||
|
// rather than password guessing.
|
||||||
|
const maxSignupsPerAddr = 10
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Invites
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type inviteJSON struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
MaxUses int64 `json:"max_uses"`
|
||||||
|
Uses int64 `json:"uses"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
|
||||||
|
// URL is the whole link, returned once when the invite is created. Like an
|
||||||
|
// integration key, only its hash is stored.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListInvites(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
|
||||||
|
FROM invites
|
||||||
|
WHERE team_id = $1
|
||||||
|
ORDER BY id DESC`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []inviteJSON{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i inviteJSON
|
||||||
|
var created, expires int64
|
||||||
|
var revoked *int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
|
||||||
|
&i.MaxUses, &i.Uses, &revoked); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||||
|
i.Revoked = revoked != nil
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
|
||||||
|
// a team's configuration: deciding who joins is configuring the team.
|
||||||
|
func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
MaxUses int64 `json:"max_uses"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
req.Role = models.RoleMember
|
||||||
|
}
|
||||||
|
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.MaxUses == 0 {
|
||||||
|
req.MaxUses = 1
|
||||||
|
}
|
||||||
|
if req.MaxUses < 1 || req.MaxUses > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
expires := time.Now().Add(inviteTTL)
|
||||||
|
|
||||||
|
var out inviteJSON
|
||||||
|
var created, expiresAt int64
|
||||||
|
if err := db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
||||||
|
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
|
||||||
|
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
|
||||||
|
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
|
||||||
|
respond(w, http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeInvite stops a link working without waiting for it to expire.
|
||||||
|
func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE invites SET revoked_at = "+nowEpoch+
|
||||||
|
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Onboarding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleTestNotification publishes one push to the caller's own topic.
|
||||||
|
//
|
||||||
|
// The point of the first-run checklist's notification step is not that a topic
|
||||||
|
// string has been typed but that a phone buzzes, and only the person holding it
|
||||||
|
// can tell whether it did. Published directly rather than through the outbox:
|
||||||
|
// the outbox row requires an incident, and this deliberately belongs to no
|
||||||
|
// incident.
|
||||||
|
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
respond(w, http.StatusServiceUnavailable,
|
||||||
|
errResp("this server has no ntfy configured, so it can send nothing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
var topic *string
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if topic == nil || *topic == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := publish(r.Context(), cfg, ntfyMessage{
|
||||||
|
Topic: *topic,
|
||||||
|
Title: "terdut test",
|
||||||
|
Message: "If this arrived, your notifications work.",
|
||||||
|
Tags: []string{"white_check_mark"},
|
||||||
|
}); err != nil {
|
||||||
|
// The failure is the useful part here: a wrong topic, a token the
|
||||||
|
// ntfy server rejects, or an ntfy that is down all look the same
|
||||||
|
// from the phone, which is silence.
|
||||||
|
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDismissOnboarding hides the first-run checklist, or brings it back.
|
||||||
|
// Stored per user rather than in the browser: somebody who finishes setting up
|
||||||
|
// on a laptop should not be nagged again on their phone.
|
||||||
|
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Dismissed *bool `json:"dismissed"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if *req.Dismissed {
|
||||||
|
_, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
|
||||||
|
} else {
|
||||||
|
_, err = db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
|
||||||
|
// and returns the response and a client holding whatever cookie came back.
|
||||||
|
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Jar: jar}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signup: %v", err)
|
||||||
|
}
|
||||||
|
return resp, client
|
||||||
|
}
|
||||||
|
|
||||||
|
// invite mints a link into the default team and returns its raw token.
|
||||||
|
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
|
||||||
|
t.Helper()
|
||||||
|
var out struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
|
||||||
|
map[string]any{"role": role, "max_uses": maxUses}), &out)
|
||||||
|
if out.URL == "" {
|
||||||
|
t.Fatal("no invite URL returned")
|
||||||
|
}
|
||||||
|
// ...?invite=<token>
|
||||||
|
i := len(out.URL) - 1
|
||||||
|
for ; i >= 0 && out.URL[i] != '='; i-- {
|
||||||
|
}
|
||||||
|
return out.URL[i+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSignupMode(t *testing.T, s *ts, mode string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("set signup mode: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default is the closed door. An install that gets a public hostname before
|
||||||
|
// anybody has thought about sign-up should not be collecting accounts.
|
||||||
|
func TestSignup_InviteOnlyByDefault(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||||
|
if info["mode"] != "invite_only" {
|
||||||
|
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invite carries the team and the role, so redeeming one lands somewhere
|
||||||
|
// usable rather than in an account that sees an empty queue.
|
||||||
|
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 1)
|
||||||
|
|
||||||
|
// The form checks the link before asking for a password.
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||||
|
if info["invite_valid"] != true {
|
||||||
|
t.Fatalf("a fresh invite should be valid: %v", info)
|
||||||
|
}
|
||||||
|
if info["invite_team"] != "Default" {
|
||||||
|
t.Errorf("the form should name the team: %v", info["invite_team"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, client := signup(t, s, map[string]any{
|
||||||
|
"username": "newcomer", "email": "n@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.IsAdmin {
|
||||||
|
t.Error("somebody who signs up must not be an administrator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed in already: the cookie came back with the response.
|
||||||
|
got, err := client.Get(s.URL + "/api/teams")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
teams := list(t, got)
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
|
||||||
|
t.Errorf("expected membership of Default as member, got %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single-use link is single-use, and the check is inside the transaction so
|
||||||
|
// two people redeeming the last use at once cannot both get in.
|
||||||
|
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 1)
|
||||||
|
|
||||||
|
first, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "first", "email": "f@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
first.Body.Close()
|
||||||
|
if first.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first redemption: %d", first.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "second", "email": "s@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
second.Body.Close()
|
||||||
|
if second.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the link reports itself unusable before anybody types a password.
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||||
|
if info["invite_valid"] != false {
|
||||||
|
t.Error("a used-up invite should report itself invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking stops a link without waiting for it to expire.
|
||||||
|
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 5)
|
||||||
|
|
||||||
|
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
|
||||||
|
if len(invites) != 1 {
|
||||||
|
t.Fatalf("expected one invite, got %d", len(invites))
|
||||||
|
}
|
||||||
|
id := int64(invites[0]["id"].(float64))
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
used, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "late", "email": "l@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
used.Body.Close()
|
||||||
|
if used.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open sign-up makes a team, because an account in no team sees an empty queue
|
||||||
|
// and can be paged by nobody.
|
||||||
|
func TestSignup_OpenModeMakesATeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
setSignupMode(t, s, "open")
|
||||||
|
|
||||||
|
missing, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
|
||||||
|
})
|
||||||
|
missing.Body.Close()
|
||||||
|
if missing.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, client := signup(t, s, map[string]any{
|
||||||
|
"username": "solo", "email": "s@test.com",
|
||||||
|
"password": "correct-horse-battery", "team_name": "Solo",
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("open sign-up: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.Get(s.URL + "/api/teams")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
teams := list(t, got)
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
|
||||||
|
t.Errorf("the creator should own their new team, got %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the mode is an administrator's decision, and it takes effect at
|
||||||
|
// once rather than at the next restart.
|
||||||
|
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
|
||||||
|
bad.Body.Close()
|
||||||
|
if bad.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSignupMode(t, s, "open")
|
||||||
|
var info map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||||
|
if info["mode"] != "open" {
|
||||||
|
t.Errorf("the change should be visible at once, got %v", info["mode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minting a link is configuring the team, so it is an owner's job.
|
||||||
|
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
_, call := member(t, s, "plain")
|
||||||
|
|
||||||
|
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A password still has to be a password, and a taken username is still taken.
|
||||||
|
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
token := invite(t, s, "member", 5)
|
||||||
|
|
||||||
|
short, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
|
||||||
|
})
|
||||||
|
short.Body.Close()
|
||||||
|
if short.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
taken, _ := signup(t, s, map[string]any{
|
||||||
|
"username": "admin", "email": "other@test.com",
|
||||||
|
"password": "correct-horse-battery", "invite": token,
|
||||||
|
})
|
||||||
|
taken.Body.Close()
|
||||||
|
if taken.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
similarDefaultLimit = 5
|
||||||
|
similarMaxLimit = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleIncidentSimilar lists earlier, resolved incidents in the same team with
|
||||||
|
// the same signature that someone left notes on, incidents with a resolution
|
||||||
|
// note first. This is the "have we seen this before" answer for a responder
|
||||||
|
// looking at a fresh incident; the plain notes are one timeline fetch away.
|
||||||
|
func handleIncidentSimilar(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := similarDefaultLimit
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid limit"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = min(n, similarMaxLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := similarIncidents(r.Context(), db, id, limit)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) {
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT o.id, o.title, o.triggered_at, o.resolved_at,
|
||||||
|
(SELECT COUNT(*) FROM incident_events e
|
||||||
|
WHERE e.incident_id = o.id AND e.type = $3)
|
||||||
|
FROM incidents i
|
||||||
|
JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature
|
||||||
|
WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM incident_events e
|
||||||
|
WHERE e.incident_id = o.id AND e.type IN ($3, $4))
|
||||||
|
ORDER BY EXISTS (SELECT 1 FROM incident_events e
|
||||||
|
WHERE e.incident_id = o.id AND e.type = $4) DESC,
|
||||||
|
o.triggered_at DESC
|
||||||
|
LIMIT $2`, id, limit, evNote, evResolutionNote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []models.SimilarIncident{}
|
||||||
|
ids := []int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s models.SimilarIncident
|
||||||
|
var triggered, resolved int64
|
||||||
|
if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.TriggeredAt = time.Unix(triggered, 0).UTC()
|
||||||
|
s.ResolvedAt = time.Unix(resolved, 0).UTC()
|
||||||
|
s.ResolutionNotes = []models.IncidentEvent{}
|
||||||
|
out = append(out, s)
|
||||||
|
ids = append(ids, s.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nrows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at
|
||||||
|
FROM incident_events e
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
WHERE e.incident_id = ANY($1) AND e.type = $2
|
||||||
|
ORDER BY e.created_at, e.id`, ids, evResolutionNote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer nrows.Close()
|
||||||
|
|
||||||
|
byID := make(map[int64]*models.SimilarIncident, len(out))
|
||||||
|
for i := range out {
|
||||||
|
byID[out[i].ID] = &out[i]
|
||||||
|
}
|
||||||
|
for nrows.Next() {
|
||||||
|
var e models.IncidentEvent
|
||||||
|
var ts int64
|
||||||
|
if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
s := byID[e.IncidentID]
|
||||||
|
s.ResolutionNotes = append(s.ResolutionNotes, e)
|
||||||
|
}
|
||||||
|
return out, nrows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postGrouped posts a firing webhook whose group labels are exactly the given
|
||||||
|
// map, unlike postWebhook, which only ever groups by alertname.
|
||||||
|
func postGrouped(t *testing.T, s *ts, fingerprint, startsAt string, groupLabels map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
labels := map[string]string{}
|
||||||
|
for k, v := range groupLabels {
|
||||||
|
labels[k] = v
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"version": "4", "status": "firing",
|
||||||
|
"groupKey": fingerprint,
|
||||||
|
"groupLabels": groupLabels,
|
||||||
|
"alerts": []map[string]any{
|
||||||
|
amAlert(fingerprint, groupLabels["alertname"], "firing", startsAt, zeroTime, labels),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post webhook: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func similar(t *testing.T, s *ts, id int) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents/"+strconv.Itoa(id)+"/similar", nil), &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same alert on another instance is the same problem; a resolution note left on
|
||||||
|
// the first one is what the second one should be shown.
|
||||||
|
func TestSimilar_IgnoresVolatileLabelsAndLeadsWithResolutionNote(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postGrouped(t, s, "fp-a", "2026-05-20T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "instance": "web-1", "job": "node"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve",
|
||||||
|
map[string]string{"resolution": "rotated the logs"}).Body.Close()
|
||||||
|
|
||||||
|
postGrouped(t, s, "fp-b", "2026-05-21T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "instance": "web-2", "job": "node"})
|
||||||
|
|
||||||
|
got := similar(t, s, 2)
|
||||||
|
if len(got) != 1 || int(got[0]["id"].(float64)) != 1 {
|
||||||
|
t.Fatalf("expected incident 1 as the only similar one, got %v", got)
|
||||||
|
}
|
||||||
|
notes := got[0]["resolution_notes"].([]any)
|
||||||
|
if len(notes) != 1 || notes[0].(map[string]any)["detail"] != "rotated the logs" {
|
||||||
|
t.Fatalf("expected the resolution note, got %v", notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different stable label (job) is a different problem, and an incident nobody
|
||||||
|
// wrote a note on has nothing to show.
|
||||||
|
func TestSimilar_DifferentSignatureOrNoNotesIsExcluded(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postGrouped(t, s, "fp-1", "2026-05-20T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "job": "node"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/notes", map[string]string{"content": "checked"}).Body.Close()
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
postGrouped(t, s, "fp-2", "2026-05-20T11:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/2/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
postGrouped(t, s, "fp-3", "2026-05-21T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||||
|
|
||||||
|
// Incident 3 matches 2 by signature, but 2 has no notes.
|
||||||
|
if got := similar(t, s, 3); len(got) != 0 {
|
||||||
|
t.Fatalf("expected nothing similar to incident 3, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open incident is not "earlier experience" yet, and the incident itself is
|
||||||
|
// never its own match.
|
||||||
|
func TestSimilar_OpenIncidentsAreNotListed(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postGrouped(t, s, "fp-o1", "2026-05-20T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/notes",
|
||||||
|
map[string]any{"content": "still open", "pinned": true}).Body.Close()
|
||||||
|
postGrouped(t, s, "fp-o2", "2026-05-21T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||||
|
|
||||||
|
if got := similar(t, s, 2); len(got) != 0 {
|
||||||
|
t.Fatalf("expected an open incident not to be listed, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listSources reads a team's alert sources as the Sources page does.
|
||||||
|
func listSources(t *testing.T, tm teamFixture) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
return list(t, tm.call(http.MethodGet, "/api/teams/"+id64(tm.id)+"/integrations", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSource mints a second source in a team and returns its key.
|
||||||
|
func addSource(t *testing.T, tm teamFixture, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
var out struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, tm.call(http.MethodPost, "/api/teams/"+id64(tm.id)+"/integrations",
|
||||||
|
map[string]string{"name": name}), &out)
|
||||||
|
return out.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that has never posted is "never", with nothing to say about alerts.
|
||||||
|
func TestSources_NeverUsedIsBlank(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
got := listSources(t, tm)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 source, got %d", len(got))
|
||||||
|
}
|
||||||
|
src := got[0]
|
||||||
|
if src["status"] != "never" || src["last_used_at"] != nil || src["last_alert_at"] != nil {
|
||||||
|
t.Errorf("a source nobody has posted on should be blank, got %v", src)
|
||||||
|
}
|
||||||
|
if src["alerts_24h"].(float64) != 0 {
|
||||||
|
t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each source is credited with what arrived on its own key, and only that.
|
||||||
|
func TestSources_AlertsAreAttributedToTheirSource(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
second := addSource(t, tm, "staging")
|
||||||
|
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||||
|
postToIntegration(t, s, tm.key, "fp-2", "CPUHot")
|
||||||
|
|
||||||
|
got := listSources(t, tm)
|
||||||
|
first, other := got[0], got[1]
|
||||||
|
if first["status"] != "active" || first["last_used_at"] == nil || first["last_alert_at"] == nil {
|
||||||
|
t.Errorf("the source that posted should be active with timestamps, got %v", first)
|
||||||
|
}
|
||||||
|
if first["alerts_24h"].(float64) != 2 {
|
||||||
|
t.Errorf("alerts_24h = %v, want 2", first["alerts_24h"])
|
||||||
|
}
|
||||||
|
if other["status"] != "never" || other["alerts_24h"].(float64) != 0 {
|
||||||
|
t.Errorf("the other source should be untouched, got %v", other)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-sending the same alert on the other key moves it: last sender wins.
|
||||||
|
postToIntegration(t, s, second, "fp-1", "DiskFull")
|
||||||
|
got = listSources(t, tm)
|
||||||
|
if got[0]["alerts_24h"].(float64) != 1 || got[1]["alerts_24h"].(float64) != 1 {
|
||||||
|
t.Errorf("fp-1 should have moved to the second source, got %v and %v",
|
||||||
|
got[0]["alerts_24h"], got[1]["alerts_24h"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A payload with no alerts in it is a webhook, not an alert: the source was
|
||||||
|
// heard from, and nothing arrived.
|
||||||
|
func TestSources_EmptyPayloadStampsUseButNotAlert(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/"+tm.key+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
src := listSources(t, tm)[0]
|
||||||
|
if src["status"] != "active" || src["last_alert_at"] != nil {
|
||||||
|
t.Errorf("want active with no alert yet, got %v", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet is "has posted, not lately"; the alert counter forgets after a day but
|
||||||
|
// the last alert's timestamp is kept.
|
||||||
|
func TestSources_QuietAfterADay(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||||
|
|
||||||
|
old := time.Now().Add(-48 * time.Hour).Unix()
|
||||||
|
s.exec(t, "UPDATE integrations SET last_used_at = $1", old)
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-1'", old)
|
||||||
|
|
||||||
|
src := listSources(t, tm)[0]
|
||||||
|
if src["status"] != "quiet" {
|
||||||
|
t.Errorf("status = %v, want quiet", src["status"])
|
||||||
|
}
|
||||||
|
if src["alerts_24h"].(float64) != 0 {
|
||||||
|
t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"])
|
||||||
|
}
|
||||||
|
if src["last_alert_at"] == nil {
|
||||||
|
t.Error("last_alert_at should survive the day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking a source does not take its alerts with it.
|
||||||
|
func TestSources_RevokeKeepsTheAlerts(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||||
|
|
||||||
|
id := int64(listSources(t, tm)[0]["id"].(float64))
|
||||||
|
resp := tm.call(http.MethodDelete, "/api/teams/"+id64(tm.id)+"/integrations/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := len(list(t, tm.call(http.MethodGet, "/api/alerts", nil))); got != 1 {
|
||||||
|
t.Errorf("the alert should outlive its source, got %d alerts", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming is an owner's, scoped to the team, and does not touch the key.
|
||||||
|
func TestSources_Rename(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
other := newTeam(t, s, "blue")
|
||||||
|
id := int64(listSources(t, tm)[0]["id"].(float64))
|
||||||
|
path := "/api/teams/" + id64(tm.id) + "/integrations/" + id64(id)
|
||||||
|
|
||||||
|
resp := tm.call(http.MethodPatch, path, map[string]string{"name": " prod "})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("rename: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if name := listSources(t, tm)[0]["name"]; name != "prod" {
|
||||||
|
t.Errorf("name = %q, want it trimmed to prod", name)
|
||||||
|
}
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull") // the old key still works
|
||||||
|
|
||||||
|
for name, body := range map[string]map[string]string{
|
||||||
|
"empty": {"name": " "},
|
||||||
|
"too long": {"name": strings.Repeat("x", 101)},
|
||||||
|
} {
|
||||||
|
resp := tm.call(http.MethodPatch, path, body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s name: expected 400, got %d", name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another team's owner cannot reach it.
|
||||||
|
resp = other.call(http.MethodPatch, "/api/teams/"+id64(other.id)+"/integrations/"+id64(id),
|
||||||
|
map[string]string{"name": "mine now"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("renaming another team's source: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-25
@@ -11,14 +11,17 @@ import (
|
|||||||
|
|
||||||
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
where, args := statsFilter(r.URL.Query())
|
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||||
|
|
||||||
|
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
|
||||||
|
// nothing is 0 — without it an empty window is a 500 rather than a
|
||||||
|
// legitimately empty report.
|
||||||
var total, firing, resolved int64
|
var total, firing, resolved int64
|
||||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT COUNT(*),
|
SELECT COUNT(*),
|
||||||
SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END),
|
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
|
||||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END)
|
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
|
||||||
FROM alerts WHERE %s`, where), args...,
|
FROM alerts WHERE %s`, where), args.all()...,
|
||||||
).Scan(&total, &firing, &resolved)
|
).Scan(&total, &firing, &resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
@@ -34,7 +37,7 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
where, args := statsFilter(r.URL.Query())
|
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||||
|
|
||||||
limit := 10
|
limit := 10
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
@@ -44,15 +47,13 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
|||||||
limit = n
|
limit = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT name, COUNT(*) AS cnt
|
SELECT name, COUNT(*) AS cnt
|
||||||
FROM alerts
|
FROM alerts
|
||||||
WHERE %s
|
WHERE %s
|
||||||
GROUP BY name
|
GROUP BY name
|
||||||
ORDER BY cnt DESC
|
ORDER BY cnt DESC
|
||||||
LIMIT ?`, where), args...)
|
LIMIT %s`, where, args.add(limit)), args.all()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -78,15 +79,15 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
where, args := statsFilter(r.URL.Query())
|
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||||
|
|
||||||
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT CAST(strftime('%%H', datetime(received_at, 'unixepoch')) AS INTEGER) AS hr,
|
SELECT EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr,
|
||||||
COUNT(*) AS cnt
|
COUNT(*) AS cnt
|
||||||
FROM alerts
|
FROM alerts
|
||||||
WHERE %s
|
WHERE %s
|
||||||
GROUP BY hr
|
GROUP BY hr
|
||||||
ORDER BY hr ASC`, where), args...)
|
ORDER BY hr ASC`, where), args.all()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -118,16 +119,17 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
where, args := statsFilter(r.URL.Query())
|
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
|
||||||
|
|
||||||
// SQLite strftime('%w') → 0=Sunday … 6=Saturday
|
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering
|
||||||
|
// SQLite's strftime('%w') returned, so the frontend needs no change.
|
||||||
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT CAST(strftime('%%w', datetime(received_at, 'unixepoch')) AS INTEGER) AS dow,
|
SELECT EXTRACT(DOW FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS dow,
|
||||||
COUNT(*) AS cnt
|
COUNT(*) AS cnt
|
||||||
FROM alerts
|
FROM alerts
|
||||||
WHERE %s
|
WHERE %s
|
||||||
GROUP BY dow
|
GROUP BY dow
|
||||||
ORDER BY dow ASC`, where), args...)
|
ORDER BY dow ASC`, where), args.all()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -159,23 +161,67 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// statsFilter builds a WHERE clause and args from optional ?from and ?to query params.
|
// handleStatsIncidents reports the queue and the two numbers a rota actually
|
||||||
func statsFilter(q url.Values) (where string, args []any) {
|
// cares about: how long it takes someone to pick work up, and how long it takes
|
||||||
clauses := []string{}
|
// to finish. Neither was computable before incidents existed — alert rows are
|
||||||
|
// mutated in place and carry no acknowledgement or closure time.
|
||||||
|
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
where, args := statsFilter(r.URL.Query(), "triggered_at", callerTeamIDs(r.Context()))
|
||||||
|
|
||||||
|
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
|
||||||
|
// The averages are not: mtta and mttr stay null on purpose, since zero
|
||||||
|
// would read as "instant" rather than "nothing to measure yet".
|
||||||
|
var total, triggered, acknowledged, resolved int64
|
||||||
|
var mtta, mttr *float64
|
||||||
|
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||||
|
SELECT COUNT(*),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
|
||||||
|
AVG(CASE WHEN acknowledged_at IS NOT NULL
|
||||||
|
THEN acknowledged_at - triggered_at END),
|
||||||
|
AVG(CASE WHEN resolved_at IS NOT NULL
|
||||||
|
THEN resolved_at - triggered_at END)
|
||||||
|
FROM incidents WHERE %s`, where), args.all()...,
|
||||||
|
).Scan(&total, &triggered, &acknowledged, &resolved, &mtta, &mttr)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(w, http.StatusOK, map[string]any{
|
||||||
|
"total": total,
|
||||||
|
"triggered": triggered,
|
||||||
|
"acknowledged": acknowledged,
|
||||||
|
"resolved": resolved,
|
||||||
|
// Null until something has actually been acknowledged or resolved —
|
||||||
|
// zero would read as "instant", which is a different claim.
|
||||||
|
"mtta_seconds": mtta,
|
||||||
|
"mttr_seconds": mttr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsFilter builds a WHERE clause and args from optional ?from and ?to query
|
||||||
|
// params, filtering on timeCol. Archived rows are always excluded, matching the
|
||||||
|
// default list views.
|
||||||
|
//
|
||||||
|
// teamIDs scopes every figure to the caller's own teams: a report that counted
|
||||||
|
// other teams' incidents would leak their volume and their names through the
|
||||||
|
// top-alerts list, and would not be a number about the reader's work anyway.
|
||||||
|
func statsFilter(q url.Values, timeCol string, teamIDs []int64) (where string, args *sqlArgs) {
|
||||||
|
args = &sqlArgs{}
|
||||||
|
clauses := []string{"archived_at IS NULL", "team_id = ANY(" + args.add(teamIDs) + ")"}
|
||||||
if from := q.Get("from"); from != "" {
|
if from := q.Get("from"); from != "" {
|
||||||
if t, err := time.Parse("2006-01-02", from); err == nil {
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
||||||
clauses = append(clauses, "received_at >= ?")
|
clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix()))
|
||||||
args = append(args, t.UTC().Unix())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if to := q.Get("to"); to != "" {
|
if to := q.Get("to"); to != "" {
|
||||||
if t, err := time.Parse("2006-01-02", to); err == nil {
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
||||||
clauses = append(clauses, "received_at < ?")
|
clauses = append(clauses, timeCol+" < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
|
||||||
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(clauses) == 0 {
|
|
||||||
return "1=1", args
|
|
||||||
}
|
|
||||||
return strings.Join(clauses, " AND "), args
|
return strings.Join(clauses, " AND "), args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,894 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleListTeams lists the caller's own teams, each with their role in it. An
|
||||||
|
// administrator listing every team goes through the admin endpoint instead:
|
||||||
|
// this one answers "what am I part of", which is what the UI's team filter and
|
||||||
|
// the combined queue are built from.
|
||||||
|
func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||||
|
FROM teams t
|
||||||
|
JOIN team_members m ON m.team_id = t.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
ORDER BY t.name`, caller.ID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []models.Team{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Team
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
teams = append(teams, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUserTeams lists one user's teams, for the admin page's per-user view:
|
||||||
|
// "what is this person in", which /api/teams cannot answer because it is always
|
||||||
|
// about the caller.
|
||||||
|
//
|
||||||
|
// Self or admin, matching the other per-user endpoints. It says which teams
|
||||||
|
// somebody belongs to and in what role — not anything those teams own, so it
|
||||||
|
// stays on the accounts side of the line the administrator flag draws.
|
||||||
|
func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no teams and a user who does not exist both list nothing,
|
||||||
|
// so the existence check is what tells them apart.
|
||||||
|
var exists bool
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||||
|
FROM teams t
|
||||||
|
JOIN team_members m ON m.team_id = t.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
ORDER BY t.name`, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []models.Team{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Team
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
teams = append(teams, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
||||||
|
// with no owner would need an administrator to repair before anybody could use
|
||||||
|
// it, so the two happen in one transaction.
|
||||||
|
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(r.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
var team models.Team
|
||||||
|
var created int64
|
||||||
|
if err := tx.QueryRowContext(r.Context(),
|
||||||
|
"INSERT INTO teams (name) VALUES ($1) RETURNING id, name, created_at",
|
||||||
|
req.Name).Scan(&team.ID, &team.Name, &created); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||||
|
team.ID, caller.ID, models.RoleOwner); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
team.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
team.Role = models.RoleOwner
|
||||||
|
respond(w, http.StatusCreated, team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteTeam removes a team and, by cascade, its incidents, alerts,
|
||||||
|
// schedule and integrations.
|
||||||
|
//
|
||||||
|
// Refused while the team still has open incidents: deleting a team is tidying
|
||||||
|
// up, and tidying up should never be how an unacknowledged page disappears.
|
||||||
|
// Resolve or archive them first, deliberately.
|
||||||
|
func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var open int
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT COUNT(*) FROM incidents WHERE team_id = $1 AND resolved_at IS NULL", teamID).
|
||||||
|
Scan(&open); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if open > 0 {
|
||||||
|
respond(w, http.StatusConflict, errResp("team still has open incidents"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(), "DELETE FROM teams WHERE id = $1", teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member statuses, as the Members page colours them.
|
||||||
|
const (
|
||||||
|
memberOnCall = "oncall"
|
||||||
|
memberReachable = "reachable"
|
||||||
|
memberUnpageable = "unpageable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memberStatus is a team member with what matters about them at 03:00: whether
|
||||||
|
// they are on call, whether a page to them would go anywhere, and whether they
|
||||||
|
// have been around. The extra fields are output only.
|
||||||
|
type memberStatus struct {
|
||||||
|
models.TeamMember
|
||||||
|
|
||||||
|
// Status is unpageable when a page to them would go nowhere — even when
|
||||||
|
// they are on call, since that is the case that matters most — on_call when
|
||||||
|
// the rota has them today, reachable otherwise.
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
OnCall bool `json:"on_call"`
|
||||||
|
|
||||||
|
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
|
||||||
|
NextShift *string `json:"next_shift,omitempty"`
|
||||||
|
|
||||||
|
// Pageable is whether they have an ntfy topic and an enabled account — the
|
||||||
|
// conditions pageLevel and the notifier skip on. Never the topic itself.
|
||||||
|
Pageable bool `json:"pageable"`
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
|
||||||
|
// LastActiveAt is the last time they used a session or an API key.
|
||||||
|
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTeamMembers names everybody in a team, with their status. Visible to
|
||||||
|
// any member: you can see who else is on the rota you are on.
|
||||||
|
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
|
||||||
|
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
|
||||||
|
u.disabled_at IS NOT NULL,
|
||||||
|
GREATEST(
|
||||||
|
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
|
||||||
|
COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)),
|
||||||
|
EXISTS (SELECT 1 FROM schedule_entries s
|
||||||
|
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2),
|
||||||
|
(SELECT MIN(date) FROM schedule_entries s
|
||||||
|
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2)
|
||||||
|
FROM team_members m
|
||||||
|
JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.team_id = $1
|
||||||
|
ORDER BY u.username`, teamID, todayUTC())
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
members := []memberStatus{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m memberStatus
|
||||||
|
var joined, lastActive int64
|
||||||
|
var hasTopic, disabled bool
|
||||||
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
|
||||||
|
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||||
|
if lastActive > 0 {
|
||||||
|
t := time.Unix(lastActive, 0).UTC()
|
||||||
|
m.LastActiveAt = &t
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case disabled:
|
||||||
|
m.Problem = "account is disabled"
|
||||||
|
case !hasTopic:
|
||||||
|
m.Problem = "has no ntfy topic"
|
||||||
|
}
|
||||||
|
m.Pageable = m.Problem == ""
|
||||||
|
switch {
|
||||||
|
case !m.Pageable:
|
||||||
|
m.Status = memberUnpageable
|
||||||
|
case m.OnCall:
|
||||||
|
m.Status = memberOnCall
|
||||||
|
default:
|
||||||
|
m.Status = memberReachable
|
||||||
|
}
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, members)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddTeamMember adds a user to a team, or changes the role of somebody
|
||||||
|
// already in it.
|
||||||
|
func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.UserID == 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("user_id is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role == "" {
|
||||||
|
req.Role = models.RoleMember
|
||||||
|
}
|
||||||
|
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
} else if managed {
|
||||||
|
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demoting the last owner is removing them by another route: the team
|
||||||
|
// would have nobody who can edit it.
|
||||||
|
if req.Role == models.RoleMember {
|
||||||
|
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO team_members (team_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
teamID, req.UserID, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
// The only foreign key that can fail here is the user: the team was
|
||||||
|
// resolved from the caller's own membership.
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveTeamMember takes a user out of a team.
|
||||||
|
//
|
||||||
|
// A team must keep an owner, for the same reason the install must keep an
|
||||||
|
// administrator: otherwise nobody can configure it, and repairing that needs
|
||||||
|
// somebody with more access than the team has.
|
||||||
|
func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
} else if managed {
|
||||||
|
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot remove the last owner of a team"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM team_members WHERE team_id = $1 AND user_id = $2", teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not a member of this team"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
|
||||||
|
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
|
||||||
|
|
||||||
|
// isSSOManagedMember reports whether the membership comes from the group sync.
|
||||||
|
// Editing it here would be undone at the person's next sign-in, so it is refused
|
||||||
|
// instead of appearing to work.
|
||||||
|
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||||
|
var managed bool
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
|
||||||
|
teamID, userID).Scan(&managed)
|
||||||
|
return managed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||||
|
var last bool
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM team_members
|
||||||
|
WHERE team_id = $1 AND user_id = $2 AND role = 'owner')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM team_members
|
||||||
|
WHERE team_id = $1 AND user_id <> $2 AND role = 'owner')`,
|
||||||
|
teamID, userID).Scan(&last)
|
||||||
|
return last, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Integrations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// sourceQuietAfter is how long a source may go without posting before the
|
||||||
|
// Sources page calls it quiet rather than active. A day is longer than any
|
||||||
|
// repeat_interval worth having, so an Alertmanager that is up and has anything
|
||||||
|
// firing never crosses it; a source with nothing firing may, and that is a
|
||||||
|
// reason to look, not proof of a fault — which is why this is a colour and not
|
||||||
|
// an alarm. Dead man's switches are where silence pages.
|
||||||
|
const sourceQuietAfter = 24 * time.Hour
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceActive = "active"
|
||||||
|
sourceQuiet = "quiet"
|
||||||
|
sourceNever = "never"
|
||||||
|
)
|
||||||
|
|
||||||
|
// integrationStatus is an integration as the Sources page shows it.
|
||||||
|
type integrationStatus struct {
|
||||||
|
models.Integration
|
||||||
|
|
||||||
|
// Status is active when the key posted within sourceQuietAfter, quiet when
|
||||||
|
// it has posted but not lately, never when it has not posted at all.
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// LastAlertAt is when an alert last arrived on this source, which is not the
|
||||||
|
// same as when it last posted: a payload with nothing usable in it stamps
|
||||||
|
// last_used_at and not this. Absent until an alert has arrived since
|
||||||
|
// migration 010 started recording it.
|
||||||
|
LastAlertAt *time.Time `json:"last_alert_at,omitempty"`
|
||||||
|
|
||||||
|
// Alerts24h counts the distinct alerts this source refreshed in the last
|
||||||
|
// day. An alert re-sent every few hours counts once, not once per re-send.
|
||||||
|
Alerts24h int64 `json:"alerts_24h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListIntegrations lists a team's integrations with what each has been
|
||||||
|
// delivering. Never the keys: those exist in plaintext only in the response that
|
||||||
|
// created them.
|
||||||
|
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at,
|
||||||
|
-- Scalar subqueries, not a join and GROUP BY: each is a
|
||||||
|
-- single range over alerts_integration_idx, where the join
|
||||||
|
-- would read every alert a source ever delivered.
|
||||||
|
(SELECT MAX(received_at) FROM alerts WHERE integration_id = i.id),
|
||||||
|
(SELECT COUNT(*) FROM alerts
|
||||||
|
WHERE integration_id = i.id AND received_at >= $2)
|
||||||
|
FROM integrations i
|
||||||
|
WHERE i.team_id = $1
|
||||||
|
ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix())
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
integrations := []integrationStatus{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i integrationStatus
|
||||||
|
var created int64
|
||||||
|
var lastUsed, lastAlert *int64
|
||||||
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed,
|
||||||
|
&lastAlert, &i.Alerts24h); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.LastUsedAt = unixPtr(lastUsed)
|
||||||
|
i.LastAlertAt = unixPtr(lastAlert)
|
||||||
|
switch {
|
||||||
|
case i.LastUsedAt == nil:
|
||||||
|
i.Status = sourceNever
|
||||||
|
case now.Sub(*i.LastUsedAt) > sourceQuietAfter:
|
||||||
|
i.Status = sourceQuiet
|
||||||
|
default:
|
||||||
|
i.Status = sourceActive
|
||||||
|
}
|
||||||
|
integrations = append(integrations, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, integrations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateIntegration mints an integration key. The key is returned once,
|
||||||
|
// in this response, and only its hash is kept — the same handling as an API key
|
||||||
|
// or an acknowledgement token.
|
||||||
|
func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Kind == "" {
|
||||||
|
req.Kind = models.IntegrationAlertmanager
|
||||||
|
}
|
||||||
|
if req.Kind != models.IntegrationAlertmanager {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("unsupported integration kind"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, hash, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var i models.Integration
|
||||||
|
var created int64
|
||||||
|
if err := db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO integrations (team_id, kind, name, key_hash)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, team_id, kind, name, created_at`,
|
||||||
|
teamID, req.Kind, req.Name, hash).
|
||||||
|
Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
i.Key = raw
|
||||||
|
i.URL = strings.TrimSuffix(publicURL, "/") + integrationPath(raw, i.Kind)
|
||||||
|
respond(w, http.StatusCreated, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRenameIntegration renames a source. The key is untouched, so nothing
|
||||||
|
// posting with it notices.
|
||||||
|
func handleRenameIntegration(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Name) > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, id, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM integrations WHERE id = $1 AND team_id = $2", id, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// integrationPath is where a sender of this kind posts. Built in one place so
|
||||||
|
// the URL handed out at creation and the route the router registers cannot
|
||||||
|
// drift apart.
|
||||||
|
func integrationPath(key, kind string) string {
|
||||||
|
return "/api/integrations/" + key + "/" + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertSource is who an arriving webhook is from: the integration whose key it
|
||||||
|
// used, and the team that integration puts its alerts in.
|
||||||
|
type alertSource struct {
|
||||||
|
integrationID int64
|
||||||
|
teamID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceForKey resolves an integration key to its source, and stamps the key's
|
||||||
|
// last use. An unknown key is not an error worth distinguishing: the caller is
|
||||||
|
// told nothing beyond "no".
|
||||||
|
func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) {
|
||||||
|
var src alertSource
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, team_id FROM integrations WHERE key_hash = $1", hashToken(key)).
|
||||||
|
Scan(&src.integrationID, &src.teamID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return alertSource{}, errUnknownIntegration
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return alertSource{}, err
|
||||||
|
}
|
||||||
|
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
||||||
|
db.ExecContext(ctx, //nolint:errcheck
|
||||||
|
"UPDATE integrations SET last_used_at = $1 WHERE id = $2",
|
||||||
|
time.Now().Unix(), src.integrationID)
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errUnknownIntegration = errors.New("unknown integration key")
|
||||||
|
|
||||||
|
// teamParam reads {teamID} from the path.
|
||||||
|
func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "teamID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid team id"))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTeamID is the oldest team, which on an upgraded install is the
|
||||||
|
// "Default" team every pre-teams row was moved into and on a fresh one is the
|
||||||
|
// team migration 003 creates. Bootstrap puts the first user in it, so somebody
|
||||||
|
// signing in to a new server lands somewhere rather than in no team at all.
|
||||||
|
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A team's dead man's switches
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// deadmanSwitchRequest is what creating a switch takes. The timeout is seconds,
|
||||||
|
// because that is what the column holds and what arithmetic is done on; a client
|
||||||
|
// renders it.
|
||||||
|
type deadmanSwitchRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSeverities are the severities an incident can open at.
|
||||||
|
var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true}
|
||||||
|
|
||||||
|
// handleListTeamDeadman lists a team's switches with what each one's heartbeats
|
||||||
|
// are doing. A team with none gets an empty list, which is a configuration and
|
||||||
|
// not an absence: answering 404 would make "off" indistinguishable from "this
|
||||||
|
// server does not do this".
|
||||||
|
func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
set, err := deadmanSetForTeam(r.Context(), db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateTeamDeadman adds one switch.
|
||||||
|
//
|
||||||
|
// Validated by parsing: a matcher with no alertname is rejected rather than
|
||||||
|
// stored, because a switch that silently watches nothing is the failure this
|
||||||
|
// feature exists to prevent.
|
||||||
|
func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req deadmanSwitchRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Matcher = strings.TrimSpace(req.Matcher)
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Severity == "" {
|
||||||
|
req.Severity = "critical"
|
||||||
|
}
|
||||||
|
if !deadmanSeverities[req.Severity] {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TimeoutSeconds <= 0 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(req.Matcher, ";") {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := parseDeadmanMatcher(req.Matcher)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(
|
||||||
|
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = m.config()
|
||||||
|
}
|
||||||
|
if len(req.Name) > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(w, http.StatusCreated, deadmanSwitchStatus{
|
||||||
|
ID: id, Name: req.Name, Matcher: m.config(),
|
||||||
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||||
|
Status: switchDormant, Sources: []deadmanSource{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
|
||||||
|
// open until somebody resolves it: deleting the switch says "stop watching", not
|
||||||
|
// "the problem is gone".
|
||||||
|
func handleDeleteTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("switch not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The whole point of #4: two teams sharing one server must not see each other's
|
||||||
|
// work. These tests build two of them and check the boundary from both sides.
|
||||||
|
|
||||||
|
type teamFixture struct {
|
||||||
|
id int64
|
||||||
|
key string // integration key: how alerts get in
|
||||||
|
call func(method, path string, body any) *http.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTeam creates a team with its own member, integration key and API key. The
|
||||||
|
// admin does the creating, as an install's first user would.
|
||||||
|
func newTeam(t *testing.T, s *ts, name string) teamFixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": name}), &team)
|
||||||
|
|
||||||
|
var integration struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/integrations",
|
||||||
|
map[string]string{"name": name + " alertmanager"}), &integration)
|
||||||
|
if integration.Key == "" {
|
||||||
|
t.Fatalf("%s: integration key was not returned", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member of this team and no other.
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": name + "-user", "email": name + "@test.com"}), &user)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.ID)+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "owner"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("%s: add member: %d", name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var key struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||||
|
map[string]string{"name": "test"}), &key)
|
||||||
|
|
||||||
|
return teamFixture{
|
||||||
|
id: team.ID,
|
||||||
|
key: integration.Key,
|
||||||
|
call: func(method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postToIntegration sends one firing alert on a team's integration key, the way
|
||||||
|
// a real Alertmanager receiver would.
|
||||||
|
func postToIntegration(t *testing.T, s *ts, key, fingerprint, name string) {
|
||||||
|
t.Helper()
|
||||||
|
payload := map[string]any{
|
||||||
|
"version": "4",
|
||||||
|
"status": "firing",
|
||||||
|
"groupKey": "{}:{alertname=\"" + name + "\"}",
|
||||||
|
"groupLabels": map[string]string{"alertname": name},
|
||||||
|
"alerts": []map[string]any{
|
||||||
|
amAlert(fingerprint, name, "firing", "2026-09-20T10:00:00Z", zeroTime, nil),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/"+key+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post alert: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("post alert: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(t *testing.T, resp *http.Response) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
decode(t, resp, &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// An alert posted on one team's key opens an incident in that team and nowhere
|
||||||
|
// else, and neither team can read the other's queue.
|
||||||
|
func TestTeams_IncidentsAreScopedToTheReceivingTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
red := newTeam(t, s, "red")
|
||||||
|
blue := newTeam(t, s, "blue")
|
||||||
|
|
||||||
|
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
|
||||||
|
postToIntegration(t, s, blue.key, "fp-blue", "BlueDiskFull")
|
||||||
|
|
||||||
|
redIncidents := list(t, red.call(http.MethodGet, "/api/incidents", nil))
|
||||||
|
if len(redIncidents) != 1 {
|
||||||
|
t.Fatalf("red should see exactly its own incident, saw %d", len(redIncidents))
|
||||||
|
}
|
||||||
|
if title := redIncidents[0]["title"]; title != "RedDiskFull" {
|
||||||
|
t.Errorf("red saw %v", title)
|
||||||
|
}
|
||||||
|
if teamID := int64(redIncidents[0]["team_id"].(float64)); teamID != red.id {
|
||||||
|
t.Errorf("red's incident belongs to team %d, want %d", teamID, red.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
blueIncidents := list(t, blue.call(http.MethodGet, "/api/incidents", nil))
|
||||||
|
if len(blueIncidents) != 1 || blueIncidents[0]["title"] != "BlueDiskFull" {
|
||||||
|
t.Fatalf("blue should see exactly its own incident, saw %v", blueIncidents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading the other team's incident by id is not found rather than
|
||||||
|
// forbidden: its existence is the other team's business.
|
||||||
|
otherID := int64(blueIncidents[0]["id"].(float64))
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/incidents/" + id64(otherID),
|
||||||
|
"/api/incidents/" + id64(otherID) + "/alerts",
|
||||||
|
"/api/incidents/" + id64(otherID) + "/timeline",
|
||||||
|
} {
|
||||||
|
resp := red.call(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("red reading %s: expected 404, got %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And cannot act on it either.
|
||||||
|
for _, path := range []string{"/acknowledge", "/resolve", "/archive"} {
|
||||||
|
resp := red.call(http.MethodPost, "/api/incidents/"+id64(otherID)+path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("red posting %s: expected 404, got %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts, the raw signal record, are scoped the same way.
|
||||||
|
func TestTeams_AlertsAndStatsAreScoped(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
red := newTeam(t, s, "red")
|
||||||
|
blue := newTeam(t, s, "blue")
|
||||||
|
|
||||||
|
postToIntegration(t, s, red.key, "fp-red", "RedDiskFull")
|
||||||
|
postToIntegration(t, s, blue.key, "fp-blue-1", "BlueDiskFull")
|
||||||
|
postToIntegration(t, s, blue.key, "fp-blue-2", "BlueMemory")
|
||||||
|
|
||||||
|
if alerts := list(t, red.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 1 {
|
||||||
|
t.Errorf("red should see 1 alert, saw %d", len(alerts))
|
||||||
|
}
|
||||||
|
if alerts := list(t, blue.call(http.MethodGet, "/api/alerts", nil)); len(alerts) != 2 {
|
||||||
|
t.Errorf("blue should see 2 alerts, saw %d", len(alerts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics count your own work only — otherwise a team's volume, and the
|
||||||
|
// names of its alerts, leak through the totals.
|
||||||
|
var stats map[string]any
|
||||||
|
decode(t, red.call(http.MethodGet, "/api/stats/alerts", nil), &stats)
|
||||||
|
if total := stats["total"].(float64); total != 1 {
|
||||||
|
t.Errorf("red's alert stats counted %v alerts, want 1", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
top := list(t, red.call(http.MethodGet, "/api/stats/alerts/top", nil))
|
||||||
|
for _, row := range top {
|
||||||
|
if name := row["name"].(string); name != "RedDiskFull" {
|
||||||
|
t.Errorf("red's top alerts named %q, which is not theirs", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same fingerprint, the same groupKey and the same date are all legitimate
|
||||||
|
// in two teams at once: two clusters running the same rules, two rotas.
|
||||||
|
func TestTeams_SameFingerprintInTwoTeams(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
red := newTeam(t, s, "red")
|
||||||
|
blue := newTeam(t, s, "blue")
|
||||||
|
|
||||||
|
postToIntegration(t, s, red.key, "fp-shared", "DiskFull")
|
||||||
|
postToIntegration(t, s, blue.key, "fp-shared", "DiskFull")
|
||||||
|
|
||||||
|
for _, team := range []struct {
|
||||||
|
name string
|
||||||
|
f teamFixture
|
||||||
|
}{{"red", red}, {"blue", blue}} {
|
||||||
|
incidents := list(t, team.f.call(http.MethodGet, "/api/incidents", nil))
|
||||||
|
if len(incidents) != 1 {
|
||||||
|
t.Errorf("%s: expected its own incident for the shared fingerprint, saw %d",
|
||||||
|
team.name, len(incidents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And both rotas can name somebody for the same day.
|
||||||
|
for _, team := range []struct {
|
||||||
|
name string
|
||||||
|
f teamFixture
|
||||||
|
}{{"red", red}, {"blue", blue}} {
|
||||||
|
var members []map[string]any
|
||||||
|
decode(t, team.f.call(http.MethodGet, "/api/teams/"+id64(team.f.id)+"/members", nil), &members)
|
||||||
|
userID := int64(members[0]["user_id"].(float64))
|
||||||
|
|
||||||
|
resp := team.f.call(http.MethodPost, "/api/teams/"+id64(team.f.id)+"/schedule",
|
||||||
|
map[string]any{"user_id": userID, "dates": []string{"2026-10-01"}})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Errorf("%s: taking 2026-10-01 returned %d", team.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown key delivers nothing, and says so rather than accepting silently.
|
||||||
|
func TestTeams_UnknownIntegrationKeyIsRejected(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
team := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/not-a-real-key/alertmanager",
|
||||||
|
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401 for an unknown key, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if incidents := list(t, team.call(http.MethodGet, "/api/incidents", nil)); len(incidents) != 0 {
|
||||||
|
t.Errorf("a rejected payload opened %d incident(s)", len(incidents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team configuration is an owner's job; working incidents is a member's.
|
||||||
|
func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
team := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
// A plain member of the same team.
|
||||||
|
var user struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "plain", "email": "plain@test.com"}), &user)
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||||
|
map[string]any{"user_id": user.ID, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
var key struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||||
|
map[string]string{"name": "test"}), &key)
|
||||||
|
|
||||||
|
call := func(method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
base := "/api/teams/" + id64(team.id)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"mint an integration key", http.MethodPost, base + "/integrations",
|
||||||
|
map[string]string{"name": "mine"}},
|
||||||
|
{"take a shift", http.MethodPost, base + "/schedule",
|
||||||
|
map[string]any{"user_id": user.ID, "dates": []string{"2026-11-01"}}},
|
||||||
|
{"add a member", http.MethodPost, base + "/members",
|
||||||
|
map[string]any{"user_id": 1}},
|
||||||
|
{"delete the team", http.MethodDelete, base, nil},
|
||||||
|
} {
|
||||||
|
resp := call(c.method, c.path, c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s: expected 403, got %d", c.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// But they can read what the team is doing.
|
||||||
|
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
|
||||||
|
resp := call(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("reading %s: expected 200, got %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||||
|
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
red := newTeam(t, s, "red")
|
||||||
|
blue := newTeam(t, s, "blue")
|
||||||
|
|
||||||
|
base := "/api/teams/" + id64(red.id)
|
||||||
|
for _, path := range []string{base + "/members", base + "/integrations", base + "/schedule"} {
|
||||||
|
resp := blue.call(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("blue reading %s: expected 404, got %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/teams lists your own, never the install's.
|
||||||
|
teams := list(t, blue.call(http.MethodGet, "/api/teams", nil))
|
||||||
|
if len(teams) != 1 || teams[0]["name"] != "blue" {
|
||||||
|
t.Errorf("blue's team list: %v", teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests run against a real Postgres, because the server does. SQLite's
|
||||||
|
// ":memory:" gave every test a private database for free; Postgres has no
|
||||||
|
// equivalent, so isolation is bought with a schema per test.
|
||||||
|
//
|
||||||
|
// A schema rather than a database: CREATE DATABASE copies a template on disk and
|
||||||
|
// costs a hundred milliseconds or so each time, while CREATE SCHEMA plus the one
|
||||||
|
// baseline migration is a few, and the suite runs a few hundred of them. Each
|
||||||
|
// test's pool is pinned to its own schema through search_path, so two tests
|
||||||
|
// cannot see each other's rows even though they share a server.
|
||||||
|
//
|
||||||
|
// TERDUT_TEST_DSN must point at a database the test role may create schemas in:
|
||||||
|
//
|
||||||
|
// postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable
|
||||||
|
//
|
||||||
|
// `make test-db` starts one locally; ci.yaml runs one as a service container.
|
||||||
|
// An unset DSN fails rather than skips, deliberately — a suite that quietly
|
||||||
|
// tests nothing is worse than one that does not run.
|
||||||
|
const testDSNEnv = "TERDUT_TEST_DSN"
|
||||||
|
|
||||||
|
// testConfig is the environment half of the server's configuration, which the
|
||||||
|
// admin settings page renders read-only and SeedSettings seeds the editable
|
||||||
|
// half from. The durations match the defaults config.Load would produce, so a
|
||||||
|
// test that never touches the settings table behaves as a fresh install does.
|
||||||
|
func testConfig() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
Addr: ":8080",
|
||||||
|
ArchiveAfter: 7 * 24 * time.Hour,
|
||||||
|
StaleAfter: 6 * time.Hour,
|
||||||
|
NotifyRepeat: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
|
||||||
|
// a path segment. Every test that does not say otherwise works inside it.
|
||||||
|
const defaultTeam = "1"
|
||||||
|
|
||||||
|
var schemaSeq int
|
||||||
|
|
||||||
|
// newTestDB returns a migrated database private to this test, and drops it
|
||||||
|
// afterwards.
|
||||||
|
func newTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := os.Getenv(testDSNEnv)
|
||||||
|
if dsn == "" {
|
||||||
|
t.Fatalf("%s is not set: these tests need Postgres.\n"+
|
||||||
|
"Run `make test-db` for a local one, then\n"+
|
||||||
|
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
|
||||||
|
testDSNEnv, testDSNEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaSeq++
|
||||||
|
schema := fmt.Sprintf("test_%d_%d", os.Getpid(), schemaSeq)
|
||||||
|
|
||||||
|
admin, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to %s: %v", testDSNEnv, err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||||
|
t.Fatalf("create schema %s: %v", schema, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := db.Open(withSearchPath(dsn, schema))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(database); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
database.Close()
|
||||||
|
cleanup, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cleanup.Close()
|
||||||
|
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||||
|
t.Logf("drop schema %s: %v", schema, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSearchPath pins a DSN to one schema, so every connection the pool opens
|
||||||
|
// lands there and nothing has to qualify a table name.
|
||||||
|
//
|
||||||
|
// Handles both DSN spellings: a postgres:// URL, and libpq's keyword/value form.
|
||||||
|
func withSearchPath(dsn, schema string) string {
|
||||||
|
opt := "-csearch_path=" + schema
|
||||||
|
|
||||||
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err == nil {
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("options", opt)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dsn + " options='" + opt + "'"
|
||||||
|
}
|
||||||
+207
-29
@@ -11,8 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/yeniklas/terdut-server/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
||||||
@@ -20,6 +20,9 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
// Password is optional; without one the first user can only use the
|
||||||
|
// API key until somebody sets it.
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
@@ -29,6 +32,19 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var passwordHash *string
|
||||||
|
if req.Password != "" {
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, err := hashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passwordHash = &h
|
||||||
|
}
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||||
@@ -40,26 +56,36 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
var userID int64
|
||||||
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
|
if err := db.QueryRowContext(r.Context(),
|
||||||
if err != nil {
|
"INSERT INTO users (username, email, password_hash, is_admin) VALUES ($1, $2, $3, true) RETURNING id",
|
||||||
|
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID, _ := res.LastInsertId()
|
|
||||||
|
|
||||||
raw, hash, err := newAPIKey()
|
raw, hash, err := randomToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keyRes, err := db.ExecContext(r.Context(),
|
var keyID int64
|
||||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, "bootstrap")
|
if err := db.QueryRowContext(r.Context(),
|
||||||
if err != nil {
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
userID, hash, "bootstrap").Scan(&keyID); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keyID, _ := keyRes.LastInsertId()
|
|
||||||
|
// The default team exists from migration 003, on a fresh install too.
|
||||||
|
// Without a membership the first user signs in to a working server with
|
||||||
|
// no queue, no schedule and nowhere for an integration to hang off.
|
||||||
|
if teamID, err := defaultTeamID(r.Context(), db); err == nil {
|
||||||
|
db.ExecContext(r.Context(), //nolint:errcheck
|
||||||
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) "+
|
||||||
|
"ON CONFLICT (team_id, user_id) DO NOTHING",
|
||||||
|
teamID, userID, models.RoleOwner)
|
||||||
|
}
|
||||||
|
|
||||||
user, _ := fetchUser(r.Context(), db, userID)
|
user, _ := fetchUser(r.Context(), db, userID)
|
||||||
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
||||||
@@ -70,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := db.QueryContext(r.Context(),
|
rows, err := db.QueryContext(r.Context(),
|
||||||
"SELECT id, username, email, created_at FROM users ORDER BY id")
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -81,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts); err != nil {
|
var disabled *int64
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
u.DisabledAt = unixPtr(disabled)
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
respond(w, http.StatusOK, users)
|
respond(w, http.StatusOK, users)
|
||||||
@@ -107,22 +135,69 @@ func handleCreateUser(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
var id int64
|
||||||
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
|
if err := db.QueryRowContext(r.Context(),
|
||||||
if err != nil {
|
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
req.Username, req.Email).Scan(&id); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
|
||||||
user, _ := fetchUser(r.Context(), db, id)
|
user, _ := fetchUser(r.Context(), db, id)
|
||||||
respond(w, http.StatusCreated, user)
|
respond(w, http.StatusCreated, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
|
||||||
|
// clears it with an empty string. The topic is a shared secret with the ntfy
|
||||||
|
// server — anyone who knows it can publish to it — so pick an unguessable one
|
||||||
|
// unless your ntfy enforces access control.
|
||||||
|
func handleSetNotifyTarget(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
NtfyTopic string `json:"ntfy_topic"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var topic *string
|
||||||
|
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
|
||||||
|
topic = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fetchUser(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
@@ -130,7 +205,22 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = ?", id)
|
// Deleting yourself is how an install ends up with no administrator at
|
||||||
|
// all, and it is never what somebody meant to do.
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
if caller.ID == id {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot delete your own account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
} else if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot delete the last administrator"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -151,6 +241,9 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -165,23 +258,23 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var exists int
|
var exists int
|
||||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", userID).Scan(&exists); err != nil {
|
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
||||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, hash, err := newAPIKey()
|
raw, hash, err := randomToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := db.ExecContext(r.Context(),
|
var keyID int64
|
||||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, req.Name)
|
if err := db.QueryRowContext(r.Context(),
|
||||||
if err != nil {
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
userID, hash, req.Name).Scan(&keyID); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keyID, _ := res.LastInsertId()
|
|
||||||
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
||||||
respond(w, http.StatusCreated, key)
|
respond(w, http.StatusCreated, key)
|
||||||
}
|
}
|
||||||
@@ -194,6 +287,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
||||||
@@ -201,7 +297,7 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := db.ExecContext(r.Context(),
|
res, err := db.ExecContext(r.Context(),
|
||||||
"DELETE FROM api_keys WHERE id = ? AND user_id = ?", keyID, userID)
|
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -215,8 +311,9 @@ func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAPIKey generates a random 32-byte key encoded as hex, plus its SHA-256 hash for storage.
|
// randomToken generates a random 32-byte secret encoded as hex, plus its SHA-256
|
||||||
func newAPIKey() (raw, hash string, err error) {
|
// hash for storage. Used for API keys and for notification acknowledge tokens.
|
||||||
|
func randomToken() (raw, hash string, err error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err = rand.Read(b); err != nil {
|
if _, err = rand.Read(b); err != nil {
|
||||||
return
|
return
|
||||||
@@ -230,11 +327,92 @@ func newAPIKey() (raw, hash string, err error) {
|
|||||||
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
err := db.QueryRowContext(ctx, "SELECT id, username, email, created_at FROM users WHERE id = ?", id).
|
var disabled *int64
|
||||||
Scan(&u.ID, &u.Username, &u.Email, &ts)
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
|
||||||
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
u.CreatedAt = time.Unix(ts, 0).UTC()
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
u.DisabledAt = unixPtr(disabled)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetAdmin grants or revokes the system administrator flag.
|
||||||
|
//
|
||||||
|
// Revoking is guarded twice: an install must keep at least one administrator,
|
||||||
|
// and you cannot demote yourself. The first stops the flag being lost
|
||||||
|
// altogether; the second stops the likelier accident, where the only admin
|
||||||
|
// clears their own flag while tidying up and locks the door behind them.
|
||||||
|
func handleSetAdmin(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IsAdmin *bool `json:"is_admin"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("is_admin is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*req.IsAdmin {
|
||||||
|
var managed bool
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
|
||||||
|
id).Scan(&managed); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if managed {
|
||||||
|
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, _ := userFromContext(r.Context())
|
||||||
|
if caller.ID == id {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
} else if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot revoke the last administrator"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fetchUser(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLastAdmin reports whether id is an administrator and no other user is one.
|
||||||
|
// A non-admin id is never the last one, so removing them is always allowed.
|
||||||
|
func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) {
|
||||||
|
var last bool
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last)
|
||||||
|
return last, err
|
||||||
|
}
|
||||||
|
|||||||
+266
-7
@@ -1,20 +1,279 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string
|
Addr string
|
||||||
DBPath string
|
|
||||||
|
// DSN is the Postgres connection string, e.g.
|
||||||
|
// postgres://terdut:secret@host:5432/terdut?sslmode=require. Required:
|
||||||
|
// unlike the SQLite path it replaced there is no sensible default, and a
|
||||||
|
// server that silently came up against the wrong database would be worse
|
||||||
|
// than one that refuses to start.
|
||||||
|
DSN string
|
||||||
|
|
||||||
|
ArchiveAfter time.Duration
|
||||||
|
|
||||||
|
// StaleAfter is how long a firing alert may go without a refreshing webhook
|
||||||
|
// before the sweeper treats it as resolved. It must exceed Alertmanager's
|
||||||
|
// repeat_interval (default 4h), which is what refreshes the alert.
|
||||||
|
StaleAfter time.Duration
|
||||||
|
|
||||||
|
// DeadmanMatchers selects the alerts that are heartbeats rather than
|
||||||
|
// problems: receiving one opens no incident, and the absence of one does.
|
||||||
|
//
|
||||||
|
// ";" separates matchers, "," the label conditions within one, "=" is exact
|
||||||
|
// equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`. Every
|
||||||
|
// matcher must name an alertname. See api.ParseDeadmanConfig.
|
||||||
|
DeadmanMatchers string
|
||||||
|
|
||||||
|
// DeadmanTimeout is how long a heartbeat may go unheard before its switch is
|
||||||
|
// declared dead. It must be *shorter* than the Alertmanager repeat_interval
|
||||||
|
// of the route carrying the heartbeat — the opposite of StaleAfter, and the
|
||||||
|
// reason a dead man's switch usually wants a route of its own. Zero disables
|
||||||
|
// dead man's switch handling entirely.
|
||||||
|
DeadmanTimeout time.Duration
|
||||||
|
|
||||||
|
// DeadmanSeverity is the severity a dead man's switch incident opens at.
|
||||||
|
// These incidents have no member alerts to derive one from.
|
||||||
|
DeadmanSeverity string
|
||||||
|
|
||||||
|
// NtfyURL is the ntfy server push notifications are published to. Empty
|
||||||
|
// disables notifications entirely.
|
||||||
|
NtfyURL string
|
||||||
|
|
||||||
|
// NtfyToken is an optional bearer token for an access-controlled ntfy.
|
||||||
|
NtfyToken string
|
||||||
|
|
||||||
|
// NtfyFallbackTopic receives incidents that open with nobody on call.
|
||||||
|
NtfyFallbackTopic string
|
||||||
|
|
||||||
|
// PublicURL is the base URL a phone uses to reach this server, used for the
|
||||||
|
// link and the Acknowledge button inside a notification. Without it
|
||||||
|
// notifications carry neither.
|
||||||
|
PublicURL string
|
||||||
|
|
||||||
|
// NotifyRepeat is how long an incident may sit unacknowledged before it is
|
||||||
|
// notified again. Zero disables reminders.
|
||||||
|
NotifyRepeat time.Duration
|
||||||
|
|
||||||
|
// DisablePasswordLogin refuses signing in, or signing up, with a password.
|
||||||
|
// It is how an install moves to SSO only, and turning it back off is the way
|
||||||
|
// in when the identity provider is down. Stated negatively so that the zero
|
||||||
|
// Config, which is what a test or a new caller builds, keeps passwords working.
|
||||||
|
DisablePasswordLogin bool
|
||||||
|
|
||||||
|
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
|
||||||
|
OIDC OIDC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC is the single sign-on configuration. Groups from the provider decide
|
||||||
|
// who may sign in, which teams they belong to, and whether they administer the
|
||||||
|
// install, in the manner of Grafana's org and role mapping.
|
||||||
|
type OIDC struct {
|
||||||
|
// Issuer is the provider's issuer URL. Discovery is fetched from
|
||||||
|
// <Issuer>/.well-known/openid-configuration. For Authentik this is the
|
||||||
|
// application's issuer, e.g. https://auth.example.com/application/o/terdut/.
|
||||||
|
// Empty turns single sign-on off.
|
||||||
|
Issuer string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
|
||||||
|
// Name is what the sign-in button calls the provider.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Scopes to request. The groups claim normally needs "profile" on Authentik.
|
||||||
|
Scopes []string
|
||||||
|
|
||||||
|
// UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read.
|
||||||
|
UsernameClaim string
|
||||||
|
EmailClaim string
|
||||||
|
GroupsClaim string
|
||||||
|
|
||||||
|
// TrustEmail links a sign-in to an existing local user by email even when the
|
||||||
|
// provider does not vouch that the address is verified. Authentik reports
|
||||||
|
// email_verified false unless told otherwise, and an install that runs its
|
||||||
|
// own provider has already decided that its addresses can be trusted.
|
||||||
|
TrustEmail bool
|
||||||
|
|
||||||
|
// AllowedGroups gates sign-in: somebody in none of them is refused, however
|
||||||
|
// well the provider authenticated them. Empty admits everybody the provider
|
||||||
|
// authenticates, and access control is left to the provider.
|
||||||
|
AllowedGroups []string
|
||||||
|
|
||||||
|
// AdminGroup grants the system administrator flag while the user is in it.
|
||||||
|
AdminGroup string
|
||||||
|
|
||||||
|
// GroupMappings grants team roles. A user in Group gets Role in Team.
|
||||||
|
GroupMappings []GroupMapping
|
||||||
|
|
||||||
|
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
|
||||||
|
// login is the only moment groups are re-read, so this is how long a change
|
||||||
|
// in the provider may take to reach terdut.
|
||||||
|
SessionMaxAge time.Duration
|
||||||
|
|
||||||
|
// parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate:
|
||||||
|
// Load cannot fail, and a mapping that was silently dropped would grant
|
||||||
|
// less access than the operator wrote down.
|
||||||
|
parseErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupMapping grants Role in Team to members of Group.
|
||||||
|
type GroupMapping struct {
|
||||||
|
Group string `json:"group"`
|
||||||
|
Team string `json:"team"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether single sign-on is configured.
|
||||||
|
func (o OIDC) Enabled() bool { return o.Issuer != "" }
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
addr := os.Getenv("TERDUT_ADDR")
|
addr := os.Getenv("TERDUT_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
}
|
}
|
||||||
dbPath := os.Getenv("TERDUT_DB_PATH")
|
deadmanMatchers := os.Getenv("TERDUT_DEADMAN_MATCHERS")
|
||||||
if dbPath == "" {
|
if deadmanMatchers == "" {
|
||||||
dbPath = "terdut.db"
|
deadmanMatchers = "alertname=Watchdog"
|
||||||
|
}
|
||||||
|
deadmanSeverity := os.Getenv("TERDUT_DEADMAN_SEVERITY")
|
||||||
|
if deadmanSeverity == "" {
|
||||||
|
deadmanSeverity = "critical"
|
||||||
|
}
|
||||||
|
return Config{
|
||||||
|
Addr: addr,
|
||||||
|
DSN: os.Getenv("TERDUT_DB_DSN"),
|
||||||
|
ArchiveAfter: duration("TERDUT_ARCHIVE_AFTER", 7*24*time.Hour),
|
||||||
|
StaleAfter: duration("TERDUT_STALE_AFTER", 6*time.Hour),
|
||||||
|
|
||||||
|
DeadmanMatchers: deadmanMatchers,
|
||||||
|
DeadmanTimeout: duration("TERDUT_DEADMAN_TIMEOUT", 15*time.Minute),
|
||||||
|
DeadmanSeverity: deadmanSeverity,
|
||||||
|
|
||||||
|
NtfyURL: os.Getenv("TERDUT_NTFY_URL"),
|
||||||
|
NtfyToken: os.Getenv("TERDUT_NTFY_TOKEN"),
|
||||||
|
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
|
||||||
|
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
|
||||||
|
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
|
||||||
|
|
||||||
|
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
|
||||||
|
OIDC: loadOIDC(),
|
||||||
}
|
}
|
||||||
return Config{Addr: addr, DBPath: dbPath}
|
}
|
||||||
|
|
||||||
|
func loadOIDC() OIDC {
|
||||||
|
o := OIDC{
|
||||||
|
Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")),
|
||||||
|
ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"),
|
||||||
|
Name: str("TERDUT_OIDC_NAME", "SSO"),
|
||||||
|
Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"),
|
||||||
|
UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"),
|
||||||
|
EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"),
|
||||||
|
GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"),
|
||||||
|
TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false),
|
||||||
|
AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""),
|
||||||
|
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
||||||
|
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil {
|
||||||
|
o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports a configuration the server should refuse to start with.
|
||||||
|
// Single sign-on is the only part that can be inconsistent: a half-configured
|
||||||
|
// provider would come up and then fail every login, which is harder to notice
|
||||||
|
// than not starting.
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
o := c.OIDC
|
||||||
|
if o.parseErr != nil {
|
||||||
|
return o.parseErr
|
||||||
|
}
|
||||||
|
if !o.Enabled() {
|
||||||
|
if c.DisablePasswordLogin {
|
||||||
|
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
||||||
|
}
|
||||||
|
if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
|
||||||
|
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
|
||||||
|
}
|
||||||
|
if o.ClientID == "" || o.ClientSecret == "" {
|
||||||
|
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
|
||||||
|
}
|
||||||
|
if c.PublicURL == "" {
|
||||||
|
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
|
||||||
|
}
|
||||||
|
if o.SessionMaxAge <= 0 {
|
||||||
|
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
||||||
|
}
|
||||||
|
for i, m := range o.GroupMappings {
|
||||||
|
if m.Group == "" || m.Team == "" {
|
||||||
|
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
|
||||||
|
}
|
||||||
|
if m.Role != "owner" && m.Role != "member" {
|
||||||
|
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" {
|
||||||
|
return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(env, def string) string {
|
||||||
|
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// list reads a comma- or space-separated env var.
|
||||||
|
func list(env, def string) []string {
|
||||||
|
s := os.Getenv(env)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
s = def
|
||||||
|
}
|
||||||
|
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolean reads a true/false env var. An unrecognised value takes the default,
|
||||||
|
// so the two flags read this way (password login on, trusting email off) both
|
||||||
|
// fail towards the cautious setting.
|
||||||
|
func boolean(env string, def bool) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
|
||||||
|
case "true", "1", "yes":
|
||||||
|
return true
|
||||||
|
case "false", "0", "no":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration reads a time.ParseDuration-formatted env var. An unset or
|
||||||
|
// unparseable value falls back to def rather than failing startup: a typo in one
|
||||||
|
// tuning knob should not take the server down.
|
||||||
|
func duration(env string, def time.Duration) time.Duration {
|
||||||
|
if s := os.Getenv(env); s != "" {
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
base := func() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"TERDUT_PUBLIC_URL": "https://terdut.example.com",
|
||||||
|
"TERDUT_OIDC_ISSUER": "https://auth.example.com/application/o/terdut/",
|
||||||
|
"TERDUT_OIDC_CLIENT_ID": "id",
|
||||||
|
"TERDUT_OIDC_CLIENT_SECRET": "secret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env func(map[string]string)
|
||||||
|
wantErr string // substring; empty means valid
|
||||||
|
}{
|
||||||
|
{"off by default", func(m map[string]string) { clear(m) }, ""},
|
||||||
|
{"minimal sso", func(m map[string]string) {}, ""},
|
||||||
|
{"groups without issuer", func(m map[string]string) {
|
||||||
|
clear(m)
|
||||||
|
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
|
||||||
|
}, "ISSUER is not"},
|
||||||
|
{"missing secret", func(m map[string]string) { delete(m, "TERDUT_OIDC_CLIENT_SECRET") }, "CLIENT_SECRET"},
|
||||||
|
{"missing public url", func(m map[string]string) { delete(m, "TERDUT_PUBLIC_URL") }, "PUBLIC_URL"},
|
||||||
|
{"bad issuer", func(m map[string]string) { m["TERDUT_OIDC_ISSUER"] = "not a url" }, "not a URL"},
|
||||||
|
{"bad mapping json", func(m map[string]string) { m["TERDUT_OIDC_GROUP_MAPPINGS"] = "{nope" }, "GROUP_MAPPINGS"},
|
||||||
|
{"bad mapping role", func(m map[string]string) {
|
||||||
|
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"admin"}]`
|
||||||
|
}, "role must be"},
|
||||||
|
{"mapping needs team", func(m map[string]string) {
|
||||||
|
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","role":"member"}]`
|
||||||
|
}, "group and team"},
|
||||||
|
{"good mapping", func(m map[string]string) {
|
||||||
|
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"owner"}]`
|
||||||
|
}, ""},
|
||||||
|
{"password off without sso", func(m map[string]string) {
|
||||||
|
clear(m)
|
||||||
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
|
}, "no way to sign in"},
|
||||||
|
{"password off with sso but no grants", func(m map[string]string) {
|
||||||
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
|
}, "nobody able"},
|
||||||
|
{"password off with admin group", func(m map[string]string) {
|
||||||
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
|
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
|
||||||
|
}, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
env := base()
|
||||||
|
tt.env(env)
|
||||||
|
for _, k := range []string{
|
||||||
|
"TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID",
|
||||||
|
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP", "TERDUT_OIDC_GROUP_MAPPINGS",
|
||||||
|
} {
|
||||||
|
t.Setenv(k, env[k])
|
||||||
|
}
|
||||||
|
err := Load().Validate()
|
||||||
|
switch {
|
||||||
|
case tt.wantErr == "" && err != nil:
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)):
|
||||||
|
t.Errorf("error %v, want one containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_OIDCDefaults(t *testing.T) {
|
||||||
|
t.Setenv("TERDUT_OIDC_ISSUER", "https://auth.example.com/")
|
||||||
|
o := Load().OIDC
|
||||||
|
if o.UsernameClaim != "preferred_username" || o.EmailClaim != "email" || o.GroupsClaim != "groups" {
|
||||||
|
t.Errorf("claim defaults: %+v", o)
|
||||||
|
}
|
||||||
|
if strings.Join(o.Scopes, " ") != "openid profile email" {
|
||||||
|
t.Errorf("scopes: %v", o.Scopes)
|
||||||
|
}
|
||||||
|
if o.SessionMaxAge.Hours() != 12 {
|
||||||
|
t.Errorf("max age: %v", o.SessionMaxAge)
|
||||||
|
}
|
||||||
|
if Load().DisablePasswordLogin {
|
||||||
|
t.Error("password login should be on by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-21
@@ -5,37 +5,73 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations
|
//go:embed migrations
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
func Open(path string) (*sql.DB, error) {
|
// pingAttempts and pingRetryDelay bound the retry on the first connection.
|
||||||
db, err := sql.Open("sqlite", path)
|
// This pod's own IP can reach the Postgres pod's node before that node's
|
||||||
|
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
|
||||||
|
// event) has added it to the allowed-source set, which fails the ping with
|
||||||
|
// "connection refused" rather than a timeout. That race resolves within
|
||||||
|
// several seconds in practice; five attempts two seconds apart give it
|
||||||
|
// comfortable room without turning a genuinely absent database into a long
|
||||||
|
// hang.
|
||||||
|
const (
|
||||||
|
pingAttempts = 5
|
||||||
|
pingRetryDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
|
||||||
|
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
|
||||||
|
//
|
||||||
|
// The pool is modest on purpose: this server's concurrency comes from a handful
|
||||||
|
// of HTTP handlers plus two background loops, and a cloud-native-pg instance
|
||||||
|
// sized for it has a low max_connections. It is still a pool, unlike the single
|
||||||
|
// connection SQLite forced, so the notifier no longer blocks a webhook.
|
||||||
|
func Open(dsn string) (*sql.DB, error) {
|
||||||
|
if dsn == "" {
|
||||||
|
return nil, fmt.Errorf("empty DSN: set TERDUT_DB_DSN")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// SQLite does not support concurrent writers; a single connection avoids locking errors.
|
db.SetMaxOpenConns(10)
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxIdleConns(5)
|
||||||
if _, err := db.Exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;"); err != nil {
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
db.Close()
|
|
||||||
return nil, fmt.Errorf("set pragmas: %w", err)
|
for attempt := 1; ; attempt++ {
|
||||||
|
err = db.Ping()
|
||||||
|
if err == nil {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
if attempt == pingAttempts {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
|
||||||
|
time.Sleep(pingRetryDelay)
|
||||||
}
|
}
|
||||||
if err := db.Ping(); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, fmt.Errorf("ping: %w", err)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate applies every embedded migration that has not been applied yet, in
|
||||||
|
// filename order, recording each in schema_migrations.
|
||||||
|
//
|
||||||
|
// Each file runs inside a transaction, which SQLite's version did not do: a
|
||||||
|
// migration that failed half way used to leave the schema in whatever state it
|
||||||
|
// had reached. Postgres has transactional DDL, so the rollback is real.
|
||||||
func Migrate(db *sql.DB) error {
|
func Migrate(db *sql.DB) error {
|
||||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version TEXT PRIMARY KEY,
|
version TEXT PRIMARY KEY,
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
)`); err != nil {
|
)`); err != nil {
|
||||||
return fmt.Errorf("create schema_migrations: %w", err)
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -55,7 +91,7 @@ func Migrate(db *sql.DB) error {
|
|||||||
|
|
||||||
for _, name := range files {
|
for _, name := range files {
|
||||||
var count int
|
var count int
|
||||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", name).Scan(&count); err != nil {
|
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = $1", name).Scan(&count); err != nil {
|
||||||
return fmt.Errorf("check migration %s: %w", name, err)
|
return fmt.Errorf("check migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -67,13 +103,28 @@ func Migrate(db *sql.DB) error {
|
|||||||
return fmt.Errorf("read migration %s: %w", name, err)
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(string(data)); err != nil {
|
if err := applyMigration(db, name, string(data)); err != nil {
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil {
|
|
||||||
return fmt.Errorf("record migration %s: %w", name, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyMigration(db *sql.DB, name, body string) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(body); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", name); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
-- The Postgres baseline: the schema as it stood at the end of the SQLite line,
|
||||||
|
-- in one file rather than ten.
|
||||||
|
--
|
||||||
|
-- The ten SQLite migrations are in git history up to the commit that introduced
|
||||||
|
-- this one, and they replay against nothing here: their shape was incremental
|
||||||
|
-- (columns added, then dropped again in 008) and 008's backfill rewrote data
|
||||||
|
-- that a Postgres install never had. An existing SQLite database is carried over
|
||||||
|
-- by scripts/sqlite-to-postgres.go, which copies rows into this schema.
|
||||||
|
--
|
||||||
|
-- Two conventions inherited deliberately:
|
||||||
|
--
|
||||||
|
-- * Timestamps are BIGINT unix seconds, not timestamptz. Everything in Go
|
||||||
|
-- already speaks epochs, and converting was a second change riding along
|
||||||
|
-- with the port. Worth revisiting on its own.
|
||||||
|
--
|
||||||
|
-- * Ids are GENERATED BY DEFAULT, not ALWAYS, so the migration script can
|
||||||
|
-- insert rows with their original ids and keep every foreign key intact.
|
||||||
|
-- setval at the end of the copy puts the sequences past them.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
-- Where this user's notifications go. NULL means they get none; incidents
|
||||||
|
-- assigned to them fall back to the configured fallback topic.
|
||||||
|
ntfy_topic TEXT,
|
||||||
|
-- NULL means the user has no password and can only use API keys.
|
||||||
|
password_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
last_used_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A session is a browser's credential, the cookie counterpart of an API key:
|
||||||
|
-- only the hash of the token is stored. expires_at slides forward while the
|
||||||
|
-- session is in use, so an on-call phone stays signed in.
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
last_seen_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||||
|
|
||||||
|
-- The machine-owned signal record: what Alertmanager says is true right now.
|
||||||
|
-- Workflow state lives on incidents, never here, because the webhook upsert owns
|
||||||
|
-- these rows and would overwrite it.
|
||||||
|
CREATE TABLE alerts (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
fingerprint TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('firing', 'resolved')),
|
||||||
|
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
annotations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
starts_at BIGINT NOT NULL,
|
||||||
|
ends_at BIGINT,
|
||||||
|
generator_url TEXT NOT NULL DEFAULT '',
|
||||||
|
received_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
archived_at BIGINT,
|
||||||
|
-- Why the alert left the firing state: 'alertmanager' when a resolved
|
||||||
|
-- webhook set it, 'expiry' when the sweeper inferred it from staleness.
|
||||||
|
resolution_source TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX alerts_status_idx ON alerts(status);
|
||||||
|
CREATE INDEX alerts_name_idx ON alerts(name);
|
||||||
|
CREATE INDEX alerts_received_at_idx ON alerts(received_at DESC);
|
||||||
|
CREATE INDEX alerts_archived_at_idx ON alerts(archived_at);
|
||||||
|
|
||||||
|
CREATE TABLE schedule_entries (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX schedule_entries_date_idx ON schedule_entries(date);
|
||||||
|
|
||||||
|
-- The human work item: what people acknowledge, assign, snooze, discuss and
|
||||||
|
-- resolve. Correlation uses Alertmanager's own groupKey, so incidents follow the
|
||||||
|
-- group_by routing tree the operator already tuned.
|
||||||
|
CREATE TABLE incidents (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
group_key TEXT NOT NULL, -- Alertmanager groupKey, opaque
|
||||||
|
title TEXT NOT NULL, -- rendered from group_labels
|
||||||
|
group_labels JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('triggered', 'acknowledged', 'resolved')),
|
||||||
|
severity TEXT, -- highest `severity` label across firing members
|
||||||
|
triggered_at BIGINT NOT NULL,
|
||||||
|
acknowledged_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
acknowledged_at BIGINT,
|
||||||
|
assigned_to BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
snoozed_until BIGINT,
|
||||||
|
resolved_at BIGINT,
|
||||||
|
resolution_source TEXT, -- 'alerts' | 'manual'
|
||||||
|
archived_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Load-bearing: at most one OPEN incident per group_key. This is what makes
|
||||||
|
-- "resolved incident + a new alert occurrence = a new incident" work, and it is
|
||||||
|
-- the constraint the webhook's find-or-open lookup relies on.
|
||||||
|
CREATE UNIQUE INDEX incidents_open_group_key_idx ON incidents(group_key) WHERE resolved_at IS NULL;
|
||||||
|
CREATE INDEX incidents_status_idx ON incidents(status);
|
||||||
|
CREATE INDEX incidents_triggered_at_idx ON incidents(triggered_at DESC);
|
||||||
|
CREATE INDEX incidents_archived_at_idx ON incidents(archived_at);
|
||||||
|
|
||||||
|
-- Membership is historical, not a pointer on alerts: one alert row (one
|
||||||
|
-- fingerprint) resolves and re-fires over time and belongs to a different
|
||||||
|
-- incident each occurrence.
|
||||||
|
CREATE TABLE incident_alerts (
|
||||||
|
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
alert_id BIGINT NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
|
||||||
|
added_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
PRIMARY KEY (incident_id, alert_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX incident_alerts_alert_id_idx ON incident_alerts(alert_id);
|
||||||
|
|
||||||
|
-- The timeline. Append-only, and the only history this server keeps: alert rows
|
||||||
|
-- are mutated in place, so without this there is no record that anything
|
||||||
|
-- happened. Notes are events too, so one query renders the whole story.
|
||||||
|
CREATE TABLE incident_events (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
-- triggered | alert_added | alert_resolved | acknowledged | unacknowledged
|
||||||
|
-- | assigned | snoozed | unsnoozed | resolved | note | notified | notify_failed
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, -- NULL = the server acted
|
||||||
|
alert_id BIGINT REFERENCES alerts(id) ON DELETE SET NULL,
|
||||||
|
detail TEXT,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX incident_events_incident_idx ON incident_events(incident_id, created_at);
|
||||||
|
|
||||||
|
-- Delivery is an outbox rather than an inline HTTP call: a POST made while
|
||||||
|
-- holding the webhook's transaction would hold a connection open across a
|
||||||
|
-- network round trip. The webhook inserts a row; the notifier goroutine
|
||||||
|
-- delivers it.
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
-- Nullable: a notification sent to the fallback topic belongs to nobody,
|
||||||
|
-- because nobody was on call when the incident opened.
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
topic TEXT NOT NULL, -- resolved at enqueue: who was on call then
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('triggered', 'reminder', 'resolved')),
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
send_after BIGINT NOT NULL, -- retry backoff watermark
|
||||||
|
attempts BIGINT NOT NULL DEFAULT 0,
|
||||||
|
sent_at BIGINT,
|
||||||
|
last_error TEXT -- kept after the last attempt, for debugging
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The delivery loop's only query: what is due and still unsent.
|
||||||
|
CREATE INDEX notifications_pending_idx ON notifications(send_after) WHERE sent_at IS NULL;
|
||||||
|
-- Reminders and resolved notices both look up an incident's newest row.
|
||||||
|
CREATE INDEX notifications_incident_idx ON notifications(incident_id, id DESC);
|
||||||
|
|
||||||
|
-- A notification body is stored on the ntfy server and cached on the device, so
|
||||||
|
-- a real API key must never appear in one. Each delivery mints its own token
|
||||||
|
-- instead: one incident, one action, one day.
|
||||||
|
CREATE TABLE incident_ack_tokens (
|
||||||
|
token_hash TEXT PRIMARY KEY, -- SHA-256 of the raw token, as with api_keys
|
||||||
|
incident_id BIGINT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX incident_ack_tokens_expires_idx ON incident_ack_tokens(expires_at);
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Stage 1 foundation. No tables yet; subsequent migrations add schema.
|
|
||||||
SELECT 1;
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- A system administrator role, and the first thing in this server that one user
|
||||||
|
-- can do and another cannot.
|
||||||
|
--
|
||||||
|
-- Until now every authenticated caller could create and delete users, set
|
||||||
|
-- anybody's password and mint API keys for anybody — auth.go said so in a
|
||||||
|
-- comment. That was defensible with one operator and a hand-made account; it is
|
||||||
|
-- not once people sign themselves up (see #7).
|
||||||
|
--
|
||||||
|
-- EVERY EXISTING USER BECOMES AN ADMIN. They already hold these powers, so
|
||||||
|
-- this migration changes nobody's access: it names what is already true, and
|
||||||
|
-- leaves demotion as a deliberate act somebody performs afterwards. The
|
||||||
|
-- alternative — promoting only user 1 — would silently strip the others, and
|
||||||
|
-- could leave an install whose only admin is an account nobody has a password
|
||||||
|
-- for.
|
||||||
|
--
|
||||||
|
-- New users are not admins: the column defaults to false, and the only ways to
|
||||||
|
-- become one are this backfill, the bootstrap endpoint, or an existing admin
|
||||||
|
-- granting it.
|
||||||
|
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
UPDATE users SET is_admin = true;
|
||||||
|
|
||||||
|
-- The queue's assignment dropdown and the on-call schedule read every user, and
|
||||||
|
-- the admin screens in #5 will filter on this.
|
||||||
|
CREATE INDEX users_is_admin_idx ON users(is_admin) WHERE is_admin;
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE api_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
key_hash TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
|
||||||
last_used_at INTEGER
|
|
||||||
);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE alerts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
fingerprint TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL CHECK(status IN ('firing', 'resolved')),
|
|
||||||
labels TEXT NOT NULL DEFAULT '{}',
|
|
||||||
annotations TEXT NOT NULL DEFAULT '{}',
|
|
||||||
starts_at INTEGER NOT NULL,
|
|
||||||
ends_at INTEGER,
|
|
||||||
generator_url TEXT NOT NULL DEFAULT '',
|
|
||||||
received_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX alerts_status_idx ON alerts(status);
|
|
||||||
CREATE INDEX alerts_name_idx ON alerts(name);
|
|
||||||
CREATE INDEX alerts_received_at_idx ON alerts(received_at DESC);
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- Teams: the unit of tenancy. Everything a person works on now belongs to one.
|
||||||
|
--
|
||||||
|
-- Until this migration the install was one shared space — every user saw every
|
||||||
|
-- alert and every incident, and the Alertmanager webhook was unauthenticated, so
|
||||||
|
-- anything that could reach the port could open an incident for everybody.
|
||||||
|
--
|
||||||
|
-- The shape, in one paragraph: a team owns its incidents, alerts, schedule and
|
||||||
|
-- integrations. A user belongs to as many teams as they like, with a role in
|
||||||
|
-- each: an `owner` configures the team, a `member` works its incidents. An
|
||||||
|
-- integration key is what an alert arrives on, and the key is what says which
|
||||||
|
-- team the alert belongs to.
|
||||||
|
--
|
||||||
|
-- EVERYTHING EXISTING MOVES INTO ONE DEFAULT TEAM, and every existing user
|
||||||
|
-- becomes an owner of it. That keeps an upgrade a no-op for the people using it:
|
||||||
|
-- the same queue, the same schedule, the same incidents, with a name on them.
|
||||||
|
|
||||||
|
CREATE TABLE teams (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
-- role is free text with a CHECK rather than an enum, so adding a third role
|
||||||
|
-- later is a migration and not a type rewrite.
|
||||||
|
CREATE TABLE team_members (
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||||
|
joined_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
PRIMARY KEY (team_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX team_members_user_idx ON team_members(user_id);
|
||||||
|
|
||||||
|
-- How alerts get in, and the only thing that says which team they belong to.
|
||||||
|
-- The key is stored as a SHA-256 hash, like api_keys and the ack tokens: a
|
||||||
|
-- leaked database gives nobody the ability to post alerts.
|
||||||
|
CREATE TABLE integrations (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('alertmanager')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
last_used_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX integrations_team_idx ON integrations(team_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- The default team, and everything that already exists moving into it.
|
||||||
|
--
|
||||||
|
-- Created unconditionally, even on an empty install, so there is always a team
|
||||||
|
-- for the bootstrap user to land in and for the first integration to hang off.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO teams (name) VALUES ('Default');
|
||||||
|
|
||||||
|
INSERT INTO team_members (team_id, user_id, role)
|
||||||
|
SELECT (SELECT id FROM teams WHERE name = 'Default'), id, 'owner' FROM users;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- team_id on everything a team owns.
|
||||||
|
--
|
||||||
|
-- Added nullable, backfilled, then made NOT NULL: adding a NOT NULL column with
|
||||||
|
-- no default to a table with rows is rejected, and a DEFAULT pointing at the
|
||||||
|
-- default team would quietly keep working after the default team is gone.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE alerts ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE incidents ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE schedule_entries ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
UPDATE alerts SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
||||||
|
UPDATE incidents SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
||||||
|
UPDATE schedule_entries SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
||||||
|
|
||||||
|
ALTER TABLE alerts ALTER COLUMN team_id SET NOT NULL;
|
||||||
|
ALTER TABLE incidents ALTER COLUMN team_id SET NOT NULL;
|
||||||
|
ALTER TABLE schedule_entries ALTER COLUMN team_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- The uniqueness rules were all written for one tenant, and every one of them
|
||||||
|
-- is wrong now: two teams monitoring two clusters legitimately see the same
|
||||||
|
-- fingerprint, the same groupKey, and want somebody on call on the same day.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE alerts DROP CONSTRAINT alerts_fingerprint_key;
|
||||||
|
CREATE UNIQUE INDEX alerts_team_fingerprint_idx ON alerts(team_id, fingerprint);
|
||||||
|
|
||||||
|
DROP INDEX incidents_open_group_key_idx;
|
||||||
|
-- Still load-bearing, now per team: at most one OPEN incident per group_key
|
||||||
|
-- within a team. This is what makes "resolved incident + a new alert occurrence
|
||||||
|
-- = a new incident" work, and what the webhook's find-or-open lookup relies on.
|
||||||
|
CREATE UNIQUE INDEX incidents_open_group_key_idx
|
||||||
|
ON incidents(team_id, group_key) WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE schedule_entries DROP CONSTRAINT schedule_entries_date_key;
|
||||||
|
CREATE UNIQUE INDEX schedule_entries_team_date_idx ON schedule_entries(team_id, date);
|
||||||
|
|
||||||
|
-- The list views all filter by team first.
|
||||||
|
CREATE INDEX alerts_team_received_idx ON alerts(team_id, received_at DESC);
|
||||||
|
CREATE INDEX incidents_team_triggered_idx ON incidents(team_id, triggered_at DESC);
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
ALTER TABLE alerts ADD COLUMN acknowledged_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
ALTER TABLE alerts ADD COLUMN acknowledged_at INTEGER;
|
|
||||||
|
|
||||||
CREATE TABLE alert_comments (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
alert_id INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX alert_comments_alert_id_idx ON alert_comments(alert_id);
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Dead man's switches become a team's own configuration.
|
||||||
|
--
|
||||||
|
-- They were three environment variables — TERDUT_DEADMAN_MATCHERS, _TIMEOUT and
|
||||||
|
-- _SEVERITY — which made them one setting for the whole install. That was the
|
||||||
|
-- last piece of the alerting path a team could not control: a team could take
|
||||||
|
-- its own alerts on its own key and still not say which of them were
|
||||||
|
-- heartbeats, or how long a silence had to last before somebody was paged.
|
||||||
|
--
|
||||||
|
-- One row per team rather than one row per switch. The unit of monitoring is
|
||||||
|
-- still the fingerprint, as it always was — two clusters sending the same
|
||||||
|
-- heartbeat alertname are two independent switches — and the matcher string
|
||||||
|
-- keeps the format the environment variable used, so a value can be moved from
|
||||||
|
-- one to the other unchanged.
|
||||||
|
--
|
||||||
|
-- No rows are seeded here: a migration cannot read the environment. The server
|
||||||
|
-- inserts a row per team at startup from its own configuration, and the same
|
||||||
|
-- values therefore carry forward into the first team's row without anybody
|
||||||
|
-- retyping them. See seedDeadmanConfigs.
|
||||||
|
CREATE TABLE deadman_configs (
|
||||||
|
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- ";" separates matchers, "," the label conditions within one, "=" is exact
|
||||||
|
-- equality: `alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat`.
|
||||||
|
-- Every matcher must name an alertname. Empty watches nothing.
|
||||||
|
matchers TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- Seconds rather than a Go duration string: the column is compared and
|
||||||
|
-- arithmetic is done on it, and a value that has to be parsed before it can
|
||||||
|
-- be believed is a value that can be stored unparseable. Zero disables the
|
||||||
|
-- team's switches entirely.
|
||||||
|
timeout_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- The severity these incidents open at. They have no member alerts to
|
||||||
|
-- derive one from, and a heartbeat's own severity label is meaningless —
|
||||||
|
-- Watchdog ships as "none".
|
||||||
|
severity TEXT NOT NULL DEFAULT 'critical',
|
||||||
|
|
||||||
|
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE schedule_entries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX schedule_entries_date_idx ON schedule_entries(date);
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Settings that an administrator can change without a redeploy, and the flag
|
||||||
|
-- that takes an account out of use without deleting it.
|
||||||
|
--
|
||||||
|
-- Three of the server's tunables were environment variables, which meant
|
||||||
|
-- changing how long an incident waits before it is paged again required editing
|
||||||
|
-- a chart, merging it, and waiting for a reconcile. They are behaviour, not
|
||||||
|
-- infrastructure, and the difference is who needs to change them and how often.
|
||||||
|
--
|
||||||
|
-- What stays in the environment: the ntfy URL and token, the database DSN, the
|
||||||
|
-- listen address and the public URL. Those are where the server is plugged in
|
||||||
|
-- rather than how it behaves, they are needed before the database is open, and
|
||||||
|
-- two of them are credentials.
|
||||||
|
--
|
||||||
|
-- Key/value rather than a column per setting. A settings table with one row and
|
||||||
|
-- a column per knob needs a migration for every new knob, and #6 and #7 will
|
||||||
|
-- both add some. The cost is that values are text and the accessor has to say
|
||||||
|
-- what type it wanted; settings.go does that in one place.
|
||||||
|
--
|
||||||
|
-- No rows are seeded here: a migration cannot read the environment. The server
|
||||||
|
-- inserts each key from its own configuration at startup, once, so an install
|
||||||
|
-- that upgrades keeps exactly the behaviour it had. See SeedSettings.
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Disabling an account rather than deleting it: the person has left, or the
|
||||||
|
-- credential is suspect, and their incidents, acknowledgements and timeline
|
||||||
|
-- entries must stay exactly where they are. Deleting a user nulls their
|
||||||
|
-- acknowledged_by and assigned_to, which quietly rewrites history.
|
||||||
|
--
|
||||||
|
-- A disabled user cannot sign in and their API keys stop working, but they are
|
||||||
|
-- still a name the timeline can show and still a member of their teams.
|
||||||
|
ALTER TABLE users ADD COLUMN disabled_at BIGINT;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Escalation: page somebody else when the first person does not answer.
|
||||||
|
--
|
||||||
|
-- This is the gap the whole multi-tenancy line of work was opened to close.
|
||||||
|
-- Until now an unacknowledged incident re-paged the same topic every
|
||||||
|
-- notify_repeat forever, which is a louder version of the same silence: if the
|
||||||
|
-- person on call is asleep, has no signal, or has left, nothing else happens.
|
||||||
|
--
|
||||||
|
-- Shape: one policy per team, an ordered list of levels, each level with a
|
||||||
|
-- timeout and a set of targets. When a level's timeout passes and the incident
|
||||||
|
-- is still triggered, the next level is paged. When the last level passes, the
|
||||||
|
-- chain repeats repeat_count times, and then the team's fallback topic is paged
|
||||||
|
-- once as the end of the line.
|
||||||
|
--
|
||||||
|
-- A team WITHOUT a policy keeps exactly today's behaviour: page the assignee,
|
||||||
|
-- then remind on the same topic. Escalation is opt-in per team, and the two
|
||||||
|
-- never both run for one incident -- see enqueueReminders.
|
||||||
|
CREATE TABLE escalation_policies (
|
||||||
|
-- One per team for now, hence the team as the key rather than an id with a
|
||||||
|
-- unique index: routing different alerts to different chains needs the
|
||||||
|
-- alert to carry something to route ON, which is a separate question.
|
||||||
|
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- How many extra times to run the whole chain after it has been walked
|
||||||
|
-- once. 0 means walk it once and stop at the fallback.
|
||||||
|
repeat_count BIGINT NOT NULL DEFAULT 0 CHECK (repeat_count >= 0 AND repeat_count <= 10),
|
||||||
|
|
||||||
|
-- Where the last page goes when every level has been tried. Per team now:
|
||||||
|
-- TERDUT_NTFY_FALLBACK_TOPIC was one topic for the whole install, which in
|
||||||
|
-- a multi-team server pages the wrong people. Empty means the chain simply
|
||||||
|
-- ends.
|
||||||
|
fallback_topic TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE escalation_levels (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL REFERENCES escalation_policies(team_id) ON DELETE CASCADE,
|
||||||
|
-- 1-based, dense. The API rewrites the whole ladder on every edit rather
|
||||||
|
-- than patching one rung, so there is no way to leave a gap.
|
||||||
|
position BIGINT NOT NULL,
|
||||||
|
-- How long this level has to produce an acknowledgement before the next one
|
||||||
|
-- is paged. Seconds, like every other duration in this schema.
|
||||||
|
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
|
||||||
|
|
||||||
|
UNIQUE (team_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Who a level pages. Either a named person, or whoever the team's rota says is
|
||||||
|
-- on call today -- which is the target that keeps working when the rota
|
||||||
|
-- changes and nobody remembers to edit the policy.
|
||||||
|
CREATE TABLE escalation_targets (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
level_id BIGINT NOT NULL REFERENCES escalation_levels(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('user', 'oncall')),
|
||||||
|
-- Set for kind='user', NULL for kind='oncall'.
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CHECK ((kind = 'user' AND user_id IS NOT NULL) OR (kind = 'oncall' AND user_id IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX escalation_targets_level_idx ON escalation_targets(level_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Where an incident is in its chain.
|
||||||
|
--
|
||||||
|
-- On the incident rather than in a side table: it is read on every notifier
|
||||||
|
-- tick alongside the incident's status, and one row per incident is exactly
|
||||||
|
-- what the state is.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 0 means no level has been paged yet, which is the state of every incident
|
||||||
|
-- that existed before escalation and of every incident in a team with no
|
||||||
|
-- policy. 1 is the first level.
|
||||||
|
ALTER TABLE incidents ADD COLUMN escalation_level BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- When the current level was entered, and therefore what its timeout is
|
||||||
|
-- measured from. NULL while escalation_level is 0.
|
||||||
|
ALTER TABLE incidents ADD COLUMN escalation_level_at BIGINT;
|
||||||
|
|
||||||
|
-- How many times the chain has been walked in full. Compared against the
|
||||||
|
-- policy's repeat_count.
|
||||||
|
ALTER TABLE incidents ADD COLUMN escalation_round BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- The notifier's escalation query: incidents still waiting, oldest level first.
|
||||||
|
CREATE INDEX incidents_escalation_idx
|
||||||
|
ON incidents(escalation_level_at)
|
||||||
|
WHERE resolved_at IS NULL AND status = 'triggered';
|
||||||
|
|
||||||
|
-- 'escalated' joins the outbox kinds: a page that went out because nobody
|
||||||
|
-- answered the last one, which is worth telling apart from the first page and
|
||||||
|
-- from a reminder when reading the timeline or debugging a delivery.
|
||||||
|
ALTER TABLE notifications DROP CONSTRAINT notifications_kind_check;
|
||||||
|
ALTER TABLE notifications ADD CONSTRAINT notifications_kind_check
|
||||||
|
CHECK (kind IN ('triggered', 'reminder', 'resolved', 'escalated'));
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Self-service sign-up, and the invite links that make it useful.
|
||||||
|
--
|
||||||
|
-- Until now the only way to get an account was for somebody who already had one
|
||||||
|
-- to create it, and the login page told people to "ask an admin". That is a
|
||||||
|
-- workable arrangement for one operator and an impossible one for a team.
|
||||||
|
--
|
||||||
|
-- An invite is a link, not an email: this server has no SMTP and adding it to
|
||||||
|
-- send one message would be a new subsystem to run, secure and monitor. The
|
||||||
|
-- person inviting sends the link however they already talk to the person they
|
||||||
|
-- are inviting.
|
||||||
|
CREATE TABLE invites (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
|
||||||
|
-- SHA-256 of the raw token, like api_keys, the integration keys and the
|
||||||
|
-- acknowledgement tokens. A leaked database hands nobody an account.
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Which team the invitee lands in, and as what. An invite always names a
|
||||||
|
-- team: an account in no team sees an empty queue and can be paged by
|
||||||
|
-- nobody, which is not a state to invite somebody into.
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||||
|
|
||||||
|
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
|
||||||
|
-- Invites expire. A link that works forever is a credential nobody
|
||||||
|
-- remembers issuing, sitting in a chat log.
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
|
||||||
|
-- Single-use by default: max_uses 1. A team onboarding six people at once
|
||||||
|
-- can raise it rather than minting six links.
|
||||||
|
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
|
||||||
|
uses BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Revoked by hand, separately from expiry, so "this link is no longer
|
||||||
|
-- wanted" and "this link timed out" stay distinguishable in the listing.
|
||||||
|
revoked_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX invites_team_idx ON invites(team_id);
|
||||||
|
|
||||||
|
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
|
||||||
|
-- did this account get here" should outlive the link that made it.
|
||||||
|
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Where a person is in the first-run checklist, so it can be resumed and
|
||||||
|
-- dismissed rather than nagging forever. One row per user, created on demand.
|
||||||
|
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Similar incidents: a signature per incident, so "has this happened before"
|
||||||
|
-- is an indexed equality instead of a search.
|
||||||
|
--
|
||||||
|
-- The signature is the alert name plus the group labels that identify WHAT is
|
||||||
|
-- broken, minus the ones that only say WHERE it happened to run this time
|
||||||
|
-- (instance, pod, ...). Two incidents with the same signature in the same team
|
||||||
|
-- are the same problem for a responder's purposes.
|
||||||
|
--
|
||||||
|
-- Computed in Go for new incidents (incidentSignature in incident_store.go).
|
||||||
|
-- The backfill below MUST produce the same string; keep the volatile list in
|
||||||
|
-- both places in step.
|
||||||
|
ALTER TABLE incidents ADD COLUMN signature TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE incidents SET signature =
|
||||||
|
COALESCE(NULLIF(group_labels->>'alertname', ''), title) || '|' ||
|
||||||
|
COALESCE((
|
||||||
|
SELECT string_agg(e.k || '=' || e.v, ',' ORDER BY e.k)
|
||||||
|
FROM jsonb_each_text(incidents.group_labels) AS e(k, v)
|
||||||
|
WHERE e.k <> 'alertname'
|
||||||
|
AND e.k NOT IN ('instance', 'pod', 'pod_name', 'pod_ip', 'container', 'container_name', 'endpoint')
|
||||||
|
), '');
|
||||||
|
|
||||||
|
CREATE INDEX incidents_signature_idx ON incidents(team_id, signature, triggered_at DESC);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Dead man's switches become rows of their own.
|
||||||
|
--
|
||||||
|
-- 004 kept a team's switches in one string with one timeout and one severity,
|
||||||
|
-- which was enough to configure them and not enough to show them: there was no
|
||||||
|
-- thing to list, nothing to hang a status on, and every switch in a team had to
|
||||||
|
-- share a deadline. A row per switch gives each its own name, matcher, timeout
|
||||||
|
-- and severity, and gives the Team → Switches page something to be a list of.
|
||||||
|
--
|
||||||
|
-- The matcher keeps the syntax the string used, one matcher per row:
|
||||||
|
-- `alertname=Watchdog,cluster=prod`. The unit of monitoring is still the
|
||||||
|
-- fingerprint, so a matcher that many clusters satisfy is still one switch row
|
||||||
|
-- watching several independent heartbeats.
|
||||||
|
CREATE TABLE deadman_switches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- What the owner calls it. Defaults to the matcher when they do not say.
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- "," separates the label conditions, "=" is exact equality, and alertname is
|
||||||
|
-- mandatory: it is what keeps the sweeper's candidate query on an index.
|
||||||
|
matcher TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Seconds of silence before the switch is declared dead. Never zero: a switch
|
||||||
|
-- that cannot fire is deleted, not disabled.
|
||||||
|
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
|
||||||
|
|
||||||
|
-- The severity its incidents open at. See 004 for why they carry their own.
|
||||||
|
severity TEXT NOT NULL DEFAULT 'critical',
|
||||||
|
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX deadman_switches_team_idx ON deadman_switches (team_id);
|
||||||
|
|
||||||
|
-- Carry every team's configuration over, one row per matcher. A team whose
|
||||||
|
-- timeout was zero had switches turned off, which is now "no rows".
|
||||||
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||||
|
SELECT c.team_id, btrim(m), btrim(m), c.timeout_seconds, c.severity
|
||||||
|
FROM deadman_configs c,
|
||||||
|
LATERAL regexp_split_to_table(c.matchers, ';') AS m
|
||||||
|
WHERE c.timeout_seconds > 0
|
||||||
|
AND btrim(m) <> ''
|
||||||
|
ORDER BY c.team_id;
|
||||||
|
|
||||||
|
-- The server seeds environment defaults into teams once, and remembers that it
|
||||||
|
-- did. An install that had a row per team was already seeded; without this
|
||||||
|
-- marker the first start after upgrading would seed teams that had switched
|
||||||
|
-- theirs off.
|
||||||
|
INSERT INTO settings (key, value)
|
||||||
|
SELECT 'deadman_seeded', '1'
|
||||||
|
WHERE EXISTS (SELECT 1 FROM deadman_configs);
|
||||||
|
|
||||||
|
DROP TABLE deadman_configs;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Which alert source an alert last arrived on.
|
||||||
|
--
|
||||||
|
-- Team -> Sources shows when each source last posted, which integrations
|
||||||
|
-- already knew (last_used_at, stamped on every webhook). What it could not say
|
||||||
|
-- was what a source delivered: an alert never recorded the key it came in on, so
|
||||||
|
-- "prod alertmanager" and "staging alertmanager" were indistinguishable once
|
||||||
|
-- inside. This column is that link, and lets the page show each source's last
|
||||||
|
-- alert and how many alerts it has kept fresh over the past day.
|
||||||
|
--
|
||||||
|
-- Last sender wins: every accepted payload restamps it, the way it advances
|
||||||
|
-- received_at. Two sources posting the same fingerprint into one team is
|
||||||
|
-- already one alert, and it is attributed to whichever spoke last.
|
||||||
|
--
|
||||||
|
-- Nullable, and not backfilled. Alerts that arrived before this migration have
|
||||||
|
-- no source, and NULL says so honestly rather than guessing. It heals by itself:
|
||||||
|
-- Alertmanager re-sends every alert each repeat_interval, and each re-send is an
|
||||||
|
-- accepted payload. Deleting a source keeps its alerts, unattributed.
|
||||||
|
ALTER TABLE alerts ADD COLUMN integration_id BIGINT REFERENCES integrations(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX alerts_integration_idx ON alerts (integration_id, received_at)
|
||||||
|
WHERE integration_id IS NOT NULL;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Single sign-on through an OpenID Connect provider (Authentik, and anything
|
||||||
|
-- else that speaks OIDC).
|
||||||
|
--
|
||||||
|
-- Four things change, and none of them touches a password user: every new column
|
||||||
|
-- has a default that says "this is how it has always worked".
|
||||||
|
--
|
||||||
|
-- 1. user_identities says which provider account a user is. It is keyed on
|
||||||
|
-- (issuer, subject), never on email or username: those are mutable at the
|
||||||
|
-- provider, and a recycled address must not inherit somebody's account. A
|
||||||
|
-- user can have several identities (a second provider later), and none at all
|
||||||
|
-- (a local, password-only user), which is why this is a table and not two
|
||||||
|
-- columns on users.
|
||||||
|
--
|
||||||
|
-- 2. team_members.source and users.admin_source record who granted a role. 'oidc'
|
||||||
|
-- rows are owned by the group sync: it adds them when a group grants access
|
||||||
|
-- and removes them when it stops, and nothing else may edit them. 'manual' rows
|
||||||
|
-- are everything that existed before this migration, and are never touched by
|
||||||
|
-- the sync. Without the marker the sync could not tell a membership it created
|
||||||
|
-- from one an owner added by hand, and would have to either leave stale access
|
||||||
|
-- behind or delete people it had no business deleting.
|
||||||
|
--
|
||||||
|
-- 3. sessions.max_expires_at is a hard ceiling on a session's life. Ordinary
|
||||||
|
-- sessions slide for as long as they are used; a session made by an SSO login
|
||||||
|
-- must not, because the login is the only moment the groups are re-read.
|
||||||
|
-- Capping the session is what makes "removed from the group in the provider"
|
||||||
|
-- take effect within a bounded time. NULL means no ceiling.
|
||||||
|
--
|
||||||
|
-- 4. oidc_logins holds a login that has been started and not yet finished: the
|
||||||
|
-- state, nonce and PKCE verifier the callback must see again. A row rather
|
||||||
|
-- than a signed cookie, so it survives a restart and needs no signing key.
|
||||||
|
-- Only the hash of the state is stored, like every other token here; the
|
||||||
|
-- nonce and verifier are useless without the state that names the row.
|
||||||
|
CREATE TABLE user_identities (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
issuer TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
last_login_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
UNIQUE (issuer, subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX user_identities_user_idx ON user_identities (user_id);
|
||||||
|
|
||||||
|
ALTER TABLE team_members
|
||||||
|
ADD COLUMN source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc'));
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN admin_source TEXT NOT NULL DEFAULT 'manual' CHECK (admin_source IN ('manual', 'oidc'));
|
||||||
|
|
||||||
|
ALTER TABLE sessions ADD COLUMN max_expires_at BIGINT;
|
||||||
|
|
||||||
|
CREATE TABLE oidc_logins (
|
||||||
|
state_hash TEXT PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
pkce_verifier TEXT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX oidc_logins_expires_idx ON oidc_logins (expires_at);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Signing in from a terminal, for clients that cannot open a browser on the
|
||||||
|
-- machine they run on (the TUI over SSH is the reason).
|
||||||
|
--
|
||||||
|
-- The flow is the OAuth device authorization grant, run by terdut itself rather
|
||||||
|
-- than the identity provider, so the terminal never talks to the provider and
|
||||||
|
-- the server issues its ordinary session at the end:
|
||||||
|
--
|
||||||
|
-- 1. The terminal asks for a login and gets two secrets: a device code it
|
||||||
|
-- keeps and polls with, and a short user code it shows the person.
|
||||||
|
-- 2. The person opens the verification URL on any device, signs in by whatever
|
||||||
|
-- means the server offers, sees the user code, and approves it.
|
||||||
|
-- 3. The terminal's next poll finds the row approved and is given a session.
|
||||||
|
--
|
||||||
|
-- Only the hash of the device code is stored, like every other token here: the
|
||||||
|
-- device code is what earns a session, so a database read must not yield one.
|
||||||
|
-- The user code is shown on screens and typed by people, so it is stored as is;
|
||||||
|
-- on its own it can only be approved, never redeemed.
|
||||||
|
--
|
||||||
|
-- user_id is the person who approved. It is empty until then, and the session
|
||||||
|
-- is minted at redemption, not at approval: an approval nobody collects must not
|
||||||
|
-- leave a live session lying about.
|
||||||
|
--
|
||||||
|
-- last_polled_at lets the server refuse a client that polls faster than the
|
||||||
|
-- interval it was told.
|
||||||
|
CREATE TABLE device_logins (
|
||||||
|
device_hash TEXT PRIMARY KEY,
|
||||||
|
user_code TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
last_polled_at BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX device_logins_expires_idx ON device_logins (expires_at);
|
||||||
|
|
||||||
|
-- Where to send the browser once a single sign-on login completes. A person who
|
||||||
|
-- opens /device?code=... without a session has to sign in first and then come
|
||||||
|
-- back to it, and the same is true of any other deep link. Validated when it is
|
||||||
|
-- stored: only a path on this server is ever kept.
|
||||||
|
ALTER TABLE oidc_logins ADD COLUMN next TEXT NOT NULL DEFAULT '/';
|
||||||
@@ -2,8 +2,18 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// Alert is the machine-owned signal record: what Alertmanager told us, and
|
||||||
|
// nothing else. It has two states, firing and resolved, and no human ever writes
|
||||||
|
// to it — acknowledgement, assignment, notes and closure all live on the
|
||||||
|
// Incident an alert belongs to.
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
||||||
|
// TeamID is the team whose integration received this alert, and TeamName
|
||||||
|
// rides along so a combined list can label a row without a second request.
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name,omitempty"`
|
||||||
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
Fingerprint string `json:"fingerprint"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // "firing" or "resolved"
|
Status string `json:"status"` // "firing" or "resolved"
|
||||||
@@ -12,10 +22,39 @@ type Alert struct {
|
|||||||
StartsAt time.Time `json:"starts_at"`
|
StartsAt time.Time `json:"starts_at"`
|
||||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||||
GeneratorURL string `json:"generator_url"`
|
GeneratorURL string `json:"generator_url"`
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
|
||||||
|
|
||||||
// Populated when the alert has been acknowledged.
|
// ReceivedAt is when the server last accepted a webhook for this
|
||||||
AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"`
|
// fingerprint, including the unchanged firing notifications Alertmanager
|
||||||
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
|
// re-sends every repeat_interval.
|
||||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
//
|
||||||
|
// This is a documented part of the public API, not an internal ingest
|
||||||
|
// detail: StartsAt never changes for an alert instance, so ReceivedAt is
|
||||||
|
// the only signal a client has that a firing alert is still being
|
||||||
|
// refreshed. The sweeper stale-dates against it (see expireStale), API
|
||||||
|
// clients render it, and GET /api/alerts is ordered by it. Anything that
|
||||||
|
// stops the webhook handler from advancing it on a re-send is a breaking
|
||||||
|
// change — see "received_at is a liveness heartbeat" in the README and
|
||||||
|
// TestWebhook_ResendBumpsReceivedAt.
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
|
||||||
|
// IncidentID is the most recent incident this alert belongs to. An alert row
|
||||||
|
// is reused across occurrences of the same fingerprint, so over its life it
|
||||||
|
// belongs to a series of incidents; incident_alerts keeps the full history
|
||||||
|
// and this is only the newest link.
|
||||||
|
IncidentID *int64 `json:"incident_id,omitempty"`
|
||||||
|
|
||||||
|
// ResolutionSource records why a resolved alert left the firing state:
|
||||||
|
// "alertmanager" for a real resolved webhook, "expiry" when the sweeper
|
||||||
|
// inferred it after the alert stopped being refreshed. Nil while firing, and
|
||||||
|
// cleared again by a re-fire under the same fingerprint.
|
||||||
|
//
|
||||||
|
// Also public API: it is how a client knows whether EndsAt was observed or
|
||||||
|
// inferred. Under "expiry" nothing ever reported an end, so EndsAt is only
|
||||||
|
// an upper bound (see expireStale) and ReceivedAt is the more truthful
|
||||||
|
// signal. Treat the value set as open — see "resolution_source says how much
|
||||||
|
// to trust ends_at" in the README, and TestWebhook_ResolvedSetsSource /
|
||||||
|
// TestExpiry_StaleFiringAlert.
|
||||||
|
ResolutionSource *string `json:"resolution_source,omitempty"`
|
||||||
|
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Comment struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
AlertID int64 `json:"alert_id"`
|
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Incident is the human work item: the thing that gets acknowledged, assigned,
|
||||||
|
// snoozed, discussed and resolved. Alerts are the machine-owned signal records
|
||||||
|
// underneath it — many alerts map to one incident, correlated by the groupKey
|
||||||
|
// Alertmanager already computed from the operator's group_by configuration.
|
||||||
|
//
|
||||||
|
// Nothing here is ever written by the Alertmanager webhook except Status, which
|
||||||
|
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
||||||
|
// stopped firing.
|
||||||
|
type Incident struct {
|
||||||
|
// EscalationLevel is which rung of its team's ladder this incident is on,
|
||||||
|
// 0 for none — either the team has no ladder, or somebody has answered.
|
||||||
|
// EscalationDueAt is when the current level runs out, so a client can say
|
||||||
|
// how long is left rather than only what already happened.
|
||||||
|
EscalationLevel int64 `json:"escalation_level"`
|
||||||
|
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
|
||||||
|
|
||||||
|
// TeamID is the team that owns this incident, fixed when it opens: an
|
||||||
|
// incident never moves between teams. TeamName rides along so the combined
|
||||||
|
// queue can badge each row without a second request.
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name,omitempty"`
|
||||||
|
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
GroupKey string `json:"group_key"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
GroupLabels map[string]string `json:"group_labels"`
|
||||||
|
|
||||||
|
// Status is "triggered", "acknowledged" or "resolved".
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// Severity is the highest `severity` label across the alerts that were
|
||||||
|
// firing when it was last recomputed. It is deliberately not cleared when an
|
||||||
|
// incident resolves — a resolved incident should still say how bad it was.
|
||||||
|
Severity *string `json:"severity,omitempty"`
|
||||||
|
|
||||||
|
TriggeredAt time.Time `json:"triggered_at"`
|
||||||
|
|
||||||
|
AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"`
|
||||||
|
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
|
||||||
|
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||||
|
|
||||||
|
AssignedToID *int64 `json:"assigned_to_id,omitempty"`
|
||||||
|
AssignedToUser *string `json:"assigned_to,omitempty"`
|
||||||
|
|
||||||
|
// SnoozedUntil hides the incident from the default queue without closing it.
|
||||||
|
// A timestamp in the past reads as "not snoozed"; nothing sweeps it.
|
||||||
|
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
|
||||||
|
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
|
||||||
|
// ResolutionSource is "alerts" when every member alert stopped firing, or
|
||||||
|
// "manual" when a human closed it. Manual resolution is terminal: a later
|
||||||
|
// occurrence opens a new incident rather than reopening this one.
|
||||||
|
ResolutionSource *string `json:"resolution_source,omitempty"`
|
||||||
|
|
||||||
|
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||||
|
|
||||||
|
// Alerts is populated by GET /api/incidents/{id} only.
|
||||||
|
Alerts []Alert `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncidentEvent is one entry in an incident's timeline. The table is append-only
|
||||||
|
// and is the only history this server keeps — alert rows are mutated in place.
|
||||||
|
//
|
||||||
|
// Type is one of: triggered, alert_added, alert_resolved, acknowledged,
|
||||||
|
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. A nil UserID
|
||||||
|
// means the server acted rather than a person.
|
||||||
|
type IncidentEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IncidentID int64 `json:"incident_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
UserID *int64 `json:"user_id,omitempty"`
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
AlertID *int64 `json:"alert_id,omitempty"`
|
||||||
|
Detail *string `json:"detail,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarIncident is an earlier, resolved incident with the same signature as
|
||||||
|
// the one being looked at. ResolutionNotes are the "what fixed it" notes;
|
||||||
|
// NoteCount counts the plain working notes, which live on the timeline.
|
||||||
|
type SimilarIncident struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TriggeredAt time.Time `json:"triggered_at"`
|
||||||
|
ResolvedAt time.Time `json:"resolved_at"`
|
||||||
|
NoteCount int `json:"note_count"`
|
||||||
|
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
|
||||||
|
}
|
||||||
@@ -3,7 +3,14 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type ScheduleEntry struct {
|
type ScheduleEntry struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
||||||
|
// TeamID is whose rota this shift belongs to; TeamName rides along so the
|
||||||
|
// combined "who is on call" view can label each entry without a second
|
||||||
|
// request.
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name,omitempty"`
|
||||||
|
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Date string `json:"date"` // YYYY-MM-DD
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Team is the unit of tenancy: it owns its incidents, alerts, schedule and
|
||||||
|
// integrations, and a user sees exactly the teams they belong to.
|
||||||
|
type Team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Role is the caller's own role in this team, populated when a team is
|
||||||
|
// listed for a particular person. Empty when nobody in particular is
|
||||||
|
// asking, as in the admin listing.
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
|
||||||
|
// Source says who granted Role, on the endpoint that lists one user's teams:
|
||||||
|
// "manual", or "oidc" when the identity provider's groups did.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team roles. An owner configures the team — its schedule, its integrations and
|
||||||
|
// who is in it. A member works its incidents.
|
||||||
|
const (
|
||||||
|
RoleOwner = "owner"
|
||||||
|
RoleMember = "member"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TeamMember is one person's membership of one team.
|
||||||
|
type TeamMember struct {
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
|
|
||||||
|
// Source is who granted the membership: "manual", or "oidc" when the
|
||||||
|
// identity provider's groups did and only they can change it.
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration is how alerts get in, and the only thing that says which team an
|
||||||
|
// arriving alert belongs to.
|
||||||
|
type Integration struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
|
|
||||||
|
// Key is the raw integration key, shown once when the integration is
|
||||||
|
// created and never stored. URL is the address to point the sender at,
|
||||||
|
// likewise only complete at creation time.
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration kinds.
|
||||||
|
const (
|
||||||
|
IntegrationAlertmanager = "alertmanager"
|
||||||
|
)
|
||||||
@@ -7,6 +7,28 @@ type User struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// NtfyTopic is where this user's push notifications go. Nil means they get
|
||||||
|
// none of their own; incidents assigned to them fall back to the configured
|
||||||
|
// fallback topic instead.
|
||||||
|
NtfyTopic *string `json:"ntfy_topic,omitempty"`
|
||||||
|
|
||||||
|
// DisabledAt is when the account was taken out of use, or nil. A disabled
|
||||||
|
// user cannot authenticate by either credential, and keeps their name on
|
||||||
|
// every acknowledgement and timeline entry they made.
|
||||||
|
DisabledAt *time.Time `json:"disabled_at,omitempty"`
|
||||||
|
|
||||||
|
// IsAdmin is the system administrator flag: managing users and API keys.
|
||||||
|
// Not omitempty — a client has to be able to tell "false" from "this server
|
||||||
|
// is too old to have the field", and the web UI decides what to show from
|
||||||
|
// it.
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
|
||||||
|
// AdminSource is who granted the flag: "manual" or "oidc". An "oidc"
|
||||||
|
// administrator follows the identity provider's groups, so the UI shows it as
|
||||||
|
// managed there and the API refuses to revoke it by hand. Only set on the
|
||||||
|
// user endpoints that show it.
|
||||||
|
AdminSource string `json:"admin_source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package oidc signs users in through an OpenID Connect provider and turns the
|
||||||
|
// groups it reports into the access terdut grants.
|
||||||
|
//
|
||||||
|
// The package knows nothing about the database or HTTP handlers: Grants is a
|
||||||
|
// pure function of configuration and groups, and Provider is the protocol. The
|
||||||
|
// api package joins them to users, teams and sessions.
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role names match models.RoleOwner and RoleMember. They are restated here so
|
||||||
|
// the package stays free of the models import; config.Validate has already
|
||||||
|
// refused anything else.
|
||||||
|
const (
|
||||||
|
roleOwner = "owner"
|
||||||
|
roleMember = "member"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grants is the access a set of groups confers.
|
||||||
|
type Grants struct {
|
||||||
|
// Admitted is false when AllowedGroups is set and the user is in none of
|
||||||
|
// them. Nothing else in the struct means anything then.
|
||||||
|
Admitted bool
|
||||||
|
|
||||||
|
// Admin is whether the user is in the admin group.
|
||||||
|
Admin bool
|
||||||
|
|
||||||
|
// Teams maps team name to role. Where several groups grant the same team the
|
||||||
|
// highest role wins, so belonging to both a members group and an owners
|
||||||
|
// group makes somebody an owner rather than whichever mapping came last.
|
||||||
|
Teams map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeGrants evaluates the configured mappings against groups.
|
||||||
|
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
||||||
|
in := make(map[string]bool, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
in[g] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
g := Grants{Teams: map[string]string{}}
|
||||||
|
|
||||||
|
g.Admitted = len(cfg.AllowedGroups) == 0
|
||||||
|
for _, allowed := range cfg.AllowedGroups {
|
||||||
|
if in[allowed] {
|
||||||
|
g.Admitted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !g.Admitted {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
||||||
|
|
||||||
|
for _, m := range cfg.GroupMappings {
|
||||||
|
if !in[m.Group] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rank(m.Role) > rank(g.Teams[m.Team]) {
|
||||||
|
g.Teams[m.Team] = m.Role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// rank orders roles; an unknown or absent role ranks lowest.
|
||||||
|
func rank(role string) int {
|
||||||
|
switch role {
|
||||||
|
case roleOwner:
|
||||||
|
return 2
|
||||||
|
case roleMember:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HigherRole reports whether role a outranks role b.
|
||||||
|
func HigherRole(a, b string) bool { return rank(a) > rank(b) }
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCfg() config.OIDC {
|
||||||
|
return config.OIDC{
|
||||||
|
AllowedGroups: []string{"terdut-users"},
|
||||||
|
AdminGroup: "terdut-admins",
|
||||||
|
GroupMappings: []config.GroupMapping{
|
||||||
|
{Group: "sre", Team: "SRE", Role: "member"},
|
||||||
|
{Group: "sre-leads", Team: "SRE", Role: "owner"},
|
||||||
|
{Group: "platform", Team: "Platform", Role: "member"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGrants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
groups []string
|
||||||
|
want Grants
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not in an allowed group is refused",
|
||||||
|
groups: []string{"sre", "terdut-admins"},
|
||||||
|
want: Grants{Admitted: false, Teams: map[string]string{}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowed but no grants",
|
||||||
|
groups: []string{"terdut-users"},
|
||||||
|
want: Grants{Admitted: true, Teams: map[string]string{}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin group grants admin",
|
||||||
|
groups: []string{"terdut-users", "terdut-admins"},
|
||||||
|
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "team roles from several groups",
|
||||||
|
groups: []string{"terdut-users", "sre", "platform"},
|
||||||
|
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "highest role wins whatever the order",
|
||||||
|
groups: []string{"sre-leads", "terdut-users", "sre"},
|
||||||
|
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ComputeGrants(testCfg(), tt.groups)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
cfg.AllowedGroups = nil
|
||||||
|
if g := ComputeGrants(cfg, nil); !g.Admitted {
|
||||||
|
t.Error("with no allowed groups configured, everybody the provider authenticates is admitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringList(t *testing.T) {
|
||||||
|
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||||
|
t.Errorf("list: %v", got)
|
||||||
|
}
|
||||||
|
if got := stringList("solo"); !reflect.DeepEqual(got, []string{"solo"}) {
|
||||||
|
t.Errorf("single string: %v", got)
|
||||||
|
}
|
||||||
|
if got := stringList(nil); got != nil {
|
||||||
|
t.Errorf("nil: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CallbackPath is where the provider sends the browser back to. Register
|
||||||
|
// <TERDUT_PUBLIC_URL>/api/oidc/callback as the redirect URI at the provider.
|
||||||
|
const CallbackPath = "/api/oidc/callback"
|
||||||
|
|
||||||
|
// Identity is what the provider says about somebody who has just signed in.
|
||||||
|
type Identity struct {
|
||||||
|
Issuer string
|
||||||
|
Subject string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
|
||||||
|
// EmailVerified is the provider's own claim. Whether to believe it is
|
||||||
|
// config.OIDC.TrustEmail's business, not this package's.
|
||||||
|
EmailVerified bool
|
||||||
|
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider runs the authorization-code flow with PKCE against one issuer.
|
||||||
|
type Provider struct {
|
||||||
|
cfg config.OIDC
|
||||||
|
redirectURL string
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
// Discovery is fetched on first use, not at startup. A provider that is
|
||||||
|
// down when terdut starts must not stop terdut starting: password login is
|
||||||
|
// the way in while it is down, and it can only be that if the server is up.
|
||||||
|
mu sync.Mutex
|
||||||
|
provider *gooidc.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Provider for cfg. publicURL is the base of the redirect URI.
|
||||||
|
func New(cfg config.OIDC, publicURL string) *Provider {
|
||||||
|
return &Provider{
|
||||||
|
cfg: cfg,
|
||||||
|
redirectURL: trimSlash(publicURL) + CallbackPath,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSlash(s string) string {
|
||||||
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name is what the sign-in button calls the provider.
|
||||||
|
func (p *Provider) Name() string { return p.cfg.Name }
|
||||||
|
|
||||||
|
// Config is the configuration this provider was built from.
|
||||||
|
func (p *Provider) Config() config.OIDC { return p.cfg }
|
||||||
|
|
||||||
|
// discover returns the provider's metadata, fetching it if need be. A failure is
|
||||||
|
// not cached, so the next login tries again.
|
||||||
|
func (p *Provider) discover(ctx context.Context) (*gooidc.Provider, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.provider != nil {
|
||||||
|
return p.provider, nil
|
||||||
|
}
|
||||||
|
ctx = gooidc.ClientContext(ctx, p.http)
|
||||||
|
prov, err := gooidc.NewProvider(ctx, p.cfg.Issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
p.provider = prov
|
||||||
|
return prov, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) oauth(prov *gooidc.Provider) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: p.cfg.ClientID,
|
||||||
|
ClientSecret: p.cfg.ClientSecret,
|
||||||
|
Endpoint: prov.Endpoint(),
|
||||||
|
RedirectURL: p.redirectURL,
|
||||||
|
Scopes: p.cfg.Scopes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVerifier returns a fresh PKCE code verifier.
|
||||||
|
func NewVerifier() string { return oauth2.GenerateVerifier() }
|
||||||
|
|
||||||
|
// AuthURL is where to send the browser to sign in.
|
||||||
|
func (p *Provider) AuthURL(ctx context.Context, state, nonce, verifier string) (string, error) {
|
||||||
|
prov, err := p.discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return p.oauth(prov).AuthCodeURL(state,
|
||||||
|
oauth2.S256ChallengeOption(verifier),
|
||||||
|
gooidc.Nonce(nonce),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange trades the authorization code for tokens, verifies the ID token
|
||||||
|
// (signature, issuer, audience, expiry and nonce) and returns who it names.
|
||||||
|
func (p *Provider) Exchange(ctx context.Context, code, verifier, nonce string) (*Identity, error) {
|
||||||
|
prov, err := p.discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx = gooidc.ClientContext(ctx, p.http)
|
||||||
|
|
||||||
|
tok, err := p.oauth(prov).Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc token exchange: %w", err)
|
||||||
|
}
|
||||||
|
raw, _ := tok.Extra("id_token").(string)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("oidc: token response has no id_token")
|
||||||
|
}
|
||||||
|
idToken, err := prov.Verifier(&gooidc.Config{ClientID: p.cfg.ClientID}).Verify(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
|
||||||
|
}
|
||||||
|
if idToken.Nonce != nonce {
|
||||||
|
return nil, errors.New("oidc: id_token nonce mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims map[string]any
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc: read claims: %w", err)
|
||||||
|
}
|
||||||
|
return p.identity(idToken.Issuer, idToken.Subject, claims), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// identity maps raw claims onto an Identity using the configured claim names.
|
||||||
|
func (p *Provider) identity(issuer, subject string, claims map[string]any) *Identity {
|
||||||
|
id := &Identity{Issuer: issuer, Subject: subject}
|
||||||
|
id.Username, _ = claims[p.cfg.UsernameClaim].(string)
|
||||||
|
id.Email, _ = claims[p.cfg.EmailClaim].(string)
|
||||||
|
id.EmailVerified, _ = claims["email_verified"].(bool)
|
||||||
|
id.Groups = stringList(claims[p.cfg.GroupsClaim])
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringList reads a claim that is a list of strings, or a single string, which
|
||||||
|
// some providers send for a one-element list.
|
||||||
|
func stringList(v any) []string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if t == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{t}
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(t))
|
||||||
|
for _, e := range t {
|
||||||
|
if s, ok := e.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyIncidentIsEmbedded(t *testing.T) {
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for file, want := range map[string]string{
|
||||||
|
"js/incident.js": "copyIncident",
|
||||||
|
"js/ui.js": "copy:",
|
||||||
|
} {
|
||||||
|
b, err := fs.ReadFile(sub, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), want) {
|
||||||
|
t.Errorf("%s lacks %s", file, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func read(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := fs.ReadFile(sub, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sign-in button has to be a link the browser navigates, not script: the
|
||||||
|
// CSP's connect-src is 'self', so a fetch to the identity provider is blocked,
|
||||||
|
// and it is a redirect to the provider that the server answers.
|
||||||
|
func TestLoginPageOffersSSOAsAPlainLink(t *testing.T) {
|
||||||
|
html := read(t, "index.html")
|
||||||
|
if !regexp.MustCompile(`<a[^>]*id="sso-link"[^>]*href="/api/oidc/login"|<a[^>]*href="/api/oidc/login"[^>]*id="sso-link"`).MatchString(html) {
|
||||||
|
t.Error("index.html has no <a id=sso-link href=/api/oidc/login>")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `id="password-login"`) {
|
||||||
|
t.Error("the password fields must sit in #password-login so a server can hide them")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every code the server can put in ?sso_error= must have a message, or a
|
||||||
|
// refused person sees a generic failure and cannot tell what to ask for.
|
||||||
|
func TestLoginExplainsEverySSOError(t *testing.T) {
|
||||||
|
js := read(t, "js/app.js")
|
||||||
|
for _, code := range []string{
|
||||||
|
"denied", "expired", "failed", "unavailable",
|
||||||
|
"not_allowed", "no_email", "email_conflict", "disabled",
|
||||||
|
} {
|
||||||
|
if !regexp.MustCompile(`\b` + code + `:`).MatchString(js) {
|
||||||
|
t.Errorf("app.js has no message for sso_error=%s", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page a terminal's prompt links to has to be reachable as a route, or the
|
||||||
|
// link 404s into the queue and the code is never seen.
|
||||||
|
func TestDevicePageIsRoutedAndCallsTheApprovalAPI(t *testing.T) {
|
||||||
|
if !strings.Contains(read(t, "index.html"), `id="view-device"`) {
|
||||||
|
t.Error("index.html has no #view-device section")
|
||||||
|
}
|
||||||
|
app := read(t, "js/app.js")
|
||||||
|
if !strings.Contains(app, "name === 'device'") || !strings.Contains(app, "device: {") {
|
||||||
|
t.Error("app.js does not route /device")
|
||||||
|
}
|
||||||
|
// The SSO button must carry the page asked for through the provider.
|
||||||
|
if !strings.Contains(app, "/api/oidc/login?next=") {
|
||||||
|
t.Error("the SSO link does not carry next=")
|
||||||
|
}
|
||||||
|
dev := read(t, "js/device.js")
|
||||||
|
for _, want := range []string{"approveDevice", "denyDevice"} {
|
||||||
|
if !strings.Contains(dev, want) {
|
||||||
|
t.Errorf("device.js never calls %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api := read(t, "js/api.js")
|
||||||
|
for _, want := range []string{"/oidc/device/approve", "/oidc/device/deny"} {
|
||||||
|
if !strings.Contains(api, want) {
|
||||||
|
t.Errorf("api.js has no call to %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO-managed access must be marked in every view that edits it.
|
||||||
|
func TestManagedAccessIsMarkedWhereItIsEdited(t *testing.T) {
|
||||||
|
for _, file := range []string{"js/team.js", "js/adminteam.js", "js/adminuser.js", "js/admin.js"} {
|
||||||
|
js := read(t, file)
|
||||||
|
if !strings.Contains(js, "ssoBadge") || !strings.Contains(js, "SSO_MANAGED") {
|
||||||
|
t.Errorf("%s does not mark or explain SSO-managed access", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,975 @@
|
|||||||
|
/* terdut web UI.
|
||||||
|
*
|
||||||
|
* Mobile first: one column, a top bar and a bottom tab bar. From 900px the tab
|
||||||
|
* bar becomes a sidebar and the queue shows list and detail side by side.
|
||||||
|
* Colour is reserved for severity and status; everything else is neutral.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f6f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eff1f4;
|
||||||
|
--surface-hover: #f7f8fa;
|
||||||
|
--border: #e2e5ea;
|
||||||
|
--border-strong: #cfd3da;
|
||||||
|
--text: #16181d;
|
||||||
|
--muted: #5b626e;
|
||||||
|
--faint: #8a909b;
|
||||||
|
|
||||||
|
--accent: #2f5bd3;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #e8eefc;
|
||||||
|
|
||||||
|
--crit: #d0342c;
|
||||||
|
--crit-soft: #fdecea;
|
||||||
|
--warn: #b86e00;
|
||||||
|
--warn-soft: #fdf3e1;
|
||||||
|
--info: #2f6fdf;
|
||||||
|
--info-soft: #e9f0fd;
|
||||||
|
--ok: #1d7f4c;
|
||||||
|
--ok-soft: #e6f5ec;
|
||||||
|
--snooze: #6b5bd2;
|
||||||
|
--snooze-soft: #efedfb;
|
||||||
|
|
||||||
|
/* Two hues that mean nothing on their own. The rota needs six colours to
|
||||||
|
tell six people apart and the palette above only has four that are not
|
||||||
|
already an alarm. */
|
||||||
|
--teal: #0f7d8c;
|
||||||
|
--teal-soft: #e3f4f6;
|
||||||
|
--pink: #b3427e;
|
||||||
|
--pink-soft: #fbe8f2;
|
||||||
|
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
||||||
|
--shadow-lg: 0 12px 32px rgb(16 24 40 / 18%);
|
||||||
|
|
||||||
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
--topbar-h: 52px;
|
||||||
|
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
||||||
|
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
||||||
|
variable rather than deleted since .view, .toast and .nav's own height
|
||||||
|
calc still read it. */
|
||||||
|
--tabbar-h: 0px;
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--surface: #171a20;
|
||||||
|
--surface-2: #1f232b;
|
||||||
|
--surface-hover: #1c2027;
|
||||||
|
--border: #2a2f38;
|
||||||
|
--border-strong: #394050;
|
||||||
|
--text: #e7e9ed;
|
||||||
|
--muted: #a0a7b3;
|
||||||
|
--faint: #737a87;
|
||||||
|
|
||||||
|
--accent: #6d8ff0;
|
||||||
|
--accent-text: #0b0d12;
|
||||||
|
--accent-soft: #1d2640;
|
||||||
|
|
||||||
|
--crit: #ff6b61;
|
||||||
|
--crit-soft: #3a1c1b;
|
||||||
|
--warn: #f0b140;
|
||||||
|
--warn-soft: #362a14;
|
||||||
|
--info: #74a3ff;
|
||||||
|
--info-soft: #1a2640;
|
||||||
|
--ok: #4cc488;
|
||||||
|
--ok-soft: #15301f;
|
||||||
|
--snooze: #a89bff;
|
||||||
|
--snooze-soft: #262245;
|
||||||
|
|
||||||
|
--teal: #4fc2d4;
|
||||||
|
--teal-soft: #0f2e33;
|
||||||
|
--pink: #f07fb8;
|
||||||
|
--pink-soft: #3a1c2d;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||||
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.45 var(--font);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button, input, textarea, select { font: inherit; color: inherit; }
|
||||||
|
code { font-family: var(--mono); font-size: 0.9em; }
|
||||||
|
h1, h2, h3 { margin: 0; line-height: 1.25; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---------- boot + login ---------- */
|
||||||
|
|
||||||
|
.boot { display: grid; place-items: center; min-height: 100dvh; }
|
||||||
|
.spinner {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.login {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
padding: calc(24px + var(--safe-top)) 16px calc(24px + var(--safe-bottom));
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%; max-width: 360px;
|
||||||
|
display: grid; gap: 14px;
|
||||||
|
}
|
||||||
|
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
|
||||||
|
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
|
||||||
|
.login-password { display: grid; gap: 14px; }
|
||||||
|
/* "or" between the single sign-on button and the password form. */
|
||||||
|
.login-divider {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin: 0;
|
||||||
|
color: var(--faint); font-size: 13px;
|
||||||
|
}
|
||||||
|
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
|
label { display: grid; gap: 6px; }
|
||||||
|
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 16px; /* 16px stops iOS zooming into the field */
|
||||||
|
}
|
||||||
|
textarea { min-height: 110px; resize: vertical; line-height: 1.45; }
|
||||||
|
input:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 0; padding: 10px 12px;
|
||||||
|
background: var(--crit-soft); color: var(--crit);
|
||||||
|
border-radius: var(--radius-sm); font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- buttons ---------- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
min-height: 44px; padding: 0 16px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
font-weight: 600; font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--surface-hover); }
|
||||||
|
.btn:disabled, .btn-sm:disabled { opacity: 0.55; cursor: default; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
|
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
|
||||||
|
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
|
||||||
|
.btn-danger:hover { background: var(--crit); filter: brightness(1.06); }
|
||||||
|
.btn-ghost { background: transparent; border-color: transparent; }
|
||||||
|
.btn-ghost:hover { background: var(--surface-2); }
|
||||||
|
.btn-block { width: 100%; }
|
||||||
|
.btn-icon { width: 44px; padding: 0; }
|
||||||
|
.btn-sm { min-height: 32px; padding: 0 8px; font-size: 13px; }
|
||||||
|
.btn-sm svg { width: 16px; height: 16px; }
|
||||||
|
.btn svg, .icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
/* ---------- app frame ---------- */
|
||||||
|
|
||||||
|
.app { min-height: 100dvh; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
height: calc(var(--topbar-h) + var(--safe-top));
|
||||||
|
padding: var(--safe-top) 16px 0;
|
||||||
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||||
|
backdrop-filter: saturate(1.4) blur(12px);
|
||||||
|
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||||
|
}
|
||||||
|
#menu-btn { position: relative; }
|
||||||
|
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
|
||||||
|
|
||||||
|
.open-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 10px; border-radius: 999px;
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
background: var(--surface-2); color: var(--muted);
|
||||||
|
}
|
||||||
|
.open-pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||||||
|
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||||
|
.open-pill.has-triggered::before { background: var(--crit); }
|
||||||
|
.open-pill.all-acked::before { background: var(--warn); }
|
||||||
|
|
||||||
|
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
||||||
|
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
||||||
|
left sidebar from 900px, where the desktop block below redeclares display. */
|
||||||
|
.nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
|
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
|
backdrop-filter: saturate(1.4) blur(12px);
|
||||||
|
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav-brand { display: none; }
|
||||||
|
.nav-link {
|
||||||
|
position: relative;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||||
|
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||||
|
/* min-width lets a column shrink below its label's natural width, which is
|
||||||
|
what stops six tabs widening the bar past the screen. */
|
||||||
|
min-width: 0; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||||
|
.nav-badge {
|
||||||
|
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||||
|
min-width: 18px; height: 18px; padding: 0 5px;
|
||||||
|
border-radius: 999px; background: var(--crit); color: #fff;
|
||||||
|
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
||||||
|
.view-page { padding-left: 16px; padding-right: 16px; }
|
||||||
|
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||||
|
|
||||||
|
/* Phone detail: the incident takes the whole screen with its own action bar,
|
||||||
|
so the tab bar and top bar step aside. */
|
||||||
|
.app.detail-open .nav,
|
||||||
|
.app.detail-open .topbar { display: none; }
|
||||||
|
.app.detail-open .pane-list { display: none; }
|
||||||
|
.app.detail-open .view-queue { padding-bottom: 0; }
|
||||||
|
.view-queue:not(.has-detail) .pane-detail { display: none; }
|
||||||
|
|
||||||
|
/* ---------- chips ---------- */
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
overflow-x: auto; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.chips::-webkit-scrollbar { display: none; }
|
||||||
|
.chip {
|
||||||
|
flex: none;
|
||||||
|
min-height: 34px; padding: 0 12px;
|
||||||
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
|
background: var(--surface); color: var(--muted);
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||||
|
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* ---------- lists ---------- */
|
||||||
|
|
||||||
|
.list { padding: 0 16px 16px; display: grid; gap: 8px; }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 1fr auto; gap: 4px 12px;
|
||||||
|
padding: 11px 14px 11px 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row:hover { background: var(--surface-hover); }
|
||||||
|
.row[aria-current="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.row.kbd-focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.row::before {
|
||||||
|
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||||
|
background: var(--sev, var(--border-strong));
|
||||||
|
}
|
||||||
|
.row-title {
|
||||||
|
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
|
||||||
|
.row-meta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px;
|
||||||
|
color: var(--muted); font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row-meta .labels { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; color: var(--faint); }
|
||||||
|
/* Which team's queue a row came from. Only rendered for somebody in more than
|
||||||
|
one team, so it never repeats the same word down the whole list. */
|
||||||
|
/* Separates the status chips from the team chips in the queue's filter row. */
|
||||||
|
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
|
||||||
|
|
||||||
|
.row-team {
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border);
|
||||||
|
color: var(--muted); font-size: 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* The page a terminal's sign-in prompt links to. */
|
||||||
|
.device-card { max-width: 420px; margin: 24px auto; padding: 20px; display: grid; gap: 14px; }
|
||||||
|
.device-code {
|
||||||
|
margin: 0; padding: 14px; text-align: center;
|
||||||
|
font: 700 30px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
/* Access the identity provider's groups grant. The tint says "not yours to edit here". */
|
||||||
|
.row-team.sso { margin-left: 6px; background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||||
|
.row.resolved .row-title { color: var(--muted); }
|
||||||
|
|
||||||
|
.sev-critical { --sev: var(--crit); }
|
||||||
|
.sev-warning { --sev: var(--warn); }
|
||||||
|
.sev-info { --sev: var(--info); }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 16px; text-align: center; color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
|
||||||
|
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.load-error {
|
||||||
|
margin: 12px 16px; padding: 10px 12px;
|
||||||
|
background: var(--crit-soft); color: var(--crit);
|
||||||
|
border-radius: var(--radius-sm); font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- badges ---------- */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 1px 8px; border-radius: 999px;
|
||||||
|
font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
|
||||||
|
background: var(--surface-2); color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||||
|
.badge.plain::before { display: none; }
|
||||||
|
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
||||||
|
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
||||||
|
.badge.st-resolved, .badge.st-healthy { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge.st-dead { background: var(--crit-soft); color: var(--crit); }
|
||||||
|
/* Dormant is the plain badge on purpose: nothing has gone wrong and nothing has
|
||||||
|
gone right, which is what the muted default already says. */
|
||||||
|
.badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); }
|
||||||
|
.badge.st-active { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge.st-reachable { background: var(--surface-2); color: var(--muted); }
|
||||||
|
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
|
||||||
|
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
|
||||||
|
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.badge.sev-info { background: var(--info-soft); color: var(--info); }
|
||||||
|
|
||||||
|
/* ---------- incident detail ---------- */
|
||||||
|
|
||||||
|
.detail { padding: 0 16px calc(96px + var(--safe-bottom)); }
|
||||||
|
.detail-head {
|
||||||
|
position: sticky; top: 0; z-index: 5;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
height: calc(var(--topbar-h) + var(--safe-top));
|
||||||
|
margin: 0 -16px; padding: var(--safe-top) 8px 0;
|
||||||
|
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||||
|
backdrop-filter: saturate(1.4) blur(12px);
|
||||||
|
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.detail-head .copy { margin-left: auto; }
|
||||||
|
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
|
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||||
|
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||||
|
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card + .card, .section + .section { margin-top: 14px; }
|
||||||
|
.card-pad { padding: 14px; }
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; padding: 14px; font-size: 14px; }
|
||||||
|
.facts dt { color: var(--muted); }
|
||||||
|
.facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.facts .sub { color: var(--faint); }
|
||||||
|
|
||||||
|
.section { margin-top: 22px; }
|
||||||
|
.section-title {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
|
||||||
|
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: var(--muted); margin: 0 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labels-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.label {
|
||||||
|
display: inline-flex; max-width: 100%;
|
||||||
|
font-family: var(--mono); font-size: 12px;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.label > span { padding: 2px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.label > span:first-child { background: var(--surface-2); color: var(--muted); }
|
||||||
|
|
||||||
|
.alert-item { padding: 12px 14px; display: grid; gap: 6px; }
|
||||||
|
.alert-item + .alert-item { border-top: 1px solid var(--border); }
|
||||||
|
.alert-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.alert-item-name { font-weight: 650; overflow-wrap: anywhere; }
|
||||||
|
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
||||||
|
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
|
||||||
|
.alert-item-foot a { color: var(--accent); font-weight: 600; }
|
||||||
|
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
|
||||||
|
details > summary::-webkit-details-marker { display: none; }
|
||||||
|
details > summary::before { content: "▸ "; }
|
||||||
|
details[open] > summary::before { content: "▾ "; }
|
||||||
|
details[open] > summary { margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.timeline { list-style: none; margin: 0; padding: 4px 0; }
|
||||||
|
.tl-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 20px 1fr; gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
.tl-item::before {
|
||||||
|
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
|
||||||
|
}
|
||||||
|
.tl-item:first-child::before { top: 16px; }
|
||||||
|
.tl-item:last-child::before { bottom: calc(100% - 16px); }
|
||||||
|
.tl-dot {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
|
||||||
|
background: var(--surface); border: 2px solid var(--faint);
|
||||||
|
}
|
||||||
|
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
|
||||||
|
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
|
||||||
|
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
|
||||||
|
.tl-snoozed .tl-dot { border-color: var(--snooze); }
|
||||||
|
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
|
||||||
|
.tl-body { min-width: 0; font-size: 14px; }
|
||||||
|
.tl-text { overflow-wrap: anywhere; }
|
||||||
|
.tl-text .who { font-weight: 650; }
|
||||||
|
.tl-time { color: var(--faint); font-size: 12px; }
|
||||||
|
.note {
|
||||||
|
margin-top: 6px; padding: 10px 12px;
|
||||||
|
background: var(--surface-2); border-radius: var(--radius-sm);
|
||||||
|
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
||||||
|
.similar { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.similar-item { padding: 10px 0; }
|
||||||
|
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
|
||||||
|
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
||||||
|
.note-actions { display: flex; justify-content: flex-end; }
|
||||||
|
.note-actions .btn { color: var(--muted); }
|
||||||
|
|
||||||
|
/* The action bar sits at the bottom of the screen on a phone, and at the
|
||||||
|
bottom of the detail pane on desktop. */
|
||||||
|
.actionbar {
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 15;
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
padding: 10px 16px calc(10px + var(--safe-bottom));
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.actionbar .btn { min-height: 48px; }
|
||||||
|
.actionbar .btn-primary { flex: 1; font-size: 16px; }
|
||||||
|
|
||||||
|
.detail-placeholder {
|
||||||
|
display: grid; place-items: center; height: 100%;
|
||||||
|
color: var(--faint); text-align: center; padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- sheet (dialog) ---------- */
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
width: 100%; max-width: 100%;
|
||||||
|
max-height: 88dvh;
|
||||||
|
margin: auto 0 0; padding: 0;
|
||||||
|
border: 0; border-radius: 16px 16px 0 0;
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.sheet::backdrop { background: rgb(0 0 0 / 40%); }
|
||||||
|
.sheet[open] { animation: sheet-up 0.18s ease-out; }
|
||||||
|
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
|
||||||
|
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
|
||||||
|
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
|
||||||
|
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
|
||||||
|
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
|
||||||
|
.sheet-form { display: grid; gap: 12px; }
|
||||||
|
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
.sheet-actions .btn { flex: 1; }
|
||||||
|
|
||||||
|
.menu { list-style: none; margin: 0 -4px; padding: 0; }
|
||||||
|
.menu-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
width: 100%; min-height: 50px; padding: 0 12px;
|
||||||
|
background: none; border: 0; border-radius: var(--radius-sm);
|
||||||
|
text-align: left; font-size: 16px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: var(--surface-2); }
|
||||||
|
.menu-item .icon { color: var(--muted); flex: none; }
|
||||||
|
.menu-item .menu-sub { margin-left: auto; color: var(--faint); font-size: 13px; }
|
||||||
|
.menu-item.danger, .menu-item.danger .icon { color: var(--crit); }
|
||||||
|
.menu-item[aria-checked="true"] { font-weight: 700; }
|
||||||
|
.menu-item[aria-checked="true"]::after { content: "✓"; margin-left: 8px; color: var(--accent); }
|
||||||
|
.menu-sep { height: 1px; background: var(--border); margin: 6px 12px; }
|
||||||
|
|
||||||
|
/* ---------- toast ---------- */
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; left: 50%; z-index: 50;
|
||||||
|
bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 12px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: calc(100% - 32px);
|
||||||
|
padding: 10px 16px; border-radius: var(--radius);
|
||||||
|
background: var(--text); color: var(--bg);
|
||||||
|
font-size: 14px; font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.toast.error { background: var(--crit); color: #fff; }
|
||||||
|
.app.detail-open ~ .toast { bottom: calc(80px + var(--safe-bottom)); }
|
||||||
|
|
||||||
|
/* ---------- on-call ---------- */
|
||||||
|
|
||||||
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||||
|
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||||
|
|
||||||
|
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
||||||
|
.avatar {
|
||||||
|
flex: none; display: grid; place-items: center;
|
||||||
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||||
|
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||||
|
.now-name { font-size: 20px; font-weight: 750; }
|
||||||
|
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||||
|
|
||||||
|
.week-nav { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
||||||
|
.days { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
||||||
|
.day + .day { border-top: 1px solid var(--border); }
|
||||||
|
.day-name { font-weight: 650; }
|
||||||
|
.day-date { color: var(--faint); font-size: 13px; }
|
||||||
|
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.day-who.nobody { color: var(--faint); font-style: italic; }
|
||||||
|
.day.today { background: var(--accent-soft); }
|
||||||
|
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
||||||
|
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
||||||
|
.day.today .day-name { color: var(--accent); }
|
||||||
|
.day.past { opacity: 0.6; }
|
||||||
|
|
||||||
|
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||||
|
.shift-list li + li { border-top: 1px solid var(--border); }
|
||||||
|
.shift-list .muted { color: var(--faint); }
|
||||||
|
|
||||||
|
/* ---------- alerts page ---------- */
|
||||||
|
|
||||||
|
.view-alerts .chips { padding-left: 0; padding-right: 0; }
|
||||||
|
.view-alerts .list { padding-left: 0; padding-right: 0; }
|
||||||
|
.row.st-firing { --sev: var(--crit); }
|
||||||
|
.row.st-resolved { --sev: var(--ok); }
|
||||||
|
.row.no-link { cursor: default; }
|
||||||
|
|
||||||
|
/* ---------- account ---------- */
|
||||||
|
|
||||||
|
.account-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
||||||
|
.account-name { font-size: 18px; font-weight: 750; }
|
||||||
|
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
||||||
|
.pw-form { display: grid; gap: 12px; padding: 16px; }
|
||||||
|
.form-ok {
|
||||||
|
margin: 0; padding: 10px 12px;
|
||||||
|
background: var(--ok-soft); color: var(--ok);
|
||||||
|
border-radius: var(--radius-sm); font-size: 14px;
|
||||||
|
}
|
||||||
|
.kbd-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.kbd-table td { padding: 8px 14px; border-top: 1px solid var(--border); }
|
||||||
|
.kbd-table tr:first-child td { border-top: 0; }
|
||||||
|
kbd {
|
||||||
|
display: inline-block; min-width: 1.6em; padding: 1px 6px;
|
||||||
|
font-family: var(--mono); font-size: 12px; text-align: center;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.only-desktop { display: none; }
|
||||||
|
.foot-note { color: var(--faint); font-size: 13px; text-align: center; margin: 24px auto; }
|
||||||
|
|
||||||
|
/* ---------- desktop ---------- */
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: static; grid-row: 1 / span 2;
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
height: auto; padding: 16px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 0; border-right: 1px solid var(--border);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||||
|
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--muted); font-size: 14px;
|
||||||
|
}
|
||||||
|
.nav-link:hover { background: var(--surface-2); }
|
||||||
|
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.nav-link svg { width: 20px; height: 20px; }
|
||||||
|
.nav-badge { position: static; margin-left: auto; }
|
||||||
|
|
||||||
|
.topbar { display: none; }
|
||||||
|
.view { padding-bottom: 0; overflow: auto; height: 100dvh; }
|
||||||
|
.view-page { padding: 8px 32px 32px; }
|
||||||
|
|
||||||
|
.view-queue {
|
||||||
|
display: grid; grid-template-columns: minmax(340px, 420px) 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.view-queue .pane { overflow: auto; height: 100dvh; }
|
||||||
|
.pane-list { border-right: 1px solid var(--border); }
|
||||||
|
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
||||||
|
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||||
|
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||||
|
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||||
|
.pane-list .chip-sep { display: none; }
|
||||||
|
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||||
|
|
||||||
|
/* On desktop the list stays visible next to the detail. */
|
||||||
|
.app.detail-open .nav { display: flex; }
|
||||||
|
.app.detail-open .pane-list { display: block; }
|
||||||
|
.detail-head .back { display: none; }
|
||||||
|
.detail-head { padding-left: 16px; }
|
||||||
|
|
||||||
|
.pane-detail { position: relative; display: flex; flex-direction: column; }
|
||||||
|
.detail { flex: 1; padding: 0 32px 24px; max-width: 900px; width: 100%; }
|
||||||
|
.detail-head { margin: 0 -32px; padding-left: 32px; }
|
||||||
|
.actionbar {
|
||||||
|
position: sticky; bottom: 0;
|
||||||
|
padding: 12px 32px;
|
||||||
|
}
|
||||||
|
.actionbar .btn-primary { flex: 0 1 240px; }
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
width: min(440px, calc(100% - 32px));
|
||||||
|
margin: auto; border-radius: 14px;
|
||||||
|
}
|
||||||
|
.sheet-grab { display: none; }
|
||||||
|
.sheet-inner { padding: 20px; }
|
||||||
|
|
||||||
|
.toast, .app.detail-open ~ .toast { bottom: 24px; }
|
||||||
|
.only-desktop { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- admin ---------------------------------------------------------------
|
||||||
|
The admin page is three tables of things you act on, so it needs table
|
||||||
|
styling the rest of the app never did: the queue is a list of links and the
|
||||||
|
account page is a form. */
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.admin-table th {
|
||||||
|
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.admin-table tr:last-child td { border-bottom: none; }
|
||||||
|
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
|
||||||
|
/* A disabled account stays readable — it is still the name on old
|
||||||
|
acknowledgements — but should not look like a working one. */
|
||||||
|
.disabled-row td { opacity: 0.55; }
|
||||||
|
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
|
||||||
|
|
||||||
|
/* --- status lists: alert sources and dead man's switches -----------------
|
||||||
|
Six columns do not fit a phone, so the table scrolls inside its card rather
|
||||||
|
than the page. A heartbeat under a switch with several is indented, the way
|
||||||
|
the escalation ladder indents its levels. */
|
||||||
|
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.table-scroll { overflow-x: auto; margin-top: 12px; }
|
||||||
|
.status-table th, .status-table td { white-space: nowrap; }
|
||||||
|
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||||
|
.status-table .source-row td { border-bottom-style: dashed; }
|
||||||
|
.status-table .source-row td:first-child { padding-left: 16px; }
|
||||||
|
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
|
|
||||||
|
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.inline-form input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
|
||||||
|
.admin-settings .setting-unit { max-width: 8em; }
|
||||||
|
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
|
||||||
|
/* A name in an admin table is the way to that row's own page -- a person's or
|
||||||
|
a team's. */
|
||||||
|
.row-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
||||||
|
.row-link:hover { color: var(--accent); text-decoration: underline; }
|
||||||
|
|
||||||
|
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
||||||
|
/* The link is shown once and never stored, so it has to be selectable and
|
||||||
|
wrap rather than scroll off the side of a phone. */
|
||||||
|
.invite-out { margin-top: 12px; font-size: 13px; }
|
||||||
|
.invite-link {
|
||||||
|
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
|
||||||
|
word-break: break-all; user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- one user, one team --------------------------------------------------
|
||||||
|
Both subject pages share this: .user-head and .user-facts are generic
|
||||||
|
despite the names, and a team fills them with its own facts. */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
||||||
|
color: var(--muted); font-size: 14px; text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
.back-link svg { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.user-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
.user-facts {
|
||||||
|
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
|
||||||
|
margin: 12px 0 0; font-size: 14px;
|
||||||
|
}
|
||||||
|
.user-facts dt { color: var(--muted); }
|
||||||
|
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||||
|
.admin-table .row-actions { margin-top: 0; gap: 6px; }
|
||||||
|
|
||||||
|
/* --- team settings -------------------------------------------------------
|
||||||
|
Forms with a label above each control, rather than the queue's rows of
|
||||||
|
links. The escalation ladder is the only nested structure in the app, so it
|
||||||
|
gets a little indentation to make the levels read as an order. */
|
||||||
|
.stacked-form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; align-items: flex-start; }
|
||||||
|
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.stacked-form label.checkbox { gap: 8px; }
|
||||||
|
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||||
|
.team-picker { margin-top: 8px; max-width: 100%; }
|
||||||
|
|
||||||
|
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||||
|
too alike down a column to read, so a day carries an initial in that
|
||||||
|
person's colour and the legend underneath says whose. A shift is then a run
|
||||||
|
of one colour, which is the shape the question actually has. */
|
||||||
|
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
|
||||||
|
.rota-wd {
|
||||||
|
padding-bottom: 4px; text-align: center;
|
||||||
|
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.rota-day {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
min-height: 52px; padding: 6px 0 8px;
|
||||||
|
border: 0; border-radius: var(--radius-sm); background: none;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
button.rota-day { cursor: pointer; }
|
||||||
|
button.rota-day:hover { background: var(--surface-2); }
|
||||||
|
/* The week number starts each row. Quiet by default, because it is a label
|
||||||
|
first; an owner's tap on it is the second thing it does. */
|
||||||
|
.rota-week {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border: 0; border-radius: var(--radius-sm); background: none;
|
||||||
|
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
button.rota-week { cursor: pointer; }
|
||||||
|
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.rota-week.current { color: var(--accent); font-weight: 700; }
|
||||||
|
/* The sheet's row of who holds each day of the week. */
|
||||||
|
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
|
||||||
|
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
|
||||||
|
.week-holder.past { opacity: 0.55; }
|
||||||
|
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
.rota-day.today { background: var(--accent-soft); }
|
||||||
|
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||||
|
.rota-day.past { opacity: 0.55; }
|
||||||
|
/* The days either side of the month are real days and are drawn, but they
|
||||||
|
belong to the month you are not looking at. */
|
||||||
|
.rota-day.outside { opacity: 0.35; }
|
||||||
|
|
||||||
|
.rota-chip {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
font-size: 12px; font-weight: 750; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
/* An empty day is a dot rather than a hole, and keeps the chip's box so the
|
||||||
|
rows stay on one baseline. */
|
||||||
|
.rota-chip.none { width: 8px; height: 8px; margin: 9px; background: var(--border-strong); }
|
||||||
|
|
||||||
|
/* Six colours, then they repeat; the initial inside still tells two people
|
||||||
|
apart. Deliberately not the severity palette — nothing here is critical. */
|
||||||
|
.rc1 { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.rc2 { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.rc3 { background: var(--snooze-soft); color: var(--snooze); }
|
||||||
|
.rc4 { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||||
|
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||||
|
|
||||||
|
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||||
|
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
|
||||||
|
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
|
||||||
|
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
||||||
|
.rota-note:first-child { margin-top: 0; }
|
||||||
|
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||||
|
.rota-bulk .stacked-form { margin-top: 4px; }
|
||||||
|
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
|
||||||
|
.ladder-level {
|
||||||
|
border-left: 3px solid var(--border-strong);
|
||||||
|
padding: 8px 0 8px 12px; margin: 12px 0;
|
||||||
|
}
|
||||||
|
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||||
|
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
|
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
|
||||||
|
/* A target that would not wake anybody says why, in place: it is the reason a
|
||||||
|
level is red, and the thing to go and fix. */
|
||||||
|
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||||
|
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* An integration key is shown exactly once, so it should look like something
|
||||||
|
to act on rather than another row of text. */
|
||||||
|
.key-panel {
|
||||||
|
margin-top: 12px; padding: 12px;
|
||||||
|
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.key-panel pre {
|
||||||
|
overflow-x: auto; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 8px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.key-url code { word-break: break-all; }
|
||||||
|
|
||||||
|
/* --- onboarding checklist ------------------------------------------------
|
||||||
|
Sits above the queue until it is finished or hidden. Deliberately plain:
|
||||||
|
it is a list of things to do, not a celebration. */
|
||||||
|
.onboarding { border-left: 3px solid var(--accent); }
|
||||||
|
.onboarding-head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.onboarding-head h2 { flex: 1; margin: 0; }
|
||||||
|
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
|
||||||
|
.checklist .step p { margin: 2px 0 0; }
|
||||||
|
.step-mark {
|
||||||
|
flex: none; width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-strong); color: var(--accent);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||||
|
}
|
||||||
|
.step.done .step-mark { border-color: var(--accent); }
|
||||||
|
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* --- sub-navigation -------------------------------------------------------
|
||||||
|
A strip of links across the top of every Admin and every Team page, one per
|
||||||
|
sub-section. Deliberately not .chip: chips filter what a page already shows,
|
||||||
|
here and in the queue, and these go somewhere. Same aria-current convention
|
||||||
|
as the tab bar, so the state lives on the attribute rather than in a class.
|
||||||
|
|
||||||
|
Six entries do not fit a phone's width, which is what the horizontal scroll
|
||||||
|
below is for -- the Team tab's strip is the one that needs it. */
|
||||||
|
.subnav {
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.subnav::-webkit-scrollbar { display: none; }
|
||||||
|
.subnav-link {
|
||||||
|
flex: none;
|
||||||
|
padding: 8px 12px; margin-bottom: -1px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.subnav-link:hover { color: var(--text); }
|
||||||
|
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
/* The overview a tab opens on, at /admin and at /team. The strip above already
|
||||||
|
links to the sections, so these carry the counts, which is the part a menu
|
||||||
|
cannot say. Not named for either tab: both use it, and the one that renamed
|
||||||
|
.user-link to .row-link is the same rename for the same reason. */
|
||||||
|
.overview-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||||
|
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||||
|
.overview-menu .card + .card { margin-top: 0; }
|
||||||
|
.overview-item { display: block; padding: 14px; }
|
||||||
|
.overview-item:hover { background: var(--surface-hover); }
|
||||||
|
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
|
.overview-item p { margin: 4px 0 0; }
|
||||||
|
|
||||||
|
/* ---------- stats page ---------- */
|
||||||
|
|
||||||
|
#view-stats .chips { padding-left: 0; padding-right: 0; }
|
||||||
|
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
|
||||||
|
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.stat-tile {
|
||||||
|
--sev: var(--border-strong);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--sev); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); padding: 12px;
|
||||||
|
}
|
||||||
|
.stat-tile.st-triggered { --sev: var(--crit); }
|
||||||
|
.stat-tile.st-acknowledged { --sev: var(--warn); }
|
||||||
|
.stat-tile.st-resolved { --sev: var(--ok); }
|
||||||
|
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
|
||||||
|
.chart-card + .chart-card { margin-top: 0; }
|
||||||
|
.chart-title {
|
||||||
|
margin-bottom: 10px; font-size: 13px; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.hbars { list-style: none; display: grid; gap: 6px; }
|
||||||
|
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
|
||||||
|
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
|
||||||
|
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.columns { display: block; width: 100%; height: auto; }
|
||||||
|
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
|
||||||
|
.columns .col-hit { fill: transparent; }
|
||||||
|
.columns .col-bar { fill: var(--accent); }
|
||||||
|
.columns .col:hover .col-bar { opacity: 0.75; }
|
||||||
|
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#1b1e25"/>
|
||||||
|
<path d="M10 34h11l5-12 8 22 6-15 3 5h11" fill="none" stroke="#ff6b61" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 265 B |
@@ -0,0 +1,161 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="theme-color" content="#f5f6f8" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="terdut">
|
||||||
|
<title>terdut</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="boot" class="boot" aria-busy="true"><span class="spinner"></span></div>
|
||||||
|
|
||||||
|
<main id="login" class="login" hidden>
|
||||||
|
<form id="login-form" class="login-card" autocomplete="on">
|
||||||
|
<div class="login-brand">
|
||||||
|
<img src="/icon.svg" alt="" width="40" height="40">
|
||||||
|
<h1>terdut</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Why a sign-in failed, when the identity provider sent the browser back
|
||||||
|
here with ?sso_error=. Kept apart from the password form's own error. -->
|
||||||
|
<p class="form-error" id="sso-error" role="alert" hidden></p>
|
||||||
|
<!-- A plain link, not a fetch: the browser has to navigate to the provider,
|
||||||
|
and the page's CSP allows no connection to anywhere else. -->
|
||||||
|
<a class="btn btn-primary btn-block" id="sso-link" href="/api/oidc/login" hidden>Sign in with SSO</a>
|
||||||
|
<p class="login-divider" id="login-or" hidden><span>or</span></p>
|
||||||
|
<div class="login-password" id="password-login">
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<p class="form-error" role="alert" hidden></p>
|
||||||
|
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||||
|
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||||
|
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||||
|
<p class="login-hint" id="signup-link" hidden>
|
||||||
|
No account? <a href="/signup">Create one</a>.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
||||||
|
what the server allows: an invite link, or open sign-up. -->
|
||||||
|
<form id="signup-form" class="login-card" autocomplete="on" hidden>
|
||||||
|
<div class="login-brand">
|
||||||
|
<img src="/icon.svg" alt="" width="40" height="40">
|
||||||
|
<h1>terdut</h1>
|
||||||
|
</div>
|
||||||
|
<p class="signup-intro" id="signup-intro"></p>
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input name="email" type="email" autocomplete="email" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
|
||||||
|
</label>
|
||||||
|
<label id="signup-team-label" hidden>
|
||||||
|
<span>Team name</span>
|
||||||
|
<input name="team_name" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<p class="form-error" role="alert" hidden></p>
|
||||||
|
<button class="btn btn-primary btn-block" type="submit">Create account</button>
|
||||||
|
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="app" class="app" hidden>
|
||||||
|
<nav class="nav" aria-label="Sections">
|
||||||
|
<a class="nav-brand" href="/">
|
||||||
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
|
<span>terdut</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
|
<span class="nav-label">Queue</span>
|
||||||
|
<span class="nav-badge" data-badge hidden></span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
||||||
|
<span class="nav-label">On-call</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
|
<span class="nav-label">Alerts</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
||||||
|
<span class="nav-label">Stats</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||||
|
<span class="nav-label">Team</span>
|
||||||
|
</a>
|
||||||
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
|
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||||
|
<span class="nav-label">Admin</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
||||||
|
<span class="nav-label">Account</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||||
|
</button>
|
||||||
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||||
|
</div>
|
||||||
|
<span class="open-pill" id="open-pill" hidden></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="view-queue" class="view view-queue" data-view="queue">
|
||||||
|
<div class="pane pane-list">
|
||||||
|
<div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div>
|
||||||
|
<div id="queue-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pane pane-detail" id="detail" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
||||||
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||||
|
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
|
||||||
|
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||||
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||||
|
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||||
|
list, and a section of its own so a deep link survives a reload. -->
|
||||||
|
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
|
||||||
|
<!-- One team, at /admin/teams/{id}: who is in it and the invites into it,
|
||||||
|
which the Team tab cannot show for a team you are not a member of. -->
|
||||||
|
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
||||||
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
|
<!-- Approve a terminal's sign-in, at /device?code=...: the page its prompt links to. -->
|
||||||
|
<section id="view-device" class="view view-page" data-view="device" hidden></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="sheet" class="sheet"></dialog>
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// Account: who you are signed in as, changing your password, signing out.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, icon, toast } from './ui.js';
|
||||||
|
import { initial } from './format.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { signOut } from './app.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-more');
|
||||||
|
|
||||||
|
// Rendered once per visit rather than on every poll, so a half-typed password
|
||||||
|
// is never wiped out from under you.
|
||||||
|
export function show() {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const { user, has_password: hasPassword } = state.me;
|
||||||
|
clear(view(),
|
||||||
|
h('div', { class: 'card account-card' },
|
||||||
|
h('div', { class: 'avatar', text: initial(user.username) }),
|
||||||
|
h('div', {},
|
||||||
|
h('div', { class: 'account-name', text: user.username }),
|
||||||
|
h('div', { class: 'account-email', text: user.email }))),
|
||||||
|
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||||
|
notifyForm(user),
|
||||||
|
|
||||||
|
...passwordSection(user, hasPassword),
|
||||||
|
|
||||||
|
h('div', { class: 'only-desktop' },
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||||
|
h('div', { class: 'card' }, shortcuts())),
|
||||||
|
|
||||||
|
h('div', { class: 'page-head' }),
|
||||||
|
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where this user's pages go. The onboarding checklist's first step sends
|
||||||
|
// people here for it, and until now there was nothing here to send them to:
|
||||||
|
// the topic could only be set with curl or by an administrator.
|
||||||
|
//
|
||||||
|
// The topic is the whole address — the server it is published to is the
|
||||||
|
// install's one ntfy, set in the deployment and not something a user picks.
|
||||||
|
function notifyForm(user) {
|
||||||
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
const topic = h('input', {
|
||||||
|
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||||
|
autocapitalize: 'none', spellcheck: false,
|
||||||
|
value: user.ntfy_topic || '',
|
||||||
|
placeholder: 'terdut-a7f3c91e',
|
||||||
|
});
|
||||||
|
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||||
|
|
||||||
|
// Only offered once a topic is saved: the test publishes to whatever the
|
||||||
|
// server has stored, not to whatever is half-typed in the field.
|
||||||
|
const test = h('button', {
|
||||||
|
class: 'btn', type: 'button', text: 'Send a test push',
|
||||||
|
hidden: !user.ntfy_topic,
|
||||||
|
onclick: async () => {
|
||||||
|
err.hidden = true;
|
||||||
|
ok.hidden = true;
|
||||||
|
test.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.testNotification();
|
||||||
|
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
||||||
|
ok.hidden = false;
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
test.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = h('form', { class: 'card pw-form' },
|
||||||
|
h('label', {},
|
||||||
|
h('span', { text: 'ntfy topic' }),
|
||||||
|
topic),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
|
||||||
|
'reach your phone. Leave it empty and they page the team’s fallback ',
|
||||||
|
'topic instead.'),
|
||||||
|
// Worth saying plainly: people reach for their own name, and the topic is
|
||||||
|
// the only thing standing between a stranger and their pages.
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||||||
|
'pick something unguessable rather than your name.'),
|
||||||
|
err, ok,
|
||||||
|
h('div', { class: 'row-actions' }, submit, test),
|
||||||
|
);
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
err.hidden = true;
|
||||||
|
ok.hidden = true;
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||||||
|
// Keep the cached user in step, so the onboarding checklist stops
|
||||||
|
// asking for this and the test button appears without a reload.
|
||||||
|
state.me.user = updated;
|
||||||
|
ok.textContent = updated.ntfy_topic
|
||||||
|
? 'Topic saved.'
|
||||||
|
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
||||||
|
ok.hidden = false;
|
||||||
|
test.hidden = !updated.ntfy_topic;
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// With password login switched off a password opens nothing, so somebody who
|
||||||
|
// has none is not asked to make one. Somebody who does keeps the form: it is
|
||||||
|
// how they change or get rid of a credential the server still remembers.
|
||||||
|
function passwordSection(user, hasPassword) {
|
||||||
|
if (!hasPassword && state.auth.password_login === false) {
|
||||||
|
const name = state.auth.oidc?.name || 'single sign-on';
|
||||||
|
return [
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
|
||||||
|
h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||||
|
passwordForm(user, hasPassword),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function passwordForm(user, hasPassword) {
|
||||||
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
const current = hasPassword
|
||||||
|
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
||||||
|
: null;
|
||||||
|
const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
|
||||||
|
const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
|
||||||
|
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' });
|
||||||
|
|
||||||
|
// A hidden username field lets password managers file the new password
|
||||||
|
// under the right account.
|
||||||
|
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
|
||||||
|
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
||||||
|
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
||||||
|
h('label', {}, h('span', { text: 'New password' }), next),
|
||||||
|
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
||||||
|
err, ok, submit,
|
||||||
|
);
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
err.hidden = true;
|
||||||
|
ok.hidden = true;
|
||||||
|
if (next.value !== again.value) {
|
||||||
|
err.textContent = 'The new passwords do not match.';
|
||||||
|
err.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.setPassword(user.id, next.value, current ? current.value : '');
|
||||||
|
state.me.has_password = true;
|
||||||
|
form.reset();
|
||||||
|
if (!current) {
|
||||||
|
// From now on the form needs the current-password field.
|
||||||
|
render();
|
||||||
|
toast('Password saved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ok.textContent = 'Password saved. Other devices have been signed out.';
|
||||||
|
ok.hidden = false;
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortcuts() {
|
||||||
|
const rows = [
|
||||||
|
['j / k', 'Move through the queue'],
|
||||||
|
['Enter', 'Open incident'],
|
||||||
|
['Esc', 'Back to the queue'],
|
||||||
|
['f', 'Cycle the queue filter'],
|
||||||
|
['a / A', 'Acknowledge / clear acknowledgement'],
|
||||||
|
['R', 'Resolve (asks first)'],
|
||||||
|
['s', 'Assign'],
|
||||||
|
['z / Z', 'Snooze / end snooze'],
|
||||||
|
['c', 'Add a note'],
|
||||||
|
['x', 'Archive / unarchive a resolved incident'],
|
||||||
|
['r', 'Refresh now'],
|
||||||
|
];
|
||||||
|
return h('table', { class: 'kbd-table' },
|
||||||
|
h('tbody', {}, rows.map(([k, v]) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
||||||
|
h('td', { text: v })))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// Administration: the teams on this server, the people who can sign in, and
|
||||||
|
// the settings that change how the server behaves.
|
||||||
|
//
|
||||||
|
// Each of those three is a route of its own, reached from a strip across the
|
||||||
|
// top, with /admin itself an overview. They used to be three cards stacked on
|
||||||
|
// one page, which meant no way to link to the settings, no way back to the top
|
||||||
|
// of the user list but scrolling, and a poll that refetched all three endpoints
|
||||||
|
// however little of the page you were looking at.
|
||||||
|
//
|
||||||
|
// Only rendered for a system administrator. The server enforces that on every
|
||||||
|
// endpoint regardless — hiding a section is a courtesy to the reader, not a
|
||||||
|
// permission — so this view simply says so rather than pretending to be a
|
||||||
|
// gate.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||||
|
import { state, myID } from './state.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-admin');
|
||||||
|
|
||||||
|
// The sub-sections, in the order the strip shows them. The overview is /admin
|
||||||
|
// itself, so it has no tab of its own. This table is the only place the four
|
||||||
|
// routes are written down: app.js parses against it and the strip is built
|
||||||
|
// from it, so adding a fifth is one line here.
|
||||||
|
export const TABS = [
|
||||||
|
{ tab: null, path: '/admin', label: 'Overview' },
|
||||||
|
{ tab: 'teams', path: '/admin/teams', label: 'Teams' },
|
||||||
|
{ tab: 'users', path: '/admin/users', label: 'Users' },
|
||||||
|
{ tab: 'settings', path: '/admin/settings', label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||||
|
// loop calls refresh() with no route — the same reason adminuser.js keeps its
|
||||||
|
// user ID in the module.
|
||||||
|
let tab = null;
|
||||||
|
let data = null; // whatever the current tab needs; the shape varies by tab
|
||||||
|
let error = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
export function show(route) {
|
||||||
|
const next = route?.tab ?? null;
|
||||||
|
// A different sub-section wants different data, so the old answer goes
|
||||||
|
// rather than being shown under the new heading until the fetch lands.
|
||||||
|
if (next !== tab) {
|
||||||
|
tab = next;
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!data) clear(view(), subnav(), spinner());
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
if (!state.me?.user?.is_admin) {
|
||||||
|
data = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = await load();
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only what the open sub-section shows. Users is the one that needs two: it
|
||||||
|
// only points at Teams for an invite if there is a team to point at, and the
|
||||||
|
// overview counts both.
|
||||||
|
async function load() {
|
||||||
|
if (tab === 'teams') return { teams: await api.adminTeams() };
|
||||||
|
if (tab === 'settings') return { settings: await api.adminSettings() };
|
||||||
|
const [teams, users] = await Promise.all([api.adminTeams(), api.users()]);
|
||||||
|
return { teams, users };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!state.me?.user?.is_admin) {
|
||||||
|
clear(view(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear(view(),
|
||||||
|
subnav(),
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
section(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function section() {
|
||||||
|
if (tab === 'teams') return teamsCard();
|
||||||
|
if (tab === 'users') return usersCard();
|
||||||
|
if (tab === 'settings') return settingsCard();
|
||||||
|
return overview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip across the top of every admin page. Ordinary links rather than
|
||||||
|
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||||
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
|
function subnav() {
|
||||||
|
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||||
|
TABS.map((t) => h('a', {
|
||||||
|
class: 'subnav-link',
|
||||||
|
href: t.path,
|
||||||
|
text: t.label,
|
||||||
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- overview --------------------------------------------------------------
|
||||||
|
|
||||||
|
// /admin itself. The strip already links to the three, so this earns its place
|
||||||
|
// by saying how much of each there is — the one thing a menu cannot.
|
||||||
|
function overview() {
|
||||||
|
const admins = data.users.filter((u) => u.is_admin).length;
|
||||||
|
const disabled = data.users.filter((u) => u.disabled_at).length;
|
||||||
|
const open = data.teams.reduce((n, t) => n + t.open_incidents, 0);
|
||||||
|
|
||||||
|
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||||
|
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||||
|
|
||||||
|
return h('div', { class: 'overview-menu' },
|
||||||
|
menuCard('/admin/teams', 'Teams', data.teams.length,
|
||||||
|
open > 0
|
||||||
|
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||||
|
: 'Nothing open anywhere.'),
|
||||||
|
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||||
|
menuCard('/admin/settings', 'Settings', null,
|
||||||
|
'How the server behaves, and where it is plugged in.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- teams -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function teamsCard() {
|
||||||
|
const rows = data.teams.map((t) =>
|
||||||
|
h('tr', {},
|
||||||
|
// The name is the way in: everything about one team lives on its own
|
||||||
|
// page, and this table stays a list rather than becoming a form.
|
||||||
|
h('td', {}, h('a', { class: 'row-link', href: `/admin/teams/${t.id}`, text: t.name })),
|
||||||
|
h('td', { class: 'num', text: String(t.members) }),
|
||||||
|
h('td', { class: 'num', text: String(t.open_incidents) }),
|
||||||
|
));
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Teams' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Open a team for who is in it, the invites into it, and renaming or ',
|
||||||
|
'deleting it. Deleting takes its alerts, incidents, schedule and ',
|
||||||
|
'integrations with it, and is refused while anything is still open.'),
|
||||||
|
h('table', { class: 'admin-table' },
|
||||||
|
h('thead', {}, h('tr', {},
|
||||||
|
h('th', { text: 'Name' }),
|
||||||
|
h('th', { class: 'num', text: 'Members' }),
|
||||||
|
h('th', { class: 'num', text: 'Open' }))),
|
||||||
|
h('tbody', {}, rows)),
|
||||||
|
newTeamForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTeamForm() {
|
||||||
|
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
|
||||||
|
const form = h('form', { class: 'inline-form' }, name,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await api.createTeam(name.value.trim());
|
||||||
|
name.value = '';
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- users -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function usersCard() {
|
||||||
|
const rows = data.users.map((u) => {
|
||||||
|
const self = u.id === myID();
|
||||||
|
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||||
|
h('td', {},
|
||||||
|
// The name is the way in: everything about one person lives on their
|
||||||
|
// own page, and this table stays a list rather than becoming a form.
|
||||||
|
h('a', { class: 'row-link', href: `/admin/users/${u.id}`, text: u.username }),
|
||||||
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
|
h('td', { class: 'muted', text: u.email }),
|
||||||
|
h('td', {},
|
||||||
|
u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null,
|
||||||
|
u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : null),
|
||||||
|
h('td', {},
|
||||||
|
// Neither action is offered for your own account: the server refuses
|
||||||
|
// both, and an enabled-looking button that always fails is worse than
|
||||||
|
// no button.
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn-sm',
|
||||||
|
type: 'button',
|
||||||
|
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||||
|
// The server refuses to revoke what the groups grant.
|
||||||
|
disabled: ssoAdmin(u),
|
||||||
|
title: ssoAdmin(u) ? SSO_MANAGED : null,
|
||||||
|
onclick: () => setAdmin(u, !u.is_admin),
|
||||||
|
}),
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn-sm danger',
|
||||||
|
type: 'button',
|
||||||
|
text: u.disabled_at ? 'Enable' : 'Disable',
|
||||||
|
onclick: () => setDisabled(u, !u.disabled_at),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Users' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||||
|
'its acknowledgements and timeline entries. Deleting a user erases those. ',
|
||||||
|
'Open a name for their teams, their password and the rest.'),
|
||||||
|
h('table', { class: 'admin-table' },
|
||||||
|
h('thead', {}, h('tr', {},
|
||||||
|
h('th', { text: 'User' }),
|
||||||
|
h('th', { text: 'Email' }),
|
||||||
|
h('th', { text: '' }),
|
||||||
|
h('th', { text: '' }))),
|
||||||
|
h('tbody', {}, rows)),
|
||||||
|
invitePointer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a person is minting them an invite into a team, not creating a row:
|
||||||
|
// whoever accepts it picks their own password, so one never passes through an
|
||||||
|
// administrator, and the link carries the team, so they do not land on an empty
|
||||||
|
// queue.
|
||||||
|
//
|
||||||
|
// The form for it lives on the team's own page. It always needed a team beside
|
||||||
|
// it, and a picker here was the admission that an invite is a fact about a team
|
||||||
|
// rather than about the server.
|
||||||
|
function invitePointer() {
|
||||||
|
return h('div', { class: 'invite-block' },
|
||||||
|
h('h3', { text: 'Add someone' }),
|
||||||
|
data.teams.length > 0
|
||||||
|
? h('p', { class: 'muted small' },
|
||||||
|
'Open the team you want them in, under ',
|
||||||
|
h('a', { class: 'row-link', href: '/admin/teams', text: 'Teams' }),
|
||||||
|
', and mint an invite there.')
|
||||||
|
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
|
||||||
|
|
||||||
|
async function setAdmin(user, next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Make ${user.username} an administrator?`,
|
||||||
|
text: 'They will be able to create and delete users, and grant this to others.',
|
||||||
|
confirmLabel: 'Make admin',
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.setUserAdmin(user.id, next);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDisabled(user, next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Disable ${user.username}?`,
|
||||||
|
text: 'They cannot sign in and their API keys stop working. Their history stays.',
|
||||||
|
confirmLabel: 'Disable',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.setUserDisabled(user.id, next);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- settings --------------------------------------------------------------
|
||||||
|
|
||||||
|
// Seconds are what the API speaks; people think in minutes and hours. The two
|
||||||
|
// are converted here rather than in the server, which should keep exactly one
|
||||||
|
// unit.
|
||||||
|
const UNITS = [
|
||||||
|
{ label: 'minutes', seconds: 60 },
|
||||||
|
{ label: 'hours', seconds: 3600 },
|
||||||
|
{ label: 'days', seconds: 86400 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function bestUnit(seconds) {
|
||||||
|
for (const u of [...UNITS].reverse()) {
|
||||||
|
if (seconds > 0 && seconds % u.seconds === 0) return u;
|
||||||
|
}
|
||||||
|
return UNITS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsCard() {
|
||||||
|
const editable = data.settings.editable || {};
|
||||||
|
const inputs = new Map();
|
||||||
|
|
||||||
|
const rows = Object.entries(editable).map(([key, s]) => {
|
||||||
|
const unit = bestUnit(s.seconds);
|
||||||
|
const value = h('input', {
|
||||||
|
type: 'number',
|
||||||
|
min: '0',
|
||||||
|
value: String(Math.round(s.seconds / unit.seconds)),
|
||||||
|
class: 'setting-value',
|
||||||
|
});
|
||||||
|
const select = h('select', { class: 'setting-unit' },
|
||||||
|
...UNITS.map((u) => h('option', {
|
||||||
|
value: String(u.seconds),
|
||||||
|
text: u.label,
|
||||||
|
selected: u.seconds === unit.seconds,
|
||||||
|
})));
|
||||||
|
inputs.set(key, () => Number(value.value) * Number(select.value));
|
||||||
|
|
||||||
|
return h('tr', {},
|
||||||
|
h('td', {}, h('strong', { text: key.replace(/_seconds$/, '').replace(/_/g, ' ') })),
|
||||||
|
h('td', { class: 'muted small', text: s.description }),
|
||||||
|
h('td', {}, value, select),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = h('form', { class: 'admin-settings' },
|
||||||
|
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Save settings' }));
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
const body = {};
|
||||||
|
for (const [key, read] of inputs) body[key] = read();
|
||||||
|
try {
|
||||||
|
await api.setAdminSettings(body);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = Object.entries(data.settings.from_env || {}).map(([k, v]) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, h('code', { text: k })),
|
||||||
|
h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Settings' }),
|
||||||
|
h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
|
||||||
|
form,
|
||||||
|
h('h3', { text: 'From the environment' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Where the server is plugged in, rather than how it behaves. These are set ',
|
||||||
|
'in the deployment and are read-only here. Credentials are never shown.'),
|
||||||
|
h('table', { class: 'admin-table' }, h('tbody', {}, env)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// One team, at /admin/teams/{id}: what it is, who is in it, the invites into
|
||||||
|
// it, and the two destructive things an administrator can do to it.
|
||||||
|
//
|
||||||
|
// The mirror of adminuser.js. That page answers "which teams is this person
|
||||||
|
// in"; this one answers "who is in this team" for a team the administrator
|
||||||
|
// need not be a member of — which the Team tab cannot do, because it only
|
||||||
|
// offers teams the viewer is in.
|
||||||
|
//
|
||||||
|
// Only rendered for a system administrator. The server enforces that on every
|
||||||
|
// endpoint regardless, so this view says so rather than pretending to be a
|
||||||
|
// gate.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { navigate } from './app.js';
|
||||||
|
import { when } from './format.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-adminteam');
|
||||||
|
|
||||||
|
let teamID = null;
|
||||||
|
let data = null; // { team, members, users, invites }
|
||||||
|
let error = null;
|
||||||
|
let busy = false;
|
||||||
|
// An invite link is shown once and never stored, so it lives here until the
|
||||||
|
// page is left rather than being toasted away after three seconds.
|
||||||
|
let freshInvite = null;
|
||||||
|
|
||||||
|
export function show(route) {
|
||||||
|
const next = route && route.team != null ? route.team : null;
|
||||||
|
if (next !== teamID) {
|
||||||
|
teamID = next;
|
||||||
|
data = null;
|
||||||
|
error = null;
|
||||||
|
freshInvite = null;
|
||||||
|
}
|
||||||
|
if (!data) clear(view(), spinner());
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
if (teamID == null || !state.me?.user?.is_admin) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The team and its members come from the admin endpoint in one answer:
|
||||||
|
// /teams/{id}/members is member-only and 404s an administrator from
|
||||||
|
// outside the team, deliberately. users() is the add-a-member picker.
|
||||||
|
const [team, users, invites] = await Promise.all([
|
||||||
|
api.adminTeam(teamID),
|
||||||
|
api.users(),
|
||||||
|
api.invites(teamID),
|
||||||
|
]);
|
||||||
|
data = { team: team.team, members: team.members, users, invites };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
// A team that is gone answers 404, where a missing user is simply absent
|
||||||
|
// from a list adminuser.js already has. So the "no such team" state has to
|
||||||
|
// be recognised here; left to the error banner it would read as a fetch
|
||||||
|
// that failed, which is a different thing and invites a retry.
|
||||||
|
if (err.status === 404) {
|
||||||
|
data = { team: null, members: [], users: [], invites: [] };
|
||||||
|
error = null;
|
||||||
|
} else {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const el = view();
|
||||||
|
if (!state.me?.user?.is_admin) {
|
||||||
|
clear(el, backLink(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.team) {
|
||||||
|
clear(el, backLink(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear(el,
|
||||||
|
backLink(),
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
identityCard(),
|
||||||
|
membersCard(),
|
||||||
|
invitesCard(),
|
||||||
|
dangerCard(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backLink() {
|
||||||
|
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity --------------------------------------------------------------
|
||||||
|
|
||||||
|
function identityCard() {
|
||||||
|
const t = data.team;
|
||||||
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
const name = h('input', {
|
||||||
|
name: 'name', type: 'text', value: t.name, required: true,
|
||||||
|
autocomplete: 'off', spellcheck: false,
|
||||||
|
});
|
||||||
|
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save name' });
|
||||||
|
|
||||||
|
// A field rather than the window.prompt this used to be. The server answers
|
||||||
|
// 409 for a name already taken, and a dialog is the wrong place to read that.
|
||||||
|
const form = h('form', { class: 'inline-form' }, name, submit);
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
err.hidden = true;
|
||||||
|
ok.hidden = true;
|
||||||
|
const next = name.value.trim();
|
||||||
|
if (!next || next === t.name) return;
|
||||||
|
busy = true;
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.renameTeam(teamID, next);
|
||||||
|
ok.textContent = 'Name saved.';
|
||||||
|
ok.hidden = false;
|
||||||
|
error = null;
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
busy = false;
|
||||||
|
submit.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
submit.disabled = false;
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
|
||||||
|
h('dl', { class: 'user-facts' },
|
||||||
|
fact('Created', when(t.created_at)),
|
||||||
|
fact('Members', String(t.members)),
|
||||||
|
fact('Open incidents', String(t.open_incidents)),
|
||||||
|
),
|
||||||
|
form, err, ok,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// An administrator passes every team-owner check without being in the team,
|
||||||
|
// which is what lets them repair a team whose owner has left. So this card
|
||||||
|
// edits rather than reporting what somebody else would have to do.
|
||||||
|
function membersCard() {
|
||||||
|
const rows = data.members.map((m) =>
|
||||||
|
h('tr', {},
|
||||||
|
// Unlike the Team tab's own member list, the name is a link: that
|
||||||
|
// person's page is where the rest of them lives.
|
||||||
|
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
|
||||||
|
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||||
|
h('td', { class: 'row-actions' },
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button',
|
||||||
|
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
// The server refuses to edit a membership the groups grant.
|
||||||
|
disabled: m.source === 'oidc',
|
||||||
|
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||||
|
// The same endpoint both ways: adding is an upsert on the role.
|
||||||
|
onclick: () => act(() =>
|
||||||
|
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||||
|
}),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
disabled: m.source === 'oidc',
|
||||||
|
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||||
|
// The server refuses the last owner with a 409, which act() shows.
|
||||||
|
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
const inTeam = new Set(data.members.map((m) => m.user_id));
|
||||||
|
// A disabled account cannot sign in, so putting one on a rota would be
|
||||||
|
// staffing the team with somebody who cannot answer.
|
||||||
|
const candidates = data.users.filter((u) => !inTeam.has(u.id) && !u.disabled_at);
|
||||||
|
const pick = h('select', {},
|
||||||
|
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Members' }),
|
||||||
|
data.members.length === 0 && h('p', { class: 'muted small' },
|
||||||
|
'Nobody is in this team. Its queue has no one to work it and its ',
|
||||||
|
'escalation has no one to reach — add somebody, or delete it.'),
|
||||||
|
data.members.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
candidates.length > 0 && form,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- invites ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Adding a person to the server is minting them an invite into a team, not
|
||||||
|
// creating a row: whoever accepts it picks their own password, so one never
|
||||||
|
// passes through an administrator, and the link carries the team, so they do
|
||||||
|
// not land on an empty queue.
|
||||||
|
//
|
||||||
|
// This lives on the team rather than on the Users page, where it used to be
|
||||||
|
// with a team picker beside it. The picker was the admission that an invite is
|
||||||
|
// a fact about a team.
|
||||||
|
function invitesCard() {
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const form = h('form', { class: 'inline-form' }, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const inv = await api.createInvite(teamID, role.value, 1);
|
||||||
|
freshInvite = inv.url;
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server lists spent and revoked invites too, and they are worth seeing:
|
||||||
|
// "who was invited here" is part of the answer to "who is in this team".
|
||||||
|
// Only a live one can be revoked, so only a live one offers the button.
|
||||||
|
const rows = (data.invites || []).map((inv) => {
|
||||||
|
const state = inviteState(inv);
|
||||||
|
return h('tr', { class: state === 'live' ? '' : 'disabled-row' },
|
||||||
|
h('td', {}, h('strong', { text: inv.role })),
|
||||||
|
h('td', { class: 'muted small', text: `${inv.uses}/${inv.max_uses} used` }),
|
||||||
|
h('td', { class: 'muted small', text: state === 'live' ? `expires ${when(inv.expires_at)}` : state }),
|
||||||
|
h('td', { class: 'row-actions' },
|
||||||
|
state === 'live' && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||||
|
onclick: () => act(() => api.revokeInvite(teamID, inv.id)),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Invites' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'An invite link puts somebody in this team and lets them choose their ',
|
||||||
|
'own password. It lasts a week and can be used once.'),
|
||||||
|
rows.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
form,
|
||||||
|
// Shown once and never stored, so it goes on the page to be copied.
|
||||||
|
freshInvite && h('p', { class: 'invite-out' },
|
||||||
|
h('strong', { text: 'Send them this link. It is shown once.' }),
|
||||||
|
h('code', { class: 'invite-link', text: freshInvite })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why a link no longer works, in the server's own order of precedence: revoked
|
||||||
|
// beats spent beats expired. Only 'live' is still usable.
|
||||||
|
function inviteState(inv) {
|
||||||
|
if (inv.revoked) return 'revoked';
|
||||||
|
if (inv.uses >= inv.max_uses) return 'used up';
|
||||||
|
if (new Date(inv.expires_at).getTime() <= Date.now()) return 'expired';
|
||||||
|
return 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function dangerCard() {
|
||||||
|
const t = data.team;
|
||||||
|
const blocked = t.open_incidents > 0;
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Delete' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Its alerts, incidents, schedule and integrations go with it. This ',
|
||||||
|
'cannot be undone. Everybody in it keeps their account and stays in ',
|
||||||
|
'whatever other teams they are in.'),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn btn-danger', type: 'button', text: `Delete ${t.name}`,
|
||||||
|
// Saying so before the click is kinder than a 409 afterwards.
|
||||||
|
disabled: blocked,
|
||||||
|
title: blocked ? 'Resolve its open incidents first' : '',
|
||||||
|
onclick: deleteTeam,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTeam() {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: `Delete ${data.team.name}?`,
|
||||||
|
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteTeam(teamID);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('Team deleted.');
|
||||||
|
// Not act(): there is no longer a page here to refresh.
|
||||||
|
navigate('/admin/teams');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing --------------------------------------------------------------
|
||||||
|
|
||||||
|
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||||
|
// 409 from the last-owner guard, and the one for a duplicate name, are the
|
||||||
|
// server explaining itself, and the reader needs to see it.
|
||||||
|
async function act(fn) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user