From 09beec367c387ec9f966108af9ce8b7732206d69 Mon Sep 17 00:00:00 2001 From: "JJediny (merge automation)" Date: Sun, 4 Oct 2026 17:28:11 -0400 Subject: [PATCH] feat(compliance): schedule JIT expiry revocation via systemd user timer (Req-3) The 2026-10 review of #82 artifacts found the root-cause gap: the audit cycle is the ONLY revocation path for expired JIT tickets, and nothing scheduled it - so an expired security_auditor admin grant had lingered for months (unrevoked user, tickets stuck active:true). Adds repo-managed, user-scope scheduling (no root, no /etc): - systemd/localobserve-jit-audit.service + .timer: hourly oneshot audit cycle, Persistent=true for missed-cycle catch-up after downtime; deliberately no cross-manager After=/Wants= (user units cannot order against docker.service; a down stack fails the run safely and the next hour retries). - scripts/install-jit-timer.sh: install/dry-run/status/remove, reusing the install-logrotate.sh REPO_ROOT sed convention; verifies generated units with systemd-analyze; documents enable-linger for headless hosts. - Taskfile: install-jit-timer / jit-timer-status / remove-jit-timer. - docs/compliance_crosswalk.md Req-3: revocation-scheduling note. - tests/test_jit_audit_timer.py: 5 hermetic static guards keeping units, installer, and Taskfile in sync with the tool subcommand. Out of scope deliberately: no timer for compliance-ledger-audit - it appends to the VCS-tracked audit_ledger.json and would keep the working tree permanently dirty; ledger appends stay a change-hygiene step. --- Taskfile.yml | 15 +++++ docs/compliance_crosswalk.md | 3 + scripts/install-jit-timer.sh | 82 ++++++++++++++++++++++++++ systemd/localobserve-jit-audit.service | 18 ++++++ systemd/localobserve-jit-audit.timer | 15 +++++ tests/test_jit_audit_timer.py | 56 ++++++++++++++++++ 6 files changed, 189 insertions(+) create mode 100755 scripts/install-jit-timer.sh create mode 100644 systemd/localobserve-jit-audit.service create mode 100644 systemd/localobserve-jit-audit.timer create mode 100644 tests/test_jit_audit_timer.py diff --git a/Taskfile.yml b/Taskfile.yml index 22d89fb..fed2dc5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -164,6 +164,21 @@ tasks: cmds: - python3 tools/compliance_rbac_jit.py audit + install-jit-timer: + desc: Install the hourly systemd user timer that runs the JIT audit revocation cycle (Req-3) + cmds: + - bash ./scripts/install-jit-timer.sh --install + + jit-timer-status: + desc: Show the JIT audit timer state (user scope) + cmds: + - bash ./scripts/install-jit-timer.sh --status + + remove-jit-timer: + desc: Disable and remove the JIT audit timer + cmds: + - bash ./scripts/install-jit-timer.sh --remove + compliance-ledger-audit: desc: Append current telemetry-config hashes to the tamper-evident audit ledger cmds: diff --git a/docs/compliance_crosswalk.md b/docs/compliance_crosswalk.md index f7f4b49..b132b7d 100644 --- a/docs/compliance_crosswalk.md +++ b/docs/compliance_crosswalk.md @@ -47,6 +47,9 @@ This audit crosswalk maps M-26-14 baseline mandates to specific architectural ob > *"In the event of a known or suspected compromise... provide logs and other relevant data to CISA and the Federal Bureau of Investigation (FBI) upon request... Logs are encrypted, access is granted just in time, permissions and workloads are regularly monitored and reviewed..."* (Par. 125-131, Appendix C) - **LocalObserve Implementation Plan**: Introduce a Just-in-Time (JIT) access permission matrix in OpenObserve. Implement a standardized log export tool matching External Auditor ingestion schemas. + +- **Revocation Scheduling (2026-10, #82 follow-up)**: + An expired JIT grant is only truly revoked when `tools/compliance_rbac_jit.py audit` runs. `task install-jit-timer` installs a systemd **user** timer (hourly, `Persistent=true`) executing that cycle; each closure lands an `EXPIRE_JIT` record in the `rbac_audit` stream. The ledger tool's honest `provisioned` flag distinguishes locally-recorded tickets from live grants, so the timer never revokes against a phantom principal. - **Assigned Tracker**: [Issue #21](https://github.com/JJediny/LocalObserve/issues/21) ### Requirement 4: Network Capture Controls, Minimization, and Retention diff --git a/scripts/install-jit-timer.sh b/scripts/install-jit-timer.sh new file mode 100755 index 0000000..a763c2a --- /dev/null +++ b/scripts/install-jit-timer.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# install-jit-timer.sh — Install the LocalObserve JIT audit systemd *user* timer. +# +# Runs `tools/compliance_rbac_jit.py audit` hourly so expired JIT access tickets +# are actually revoked (M-26-14 Req-3). Unscheduled expiry was the failure mode +# behind the lingering security_auditor admin user found in the #82 review. +# +# User scope: no root required, no /etc changes. See the enable-linger note on +# install for headless hosts. +# +# Usage: +# bash scripts/install-jit-timer.sh # install + enable (user scope) +# bash scripts/install-jit-timer.sh --dry-run # generate + verify units, install nothing +# bash scripts/install-jit-timer.sh --status # show timer state +# bash scripts/install-jit-timer.sh --remove # disable + uninstall + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +UNITS=(localobserve-jit-audit.service localobserve-jit-audit.timer) + +generate_units() { + local target_dir="$1" + mkdir -p "$target_dir" + for unit in "${UNITS[@]}"; do + # Same path-rewrite convention as install-logrotate.sh: the repo copies + # carry the default checkout location and are localized on install. + sed "s|/home/john/LocalObserve|${REPO_ROOT}|g" "${REPO_ROOT}/systemd/${unit}" > "${target_dir}/${unit}" + done +} + +verify_units() { + local dir="$1" + if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze verify "${dir}/localobserve-jit-audit.service" + systemd-analyze verify "${dir}/localobserve-jit-audit.timer" + echo "[+] systemd-analyze verify: units OK" + else + echo "[!] systemd-analyze not found; syntax verification skipped." >&2 + fi +} + +case "${1:-}" in + --dry-run) + tmp_dir=$(mktemp -d) + generate_units "$tmp_dir" + echo "[*] Generated for REPO_ROOT=${REPO_ROOT}:" + grep -HnE 'ExecStart|WorkingDirectory|OnUnitActiveSec|Persistent' "$tmp_dir"/* + verify_units "$tmp_dir" || { rm -rf "$tmp_dir"; exit 1; } + rm -rf "$tmp_dir" + echo "[+] Dry run complete — nothing installed." + exit 0 + ;; + --status) + systemctl --user list-timers 'localobserve-jit-audit*' --no-pager || true + systemctl --user is-enabled localobserve-jit-audit.timer 2>/dev/null || echo "[!] Timer not enabled." + exit 0 + ;; + --remove) + systemctl --user disable --now localobserve-jit-audit.timer 2>/dev/null || true + rm -f "${UNIT_DIR}/localobserve-jit-audit.service" "${UNIT_DIR}/localobserve-jit-audit.timer" + systemctl --user daemon-reload + echo "[+] JIT audit timer disabled and unit files removed." + exit 0 + ;; + --install|"") + generate_units "$UNIT_DIR" + verify_units "$UNIT_DIR" + systemctl --user daemon-reload + systemctl --user enable --now localobserve-jit-audit.timer + echo "[+] Installed ${UNITS[*]} -> ${UNIT_DIR}" + echo "[+] Hourly JIT expiry revocation is active." + echo "[!] Headless hosts: user services stop at logout unless lingering is on:" + echo " loginctl enable-linger $(id -un)" + exit 0 + ;; + *) + echo "Usage: $0 [--install|--dry-run|--status|--remove]" >&2 + exit 1 + ;; +esac diff --git a/systemd/localobserve-jit-audit.service b/systemd/localobserve-jit-audit.service new file mode 100644 index 0000000..dabcf2b --- /dev/null +++ b/systemd/localobserve-jit-audit.service @@ -0,0 +1,18 @@ +[Unit] +Description=LocalObserve JIT access expiry revocation cycle (M-26-14 Req-3) +Documentation=https://github.com/JJediny/LocalObserve/issues/21 +# NOTE: deliberately no After=/Wants= on docker.service / network-online.target — +# user-scope units cannot order against system-manager units. If OpenObserve is +# down, the audit run fails safely and the hourly timer simply retries. + +[Service] +Type=oneshot +WorkingDirectory=/home/john/LocalObserve +# /usr/bin/python3: system interpreter (the tool is stdlib-only; conda on the +# developer PATH is not visible to systemd user services). +ExecStart=/usr/bin/python3 /home/john/LocalObserve/tools/compliance_rbac_jit.py audit +# Revocation needs the OpenObserve API; when the stack is down the run fails +# cheaply and the timer retries. Outcomes land in the user journal. +StandardOutput=journal +StandardError=journal +Nice=10 diff --git a/systemd/localobserve-jit-audit.timer b/systemd/localobserve-jit-audit.timer new file mode 100644 index 0000000..3dd1ef6 --- /dev/null +++ b/systemd/localobserve-jit-audit.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Hourly LocalObserve JIT ticket expiry audit (M-26-14 Req-3) + +[Timer] +# First cycle shortly after a (re)boot; then hourly. Tickets are short-lived +# (the CLI's own message promises expiry), so a laggy schedule re-creates the +# lingering-admin failure mode found in the #82 review. Persistent=true makes +# a missed cycle fire immediately once the machine comes back. +OnBootSec=5min +OnUnitActiveSec=1h +AccuracySec=5min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/tests/test_jit_audit_timer.py b/tests/test_jit_audit_timer.py new file mode 100644 index 0000000..9dffde0 --- /dev/null +++ b/tests/test_jit_audit_timer.py @@ -0,0 +1,56 @@ +"""Static checks for the scheduled JIT audit revocation cycle (M-26-14 Req-3). + +`tools/compliance_rbac_jit.py audit` is the ONLY revocation path for expired JIT +tickets; unscheduled expiry produced a lingering admin user in the 2026-10 #82 +review. These tests keep the systemd units, installer, and Taskfile wiring in +sync with the tool — mirroring the content-assertion style used by +test_container_image_release.py. +""" + +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SERVICE = ROOT / "systemd" / "localobserve-jit-audit.service" +TIMER = ROOT / "systemd" / "localobserve-jit-audit.timer" +INSTALLER = ROOT / "scripts" / "install-jit-timer.sh" +TASKFILE = ROOT / "Taskfile.yml" + + +def test_units_and_installer_exist(): + assert SERVICE.exists(), "missing systemd/localobserve-jit-audit.service" + assert TIMER.exists(), "missing systemd/localobserve-jit-audit.timer" + assert INSTALLER.exists(), "missing scripts/install-jit-timer.sh" + + +def test_service_invokes_the_audit_cycle(): + text = SERVICE.read_text(encoding="utf-8") + assert "Type=oneshot" in text + # Must run the same command the docs/Taskfile advertise — a rename of the + # tool or subcommand must fail this test, not rot silently in a timer. + assert re.search(r"python3\s+\S+/tools/compliance_rbac_jit\.py\s+audit", text) + assert "WorkingDirectory=/home/john/LocalObserve" in text # rewritten by installer + + +def test_timer_is_periodic_persistent_and_installed(): + text = TIMER.read_text(encoding="utf-8") + assert "OnUnitActiveSec=" in text, "timer must recur, not just boot once" + assert "Persistent=true" in text, "missed cycles must fire after downtime" + assert "WantedBy=timers.target" in text + + +def test_installer_is_valid_bash_localizes_paths_and_targets_user_scope(): + result = subprocess.run(["bash", "-n", str(INSTALLER)], capture_output=True, text=True) + assert result.returncode == 0, f"installer syntax error: {result.stderr}" + text = INSTALLER.read_text(encoding="utf-8") + assert "systemctl --user" in text, "must install at user scope (no root)" + assert "systemctl --user enable --now localobserve-jit-audit.timer" in text + # Path localization convention (matches install-logrotate.sh). + assert "s|/home/john/LocalObserve|${REPO_ROOT}|g" in text + + +def test_taskfile_exposes_install_status_and_remove(): + text = TASKFILE.read_text(encoding="utf-8") + assert "install-jit-timer" in text + assert "scripts/install-jit-timer.sh" in text