pushmetrics-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. pm/__init__.py +1 -0
  2. pm/__main__.py +4 -0
  3. pm/_alias.py +164 -0
  4. pm/auth/__init__.py +0 -0
  5. pm/auth/device_flow.py +187 -0
  6. pm/auth/exchange.py +79 -0
  7. pm/auth/jwt_cache.py +163 -0
  8. pm/auth/secret_files.py +58 -0
  9. pm/auth/session.py +440 -0
  10. pm/auth/store.py +160 -0
  11. pm/auth/token.py +289 -0
  12. pm/cli.py +282 -0
  13. pm/client/__init__.py +68 -0
  14. pm/client/access_tokens.py +54 -0
  15. pm/client/base.py +229 -0
  16. pm/client/blocks.py +461 -0
  17. pm/client/databases.py +147 -0
  18. pm/client/favorites.py +14 -0
  19. pm/client/folders.py +10 -0
  20. pm/client/integrations.py +14 -0
  21. pm/client/organizations.py +20 -0
  22. pm/client/recipients.py +92 -0
  23. pm/client/reports.py +148 -0
  24. pm/client/roles.py +100 -0
  25. pm/client/runs.py +134 -0
  26. pm/client/saved_blocks.py +13 -0
  27. pm/client/tableau.py +108 -0
  28. pm/client/users.py +71 -0
  29. pm/client/webhooks.py +43 -0
  30. pm/client/workspaces.py +34 -0
  31. pm/commands/__init__.py +0 -0
  32. pm/commands/_block_factory.py +762 -0
  33. pm/commands/_helpers.py +178 -0
  34. pm/commands/auth.py +222 -0
  35. pm/commands/block.py +26 -0
  36. pm/commands/chart.py +20 -0
  37. pm/commands/db.py +291 -0
  38. pm/commands/email.py +13 -0
  39. pm/commands/explore.py +20 -0
  40. pm/commands/favorites.py +77 -0
  41. pm/commands/folders.py +113 -0
  42. pm/commands/help.py +643 -0
  43. pm/commands/integrations.py +95 -0
  44. pm/commands/metrics_view.py +195 -0
  45. pm/commands/organizations.py +56 -0
  46. pm/commands/recipient.py +226 -0
  47. pm/commands/reports.py +364 -0
  48. pm/commands/roles.py +102 -0
  49. pm/commands/runs.py +214 -0
  50. pm/commands/saved_blocks.py +97 -0
  51. pm/commands/sftp.py +19 -0
  52. pm/commands/slack.py +13 -0
  53. pm/commands/slides.py +216 -0
  54. pm/commands/sql.py +17 -0
  55. pm/commands/tableau.py +148 -0
  56. pm/commands/token.py +107 -0
  57. pm/commands/users.py +262 -0
  58. pm/commands/webhooks.py +132 -0
  59. pm/commands/workspace.py +156 -0
  60. pm/config.py +192 -0
  61. pm/errors.py +228 -0
  62. pm/log.py +63 -0
  63. pm/models/__init__.py +31 -0
  64. pm/models/access_tokens.py +25 -0
  65. pm/models/blocks.py +21 -0
  66. pm/models/databases.py +15 -0
  67. pm/models/favorites.py +14 -0
  68. pm/models/folders.py +14 -0
  69. pm/models/integrations.py +20 -0
  70. pm/models/organizations.py +13 -0
  71. pm/models/recipients.py +41 -0
  72. pm/models/reports.py +16 -0
  73. pm/models/roles.py +22 -0
  74. pm/models/runs.py +56 -0
  75. pm/models/saved_blocks.py +15 -0
  76. pm/models/users.py +36 -0
  77. pm/models/webhooks.py +23 -0
  78. pm/models/workspaces.py +16 -0
  79. pm/output.py +92 -0
  80. pm/types.py +130 -0
  81. pm/validators.py +499 -0
  82. pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
  83. pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
  84. pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
  85. pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
  86. pushmetrics_cli-0.1.0.dist-info/top_level.txt +1 -0
pm/auth/session.py ADDED
@@ -0,0 +1,440 @@
1
+ """PMSession — authenticated requests.Session wrapper with silent recovery."""
2
+ from __future__ import annotations
3
+
4
+ import http.cookiejar as cookiejar
5
+ import ipaddress
6
+ import logging
7
+ import os
8
+ import time
9
+
10
+ from urllib.parse import urlsplit
11
+
12
+ import click
13
+ import requests
14
+
15
+ from pm.auth import device_flow, exchange, jwt_cache, store
16
+ from pm.auth import token as token_auth
17
+ from pm.config import Profile
18
+ from pm.log import log_request, log_response
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # pm-consolidation follow-up — when the prefect agent invokes the pm
24
+ # subprocess, it sets PM_AGENT_UID so the API can scope block.list /
25
+ # block.get to the agent's assigned blocks (see
26
+ # superset/pushmetrics/security/agent_scope.py for the backend half).
27
+ # Read once at session construction; tagged onto every request below.
28
+ _AGENT_UID_HEADER = "X-Agent-Uid"
29
+
30
+
31
+ # Anything with whitespace or control characters must never reach requests
32
+ # (InvalidHeader echoes the value). One pattern, shared with the JWT cache.
33
+ _JWT_SHAPE_RE = jwt_cache.JWT_SHAPE_RE
34
+
35
+ JWT_SHAPE_HINT = (
36
+ "PM_API_JWT is malformed: it must be a single token with no spaces or "
37
+ "line breaks."
38
+ )
39
+
40
+
41
+ class InsecureApiUrlError(click.ClickException):
42
+ """A bearer credential would travel in cleartext to a non-loopback host."""
43
+
44
+
45
+ # Harness-only opt-in: the prefect pod reaches the app over the in-cluster
46
+ # plain-http Service (TLS terminates at the ingress), so cli_adapter.run_pm
47
+ # sets this for the delegation subprocess. It is honoured ONLY in delegation
48
+ # mode (PM_API_JWT); a PAT is never sent in cleartext, whatever the env says.
49
+ ALLOW_PLAIN_HTTP_ENV_VAR = "PM_ALLOW_PLAIN_HTTP"
50
+
51
+
52
+ def _plain_http_opted_in() -> bool:
53
+ return (os.environ.get(ALLOW_PLAIN_HTTP_ENV_VAR) or "").strip() == "1"
54
+
55
+
56
+ def _refuse_bearer_over_plain_http(api_url: str, *, is_delegation: bool = False) -> None:
57
+ """Token and delegation mode attach `Authorization: Bearer` to the very
58
+ first request, before any server-side 301 to https could help. Loopback
59
+ http is the local and agent profiles' legitimate case (security S6);
60
+ in-cluster http is the harness's, behind an explicit opt-in."""
61
+ parts = urlsplit(api_url)
62
+ if parts.scheme != "http" or _is_loopback_host(parts.hostname or ""):
63
+ return
64
+ if is_delegation and _plain_http_opted_in():
65
+ logger.debug("plain http to %s allowed: delegation mode with %s=1", parts.hostname, ALLOW_PLAIN_HTTP_ENV_VAR)
66
+ return
67
+ raise InsecureApiUrlError(
68
+ f"Refusing to send a bearer credential over plain http to "
69
+ f"{parts.hostname}. Use https:// in --api-url, PM_API_URL, or "
70
+ f"`pm config set <profile> api_url https://...`."
71
+ )
72
+
73
+
74
+ # A 502/503/504 on a read is almost always one bad moment at the ingress
75
+ # (production: one page of a 14-page `pm report list` 502'd, the next run of
76
+ # the same pages passed). GETs are safe to repeat, so retry them a few times
77
+ # before failing the whole command. Writes are never retried.
78
+ _RETRY_STATUSES = frozenset({502, 503, 504})
79
+ _RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
80
+ _sleep = time.sleep # patched in tests
81
+
82
+
83
+ class PMConnectionError(click.ClickException):
84
+ """The API could not be reached. The transport error's own text is never
85
+ shown: it can embed the request, headers included."""
86
+
87
+
88
+ class NotAuthenticatedError(click.ClickException):
89
+ def __init__(
90
+ self,
91
+ message: str = (
92
+ "Not logged in. Run `pm login --token` with a personal access "
93
+ "token, or set PM_API_TOKEN."
94
+ ),
95
+ ):
96
+ super().__init__(message)
97
+
98
+
99
+ def _is_loopback_host(host: str) -> bool:
100
+ host = (host or "").lower().rstrip(".")
101
+ if host == "localhost":
102
+ return True
103
+ try:
104
+ return ipaddress.ip_address(host).is_loopback
105
+ except ValueError:
106
+ return False
107
+
108
+
109
+ def _strip_secure_for_local_http(jar, api_url: str) -> None:
110
+ """Clear the Secure flag on cookies bound for a loopback http backend.
111
+
112
+ Browsers treat http://localhost and http://127.0.0.1 as trustworthy
113
+ origins, so a backend running with SESSION_COOKIE_SECURE=True (prod
114
+ parity) still works in the local web app. Python's cookie handling has no
115
+ such carve-out — and requests rebuilds the jar with the default policy on
116
+ every send (Session.prepare_request -> merge_cookies), so a custom policy
117
+ can't override it either. Without this, the Secure ``superset_session``
118
+ cookie is silently dropped and every CLI request against a local backend
119
+ goes out anonymous, 401ing on the first protected endpoint. Non-loopback
120
+ http targets keep browser semantics: Secure cookies stay unsent.
121
+
122
+ Only cookies whose own domain is loopback are touched: the jar is written
123
+ back to the credential store by ``persist()``, and a cookie for any other
124
+ host must never be de-secured durably. http.cookiejar stores unqualified
125
+ request hosts with a ``.local`` suffix (``localhost.local``), so that
126
+ suffix is peeled before the loopback check.
127
+ """
128
+ parts = urlsplit(api_url)
129
+ if parts.scheme != "http" or not _is_loopback_host(parts.hostname or ""):
130
+ return
131
+ for cookie in jar:
132
+ domain = (cookie.domain or "").lstrip(".")
133
+ if domain.endswith(".local"):
134
+ domain = domain[: -len(".local")]
135
+ if _is_loopback_host(domain):
136
+ cookie.secure = False
137
+
138
+
139
+ class PMSession:
140
+ """Authenticated session against the PushMetrics API.
141
+
142
+ Three auth modes:
143
+
144
+ - **Cookie mode**: the Auth0 device-flow session cookie (carries
145
+ workspace_id for Postgres RLS). On 401, a single recovery is attempted via
146
+ the stored Auth0 refresh token. Internal-team interactive use; the
147
+ lowest-priority mode (used only when no PAT / delegation JWT is present).
148
+ - **Token mode** (``token=...``): a raw Personal Access Token is exchanged for
149
+ a short-lived workspace-scoped JWT, sent as ``Authorization: Bearer``. No
150
+ cookie jar and no Auth0 path. On 401 the PAT is re-exchanged once.
151
+ - **Delegation mode** (``static_jwt=...``): a pre-minted workspace-scoped
152
+ JWT (the agent harness sets ``PM_API_JWT``) used as-is. No exchange and
153
+ no refresh authority - a 401 is final.
154
+
155
+ ``from_profile`` resolution order: ``PM_API_JWT`` > PAT (``PM_API_TOKEN``
156
+ env or stored ``pm login --token`` token) > stored cookie credentials.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ profile: Profile,
162
+ creds: store.StoredCredentials | None = None,
163
+ *,
164
+ token: str | None = None,
165
+ static_jwt: str | None = None,
166
+ ):
167
+ self.profile = profile
168
+ self.creds = creds
169
+ self._session = requests.Session()
170
+ # Token-mode state. _token is the raw PAT; _jwt is the cached
171
+ # short-lived exchange result, refreshed shortly before expiry.
172
+ self._token = token
173
+ self._jwt: str | None = None
174
+ self._jwt_expires_at = 0.0
175
+ # Static-bearer mode (agent delegation): a pre-minted workspace-scoped
176
+ # JWT used as-is. No exchange, no refresh authority - a 401 is final.
177
+ # Normalize once: a blank/whitespace static_jwt is treated as unset so
178
+ # it can't send an empty ``Authorization: Bearer`` nor suppress the
179
+ # cookie jar / other auth modes.
180
+ self._static_jwt = (static_jwt or "").strip() or None
181
+ if token is not None or self._static_jwt is not None:
182
+ _refuse_bearer_over_plain_http(
183
+ profile.api_url, is_delegation=self._static_jwt is not None
184
+ )
185
+ if self._static_jwt is not None:
186
+ # Delegation invariant: the pre-minted JWT is the WHOLE session.
187
+ # Freeze the jar so a Flask-re-issued ``superset_session`` (or any
188
+ # other Set-Cookie) is never stored and never rides along on the
189
+ # next request of a multi-request command — auth and workspace
190
+ # scope must come from the Bearer header alone. Same immutability
191
+ # idea as the env-cookie _RejectServerCookiesPolicy this branch
192
+ # removed with the cookie delegation path.
193
+ self._session.cookies.set_policy(
194
+ cookiejar.DefaultCookiePolicy(allowed_domains=[])
195
+ )
196
+ if token is None and self._static_jwt is None:
197
+ if creds is None:
198
+ raise ValueError(
199
+ "PMSession needs credentials: pass `creds` (cookie mode), "
200
+ "`token` (bearer mode), or `static_jwt` (delegation mode)."
201
+ )
202
+ jar = creds.cookies()
203
+ _strip_secure_for_local_http(jar, profile.api_url)
204
+ self._session.cookies = jar
205
+ # Read PM_AGENT_UID once at construction. Empty string treated
206
+ # same as unset so misconfigured callers don't accidentally
207
+ # send "X-Agent-Uid: " to the API.
208
+ self._agent_uid = (os.environ.get("PM_AGENT_UID") or "").strip() or None
209
+
210
+ @classmethod
211
+ def from_profile(cls, profile: Profile) -> "PMSession":
212
+ # Delegation JWT wins outright: PM_API_JWT is set only by the agent
213
+ # harness (cli_adapter), where the pre-minted credential IS the
214
+ # session and nothing may fall through to stored user credentials.
215
+ static_jwt = (os.environ.get("PM_API_JWT") or "").strip()
216
+ if static_jwt:
217
+ if not _JWT_SHAPE_RE.fullmatch(static_jwt):
218
+ raise NotAuthenticatedError(JWT_SHAPE_HINT)
219
+ return cls(profile, static_jwt=static_jwt)
220
+ # Bearer PAT takes priority over the cookie jar — it's the explicit
221
+ # programmatic-auth signal (env var or `pm login --token`).
222
+ token = token_auth.load_token(profile.name)
223
+ if token:
224
+ return cls(profile, token=token)
225
+ creds = store.load(profile.name)
226
+ if creds is None:
227
+ raise NotAuthenticatedError()
228
+ return cls(profile, creds)
229
+
230
+ def request(self, method: str, path: str, **kwargs) -> requests.Response:
231
+ url = f"{self.profile.api_url}{path}"
232
+ kwargs.setdefault("timeout", 60)
233
+ headers = dict(kwargs.get("headers") or {})
234
+ # Forward agent identity to the API for backend scope filters.
235
+ if self._agent_uid:
236
+ headers.setdefault(_AGENT_UID_HEADER, self._agent_uid)
237
+ # Static-bearer mode: attach the pre-minted delegation JWT as-is.
238
+ if self._static_jwt is not None:
239
+ headers["Authorization"] = f"Bearer {self._static_jwt}"
240
+ # Token mode: attach a fresh Bearer JWT (exchanged from the PAT).
241
+ elif self._token is not None:
242
+ headers["Authorization"] = f"Bearer {self._ensure_jwt()}"
243
+ if headers:
244
+ kwargs["headers"] = headers
245
+ # Cookie mode: any earlier response in this process may have re-issued
246
+ # the session cookie with Secure (SESSION_COOKIE_SECURE=True backend),
247
+ # which would silently drop it from this send — re-normalize each time.
248
+ if self._token is None and self._static_jwt is None:
249
+ _strip_secure_for_local_http(self._session.cookies, self.profile.api_url)
250
+ resp = self._send(method, url, kwargs)
251
+ # Recover at most once per request (request-local, not session-wide):
252
+ # a JWT can expire mid-session, so a later request must still be able to
253
+ # re-exchange. There's no retry loop, so this can't spin.
254
+ if resp.status_code == 401:
255
+ logger.debug("Got 401; attempting a single auth recovery.")
256
+ if self._token is not None:
257
+ # The rejected JWT must not be presented again by the next
258
+ # process either: forget it before minting a replacement.
259
+ self._forget_jwt()
260
+ if self._recover():
261
+ # Re-stamp the Bearer header with the freshly minted JWT.
262
+ if self._token is not None:
263
+ kwargs.setdefault("headers", headers)
264
+ kwargs["headers"]["Authorization"] = f"Bearer {self._jwt}"
265
+ resp = self._send(method, url, kwargs)
266
+ if resp.status_code == 401:
267
+ # CONTRACT: these messages carry "auth failed: 401" so the
268
+ # prefect cli_adapter's stderr status scrape
269
+ # (_API_STATUS_RE, "failed:\s*(\d{3})") classifies them as
270
+ # an auth denial - report tools must NOT fall back to the
271
+ # direct-DB path on an expired/invalid credential.
272
+ if self._static_jwt is not None:
273
+ raise NotAuthenticatedError(
274
+ "API auth failed: 401 - delegation credential expired "
275
+ "or invalid (PM_API_JWT). The delegation cannot be "
276
+ "refreshed; a new one must be minted."
277
+ )
278
+ if self._token is not None:
279
+ # The replacement JWT was rejected too: never present it
280
+ # again from the cache.
281
+ self._forget_jwt()
282
+ raise NotAuthenticatedError(
283
+ "API auth failed: 401 - bearer token rejected "
284
+ "(expired, revoked, or invalid). Check PM_API_TOKEN "
285
+ "or run `pm login --token`."
286
+ )
287
+ raise NotAuthenticatedError(
288
+ "API auth failed: 401 - session expired. "
289
+ "Run `pm login` again."
290
+ )
291
+ return resp
292
+
293
+ def _send(self, method: str, url: str, kwargs: dict) -> requests.Response:
294
+ # Assumes every GET is idempotent. The only state-changing GETs the CLI
295
+ # sends, /workspace/<pk>/switch and /logout/, are idempotent (verified by security).
296
+ retryable = method.upper() == "GET"
297
+ for delay in (*_RETRY_BACKOFF_SECONDS, None):
298
+ started = log_request(logger, method, url)
299
+ try:
300
+ resp = self._session.request(method, url, **kwargs)
301
+ except requests.RequestException as exc:
302
+ raise PMConnectionError(
303
+ token_auth.unreachable_message(self.profile.api_url, exc)
304
+ )
305
+ log_response(logger, resp, started)
306
+ if not retryable or resp.status_code not in _RETRY_STATUSES or delay is None:
307
+ return resp
308
+ logger.debug("got %d; retrying in %.1fs", resp.status_code, delay)
309
+ _sleep(delay)
310
+ return resp
311
+
312
+ def get(self, path: str, **kwargs) -> requests.Response:
313
+ return self.request("GET", path, **kwargs)
314
+
315
+ def post(self, path: str, **kwargs) -> requests.Response:
316
+ return self.request("POST", path, **kwargs)
317
+
318
+ def put(self, path: str, **kwargs) -> requests.Response:
319
+ return self.request("PUT", path, **kwargs)
320
+
321
+ def delete(self, path: str, **kwargs) -> requests.Response:
322
+ return self.request("DELETE", path, **kwargs)
323
+
324
+ def persist(self) -> None:
325
+ """Write the current cookiejar back to the credential store.
326
+
327
+ No-op in token / delegation mode (there is no cookie jar to persist;
328
+ the PAT is managed via `pm login --token` / `PM_API_TOKEN`, and the
329
+ delegation JWT via `PM_API_JWT`).
330
+ """
331
+ if self._token is not None or self._static_jwt is not None:
332
+ return
333
+ self.creds.set_cookies(self._session.cookies)
334
+ store.save(self.creds)
335
+
336
+ def _ensure_jwt(self) -> str:
337
+ """Return a valid Bearer JWT: the in-process one, else the one cached
338
+ by an earlier process for this profile/host/PAT, else a fresh
339
+ exchange."""
340
+ if self._jwt is None:
341
+ self._adopt_cached_jwt()
342
+ if self._jwt is None or time.monotonic() >= self._jwt_expires_at:
343
+ self._exchange()
344
+ return self._jwt
345
+
346
+ def _adopt_cached_jwt(self) -> None:
347
+ cached = jwt_cache.load(self.profile.name, self.profile.api_url, self._token)
348
+ if cached is None:
349
+ return
350
+ self._jwt = cached.jwt
351
+ self._jwt_expires_at = time.monotonic() + cached.seconds_left()
352
+
353
+ def _forget_jwt(self) -> None:
354
+ self._jwt = None
355
+ self._jwt_expires_at = 0.0
356
+ jwt_cache.clear(self.profile.name)
357
+
358
+ def _exchange(self) -> None:
359
+ result = token_auth.exchange_pat_for_jwt(
360
+ self._session, self.profile.api_url, self._token
361
+ )
362
+ self._jwt = result.access_token
363
+ # Refresh before the server-side expiry to avoid racing it
364
+ # mid-request (same margin as the cross-process cache), but never
365
+ # past the server TTL: if expires_in is tiny, cache_for clamps to 0
366
+ # so the next request re-exchanges rather than reusing an
367
+ # already-expired JWT.
368
+ cache_for = max(0, result.expires_in - jwt_cache.EXPIRY_MARGIN_SECONDS)
369
+ self._jwt_expires_at = time.monotonic() + cache_for
370
+ jwt_cache.save(
371
+ self.profile.name,
372
+ self.profile.api_url,
373
+ self._token,
374
+ result.access_token,
375
+ result.expires_in,
376
+ )
377
+
378
+ def _recover(self) -> bool:
379
+ """Single-shot auth recovery after a 401: re-exchange the PAT in token
380
+ mode, or silently re-login via Auth0 in cookie mode."""
381
+ if self._static_jwt is not None:
382
+ # A pre-minted delegation JWT has no refresh authority: the PAT
383
+ # or session that could mint a fresh one lives with the caller.
384
+ return False
385
+ if self._token is not None:
386
+ try:
387
+ self._exchange()
388
+ return True
389
+ except Exception as exc:
390
+ logger.debug("PAT re-exchange failed: %s", exc)
391
+ return False
392
+ return self._silent_relogin()
393
+
394
+ def _silent_relogin(self) -> bool:
395
+ if not self.creds.auth0_refresh_token or not self.profile.auth0_client_id:
396
+ return False
397
+ try:
398
+ token = device_flow.refresh_auth0_token(
399
+ domain=self.profile.auth0_domain,
400
+ client_id=self.profile.auth0_client_id,
401
+ refresh_token=self.creds.auth0_refresh_token,
402
+ )
403
+ self._session.cookies.clear()
404
+ result = exchange.exchange_for_session(
405
+ self._session, self.profile.api_url, token.access_token
406
+ )
407
+ # The re-issued session cookie carries Secure again; re-normalize
408
+ # so the retry and the workspace restore below aren't anonymous.
409
+ _strip_secure_for_local_http(self._session.cookies, self.profile.api_url)
410
+ self.creds.pm_access_token = (
411
+ result.pm_access_token or self.creds.pm_access_token
412
+ )
413
+ self.creds.pm_refresh_token = (
414
+ result.pm_refresh_token or self.creds.pm_refresh_token
415
+ )
416
+ if token.refresh_token:
417
+ self.creds.auth0_refresh_token = token.refresh_token
418
+
419
+ if self.creds.last_workspace_uid:
420
+ ws_resp = self._session.get(
421
+ f"{self.profile.api_url}/api/v1/workspace/", timeout=15
422
+ )
423
+ if ws_resp.status_code == 200:
424
+ body = ws_resp.json()
425
+ items = body.get("result", []) if isinstance(body, dict) else body
426
+ for ws in items:
427
+ if ws.get("uid") == self.creds.last_workspace_uid:
428
+ pk = ws.get("id")
429
+ if pk:
430
+ self._session.get(
431
+ f"{self.profile.api_url}/api/v1/workspace/{pk}/switch",
432
+ timeout=15,
433
+ )
434
+ break
435
+
436
+ self.persist()
437
+ return True
438
+ except Exception as exc:
439
+ logger.debug("Silent re-login failed: %s", exc)
440
+ return False
pm/auth/store.py ADDED
@@ -0,0 +1,160 @@
1
+ """Credential persistence.
2
+
3
+ Primary backend: OS keyring (Windows Credential Manager / macOS Keychain /
4
+ Linux Secret Service). Fallback: ~/.pm/credentials.json (chmod 0600).
5
+
6
+ Stores the Auth0 device-flow login's artifacts (session cookiejar + refresh
7
+ token) for interactive/internal use. The cookiejar carries workspace_id for
8
+ RLS; the Auth0 refresh token silently re-establishes the session when cookies
9
+ expire. Programmatic auth (PATs via ``PM_API_TOKEN``, delegation JWTs via
10
+ ``PM_API_JWT``) does not touch this store - see ``pm/auth/token.py`` and
11
+ ``pm/auth/session.py``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json
17
+ import logging
18
+ from dataclasses import asdict, dataclass
19
+ from typing import List, Optional
20
+
21
+ from requests.cookies import RequestsCookieJar, create_cookie
22
+
23
+ from pm.auth.secret_files import read_private_json, write_private_json
24
+ from pm.config import CONFIG_DIR, keyring_account
25
+ from pm.log import timed
26
+
27
+ try:
28
+ import keyring
29
+ _KEYRING_AVAILABLE = True
30
+ except Exception:
31
+ _KEYRING_AVAILABLE = False
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ KEYRING_SERVICE = "pushmetrics-cli"
36
+ FALLBACK_FILE = CONFIG_DIR / "credentials.json"
37
+
38
+
39
+ # Cookie serialization: JSON only. ``pickle.loads`` on stored bytes is
40
+ # RCE-by-design — we encode cookies as a list of plain dicts and base64 the
41
+ # resulting JSON.
42
+ def encode_cookies_json(jar: RequestsCookieJar) -> str:
43
+ """Serialize a RequestsCookieJar to a base64-encoded JSON list of cookie dicts."""
44
+ items: List[dict] = []
45
+ for c in jar:
46
+ items.append({
47
+ "name": c.name,
48
+ "value": c.value,
49
+ "domain": c.domain or "",
50
+ "path": c.path or "/",
51
+ "secure": bool(c.secure),
52
+ # `expires` is either an int (unix epoch) or None.
53
+ "expires": c.expires,
54
+ })
55
+ return base64.b64encode(json.dumps(items).encode("utf-8")).decode("ascii")
56
+
57
+
58
+ def decode_cookies_json(b64: str) -> RequestsCookieJar:
59
+ """Inverse of ``encode_cookies_json``. Raises on malformed input."""
60
+ raw = base64.b64decode(b64.encode("ascii"))
61
+ items = json.loads(raw.decode("utf-8"))
62
+ if not isinstance(items, list):
63
+ raise ValueError("expected list of cookie dicts")
64
+ jar = RequestsCookieJar()
65
+ for d in items:
66
+ if not isinstance(d, dict) or "name" not in d or "value" not in d:
67
+ raise ValueError("cookie dict missing name/value")
68
+ jar.set_cookie(create_cookie(
69
+ name=d["name"],
70
+ value=d["value"],
71
+ domain=d.get("domain") or "",
72
+ path=d.get("path") or "/",
73
+ secure=bool(d.get("secure", False)),
74
+ expires=d.get("expires"),
75
+ ))
76
+ return jar
77
+
78
+
79
+ @dataclass
80
+ class StoredCredentials:
81
+ profile: str
82
+ api_url: str
83
+ cookies_b64: str = ""
84
+ auth0_refresh_token: Optional[str] = None
85
+ pm_access_token: Optional[str] = None
86
+ pm_refresh_token: Optional[str] = None
87
+ last_workspace_uid: Optional[str] = None
88
+
89
+ def cookies(self) -> RequestsCookieJar:
90
+ if not self.cookies_b64:
91
+ return RequestsCookieJar()
92
+ # JSON only. A record that does not decode (including the pickle
93
+ # format internal builds wrote before 2026-07) reads as "not logged
94
+ # in"; pickle.loads on stored bytes is never acceptable in a shipped
95
+ # credential store (security S5).
96
+ try:
97
+ return decode_cookies_json(self.cookies_b64)
98
+ except Exception:
99
+ logger.debug("stored cookies for profile %s are unreadable; ignoring", self.profile)
100
+ return RequestsCookieJar()
101
+
102
+ def set_cookies(self, jar: RequestsCookieJar) -> None:
103
+ self.cookies_b64 = encode_cookies_json(jar)
104
+
105
+
106
+ def _fallback_load_all() -> dict:
107
+ return read_private_json(FALLBACK_FILE)
108
+
109
+
110
+ def _fallback_save_all(data: dict) -> None:
111
+ # The file holds the Auth0 refresh token and session cookies: it must be
112
+ # 0600 from creation, never write-then-chmod (security report S2).
113
+ write_private_json(FALLBACK_FILE, data)
114
+
115
+
116
+ def load(profile: str) -> Optional[StoredCredentials]:
117
+ if _KEYRING_AVAILABLE:
118
+ try:
119
+ with timed(logger, "keyring.load"):
120
+ raw = keyring.get_password(KEYRING_SERVICE, keyring_account(profile))
121
+ if raw:
122
+ return StoredCredentials(**json.loads(raw))
123
+ except Exception:
124
+ pass
125
+ data = _fallback_load_all().get(profile)
126
+ if not data:
127
+ return None
128
+ return StoredCredentials(**data)
129
+
130
+
131
+ def _to_storable_dict(creds: StoredCredentials) -> dict:
132
+ """Serialize creds to a dict suitable for keyring/fallback storage."""
133
+ return dict(asdict(creds))
134
+
135
+
136
+ def save(creds: StoredCredentials) -> None:
137
+ storable = _to_storable_dict(creds)
138
+ payload = json.dumps(storable)
139
+ if _KEYRING_AVAILABLE:
140
+ try:
141
+ with timed(logger, "keyring.save"):
142
+ keyring.set_password(KEYRING_SERVICE, keyring_account(creds.profile), payload)
143
+ return
144
+ except Exception:
145
+ pass
146
+ data = _fallback_load_all()
147
+ data[creds.profile] = storable
148
+ _fallback_save_all(data)
149
+
150
+
151
+ def clear(profile: str) -> None:
152
+ if _KEYRING_AVAILABLE:
153
+ try:
154
+ keyring.delete_password(KEYRING_SERVICE, keyring_account(profile))
155
+ except Exception:
156
+ pass
157
+ data = _fallback_load_all()
158
+ if profile in data:
159
+ del data[profile]
160
+ _fallback_save_all(data)