Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/compliance_crosswalk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions scripts/install-jit-timer.sh
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions systemd/localobserve-jit-audit.service
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions systemd/localobserve-jit-audit.timer
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions tests/test_jit_audit_timer.py
Original file line number Diff line number Diff line change
@@ -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
Loading