Coverage for cli/monitoring_user_mgmt.py: 91.94%
52 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Grafana user management over the admin HTTP API (cluster observability).
3Grafana uses its own native user database (self sign-up and anonymous access are
4disabled), so ``gco monitoring users`` drives Grafana's admin HTTP API rather
5than Cognito. Because Grafana is a private ``ClusterIP`` Service, these calls go
6through a ``gco monitoring open`` port-forward (default ``http://localhost:3000``).
8Admin credentials come from the chart-generated ``kube-prometheus-stack-grafana``
9Secret (read here via ``kubectl``) unless the caller supplies them explicitly.
11The HTTP functions are thin, mockable wrappers over ``requests`` (unit-tested by
12patching ``requests``), mirroring :mod:`cli.analytics_user_mgmt`'s shape.
13"""
15from __future__ import annotations
17import base64
18import json
19import re
20import secrets
21import subprocess
23import requests
25DEFAULT_GRAFANA_URL = "http://localhost:3000"
26DEFAULT_NAMESPACE = "monitoring"
27DEFAULT_ADMIN_SECRET = "kube-prometheus-stack-grafana"
29_HTTP_TIMEOUT_SECONDS = 15
30_NAMESPACE_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
31_SECRET_RE = re.compile(r"^[a-z0-9]([a-z0-9.\-]{0,251}[a-z0-9])?$")
34def generate_password(n_bytes: int = 24) -> str:
35 """Return a cryptographically strong, URL-safe password."""
36 return secrets.token_urlsafe(n_bytes)
39def read_grafana_admin_credentials(
40 namespace: str = DEFAULT_NAMESPACE, secret_name: str = DEFAULT_ADMIN_SECRET
41) -> tuple[str, str]:
42 """Read ``(admin_user, admin_password)`` from the Grafana Secret via kubectl.
44 Requires cluster access (the same connectivity ``gco monitoring open`` needs).
45 Raises ``RuntimeError`` if kubectl fails or the keys are absent.
46 """
47 if not _NAMESPACE_RE.match(namespace):
48 raise ValueError(f"Invalid namespace {namespace!r}")
49 if not _SECRET_RE.match(secret_name): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 raise ValueError(f"Invalid secret name {secret_name!r}")
52 cmd = ["kubectl", "get", "secret", secret_name, "-n", namespace, "-o", "json"]
53 try:
54 result = subprocess.run(
55 cmd, capture_output=True, text=True
56 ) # nosemgrep: dangerous-subprocess-use-audit - inputs validated above; list form, no shell=True
57 except FileNotFoundError as exc:
58 raise RuntimeError(
59 "kubectl not found. Install kubectl and ensure it's on your PATH."
60 ) from exc
61 if result.returncode != 0:
62 raise RuntimeError(
63 f"Failed to read Secret {namespace}/{secret_name}: {result.stderr.strip()}"
64 )
66 data = json.loads(result.stdout or "{}").get("data", {})
67 if "admin-user" not in data or "admin-password" not in data:
68 raise RuntimeError(
69 f"Secret {namespace}/{secret_name} does not carry admin-user/admin-password"
70 )
71 user = base64.b64decode(data["admin-user"]).decode("utf-8")
72 password = base64.b64decode(data["admin-password"]).decode("utf-8")
73 return user, password
76def create_user(
77 base_url: str,
78 auth: tuple[str, str],
79 *,
80 login: str,
81 password: str,
82 email: str | None = None,
83 name: str | None = None,
84) -> int:
85 """Create a Grafana user via ``POST /api/admin/users``; return the new id."""
86 body: dict[str, str] = {"login": login, "password": password, "name": name or login}
87 if email: 87 ↛ 89line 87 didn't jump to line 89 because the condition on line 87 was always true
88 body["email"] = email
89 resp = requests.post(
90 f"{base_url}/api/admin/users", auth=auth, json=body, timeout=_HTTP_TIMEOUT_SECONDS
91 )
92 resp.raise_for_status()
93 return int(resp.json()["id"])
96def list_users(base_url: str, auth: tuple[str, str]) -> list[dict[str, object]]:
97 """List organisation users via ``GET /api/org/users``."""
98 resp = requests.get(f"{base_url}/api/org/users", auth=auth, timeout=_HTTP_TIMEOUT_SECONDS)
99 resp.raise_for_status()
100 result = resp.json()
101 return result if isinstance(result, list) else []
104def lookup_user_id(base_url: str, auth: tuple[str, str], login_or_email: str) -> int:
105 """Resolve a login/email to a numeric user id via ``GET /api/users/lookup``."""
106 resp = requests.get(
107 f"{base_url}/api/users/lookup",
108 params={"loginOrEmail": login_or_email},
109 auth=auth,
110 timeout=_HTTP_TIMEOUT_SECONDS,
111 )
112 resp.raise_for_status()
113 return int(resp.json()["id"])
116def delete_user(base_url: str, auth: tuple[str, str], user_id: int) -> None:
117 """Delete a Grafana user via ``DELETE /api/admin/users/<id>``."""
118 resp = requests.delete(
119 f"{base_url}/api/admin/users/{int(user_id)}",
120 auth=auth,
121 timeout=_HTTP_TIMEOUT_SECONDS,
122 )
123 resp.raise_for_status()