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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pm/__main__.py
ADDED
pm/_alias.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Click group with command-name aliases + fuzzy "Did you mean?".
|
|
2
|
+
|
|
3
|
+
pm-tool introduces canonical verbs (`list`, `create`, `delete`, `get`) that
|
|
4
|
+
replace older non-REST names (`ls`, `add`, `remove`). The legacy names stay
|
|
5
|
+
as soft aliases — both forms work — so existing CLI users aren't broken.
|
|
6
|
+
Same pattern for the plural→singular group rename (`reports`→`report`).
|
|
7
|
+
|
|
8
|
+
When neither a direct match nor an alias resolves, the group attempts a
|
|
9
|
+
fuzzy suggestion via difflib so typos like ``pm blcok`` get a hint
|
|
10
|
+
toward ``block`` instead of a bare "No such command" error.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
|
|
14
|
+
from pm._alias import AliasedGroup
|
|
15
|
+
|
|
16
|
+
@click.group(name="user", cls=AliasedGroup, aliases={"add": "create"})
|
|
17
|
+
def user():
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@user.command(name="create")
|
|
21
|
+
def create_user(...):
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
Now ``pm user create`` and ``pm user add`` both resolve to ``create_user``.
|
|
25
|
+
Aliases are NOT listed in ``--help`` (to keep the canonical surface clean);
|
|
26
|
+
``pm help`` (pm-foundation) is the agent-facing help system and will
|
|
27
|
+
mention compatibility aliases explicitly when relevant.
|
|
28
|
+
|
|
29
|
+
Groups that don't need legacy aliases still benefit from the fuzzy
|
|
30
|
+
suggestion behavior — pass ``aliases={}`` (the default) and the
|
|
31
|
+
suggestion path is the only added behavior over a plain ``click.Group``.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import difflib
|
|
36
|
+
from typing import Optional
|
|
37
|
+
|
|
38
|
+
import click
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Fuzzy threshold. Codex review round 2 (2026-06-09) showed that 0.7
|
|
42
|
+
# is too tight for 3-char verbs — common transpositions/substitutions
|
|
43
|
+
# like ``gte→get`` and ``rnu→run`` score about 0.67. 0.6 picks those
|
|
44
|
+
# up while still rejecting genuinely-different tokens (``xyz`` vs
|
|
45
|
+
# any verb scores ~0.0). The MIN_LEN floor was also dropped: short
|
|
46
|
+
# tokens like ``ge → get`` (0.8) are HELPFUL suggestions, not noise.
|
|
47
|
+
_FUZZY_CUTOFF = 0.6
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AliasedGroup(click.Group):
|
|
51
|
+
"""Click group that resolves a configurable map of legacy command
|
|
52
|
+
names to their canonical equivalents.
|
|
53
|
+
|
|
54
|
+
Aliases are checked AFTER direct command lookup, so a real command
|
|
55
|
+
with the same name as an alias would always win (defensive — no
|
|
56
|
+
silent shadowing). Unknown commands trigger a fuzzy suggestion
|
|
57
|
+
when one is available; otherwise Click's standard error renders.
|
|
58
|
+
|
|
59
|
+
Subgroups created via ``@aliased_group_instance.group()`` inherit
|
|
60
|
+
this class via the ``group()`` override below — without it, nested
|
|
61
|
+
``@cli.group()`` declarations default back to ``click.Group`` and
|
|
62
|
+
miss the typo behavior. Codex review round 2 catch on
|
|
63
|
+
``cli/pm/cli.py:85`` (``@cli.group(name="config")``).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args, aliases: Optional[dict[str, str]] = None, **kwargs):
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
self._aliases: dict[str, str] = aliases or {}
|
|
69
|
+
|
|
70
|
+
def group(self, *args, **kwargs):
|
|
71
|
+
"""Default subgroup class to ``type(self)`` so nested
|
|
72
|
+
``@aliased_group.group()`` declarations inherit the runtime
|
|
73
|
+
class — transitively, for subclasses of ``AliasedGroup``. Codex
|
|
74
|
+
round 3 catch: hard-coding ``AliasedGroup`` here lost subclass
|
|
75
|
+
behavior on subgroups (a hypothetical ``FancyAliasedGroup``
|
|
76
|
+
would have nested groups fall back to plain ``AliasedGroup``).
|
|
77
|
+
Callers can still override with ``cls=`` if they need a
|
|
78
|
+
different group class for a specific subgroup.
|
|
79
|
+
"""
|
|
80
|
+
kwargs.setdefault("cls", type(self))
|
|
81
|
+
return super().group(*args, **kwargs)
|
|
82
|
+
|
|
83
|
+
def get_command(self, ctx: click.Context, cmd_name: str):
|
|
84
|
+
rv = super().get_command(ctx, cmd_name)
|
|
85
|
+
if rv is not None:
|
|
86
|
+
return rv
|
|
87
|
+
canonical = self._aliases.get(cmd_name)
|
|
88
|
+
if canonical is not None:
|
|
89
|
+
return super().get_command(ctx, canonical)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def resolve_command(self, ctx: click.Context, args):
|
|
93
|
+
"""Run Click's normal resolution; on a missing-command
|
|
94
|
+
``UsageError``, attempt a fuzzy suggestion.
|
|
95
|
+
|
|
96
|
+
Codex round 2 catch: catching every ``UsageError`` over-fires
|
|
97
|
+
on option errors like ``pm --block`` ("No such option"), which
|
|
98
|
+
would mis-render as "Did you mean 'block'?". Filter by the
|
|
99
|
+
``No such command`` prefix Click uses for unknown-command
|
|
100
|
+
errors so option errors fall through to the standard renderer.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
return super().resolve_command(ctx, args)
|
|
104
|
+
except click.UsageError as exc:
|
|
105
|
+
if not _is_no_such_command_error(exc):
|
|
106
|
+
raise
|
|
107
|
+
attempted = args[0] if args else ""
|
|
108
|
+
suggestion = self._fuzzy_suggest(ctx, attempted)
|
|
109
|
+
if suggestion is None:
|
|
110
|
+
raise
|
|
111
|
+
raise click.UsageError(
|
|
112
|
+
f"No such command {attempted!r}. "
|
|
113
|
+
f"Did you mean {suggestion!r}?",
|
|
114
|
+
ctx=ctx,
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
def _fuzzy_suggest(self, ctx: click.Context, attempted: str) -> Optional[str]:
|
|
118
|
+
"""Return the best fuzzy match for ``attempted`` against this
|
|
119
|
+
group's registered commands, or None when no candidate scores
|
|
120
|
+
above the cutoff.
|
|
121
|
+
|
|
122
|
+
Codex round 2 catch: aliases are included in the candidate
|
|
123
|
+
SEARCH set so typos near a legacy alias still resolve, but
|
|
124
|
+
the returned suggestion is the canonical command — never the
|
|
125
|
+
alias. This matches the project's "aliases work but aren't
|
|
126
|
+
advertised in --help" stance.
|
|
127
|
+
|
|
128
|
+
Codex round 3 catch: defensive check that the resolved
|
|
129
|
+
canonical actually exists in ``list_commands``. A malformed
|
|
130
|
+
alias map (``aliases={"ls": "nonexistent"}``) would otherwise
|
|
131
|
+
surface ``"nonexistent"`` as the suggestion — a bad UX on
|
|
132
|
+
top of a config error.
|
|
133
|
+
"""
|
|
134
|
+
if not attempted:
|
|
135
|
+
return None
|
|
136
|
+
registered = set(self.list_commands(ctx))
|
|
137
|
+
candidates = list(registered) + list(self._aliases.keys())
|
|
138
|
+
matches = difflib.get_close_matches(
|
|
139
|
+
attempted, candidates, n=1, cutoff=_FUZZY_CUTOFF,
|
|
140
|
+
)
|
|
141
|
+
if not matches:
|
|
142
|
+
return None
|
|
143
|
+
match = matches[0]
|
|
144
|
+
# Resolve alias → canonical. If ``match`` is a real command,
|
|
145
|
+
# ``self._aliases.get(match)`` returns None and we keep the
|
|
146
|
+
# original — the get() default lookup pattern.
|
|
147
|
+
resolved = self._aliases.get(match, match)
|
|
148
|
+
# Defensive: drop dangling-alias suggestions rather than
|
|
149
|
+
# render a misleading "Did you mean ..." for a command that
|
|
150
|
+
# doesn't exist.
|
|
151
|
+
if resolved not in registered:
|
|
152
|
+
return None
|
|
153
|
+
return resolved
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_no_such_command_error(exc: click.UsageError) -> bool:
|
|
157
|
+
"""Click renders unknown-command errors with a message starting
|
|
158
|
+
``"No such command"``. Option errors start ``"No such option"`` and
|
|
159
|
+
other UsageErrors (missing args, invalid values) have unrelated
|
|
160
|
+
prefixes. Filter strictly so the fuzzy suggestion only attaches
|
|
161
|
+
where it makes sense.
|
|
162
|
+
"""
|
|
163
|
+
message = exc.message if exc.message else ""
|
|
164
|
+
return message.lower().startswith("no such command")
|
pm/auth/__init__.py
ADDED
|
File without changes
|
pm/auth/device_flow.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Auth0 Device Authorization Flow (RFC 8628)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from pm.log import log_request, log_response, redact_secrets
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceFlowError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# auth0_domain is optional on a profile (token login never needs it), so the
|
|
22
|
+
# device flow is where its absence is caught: before any URL is built from it.
|
|
23
|
+
MISSING_DOMAIN_MESSAGE = (
|
|
24
|
+
"Browser login needs auth0_domain on this profile. Set it with "
|
|
25
|
+
"`pm config set <profile> auth0_domain auth.pushmetrics.io`, or use "
|
|
26
|
+
"`pm login --token` instead."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_domain(domain: Optional[str]) -> str:
|
|
31
|
+
if not domain:
|
|
32
|
+
raise DeviceFlowError(MISSING_DOMAIN_MESSAGE)
|
|
33
|
+
return domain
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DeviceCodePayload:
|
|
38
|
+
device_code: str
|
|
39
|
+
user_code: str
|
|
40
|
+
verification_uri: str
|
|
41
|
+
verification_uri_complete: str
|
|
42
|
+
expires_in: int
|
|
43
|
+
interval: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TokenPayload:
|
|
48
|
+
access_token: str
|
|
49
|
+
refresh_token: Optional[str]
|
|
50
|
+
id_token: Optional[str]
|
|
51
|
+
expires_in: int
|
|
52
|
+
token_type: str
|
|
53
|
+
scope: Optional[str]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def request_device_code(
|
|
57
|
+
domain: Optional[str],
|
|
58
|
+
client_id: str,
|
|
59
|
+
audience: Optional[str],
|
|
60
|
+
scope: str = "openid profile email offline_access",
|
|
61
|
+
) -> DeviceCodePayload:
|
|
62
|
+
url = f"https://{_require_domain(domain)}/oauth/device/code"
|
|
63
|
+
started = log_request(logger, "POST", url)
|
|
64
|
+
response = requests.post(
|
|
65
|
+
url,
|
|
66
|
+
data={
|
|
67
|
+
"client_id": client_id,
|
|
68
|
+
"scope": scope,
|
|
69
|
+
"audience": audience,
|
|
70
|
+
},
|
|
71
|
+
timeout=30,
|
|
72
|
+
)
|
|
73
|
+
log_response(logger, response, started)
|
|
74
|
+
if response.status_code != 200:
|
|
75
|
+
raise DeviceFlowError(
|
|
76
|
+
f"Auth0 device code request failed: {response.status_code} {redact_secrets(response.text[:300])}"
|
|
77
|
+
)
|
|
78
|
+
body = response.json()
|
|
79
|
+
return DeviceCodePayload(
|
|
80
|
+
device_code=body["device_code"],
|
|
81
|
+
user_code=body["user_code"],
|
|
82
|
+
verification_uri=body["verification_uri"],
|
|
83
|
+
verification_uri_complete=body.get(
|
|
84
|
+
"verification_uri_complete", body["verification_uri"]
|
|
85
|
+
),
|
|
86
|
+
expires_in=body.get("expires_in", 900),
|
|
87
|
+
interval=body.get("interval", 5),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def poll_for_token(
|
|
92
|
+
domain: Optional[str],
|
|
93
|
+
client_id: str,
|
|
94
|
+
payload: DeviceCodePayload,
|
|
95
|
+
*,
|
|
96
|
+
no_browser: bool = False,
|
|
97
|
+
) -> TokenPayload:
|
|
98
|
+
"""Poll Auth0 until the user approves the device code or it expires."""
|
|
99
|
+
domain = _require_domain(domain)
|
|
100
|
+
if not no_browser:
|
|
101
|
+
try:
|
|
102
|
+
webbrowser.open(payload.verification_uri_complete)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
deadline = time.monotonic() + payload.expires_in
|
|
107
|
+
interval = max(1, payload.interval)
|
|
108
|
+
|
|
109
|
+
while True:
|
|
110
|
+
if time.monotonic() >= deadline:
|
|
111
|
+
raise DeviceFlowError("Device code expired before authorization completed.")
|
|
112
|
+
|
|
113
|
+
time.sleep(interval)
|
|
114
|
+
|
|
115
|
+
url = f"https://{domain}/oauth/token"
|
|
116
|
+
started = log_request(logger, "POST", url)
|
|
117
|
+
response = requests.post(
|
|
118
|
+
url,
|
|
119
|
+
data={
|
|
120
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
121
|
+
"device_code": payload.device_code,
|
|
122
|
+
"client_id": client_id,
|
|
123
|
+
},
|
|
124
|
+
timeout=30,
|
|
125
|
+
)
|
|
126
|
+
log_response(logger, response, started)
|
|
127
|
+
|
|
128
|
+
if response.status_code == 200:
|
|
129
|
+
body = response.json()
|
|
130
|
+
return TokenPayload(
|
|
131
|
+
access_token=body["access_token"],
|
|
132
|
+
refresh_token=body.get("refresh_token"),
|
|
133
|
+
id_token=body.get("id_token"),
|
|
134
|
+
expires_in=body.get("expires_in", 0),
|
|
135
|
+
token_type=body.get("token_type", "Bearer"),
|
|
136
|
+
scope=body.get("scope"),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
err = response.json()
|
|
141
|
+
err_code = err.get("error", "")
|
|
142
|
+
except Exception:
|
|
143
|
+
err_code = ""
|
|
144
|
+
|
|
145
|
+
if err_code == "authorization_pending":
|
|
146
|
+
continue
|
|
147
|
+
if err_code == "slow_down":
|
|
148
|
+
interval = min(interval + 5, 60)
|
|
149
|
+
continue
|
|
150
|
+
if err_code == "expired_token":
|
|
151
|
+
raise DeviceFlowError("Authorization timed out — the user code expired.")
|
|
152
|
+
if err_code == "access_denied":
|
|
153
|
+
raise DeviceFlowError("Authorization was denied by the user.")
|
|
154
|
+
raise DeviceFlowError(
|
|
155
|
+
f"Auth0 token poll failed: {response.status_code} {redact_secrets(response.text[:300])}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def refresh_auth0_token(
|
|
160
|
+
domain: Optional[str], client_id: str, refresh_token: str
|
|
161
|
+
) -> TokenPayload:
|
|
162
|
+
"""Exchange a refresh token for a fresh access token."""
|
|
163
|
+
url = f"https://{_require_domain(domain)}/oauth/token"
|
|
164
|
+
started = log_request(logger, "POST", url)
|
|
165
|
+
response = requests.post(
|
|
166
|
+
url,
|
|
167
|
+
data={
|
|
168
|
+
"grant_type": "refresh_token",
|
|
169
|
+
"client_id": client_id,
|
|
170
|
+
"refresh_token": refresh_token,
|
|
171
|
+
},
|
|
172
|
+
timeout=30,
|
|
173
|
+
)
|
|
174
|
+
log_response(logger, response, started)
|
|
175
|
+
if response.status_code != 200:
|
|
176
|
+
raise DeviceFlowError(
|
|
177
|
+
f"Auth0 refresh failed: {response.status_code} {redact_secrets(response.text[:300])}"
|
|
178
|
+
)
|
|
179
|
+
body = response.json()
|
|
180
|
+
return TokenPayload(
|
|
181
|
+
access_token=body["access_token"],
|
|
182
|
+
refresh_token=body.get("refresh_token", refresh_token),
|
|
183
|
+
id_token=body.get("id_token"),
|
|
184
|
+
expires_in=body.get("expires_in", 0),
|
|
185
|
+
token_type=body.get("token_type", "Bearer"),
|
|
186
|
+
scope=body.get("scope"),
|
|
187
|
+
)
|
pm/auth/exchange.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Exchange an Auth0 access token for a PushMetrics session.
|
|
2
|
+
|
|
3
|
+
This hits POST /api/v1/security/login with an `Authorization: Bearer <auth0_token>`
|
|
4
|
+
header. The response sets the Flask session cookie (carrying workspace_id for
|
|
5
|
+
Postgres RLS) onto the provided requests.Session, AND returns PushMetrics-issued
|
|
6
|
+
JWT access/refresh tokens in the body.
|
|
7
|
+
|
|
8
|
+
This is the Auth0 device-flow (cookie) login path, one of the CLI's three
|
|
9
|
+
auth modes. The session cookie is what authenticates subsequent calls in
|
|
10
|
+
this mode; the returned JWTs are stored for silent re-login. Programmatic
|
|
11
|
+
callers use ``PM_API_TOKEN`` (PAT) or ``PM_API_JWT`` (delegation) instead and
|
|
12
|
+
never hit this path - see ``session.py`` for the resolution order.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from pm.log import log_request, log_response, redact_secrets
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LoginError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PMLoginResult:
|
|
33
|
+
pm_access_token: Optional[str]
|
|
34
|
+
pm_refresh_token: Optional[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def exchange_for_session(
|
|
38
|
+
session: requests.Session,
|
|
39
|
+
api_url: str,
|
|
40
|
+
auth0_access_token: str,
|
|
41
|
+
*,
|
|
42
|
+
want_refresh: bool = True,
|
|
43
|
+
) -> PMLoginResult:
|
|
44
|
+
url = f"{api_url}/api/v1/security/login"
|
|
45
|
+
started = log_request(logger, "POST", url)
|
|
46
|
+
resp = session.post(
|
|
47
|
+
url,
|
|
48
|
+
json={"refresh": want_refresh},
|
|
49
|
+
headers={"Authorization": f"Bearer {auth0_access_token}"},
|
|
50
|
+
timeout=30,
|
|
51
|
+
)
|
|
52
|
+
log_response(logger, resp, started)
|
|
53
|
+
if resp.status_code != 200:
|
|
54
|
+
raise LoginError(
|
|
55
|
+
f"PushMetrics login failed: {resp.status_code} {redact_secrets(resp.text[:300])}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
body = resp.json() if resp.content else {}
|
|
60
|
+
except ValueError:
|
|
61
|
+
raise LoginError(
|
|
62
|
+
"PushMetrics login returned a non-JSON response (got HTML/redirect). "
|
|
63
|
+
"This usually means the backend rejected the Auth0 token "
|
|
64
|
+
"(verify_auth0_token failed) and fell back to the standard login page. "
|
|
65
|
+
f"First 300 chars: {redact_secrets(resp.text[:300])!r}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
pm_access_token = body.get("access_token")
|
|
69
|
+
if not pm_access_token:
|
|
70
|
+
raise LoginError(
|
|
71
|
+
"PushMetrics login succeeded (200) but returned no access_token. "
|
|
72
|
+
"The Auth0 token was not accepted by /security/login. "
|
|
73
|
+
f"Body: {redact_secrets(repr(body))}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return PMLoginResult(
|
|
77
|
+
pm_access_token=pm_access_token,
|
|
78
|
+
pm_refresh_token=body.get("refresh_token"),
|
|
79
|
+
)
|
pm/auth/jwt_cache.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Per-profile cache of the JWT exchanged from a Personal Access Token.
|
|
2
|
+
|
|
3
|
+
``PMSession`` exchanges the PAT once per process. Every ``pm`` invocation is
|
|
4
|
+
a process, and the exchange endpoint is rate-limited per IP (10/min in
|
|
5
|
+
prod), so a shell loop over ``pm`` commands hit 429 on the 11th call. This
|
|
6
|
+
module keeps the short-lived JWT across processes with the same safety as
|
|
7
|
+
the PAT itself: OS keyring first, else a ``0600`` fallback file.
|
|
8
|
+
|
|
9
|
+
A record is only a hit when ALL of these hold; anything else is a miss:
|
|
10
|
+
|
|
11
|
+
- the profile name matches (the record key);
|
|
12
|
+
- ``api_url`` matches, so a JWT minted for prod never rides along to a host
|
|
13
|
+
given with ``--api-url`` / ``PM_API_URL`` under the same profile;
|
|
14
|
+
- the PAT fingerprint matches, so a changed ``PM_API_TOKEN`` or a fresh
|
|
15
|
+
``pm login --token`` never reuses the previous token's JWT;
|
|
16
|
+
- the wall-clock expiry (exchange time + ``expires_in`` - margin) is in the
|
|
17
|
+
future. The TTL is the server's; it is never extended client-side.
|
|
18
|
+
|
|
19
|
+
The raw PAT is never stored here, only ``sha256(pat)[:16]``. The JWT value
|
|
20
|
+
is never logged: hit/miss lines carry the profile name only. Delegation
|
|
21
|
+
mode (``PM_API_JWT``) never reads or writes this cache.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import asdict, dataclass
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from pm.auth.secret_files import read_private_json, write_private_json
|
|
34
|
+
from pm.config import CONFIG_DIR, keyring_account
|
|
35
|
+
from pm.log import timed
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import keyring
|
|
39
|
+
_KEYRING_AVAILABLE = True
|
|
40
|
+
except Exception:
|
|
41
|
+
_KEYRING_AVAILABLE = False
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Distinct from the PAT ("pushmetrics-cli-pat") and cookie ("pushmetrics-cli")
|
|
46
|
+
# services so the cached JWT can never overwrite or shadow either.
|
|
47
|
+
KEYRING_SERVICE = "pushmetrics-cli-jwt"
|
|
48
|
+
FALLBACK_FILE = CONFIG_DIR / "jwt_cache.json"
|
|
49
|
+
|
|
50
|
+
# A JWT is base64url segments joined by dots; anything with whitespace or
|
|
51
|
+
# control characters must never reach a header. Shared with PM_API_JWT.
|
|
52
|
+
JWT_SHAPE_RE = re.compile(r"^[A-Za-z0-9._\-]+$")
|
|
53
|
+
|
|
54
|
+
# Seconds taken off the server TTL so a JWT is never presented in the last
|
|
55
|
+
# moments before the server stops accepting it.
|
|
56
|
+
EXPIRY_MARGIN_SECONDS = 60
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CachedJwt:
|
|
61
|
+
jwt: str
|
|
62
|
+
expires_at: float
|
|
63
|
+
api_url: str
|
|
64
|
+
pat_fingerprint: str
|
|
65
|
+
|
|
66
|
+
def seconds_left(self, now: Optional[float] = None) -> float:
|
|
67
|
+
return self.expires_at - (time.time() if now is None else now)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fingerprint(raw_pat: str) -> str:
|
|
71
|
+
"""Non-reversible identity of a PAT; the raw value is never persisted."""
|
|
72
|
+
return hashlib.sha256(raw_pat.encode("utf-8")).hexdigest()[:16]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load(profile: str, api_url: str, raw_pat: str) -> Optional[CachedJwt]:
|
|
76
|
+
"""Return the cached JWT bound to this profile, host and PAT, if still valid."""
|
|
77
|
+
record = _read_record(profile)
|
|
78
|
+
if record is None:
|
|
79
|
+
logger.debug("jwt cache miss for profile %s", profile)
|
|
80
|
+
return None
|
|
81
|
+
if record.api_url != api_url or record.pat_fingerprint != fingerprint(raw_pat):
|
|
82
|
+
logger.debug("jwt cache miss for profile %s (different host or token)", profile)
|
|
83
|
+
return None
|
|
84
|
+
if record.seconds_left() <= 0:
|
|
85
|
+
logger.debug("jwt cache miss for profile %s (expired)", profile)
|
|
86
|
+
return None
|
|
87
|
+
logger.debug("jwt cache hit for profile %s", profile)
|
|
88
|
+
return record
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def save(profile: str, api_url: str, raw_pat: str, jwt: str, expires_in: int) -> None:
|
|
92
|
+
record = CachedJwt(
|
|
93
|
+
jwt=jwt,
|
|
94
|
+
expires_at=time.time() + max(0, expires_in - EXPIRY_MARGIN_SECONDS),
|
|
95
|
+
api_url=api_url,
|
|
96
|
+
pat_fingerprint=fingerprint(raw_pat),
|
|
97
|
+
)
|
|
98
|
+
_write_record(profile, record)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def clear(profile: str) -> None:
|
|
102
|
+
"""Forget the cached JWT for ``profile`` (keyring and file). Idempotent."""
|
|
103
|
+
if _KEYRING_AVAILABLE:
|
|
104
|
+
try:
|
|
105
|
+
keyring.delete_password(KEYRING_SERVICE, keyring_account(profile))
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.debug("keyring jwt delete for %s: %s", profile, type(exc).__name__)
|
|
108
|
+
data = read_private_json(FALLBACK_FILE)
|
|
109
|
+
if profile in data:
|
|
110
|
+
del data[profile]
|
|
111
|
+
write_private_json(FALLBACK_FILE, data)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── storage ─────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_record(profile: str) -> Optional[CachedJwt]:
|
|
118
|
+
raw = None
|
|
119
|
+
if _KEYRING_AVAILABLE:
|
|
120
|
+
try:
|
|
121
|
+
with timed(logger, "keyring.load_jwt"):
|
|
122
|
+
raw = keyring.get_password(KEYRING_SERVICE, keyring_account(profile))
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
logger.debug("keyring jwt load failed for %s: %s", profile, type(exc).__name__)
|
|
125
|
+
if raw:
|
|
126
|
+
return _parse(raw, profile)
|
|
127
|
+
data = read_private_json(FALLBACK_FILE).get(profile)
|
|
128
|
+
return _parse(data, profile) if data else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse(raw, profile: str) -> Optional[CachedJwt]:
|
|
132
|
+
"""A corrupt record is a miss, never a traceback."""
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(raw) if isinstance(raw, str) else raw
|
|
135
|
+
record = CachedJwt(
|
|
136
|
+
jwt=str(data["jwt"]),
|
|
137
|
+
expires_at=float(data["expires_at"]),
|
|
138
|
+
api_url=str(data["api_url"]),
|
|
139
|
+
pat_fingerprint=str(data["pat_fingerprint"]),
|
|
140
|
+
)
|
|
141
|
+
except (KeyError, TypeError, ValueError):
|
|
142
|
+
logger.debug("jwt cache record for %s is corrupt; treating as miss", profile)
|
|
143
|
+
return None
|
|
144
|
+
if not JWT_SHAPE_RE.fullmatch(record.jwt):
|
|
145
|
+
# A hand-edited record would otherwise surface as a transport error
|
|
146
|
+
# ("Could not reach ... (InvalidHeader)") and misdirect the user.
|
|
147
|
+
logger.debug("jwt cache record for %s is malformed; treating as miss", profile)
|
|
148
|
+
return None
|
|
149
|
+
return record
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _write_record(profile: str, record: CachedJwt) -> None:
|
|
153
|
+
storable = asdict(record)
|
|
154
|
+
if _KEYRING_AVAILABLE:
|
|
155
|
+
try:
|
|
156
|
+
with timed(logger, "keyring.save_jwt"):
|
|
157
|
+
keyring.set_password(KEYRING_SERVICE, keyring_account(profile), json.dumps(storable))
|
|
158
|
+
return
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
logger.debug("keyring jwt save failed for %s: %s", profile, type(exc).__name__)
|
|
161
|
+
data = read_private_json(FALLBACK_FILE)
|
|
162
|
+
data[profile] = storable
|
|
163
|
+
write_private_json(FALLBACK_FILE, data)
|
pm/auth/secret_files.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Private JSON files for the credential fallback stores.
|
|
2
|
+
|
|
3
|
+
One implementation of the "0600 from creation" rule for every file that can
|
|
4
|
+
hold a secret (``credentials.json``, ``tokens.json``, ``jwt_cache.json``):
|
|
5
|
+
|
|
6
|
+
- the data is written to a sibling temp file opened ``O_CREAT`` with mode
|
|
7
|
+
``0o600``, so there is no window in which a world-readable file holds the
|
|
8
|
+
secret (write-then-chmod has one, and a crash between the two steps leaves
|
|
9
|
+
it open for good);
|
|
10
|
+
- the temp file is then ``os.replace``d over the target, so a crash mid-write
|
|
11
|
+
leaves the previous record intact rather than a torn file;
|
|
12
|
+
- a corrupt or missing file reads as empty, never as a traceback.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
PRIVATE_MODE = 0o600
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_private_json(path: Path) -> Dict[str, Any]:
|
|
29
|
+
"""Return the file's JSON object, or ``{}`` when it is missing or unreadable."""
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except (OSError, ValueError) as exc:
|
|
35
|
+
logger.debug("ignoring unreadable %s: %s", path.name, type(exc).__name__)
|
|
36
|
+
return {}
|
|
37
|
+
return data if isinstance(data, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_private_json(path: Path, data: Dict[str, Any]) -> None:
|
|
41
|
+
"""Atomically replace ``path`` with ``data``, owner-readable only."""
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
# Unique name + O_EXCL: a leftover temp file from a crashed process is
|
|
44
|
+
# never reopened with whatever mode it had.
|
|
45
|
+
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
46
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, PRIVATE_MODE)
|
|
47
|
+
try:
|
|
48
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
49
|
+
f.write(json.dumps(data))
|
|
50
|
+
os.replace(tmp, path)
|
|
51
|
+
except BaseException:
|
|
52
|
+
# Best-effort rollback: an unlink failure must never replace the
|
|
53
|
+
# original write/replace error the caller needs to see.
|
|
54
|
+
try:
|
|
55
|
+
os.unlink(tmp)
|
|
56
|
+
except OSError as unlink_exc:
|
|
57
|
+
logger.debug("could not remove %s: %s", tmp.name, type(unlink_exc).__name__)
|
|
58
|
+
raise
|