Coverage for gco / services / grafana_rotator.py: 100.00%
74 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Scheduled rotation of the Grafana admin password (cluster observability).
3Runs as an in-cluster ``CronJob`` (see
4``lambda/kubectl-applier-simple/manifests/post-helm-grafana-credential-rotation.yaml``)
5using an existing GCO service image, so no extra container image is introduced.
7Why an active reset rather than a restart: the kube-prometheus-stack Grafana
8subchart auto-generates the admin credential into the ``<release>-grafana``
9Secret and Grafana persists it in its own database. ``GF_SECURITY_ADMIN_PASSWORD``
10only seeds the password on Grafana's *first* start, so patching the Secret and
11restarting the pod does not change the live password. Rotation therefore resets
12the password through Grafana's admin HTTP API and then updates the Secret that
13``gco monitoring`` reads at runtime.
15Order is admin-API-first so the live password and the Secret only diverge for
16the single Secret-patch call that follows a successful reset:
18 1. Read the current admin user/password from the Secret.
19 2. Generate a new strong password.
20 3. ``GET /api/user`` -> the admin user's numeric id.
21 4. ``PUT /api/admin/users/<id>/password`` with the new password.
22 5. Patch the Secret's ``admin-password`` to the new value.
24Least privilege: the only Kubernetes permission the CronJob's ServiceAccount is
25granted is ``get``/``patch`` on the single Grafana Secret in the ``monitoring``
26namespace. The reset goes through a ClusterIP Service, so no ``pods/exec`` and
27no AWS/IRSA permissions are required.
29Transport: the admin API calls carry the admin credential, so they go over
30verified HTTPS to the ``grafana-tls`` Service, whose ``grafana-tls-proxy``
31sidecar terminates TLS with a leaf issued by the GCO internal CA. The rotator
32trusts that CA alone (the ``ca.crt`` key projected at ``/var/run/gco/ca``, or
33``GCO_INTERNAL_CA_FILE``) and verifies the Service host name. An ``https`` URL
34with no usable CA bundle fails closed before any credential is read; a plain
35``http`` override (local runs against a port-forward) opens no TLS session and
36needs no bundle.
37"""
39from __future__ import annotations
41import base64
42import logging
43import os
44import secrets
45import sys
46from urllib.parse import urlsplit
48import requests
49from kubernetes import client, config
50from kubernetes.client.rest import ApiException
52from gco.services import internal_tls
54logger = logging.getLogger("gco.grafana_rotator")
56# Defaults match the kube-prometheus-stack release name GCO installs
57# (the helm-installer uses the chart key as the release name, so the Grafana
58# subchart resources are named ``kube-prometheus-stack-grafana``). All three are
59# overridable via env so the manifest stays the single source of truth.
60DEFAULT_NAMESPACE = "monitoring"
61DEFAULT_SECRET_NAME = "kube-prometheus-stack-grafana" # nosec B105 # Kubernetes Secret name, not its contents
62# The TLS front door GCO adds beside the chart's plaintext Service
63# (post-helm-monitoring-tls.yaml): 3443 -> the grafana-tls-proxy sidecar ->
64# Grafana over the pod's loopback.
65DEFAULT_SERVICE_URL = "https://grafana-tls.monitoring.svc.cluster.local:3443"
67_ADMIN_USER_KEY = "admin-user"
68_ADMIN_PASSWORD_KEY = "admin-password" # nosec B105 # data key inside the Secret, not the value
70# 24 random bytes -> ~32 URL-safe characters. Comfortably above any sane
71# minimum-length policy while staying a valid Grafana password.
72_PASSWORD_ENTROPY_BYTES = 24
73_HTTP_TIMEOUT_SECONDS = 15
76def generate_password(n_bytes: int = _PASSWORD_ENTROPY_BYTES) -> str:
77 """Return a cryptographically strong, URL-safe password string."""
78 return secrets.token_urlsafe(n_bytes)
81def tls_verify_for(service_url: str) -> str | bool:
82 """Return the ``requests`` ``verify=`` value for the Grafana admin URL.
84 ``https`` verifies against the GCO internal CA bundle only: ``requests``
85 given a bundle path loads no other trust anchors, and host name checking
86 stays on. The bundle is parsed here first, so a missing or unusable file
87 raises :class:`~gco.services.internal_tls.InternalTLSError` naming the path
88 and the variable to set, instead of failing later inside ``requests`` with
89 the admin credential already in hand. Any other scheme opens no TLS
90 session, so the library default is returned and no bundle is required.
91 """
92 if urlsplit(service_url).scheme.lower() != "https":
93 return True
94 ca_file = internal_tls.internal_ca_file()
95 try:
96 internal_tls.internal_ssl_context(ca_file)
97 except internal_tls.InternalTLSError as exc:
98 raise internal_tls.InternalTLSError(
99 f"refusing to call {service_url} without the GCO internal CA: {exc}; mount the "
100 f"ca.crt key at {ca_file} or point {internal_tls.INTERNAL_CA_FILE_ENV} at it"
101 ) from exc
102 return str(ca_file)
105def read_admin_credentials(
106 core_v1: client.CoreV1Api, namespace: str, secret_name: str
107) -> tuple[str, str]:
108 """Return ``(admin_user, admin_password)`` decoded from the Grafana Secret."""
109 secret = core_v1.read_namespaced_secret(secret_name, namespace)
110 data = secret.data or {}
111 missing = [k for k in (_ADMIN_USER_KEY, _ADMIN_PASSWORD_KEY) if k not in data]
112 if missing:
113 raise KeyError(f"Secret {namespace}/{secret_name} is missing key(s): {', '.join(missing)}")
114 user = base64.b64decode(data[_ADMIN_USER_KEY]).decode("utf-8")
115 password = base64.b64decode(data[_ADMIN_PASSWORD_KEY]).decode("utf-8")
116 return user, password
119def get_admin_user_id(service_url: str, auth: tuple[str, str], verify: str | bool = True) -> int:
120 """Look up the authenticated admin user's numeric id via ``GET /api/user``.
122 Resolving the id at runtime avoids hardcoding the bootstrap admin id (1),
123 which would silently target the wrong account if the org admin ever changes.
124 """
125 resp = requests.get(
126 f"{service_url}/api/user", auth=auth, timeout=_HTTP_TIMEOUT_SECONDS, verify=verify
127 )
128 resp.raise_for_status()
129 return int(resp.json()["id"])
132def reset_admin_password(
133 service_url: str,
134 auth: tuple[str, str],
135 user_id: int,
136 new_password: str,
137 verify: str | bool = True,
138) -> None:
139 """Reset the admin password through Grafana's admin API."""
140 resp = requests.put(
141 f"{service_url}/api/admin/users/{user_id}/password",
142 auth=auth,
143 json={"password": new_password},
144 timeout=_HTTP_TIMEOUT_SECONDS,
145 verify=verify,
146 )
147 resp.raise_for_status()
150def patch_secret_password(
151 core_v1: client.CoreV1Api, namespace: str, secret_name: str, new_password: str
152) -> None:
153 """Write the new password back into the Grafana Secret (base64 ``data``)."""
154 encoded = base64.b64encode(new_password.encode("utf-8")).decode("utf-8")
155 core_v1.patch_namespaced_secret(
156 secret_name, namespace, {"data": {_ADMIN_PASSWORD_KEY: encoded}}
157 )
160def rotate(core_v1: client.CoreV1Api, namespace: str, secret_name: str, service_url: str) -> None:
161 """Rotate the Grafana admin password end to end.
163 Resolves the transport trust first (so a missing CA bundle fails before
164 any credential is read), reads the current credential, resets the live
165 password through the admin API, then persists the new value to the
166 Secret. The password itself is never logged.
167 """
168 verify = tls_verify_for(service_url)
169 user, current_password = read_admin_credentials(core_v1, namespace, secret_name)
170 new_password = generate_password()
171 auth = (user, current_password)
172 user_id = get_admin_user_id(service_url, auth, verify)
173 reset_admin_password(service_url, auth, user_id, new_password, verify)
174 patch_secret_password(core_v1, namespace, secret_name, new_password)
175 # Audit line carries only non-secret identifiers (username, user id, service
176 # URL, namespace/secret names). The password itself is never logged.
177 # nosemgrep: python-logger-credential-disclosure
178 logger.info(
179 "Rotated Grafana admin credential for user %r (id=%s) via %s (secret %s/%s)",
180 user,
181 user_id,
182 service_url,
183 namespace,
184 secret_name,
185 )
188def main() -> int:
189 """CronJob entrypoint. Returns a process exit code (0 ok, 1 on failure)."""
190 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
191 namespace = os.environ.get("GRAFANA_NAMESPACE", DEFAULT_NAMESPACE)
192 secret_name = os.environ.get("GRAFANA_ADMIN_SECRET", DEFAULT_SECRET_NAME)
193 service_url = os.environ.get("GRAFANA_SERVICE_URL", DEFAULT_SERVICE_URL).rstrip("/")
195 try:
196 config.load_incluster_config()
197 except config.ConfigException:
198 # Allows local dry-runs against a configured kubeconfig; in the CronJob
199 # the in-cluster path always wins.
200 config.load_kube_config()
202 core_v1 = client.CoreV1Api()
203 try:
204 rotate(core_v1, namespace, secret_name, service_url)
205 except (
206 ApiException,
207 requests.RequestException,
208 internal_tls.InternalTLSError,
209 KeyError,
210 ValueError,
211 ) as exc:
212 # Only the caught exception is logged; it carries no credential material.
213 # nosemgrep: python-logger-credential-disclosure
214 logger.error("Grafana admin credential rotation failed: %s", exc)
215 return 1
216 return 0
219if __name__ == "__main__":
220 sys.exit(main())