From d2c959bebdbc999bb406f5ab3b86046e2fba16b8 Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Mon, 29 Jun 2026 16:58:45 -0600 Subject: [PATCH] Add identity and access audit --- README.md | 20 +++++++ README.md.tmpl | 20 +++++++ docs/jeannie.1.md | 5 ++ jeannie | 10 +++- scripts/access-audit | 122 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100755 scripts/access-audit diff --git a/README.md b/README.md index 6989937..5d1c1cf 100644 --- a/README.md +++ b/README.md @@ -776,6 +776,26 @@ Run the same validation locally before promoting: ./jeannie validate ``` +## Identity and access audit + +Run a read-only access inventory from the Debian server with: + +```bash +./jeannie access-audit +``` + +The audit reports configured SSH targets and key file modes, Git/Gitea remotes, +Tailscale ACL policy validation, current Kubernetes authorization, broad +cluster-admin bindings, automation service accounts, and Gitea runner status. +It does not create users, tokens, kubeconfigs, or keys. Use it to move toward +separate identities: + +- admin kubeconfig only on the Debian control host +- read-only kubeconfig for dashboards and audits +- separate service accounts for automation +- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge +- repo-managed Tailscale ACLs + The workflow only blocks automatic deploy for external Gitea service changes: files under `infra/gitea/`, or edits inside the `deploy_gitea`, `install_gitea_backup_timer`, `backup_gitea`, or `drill_gitea_restore` diff --git a/README.md.tmpl b/README.md.tmpl index 68fded6..5e84f92 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -776,6 +776,26 @@ Run the same validation locally before promoting: ./{{ main_script }} validate ``` +## Identity and access audit + +Run a read-only access inventory from the Debian server with: + +```bash +./{{ main_script }} access-audit +``` + +The audit reports configured SSH targets and key file modes, Git/Gitea remotes, +Tailscale ACL policy validation, current Kubernetes authorization, broad +cluster-admin bindings, automation service accounts, and Gitea runner status. +It does not create users, tokens, kubeconfigs, or keys. Use it to move toward +separate identities: + +- admin kubeconfig only on the Debian control host +- read-only kubeconfig for dashboards and audits +- separate service accounts for automation +- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge +- repo-managed Tailscale ACLs + The workflow only blocks automatic deploy for external Gitea service changes: files under `infra/gitea/`, or edits inside the `deploy_gitea`, `install_gitea_backup_timer`, `backup_gitea`, or `drill_gitea_restore` diff --git a/docs/jeannie.1.md b/docs/jeannie.1.md index fd542c8..cda1391 100644 --- a/docs/jeannie.1.md +++ b/docs/jeannie.1.md @@ -57,6 +57,11 @@ deployment readiness, pod restarts, node pressure, disk pressure, Traefik inventory, generated docs, Bash syntax, shellcheck, YAML parsing, OpenTofu formatting, tailnet policy validation, and optional static security checks. +`access-audit` +: Run a read-only access inventory across SSH targets, Git/Gitea remotes, +Tailscale ACL validation, Kubernetes RBAC, automation service accounts, and the +Gitea runner. + `nuke` : Destroy Kubernetes state and Pimox worker VMs. Requires `LAB_CONFIRM_NUKE=homelab`. diff --git a/jeannie b/jeannie index b07643e..b9aaf7f 100755 --- a/jeannie +++ b/jeannie @@ -5943,6 +5943,11 @@ validate_homelab() { "${REPO_ROOT}/scripts/validate-homelab" } +access_audit() { + require_debian_server "access-audit" + "${REPO_ROOT}/scripts/access-audit" +} + case "${1:-}" in up) up @@ -5965,6 +5970,9 @@ case "${1:-}" in validate) validate_homelab ;; + access-audit) + access_audit + ;; apps) require_debian_server "apps" jeannie_log_start "apps" @@ -6094,7 +6102,7 @@ case "${1:-}" in echo "Log: ${JEANNIE_LOG_FILE}" ;; *) - echo "Usage: $0 {up|plan [all|provisioning|cluster|platform|apps|edge]|rebuild-cluster|stop-cluster|start-cluster|status|validate|apps|website-translation-model|website-ollama-listen|ollama-setup|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|state-backup|fix-debian-docker-root|secrets-init|secrets-check|tailnet-policy-check|ai-index|ai-check|security-scan|security-prepare|security-zap|security-k8s|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-runtime|security-attack-path|openwrt|nuke}" + echo "Usage: $0 {up|plan [all|provisioning|cluster|platform|apps|edge]|rebuild-cluster|stop-cluster|start-cluster|status|validate|access-audit|apps|website-translation-model|website-ollama-listen|ollama-setup|deploy-gitea|rpi-services|bootstrap-gitea-repo|backup-gitea|drill-gitea-restore|install-gitea-runner|move-prometheus-stack-workers|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|state-backup|fix-debian-docker-root|secrets-init|secrets-check|tailnet-policy-check|ai-index|ai-check|security-scan|security-prepare|security-zap|security-k8s|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-runtime|security-attack-path|openwrt|nuke}" exit 1 ;; esac diff --git a/scripts/access-audit b/scripts/access-audit new file mode 100755 index 0000000..d9e2210 --- /dev/null +++ b/scripts/access-audit @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KUBECONFIG="${KUBECONFIG:-${HOME}/.kube/config}" + +section() { + printf '\n== %s ==\n' "$1" +} + +warn_file_mode() { + local path="$1" + local mode + + if [[ ! -e "${path}" ]]; then + printf '%-34s missing - %s\n' "key file" "${path}" + return 0 + fi + mode="$(stat -c '%a' "${path}" 2>/dev/null || stat -f '%Lp' "${path}" 2>/dev/null || true)" + printf '%-34s %s mode=%s\n' "key file" "${path}" "${mode:-unknown}" + case "${mode}" in + 600 | 400) + ;; + *) + printf ' warn: private SSH keys should normally be 0600 or 0400\n' + ;; + esac +} + +ssh_inventory() { + section "SSH Inventory" + printf '%-18s %s@%s key=%s\n' "Debian" "${USER:-jv}" "${LAB_DEBIAN_HOST:-${LAB_GITEA_HOST:-192.168.100.73}}" "${LAB_DEBIAN_SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}" + printf '%-18s %s@%s key=%s\n' "RPi4" "${LAB_RPI_USER:-${LAB_RASPBERRY_USER:-jv}}" "${LAB_RPI_HOST:-${LAB_RASPBERRY_HOST:-192.168.100.89}}" "${LAB_RPI_SSH_KEY_PATH:-${LAB_RASPBERRY_SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}}" + printf '%-18s %s@%s key=%s\n' "Pimox" "${LAB_PIMOX_USER:-${TF_VAR_pimox_user:-jv}}" "${LAB_PIMOX_HOST:-${TF_VAR_pimox_host:-192.168.100.80}}" "${LAB_PIMOX_SSH_KEY_PATH:-${TF_VAR_pimox_ssh_key_path:-${HOME}/.ssh/id_ed25519}}" + printf '%-18s %s@%s\n' "OCI edge" "${LAB_EDGE_USER:-ubuntu}" "${LAB_EDGE_HOST:-132.145.170.74}" + + warn_file_mode "${LAB_DEBIAN_SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}" +} + +git_access() { + section "Git Remotes" + git -C "${REPO_ROOT}" remote -v || true + + printf '\nCurrent branch:\n' + git -C "${REPO_ROOT}" branch -vv || true + + printf '\nGitea deploy key expected path: %s\n' "${LAB_GITEA_REPO_SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519.pub}" + printf 'Gitea deploy key title: %s\n' "${LAB_GITEA_REPO_DEPLOY_KEY_TITLE:-debian-host-my-homelab-configs}" + printf 'Gitea deploy key read-only: %s\n' "${LAB_GITEA_REPO_DEPLOY_KEY_READ_ONLY:-false}" + if [[ "${LAB_GITEA_REPO_DEPLOY_KEY_READ_ONLY:-false}" != "true" ]]; then + printf ' warn: deploy key is expected to be write-capable for Actions/deploy mirroring\n' + fi +} + +tailnet_access() { + section "Tailscale" + if command -v tailscale >/dev/null 2>&1; then + tailscale ip -4 || true + tailscale status || true + else + printf 'tailscale command not installed\n' + fi + + printf '\nRepo-managed ACL validation:\n' + "${REPO_ROOT}/scripts/validate-tailnet-policy" || true +} + +kubernetes_access() { + section "Kubernetes Access" + if ! command -v kubectl >/dev/null 2>&1; then + printf 'kubectl not installed\n' + return 0 + fi + if [[ ! -s "${KUBECONFIG}" ]]; then + printf 'kubeconfig missing: %s\n' "${KUBECONFIG}" + return 0 + fi + + printf 'KUBECONFIG: %s\n' "${KUBECONFIG}" + kubectl --kubeconfig "${KUBECONFIG}" config current-context 2>/dev/null || true + + printf '\nCan current user perform broad actions?\n' + for verb in get list create update delete; do + printf '%-10s ' "${verb}" + kubectl --kubeconfig "${KUBECONFIG}" auth can-i "${verb}" pods --all-namespaces 2>/dev/null || true + done + + printf '\nClusterRoleBindings to cluster-admin:\n' + kubectl --kubeconfig "${KUBECONFIG}" get clusterrolebinding -o jsonpath='{range .items[?(@.roleRef.name=="cluster-admin")]}{.metadata.name}{" -> "}{range .subjects[*]}{.kind}{"/"}{.namespace}{"/"}{.name}{" "}{end}{"\n"}{end}' 2>/dev/null || true + + printf '\nAutomation service accounts:\n' + kubectl --kubeconfig "${KUBECONFIG}" get serviceaccounts -A 2>/dev/null | + awk 'NR == 1 || /argocd|gitea|runner|kyverno|tetragon|prometheus|jeannie|homelab/' + + cat <<'EOF' + +Read-only kubeconfig target: +- Create a dedicated read-only ClusterRole/Binding for dashboards and audits. +- Keep admin kubeconfig only on the Debian control host. +- Use separate service accounts for automation instead of reusing admin. +EOF +} + +runner_access() { + section "Automation Runners" + if command -v systemctl >/dev/null 2>&1; then + systemctl is-active homelab-gitea-runner.service 2>/dev/null || true + systemctl status homelab-gitea-runner.service --no-pager -l 2>/dev/null | sed -n '1,12p' || true + else + printf 'systemctl unavailable\n' + fi +} + +main() { + ssh_inventory + git_access + tailnet_access + kubernetes_access + runner_access +} + +main "$@"