From d48e2220543787a94dafebd14a731054f1d6706c Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 25 Apr 2026 23:11:26 +0000 Subject: [PATCH] monitoring: lock Finance (Personal) folder to admin + fix cash classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder ACL: - Move uk-payslip + wealth dashboards to a new "Finance (Personal)" folder; job-hunter + fire-planner stay in "Finance" (open). - New null_resource calls Grafana's folder permissions API after the dashboard sidecar materialises the folder, setting an admin-only ACL ({Admin: 4}). Default Viewer/Editor inheritance is overridden, so anonymous-Viewer (auth.anonymous=true) is denied. Server-admin always retains access. - Verified: anonymous → 403 on uk-payslip + wealth, 200 on control dashboards (node-exporter); admin → 200 on all. Wealth cash fix: - Wealthfolio dumps WORKPLACE_PENSION wrappers entirely into cash_balance because it doesn't track underlying fund holdings. Reclassify pension cash as invested in the "Cash vs invested" panel so the cash series reflects actual uninvested broker cash (~£16k T212 ISA + Schwab) instead of phantom £154k. Pre-fix: cash=£153,789 / invested=£870,282 / total=£1,024,071 Post-fix: cash=£16,064 / invested=£1,008,008 / total=£1,024,071 --- stacks/monitoring/main.tf | 1 + .../modules/monitoring/dashboards/wealth.json | 4 +- .../monitoring/modules/monitoring/grafana.tf | 66 ++++++++++++++++++- stacks/monitoring/modules/monitoring/main.tf | 4 ++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/stacks/monitoring/main.tf b/stacks/monitoring/main.tf index c4961fdd..0c207aa0 100644 --- a/stacks/monitoring/main.tf +++ b/stacks/monitoring/main.tf @@ -30,6 +30,7 @@ module "monitoring" { haos_api_token = data.vault_kv_secret_v2.secrets.data["haos_api_token"] pve_password = data.vault_kv_secret_v2.secrets.data["pve_password"] grafana_admin_password = data.vault_kv_secret_v2.secrets.data["grafana_admin_password"] + kube_config_path = var.kube_config_path registry_user = data.vault_kv_secret_v2.viktor.data["registry_user"] registry_password = data.vault_kv_secret_v2.viktor.data["registry_password"] tier = local.tiers.cluster diff --git a/stacks/monitoring/modules/monitoring/dashboards/wealth.json b/stacks/monitoring/modules/monitoring/dashboards/wealth.json index 8fa3704c..b3dddfe7 100644 --- a/stacks/monitoring/modules/monitoring/dashboards/wealth.json +++ b/stacks/monitoring/modules/monitoring/dashboards/wealth.json @@ -353,7 +353,7 @@ { "id": 9, "title": "Cash vs invested (stacked)", - "description": "Daily breakdown of cash holdings vs market value of investments, summed across all accounts.", + "description": "Daily breakdown of uninvested broker cash vs market value of investments. WORKPLACE_PENSION accounts (Fidelity) are reclassified entirely as invested — Wealthfolio dumps pension wrappers into cash_balance because it doesn't track the underlying fund holdings, but they are not actually cash.", "type": "timeseries", "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, "gridPos": {"h": 10, "w": 24, "x": 0, "y": 35}, @@ -400,7 +400,7 @@ "rawQuery": true, "editorMode": "code", "format": "time_series", - "rawSql": "SELECT valuation_date::timestamp AS \"time\", SUM(cash_balance) AS cash, SUM(investment_market_value) AS invested FROM daily_account_valuation WHERE $__timeFilter(valuation_date) GROUP BY valuation_date ORDER BY valuation_date" + "rawSql": "SELECT d.valuation_date::timestamp AS \"time\", SUM(CASE WHEN a.account_type = 'WORKPLACE_PENSION' THEN 0 ELSE d.cash_balance END) AS cash, SUM(CASE WHEN a.account_type = 'WORKPLACE_PENSION' THEN d.cash_balance + d.investment_market_value ELSE d.investment_market_value END) AS invested FROM daily_account_valuation d JOIN accounts a ON a.id = d.account_id WHERE $__timeFilter(d.valuation_date) GROUP BY d.valuation_date ORDER BY d.valuation_date" } ] }, diff --git a/stacks/monitoring/modules/monitoring/grafana.tf b/stacks/monitoring/modules/monitoring/grafana.tf index b7f8d261..b5a5f249 100644 --- a/stacks/monitoring/modules/monitoring/grafana.tf +++ b/stacks/monitoring/modules/monitoring/grafana.tf @@ -134,11 +134,19 @@ locals { # Applications "qbittorrent.json" = "Applications" "realestate-crawler.json" = "Applications" - "uk-payslip.json" = "Finance" + "uk-payslip.json" = "Finance (Personal)" + "wealth.json" = "Finance (Personal)" "job-hunter.json" = "Finance" - "wealth.json" = "Finance" "fire-planner.json" = "Finance" } + + # Folders restricted to the Grafana admin user (anonymous Viewer + any future + # non-admin users are denied). Permission set by null_resource below via the + # Grafana folder permissions API after the dashboard sidecar auto-creates the + # folder. Server-admin always retains access regardless of folder ACL. + admin_only_folders = [ + "Finance (Personal)", + ] } resource "kubernetes_config_map" "grafana_dashboards" { @@ -159,6 +167,60 @@ resource "kubernetes_config_map" "grafana_dashboards" { } } +# Lock down "admin only" folders via Grafana folder permissions API. +# Default org-role inheritance gives Viewer + Editor read access to every +# folder; explicitly setting the folder ACL to {Admin: 4} overrides that +# inheritance so Viewer/Editor (incl. anonymous-Viewer) get no access. +# The Grafana super-admin (`admin` user) always retains access regardless. +resource "null_resource" "grafana_admin_only_folder_acl" { + for_each = toset(local.admin_only_folders) + + # Re-runs on tg apply (cheap, idempotent API call). Catches drift if anyone + # edits permissions via the UI or the folder is rebuilt. + triggers = { + folder = each.value + always = timestamp() + } + + provisioner "local-exec" { + interpreter = ["/bin/bash", "-c"] + command = <<-EOT + set -euo pipefail + FOLDER='${each.value}' + KUBECONFIG_FLAG='--kubeconfig ${var.kube_config_path}' + POD=$(kubectl $KUBECONFIG_FLAG get pod -n monitoring -l app.kubernetes.io/name=grafana -o jsonpath='{.items[0].metadata.name}') + ADMIN_PW=$(kubectl $KUBECONFIG_FLAG get secret -n monitoring grafana -o jsonpath='{.data.admin-password}' | base64 -d) + + # Wait up to 60s for the dashboard sidecar to materialise the folder. + for i in $(seq 1 12); do + FOLDER_UID=$(kubectl $KUBECONFIG_FLAG exec -n monitoring "$POD" -c grafana -- \ + curl -sf -u "admin:$ADMIN_PW" "http://localhost:3000/api/folders" \ + | python3 -c "import json,sys; folders=json.load(sys.stdin); print(next((f['uid'] for f in folders if f['title']==sys.argv[1]), ''))" "$FOLDER" || true) + if [ -n "$FOLDER_UID" ]; then break; fi + sleep 5 + done + + if [ -z "$FOLDER_UID" ]; then + echo "ERROR: folder '$FOLDER' not found in Grafana after 60s" + exit 1 + fi + + # Admin-only ACL. permission codes: 1=View, 2=Edit, 4=Admin. + kubectl $KUBECONFIG_FLAG exec -n monitoring "$POD" -c grafana -- \ + curl -sf -u "admin:$ADMIN_PW" -X POST \ + -H "Content-Type: application/json" \ + -d '{"items":[{"role":"Admin","permission":4}]}' \ + "http://localhost:3000/api/folders/$FOLDER_UID/permissions" >/dev/null + echo "set admin-only ACL on folder '$FOLDER' (uid=$FOLDER_UID)" + EOT + } + + depends_on = [ + helm_release.grafana, + kubernetes_config_map.grafana_dashboards, + ] +} + resource "helm_release" "grafana" { namespace = kubernetes_namespace.monitoring.metadata[0].name create_namespace = true diff --git a/stacks/monitoring/modules/monitoring/main.tf b/stacks/monitoring/modules/monitoring/main.tf index db0c798e..d55ac703 100644 --- a/stacks/monitoring/modules/monitoring/main.tf +++ b/stacks/monitoring/modules/monitoring/main.tf @@ -27,6 +27,10 @@ variable "grafana_admin_password" { type = string sensitive = true } +variable "kube_config_path" { + type = string + sensitive = true +} variable "tier" { type = string } variable "mysql_host" { type = string } variable "registry_user" {