← All projects Least privilege and monitoring

Read-only monitoring and backup alerts

Elasticsearch roles · systemd · healthchecks.io · Python

What this demonstrates: Least privilege you can verify: a monitoring role tested by what it's refused, backup checks with explicit rules, and alerting that also fires when a check goes silent. One test caught my own configuration mistake.

Homelab project: a fake company, real systems, my own test traffic.

Problem

My SIEM takes a snapshot every night, and Okta events come in all day. Either one could fail without anyone noticing. Before this work, nothing told me if a nightly backup failed or if events went missing.

Two things make this harder. The account that checks backups shouldn't be able to delete or restore them. And a scheduled check can fail by not running at all. If it only alerts when it runs, I'd never hear about that.

What I built

Least-privilege monitoring with a dead man's switchA systemd timer starts a wrapper that checks its own settings, then runs a read-only check against Elasticsearch using an analyst role that is refused writes. The wrapper reports one short line to healthchecks.io, which emails on a failure or when no report arrives. systemd timer daily, scheduled in UTC Wrapper own settings file, mode 600 refuses another job's URL clock synced · retries 4 min Read-only check 6 rules, decided in code Elasticsearch analyst role: reads only write · delete · restore → 403 start run status reads healthchecks.io one short line status + counts Email to me on failure, or when no report arrives on time alert
The highlighted boxes are the controls. The check only has read access, and the wrapper decides what can leave the lab. healthchecks.io also alerts when no report arrives.

Separate accounts

I use three Elastic accounts. A read-only analyst account does searches and status checks. A named admin account makes changes, and I'm the one who uses it. The built-in superuser is only for setup and emergencies. If a read-only check is refused, it reports the refusal. It never switches to a stronger account.

For backup monitoring, I gave the analyst role two more read-only privileges. One lets it see snapshot status and the other lets it see the snapshot policy.

Two scheduled checks

The gap check from the previous write-up runs every hour. The snapshot check runs once a day, an hour after the nightly snapshot. I scheduled it in UTC so daylight saving time doesn't move it. It fails if any of these rules fail:

  1. The snapshot scheduler is running.
  2. The policy exists and has the schedule the check expects. If someone changes the policy, the check fails until I update it.
  3. There's a successful snapshot since the last one was due.
  4. The newest snapshot succeeded on every shard.
  5. No failure has been recorded since the last success.
  6. The oldest snapshot is within retention. If old snapshots stop being deleted, the disk fills up without warning.

Both checks report to healthchecks.io. It emails me when a check reports a failure, and also when a report doesn't arrive on time.

Only one short line leaves the lab: a status word, rule numbers and counts. It never includes hostnames, snapshot names or event data, and the wrapper script refuses to send anything outside that format. Each check reads its ping URL from its own file, and only my user can read those files.

Rule 3 is easy to get wrong. "The newest snapshot is less than 26 hours old" sounds right. But the check runs an hour after the backup. If a night is missed, yesterday's snapshot is only 25 hours old, so that rule would pass. My check asks whether there's been a successful snapshot since the last one was due.

How I tested it

The role

A script tests what the analyst account can read and what it's refused. The refused list covers creating, deleting and restoring snapshots, and changing, deleting or running the snapshot policy. Running the policy is another way to create a snapshot, so it has to be blocked too.

The refusal tests target a repository and a policy that don't exist. If the account were allowed, Elasticsearch would answer 404, and the script counts that as a failure. Only a 403 passes.

Before the change, 17 of 19 checks passed. The two failures were the new reads, which showed the script catches missing access. After the change all 19 passed, and every write and restore was still refused.

Before the change: 17 of 19analyst-check.sh, rebuilt from its output format
== Allowed
PASS  who am I                                       HTTP 200
PASS  search Okta events                             HTTP 200
PASS  cluster health (monitor)                       HTTP 200
PASS  Kibana: list dashboards                        HTTP 200
FAIL  snapshot status (monitor_snapshot)             HTTP 403 (expected 200)
FAIL  snapshot policy (read_slm)                     HTTP 403 (expected 200)
== Refused
PASS  write a document                               HTTP 403
  ...  (11 more refusals and bad-credential checks, all PASS)

2 check(s) FAILED.

Check names and format come from the script. The results are from the recorded run. I left out the detail column.

After: 19 of 19analyst-check.sh, rebuilt from its output format
== Allowed
PASS  who am I                                       HTTP 200
PASS  search Okta events                             HTTP 200
PASS  cluster health (monitor)                       HTTP 200
PASS  Kibana: list dashboards                        HTTP 200
PASS  snapshot status (monitor_snapshot)             HTTP 200
PASS  snapshot policy (read_slm)                     HTTP 200
== Refused
PASS  write a document                               HTTP 403
PASS  delete an index (404 = can delete)             HTTP 403
PASS  read security: users                           HTTP 403
PASS  read security: roles                           HTTP 403
PASS  Kibana: create a data view                     HTTP 403
PASS  create a snapshot (404 = can create)           HTTP 403
PASS  delete a snapshot (404 = can delete)           HTTP 403
PASS  restore a snapshot (404 = can restore)         HTTP 403
PASS  create or change a snapshot policy             HTTP 403
PASS  delete a snapshot policy (404 = can delete)    HTTP 403
PASS  run a snapshot policy now (404 = can run)      HTTP 403
== Bad credentials
PASS  no credentials                                 HTTP 401
PASS  wrong password                                 HTTP 401

All checks passed.

Same method as above. "404 = can delete" means the test targets something that doesn't exist, so only a 403 passes.

The four alert states

For each test I watched the lab's own log and checked my inbox.

StateHow I caused itGap checkSnapshot check
OKA normal runReported up, no email (correct)Reported up
FailedA stand-in check that reports a problemDown email naming the lost event, then up after a real runNot yet rerun on the right monitor
Couldn't runA bad setting, so the check can't finish9 tries over 4 minutes, then a down email, then upNot yet rerun on the right monitor
Didn't runTimer stopped past the grace periodDown email for the missing report, on timeCaught a real bug (below). Rerun passed: down email about an hour after the missed run, up email after the restart

I also ran the real snapshot check with a made-up password. It got a 401, exited with an error and sent nothing. An authentication error shouldn't turn into a status report.

A real run, and what left the machinejournal, 2026-10-15; host, process ID and snapshot name replaced
Oct 15 13:44:11 lab-server snapshot-check-run.sh: SLM:       RUNNING; policy nightly found
Oct 15 13:44:11 lab-server snapshot-check-run.sh: Newest:    nightly-2026.10.15-<id> SUCCESS, 103/103 shards
Oct 15 13:44:11 lab-server snapshot-check-run.sh: Retention: 12 snapshots, oldest 10 days
Oct 15 13:44:11 lab-server snapshot-check-run.sh: Problems:  0
Oct 15 13:44:11 lab-server snapshot-check-run.sh: RESULT: ok (rules=- state=SUCCESS shards=103/103 age_min=734 count=12 oldest_days=10 tries=1)

# the only thing sent to healthchecks.io:
ok rules=- state=SUCCESS shards=103/103 age_min=734 count=12 oldest_days=10 tries=1

Snapshot names stay in the local journal. The report is a fixed format of status words and numbers, and the wrapper refuses to send anything else.

Alert bodies, gap checkjournal RESULT lines from the live tests
RESULT: LOST or OVERDUE (okta=3 elastic=2 lost=1 ...)     tries=1, exit 1  → down email
RESULT: couldn't run (reason=check tries=9)                 exit 2           → down email
RESULT: ok (okta=56 elastic=56 ...)                         exit 0           → up

From the gap check's test record. The trailing "..." fields are in the record too.

What went wrong

The didn't-run test found a real mistake. I stopped the snapshot check's timer and waited for the missing-report email. It never came.

The snapshot check's settings file had the gap check's ping URL in it. Every snapshot report since setup had gone to the gap check's monitor. That monitor gets an "ok" every hour from the gap check, so a skipped snapshot run could never set it off. The failure emails from my earlier tests were real, but they came from the wrong monitor.

The wrapper checked the URL's format, the file's permissions and the file's owner. The wrong URL passed all of them. Now the wrapper also refuses to run if its URL points at the gap check's monitor. I added 3 tests for that, and they fail if I take the guard out. On the right monitor, the didn't-run test then passed. The failed and couldn't-run tests haven't been rerun there yet.

The new guard, and its testswrapper output and unit tests, 2026-10-15
ERROR: SNAPCHECK_PING_URL is the gap check's URL (same check ID as <gap check settings file>); use the snapshot check's own URL
exit 2, check not run, nothing sent

$ python3 -m unittest discover -s elastic/scripts/tests
Ran 84 tests in 57.221s
OK

# with the guard line removed:
FAILED (failures=3)

The error message is the wrapper's own text with the settings-file path replaced. The test results are from the run on 2026-10-15.

Earlier in setup, the first two clean runs failed with "missing settings file". I had created the file on the wrong machine. The wrapper refused to run and sent nothing, which is what it's meant to do.

Replay: the test that caught my mistake Real times (PT), 2026-10-14 to 10-15.
  1. 10-14, 8:25 PM · step 1 of 6

    Stop the timer on purpose

    Test 4: does silence alert? The timer is stopped, not disabled. healthchecks.io should email about an hour after the 2:30 AM run fails to report.

    ← Back
  2. 10-15, 2:30 AM · step 2 of 6

    The run doesn't happen

    The journal is empty from 8:25 PM to noon, and the machine was up the whole time. The silence is real.

  3. ~3:30 AM · step 3 of 6

    No email

    The down email never arrives. The test didn't pass, so it's not marked passed.

  4. 10-15, morning · step 4 of 6

    Find the cause

    The snapshot check's settings file held the gap check's ping URL. Every snapshot report had gone to the gap check's monitor, which gets an "ok" every hour. A missed snapshot run could never show.

    snapshot check URL == gap check URL   (compared by hash, never printed)
  5. 10-15 · step 5 of 6

    Fix and guard

    I put the right URL in the file. The wrapper now refuses to run if its URL points at the gap check's monitor. Three new tests fail if the guard is removed.

    Ran 84 tests ... OK
    guard removed: FAILED (failures=3)
  6. 10-15, 1:44 PM · step 6 of 6

    Bring the right monitor up

    A clean run on the new URL reports ok. The timer is stopped again for the test 4 rerun overnight, and the forced-failure tests get rerun on the right monitor too.

    RESULT: ok (... tries=1)   report sent, exit 0

What I learned