Coverage for cli / monitoring_user_mgmt.py: 100.00%
52 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"""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" # nosec B105 # Kubernetes Secret name, not its contents
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):
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,
56 capture_output=True,
57 text=True,
58 check=False,
59 ) # nosemgrep: dangerous-subprocess-use-audit - inputs validated above; list form, no shell=True
60 except FileNotFoundError as exc:
61 raise RuntimeError(
62 "kubectl not found. Install kubectl and ensure it's on your PATH."
63 ) from exc
64 if result.returncode != 0:
65 raise RuntimeError(
66 f"Failed to read Secret {namespace}/{secret_name}: {result.stderr.strip()}"
67 )
69 data = json.loads(result.stdout or "{}").get("data", {})
70 if "admin-user" not in data or "admin-password" not in data:
71 raise RuntimeError(
72 f"Secret {namespace}/{secret_name} does not carry admin-user/admin-password"
73 )
74 user = base64.b64decode(data["admin-user"]).decode("utf-8")
75 password = base64.b64decode(data["admin-password"]).decode("utf-8")
76 return user, password
79def create_user(
80 base_url: str,
81 auth: tuple[str, str],
82 *,
83 login: str,
84 password: str,
85 email: str | None = None,
86 name: str | None = None,
87) -> int:
88 """Create a Grafana user via ``POST /api/admin/users``; return the new id."""
89 body: dict[str, str] = {"login": login, "password": password, "name": name or login}
90 if email:
91 body["email"] = email
92 resp = requests.post(
93 f"{base_url}/api/admin/users", auth=auth, json=body, timeout=_HTTP_TIMEOUT_SECONDS
94 )
95 resp.raise_for_status()
96 return int(resp.json()["id"])
99def list_users(base_url: str, auth: tuple[str, str]) -> list[dict[str, object]]:
100 """List organisation users via ``GET /api/org/users``."""
101 resp = requests.get(f"{base_url}/api/org/users", auth=auth, timeout=_HTTP_TIMEOUT_SECONDS)
102 resp.raise_for_status()
103 result = resp.json()
104 return result if isinstance(result, list) else []
107def lookup_user_id(base_url: str, auth: tuple[str, str], login_or_email: str) -> int:
108 """Resolve a login/email to a numeric user id via ``GET /api/users/lookup``."""
109 resp = requests.get(
110 f"{base_url}/api/users/lookup",
111 params={"loginOrEmail": login_or_email},
112 auth=auth,
113 timeout=_HTTP_TIMEOUT_SECONDS,
114 )
115 resp.raise_for_status()
116 return int(resp.json()["id"])
119def delete_user(base_url: str, auth: tuple[str, str], user_id: int) -> None:
120 """Delete a Grafana user via ``DELETE /api/admin/users/<id>``."""
121 resp = requests.delete(
122 f"{base_url}/api/admin/users/{int(user_id)}",
123 auth=auth,
124 timeout=_HTTP_TIMEOUT_SECONDS,
125 )
126 resp.raise_for_status()