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.
- pm/__init__.py +1 -0
- pm/__main__.py +4 -0
- pm/_alias.py +164 -0
- pm/auth/__init__.py +0 -0
- pm/auth/device_flow.py +187 -0
- pm/auth/exchange.py +79 -0
- pm/auth/jwt_cache.py +163 -0
- pm/auth/secret_files.py +58 -0
- pm/auth/session.py +440 -0
- pm/auth/store.py +160 -0
- pm/auth/token.py +289 -0
- pm/cli.py +282 -0
- pm/client/__init__.py +68 -0
- pm/client/access_tokens.py +54 -0
- pm/client/base.py +229 -0
- pm/client/blocks.py +461 -0
- pm/client/databases.py +147 -0
- pm/client/favorites.py +14 -0
- pm/client/folders.py +10 -0
- pm/client/integrations.py +14 -0
- pm/client/organizations.py +20 -0
- pm/client/recipients.py +92 -0
- pm/client/reports.py +148 -0
- pm/client/roles.py +100 -0
- pm/client/runs.py +134 -0
- pm/client/saved_blocks.py +13 -0
- pm/client/tableau.py +108 -0
- pm/client/users.py +71 -0
- pm/client/webhooks.py +43 -0
- pm/client/workspaces.py +34 -0
- pm/commands/__init__.py +0 -0
- pm/commands/_block_factory.py +762 -0
- pm/commands/_helpers.py +178 -0
- pm/commands/auth.py +222 -0
- pm/commands/block.py +26 -0
- pm/commands/chart.py +20 -0
- pm/commands/db.py +291 -0
- pm/commands/email.py +13 -0
- pm/commands/explore.py +20 -0
- pm/commands/favorites.py +77 -0
- pm/commands/folders.py +113 -0
- pm/commands/help.py +643 -0
- pm/commands/integrations.py +95 -0
- pm/commands/metrics_view.py +195 -0
- pm/commands/organizations.py +56 -0
- pm/commands/recipient.py +226 -0
- pm/commands/reports.py +364 -0
- pm/commands/roles.py +102 -0
- pm/commands/runs.py +214 -0
- pm/commands/saved_blocks.py +97 -0
- pm/commands/sftp.py +19 -0
- pm/commands/slack.py +13 -0
- pm/commands/slides.py +216 -0
- pm/commands/sql.py +17 -0
- pm/commands/tableau.py +148 -0
- pm/commands/token.py +107 -0
- pm/commands/users.py +262 -0
- pm/commands/webhooks.py +132 -0
- pm/commands/workspace.py +156 -0
- pm/config.py +192 -0
- pm/errors.py +228 -0
- pm/log.py +63 -0
- pm/models/__init__.py +31 -0
- pm/models/access_tokens.py +25 -0
- pm/models/blocks.py +21 -0
- pm/models/databases.py +15 -0
- pm/models/favorites.py +14 -0
- pm/models/folders.py +14 -0
- pm/models/integrations.py +20 -0
- pm/models/organizations.py +13 -0
- pm/models/recipients.py +41 -0
- pm/models/reports.py +16 -0
- pm/models/roles.py +22 -0
- pm/models/runs.py +56 -0
- pm/models/saved_blocks.py +15 -0
- pm/models/users.py +36 -0
- pm/models/webhooks.py +23 -0
- pm/models/workspaces.py +16 -0
- pm/output.py +92 -0
- pm/types.py +130 -0
- pm/validators.py +499 -0
- pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
- pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
- pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
- pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|