ermes-cli 1.0.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.
- ermes_cli/__init__.py +1 -0
- ermes_cli/admin_api.py +285 -0
- ermes_cli/auth0.py +220 -0
- ermes_cli/client.py +100 -0
- ermes_cli/commands/__init__.py +0 -0
- ermes_cli/commands/auth_cmds.py +386 -0
- ermes_cli/commands/campaigns.py +321 -0
- ermes_cli/commands/config_cmd.py +105 -0
- ermes_cli/commands/data_paths.py +103 -0
- ermes_cli/commands/export.py +238 -0
- ermes_cli/commands/ingest.py +101 -0
- ermes_cli/commands/journeys.py +182 -0
- ermes_cli/commands/keys.py +113 -0
- ermes_cli/commands/lists.py +137 -0
- ermes_cli/commands/metrics.py +398 -0
- ermes_cli/commands/projects.py +286 -0
- ermes_cli/commands/providers.py +184 -0
- ermes_cli/commands/subscriptions.py +75 -0
- ermes_cli/commands/tags.py +86 -0
- ermes_cli/commands/templates.py +133 -0
- ermes_cli/commands/users.py +133 -0
- ermes_cli/commands/write_cmds.py +87 -0
- ermes_cli/config.py +393 -0
- ermes_cli/errors.py +34 -0
- ermes_cli/export_utils.py +263 -0
- ermes_cli/keyring_store.py +145 -0
- ermes_cli/main.py +193 -0
- ermes_cli/manifest.py +218 -0
- ermes_cli/mcp_server.py +167 -0
- ermes_cli/output.py +58 -0
- ermes_cli/routes.yaml +163 -0
- ermes_cli/rules.py +54 -0
- ermes_cli/write_mode.py +178 -0
- ermes_cli-1.0.0.dist-info/METADATA +281 -0
- ermes_cli-1.0.0.dist-info/RECORD +37 -0
- ermes_cli-1.0.0.dist-info/WHEEL +4 -0
- ermes_cli-1.0.0.dist-info/entry_points.txt +3 -0
ermes_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
ermes_cli/admin_api.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Thin HTTP client for Ermes admin endpoints used during `ermes login`.
|
|
2
|
+
|
|
3
|
+
Separate from `client.py` (which hits `/api/client/*` with a `sk_` key)
|
|
4
|
+
because login bootstrapping uses a different auth shape: an Ermes admin
|
|
5
|
+
JWT (minted by `/api/cli/exchange` after Auth0 device flow). The JWT
|
|
6
|
+
lets us call `/api/admin/projects/all` to enumerate projects, and
|
|
7
|
+
`/api/admin/projects/:id/cli-key` to provision this admin's key for a
|
|
8
|
+
project on first use (`ermes use`, `ERMES_PROJECT`). The `keys` group also
|
|
9
|
+
goes through the JWT since `/api/client/keys` no longer accepts sk_ keys.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AdminAPIError(Exception):
|
|
22
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ExchangeResult:
|
|
29
|
+
ermes_access_token: str
|
|
30
|
+
expires_at: str
|
|
31
|
+
admin_id: int
|
|
32
|
+
admin_email: str
|
|
33
|
+
organization_id: int
|
|
34
|
+
role: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ProjectSummary:
|
|
39
|
+
id: int
|
|
40
|
+
name: str
|
|
41
|
+
role: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ProjectKey:
|
|
46
|
+
id: int
|
|
47
|
+
name: str
|
|
48
|
+
value: str
|
|
49
|
+
scope: str
|
|
50
|
+
role: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _post(url: str, json: dict, token: str | None = None, timeout: float = 15.0) -> Any:
|
|
54
|
+
headers = {"Content-Type": "application/json"}
|
|
55
|
+
if token:
|
|
56
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
57
|
+
# No redirects: a 30x would re-send the bearer id_token to another host.
|
|
58
|
+
resp = requests.post(url, json=json, headers=headers, timeout=timeout, allow_redirects=False)
|
|
59
|
+
_raise_for_status(resp)
|
|
60
|
+
return resp.json() if resp.content else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get(
|
|
64
|
+
url: str, token: str, params: dict | None = None, timeout: float = 15.0
|
|
65
|
+
) -> Any:
|
|
66
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
67
|
+
resp = requests.get(url, headers=headers, params=params, timeout=timeout, allow_redirects=False)
|
|
68
|
+
_raise_for_status(resp)
|
|
69
|
+
return resp.json() if resp.content else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def request(
|
|
73
|
+
method: str,
|
|
74
|
+
url: str,
|
|
75
|
+
token: str,
|
|
76
|
+
json: dict | None = None,
|
|
77
|
+
params: dict | None = None,
|
|
78
|
+
timeout: float = 15.0,
|
|
79
|
+
) -> Any:
|
|
80
|
+
"""Any admin-JWT call; the `keys` group uses it against /api/admin/projects/:id/keys."""
|
|
81
|
+
resp = requests.request(
|
|
82
|
+
method, url, json=json, params=params,
|
|
83
|
+
headers={"Authorization": f"Bearer {token}"}, timeout=timeout, allow_redirects=False,
|
|
84
|
+
)
|
|
85
|
+
_raise_for_status(resp)
|
|
86
|
+
return resp.json() if resp.content else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _raise_for_status(resp: requests.Response) -> None:
|
|
90
|
+
if 300 <= resp.status_code < 400:
|
|
91
|
+
# Redirects are not followed (the bearer would be re-sent elsewhere), and a
|
|
92
|
+
# 30x body is not the JSON the callers expect: name it instead of a TypeError.
|
|
93
|
+
raise AdminAPIError(
|
|
94
|
+
f"unexpected redirect to {resp.headers.get('Location', '?')}; refusing to resend credentials",
|
|
95
|
+
resp.status_code,
|
|
96
|
+
)
|
|
97
|
+
if resp.status_code >= 400:
|
|
98
|
+
try:
|
|
99
|
+
body = resp.json()
|
|
100
|
+
message = body.get("message") or body.get("error") or resp.text[:300]
|
|
101
|
+
code = body.get("code")
|
|
102
|
+
detail = f"{message} (code={code})" if code else message
|
|
103
|
+
except ValueError:
|
|
104
|
+
detail = resp.text[:300] or f"HTTP {resp.status_code}"
|
|
105
|
+
raise AdminAPIError(detail, resp.status_code)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# /api/cli/exchange
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def exchange_auth0_token(
|
|
114
|
+
base_url: str,
|
|
115
|
+
auth0_access_token: str,
|
|
116
|
+
id_token: str | None = None,
|
|
117
|
+
) -> ExchangeResult:
|
|
118
|
+
"""Trade an Auth0 access token for an Ermes admin JWT.
|
|
119
|
+
|
|
120
|
+
`id_token` is optional. Sending it lets the platform fall back to the
|
|
121
|
+
id_token's `email` claim when the access_token doesn't carry one
|
|
122
|
+
(Auth0's default behavior, absent a custom Action). Audience pinning
|
|
123
|
+
on the platform side keeps this safe: the id_token must be issued for
|
|
124
|
+
the same Auth0 client_id that minted the access_token.
|
|
125
|
+
|
|
126
|
+
Maps platform error codes (1009-1013) to actionable AdminAPIError
|
|
127
|
+
messages so the CLI can give the user a clear next step rather than a
|
|
128
|
+
raw HTTP code.
|
|
129
|
+
"""
|
|
130
|
+
url = f"{base_url.rstrip('/')}/api/cli/exchange"
|
|
131
|
+
payload: dict = {"access_token": auth0_access_token}
|
|
132
|
+
if id_token:
|
|
133
|
+
payload["id_token"] = id_token
|
|
134
|
+
try:
|
|
135
|
+
body = _post(url, payload)
|
|
136
|
+
except AdminAPIError as exc:
|
|
137
|
+
# Re-frame known platform error codes for the CLI user.
|
|
138
|
+
if exc.status_code == 404 and "1009" in str(exc):
|
|
139
|
+
raise AdminAPIError(
|
|
140
|
+
"Ermes server doesn't have CLI exchange enabled "
|
|
141
|
+
"(AUTH_CLI_AUDIENCE not set on the API). Ask your ops team.",
|
|
142
|
+
exc.status_code,
|
|
143
|
+
) from exc
|
|
144
|
+
if exc.status_code == 403 and "1013" in str(exc):
|
|
145
|
+
raise AdminAPIError(
|
|
146
|
+
"Your Auth0 email is not verified. Open the verification email "
|
|
147
|
+
"from Auth0, confirm it, then run `ermes login` again.",
|
|
148
|
+
exc.status_code,
|
|
149
|
+
) from exc
|
|
150
|
+
if exc.status_code == 403 and "1012" in str(exc):
|
|
151
|
+
raise AdminAPIError(
|
|
152
|
+
"Authenticated, but no Ermes admin account exists for this email. "
|
|
153
|
+
"Ask an organization owner to invite you in the Ermes UI first.",
|
|
154
|
+
exc.status_code,
|
|
155
|
+
) from exc
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
return ExchangeResult(
|
|
159
|
+
ermes_access_token=body["access_token"],
|
|
160
|
+
expires_at=body["expires_at"],
|
|
161
|
+
admin_id=body["admin"]["id"],
|
|
162
|
+
admin_email=body["admin"]["email"],
|
|
163
|
+
organization_id=body["admin"]["organization_id"],
|
|
164
|
+
role=body["admin"]["role"],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# /api/admin/projects/all + /api/admin/projects/:id/keys
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def list_all_projects(base_url: str, ermes_jwt: str) -> list[ProjectSummary]:
|
|
174
|
+
"""Enumerate every project the authenticated admin can access."""
|
|
175
|
+
url = f"{base_url.rstrip('/')}/api/admin/projects/all"
|
|
176
|
+
data = _get(url, ermes_jwt)
|
|
177
|
+
# Endpoint returns a flat list per ProjectController.allProjects.
|
|
178
|
+
projects: list[ProjectSummary] = []
|
|
179
|
+
for p in data or []:
|
|
180
|
+
projects.append(
|
|
181
|
+
ProjectSummary(
|
|
182
|
+
id=int(p["id"]),
|
|
183
|
+
name=str(p.get("name") or p["id"]),
|
|
184
|
+
role=str(p.get("role") or "admin"),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return projects
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def list_project_keys(
|
|
191
|
+
base_url: str, ermes_jwt: str, project_id: int
|
|
192
|
+
) -> list[ProjectKey]:
|
|
193
|
+
"""List all (non-deleted) API keys for one project."""
|
|
194
|
+
url = f"{base_url.rstrip('/')}/api/admin/projects/{project_id}/keys"
|
|
195
|
+
# `pagedApiKeys` returns `{results: [...], total, ...}`; the page size param is
|
|
196
|
+
# `limit` (searchParamsSchema, default 25), so ask for one big page explicitly.
|
|
197
|
+
data = _get(url, ermes_jwt, params={"limit": 200})
|
|
198
|
+
if isinstance(data, dict) and "results" in data:
|
|
199
|
+
items = data["results"]
|
|
200
|
+
elif isinstance(data, list):
|
|
201
|
+
items = data
|
|
202
|
+
else:
|
|
203
|
+
items = []
|
|
204
|
+
return [
|
|
205
|
+
ProjectKey(
|
|
206
|
+
id=int(item["id"]),
|
|
207
|
+
name=str(item.get("name") or ""),
|
|
208
|
+
value=str(item.get("value") or ""),
|
|
209
|
+
scope=str(item.get("scope") or ""),
|
|
210
|
+
role=str(item.get("role") or ""),
|
|
211
|
+
)
|
|
212
|
+
for item in items
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def create_project_key(
|
|
217
|
+
base_url: str,
|
|
218
|
+
ermes_jwt: str,
|
|
219
|
+
project_id: int,
|
|
220
|
+
name: str,
|
|
221
|
+
scope: str = "secret",
|
|
222
|
+
role: str = "admin",
|
|
223
|
+
) -> ProjectKey:
|
|
224
|
+
"""Mint a new sk_/pk_ key for one project."""
|
|
225
|
+
url = f"{base_url.rstrip('/')}/api/admin/projects/{project_id}/keys"
|
|
226
|
+
body = _post(
|
|
227
|
+
url,
|
|
228
|
+
{"name": name, "scope": scope, "role": role},
|
|
229
|
+
token=ermes_jwt,
|
|
230
|
+
)
|
|
231
|
+
return ProjectKey(
|
|
232
|
+
id=int(body["id"]),
|
|
233
|
+
name=str(body.get("name") or ""),
|
|
234
|
+
value=str(body.get("value") or ""),
|
|
235
|
+
scope=str(body.get("scope") or ""),
|
|
236
|
+
role=str(body.get("role") or ""),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create_project(
|
|
241
|
+
base_url: str,
|
|
242
|
+
ermes_jwt: str,
|
|
243
|
+
name: str,
|
|
244
|
+
timezone: str,
|
|
245
|
+
locale: str,
|
|
246
|
+
description: str | None = None,
|
|
247
|
+
) -> ProjectSummary:
|
|
248
|
+
"""Create a new project in the authenticated admin's organization.
|
|
249
|
+
|
|
250
|
+
POST /api/admin/projects. Requires the caller's org role to be `admin`
|
|
251
|
+
or `owner` (the platform enforces this; a `member` gets a 403 that
|
|
252
|
+
surfaces here as an AdminAPIError). The creating admin is automatically
|
|
253
|
+
added as a project admin server-side, so the returned summary's role is
|
|
254
|
+
always "admin".
|
|
255
|
+
"""
|
|
256
|
+
url = f"{base_url.rstrip('/')}/api/admin/projects"
|
|
257
|
+
payload: dict = {"name": name, "timezone": timezone, "locale": locale}
|
|
258
|
+
if description is not None:
|
|
259
|
+
payload["description"] = description
|
|
260
|
+
body = _post(url, payload, token=ermes_jwt)
|
|
261
|
+
return ProjectSummary(
|
|
262
|
+
id=int(body["id"]),
|
|
263
|
+
name=str(body.get("name") or name),
|
|
264
|
+
role="admin",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def create_cli_key(
|
|
269
|
+
base_url: str, ermes_jwt: str, project_id: int, hostname: str | None = None
|
|
270
|
+
) -> ProjectKey:
|
|
271
|
+
"""Find-or-create THIS admin's CLI key for one project (DEV-2259).
|
|
272
|
+
|
|
273
|
+
`POST /api/admin/projects/:id/cli-key` binds the key to the calling admin
|
|
274
|
+
and to the hostname, so removing the admin from the project revokes it and
|
|
275
|
+
two admins on the same host never share a key.
|
|
276
|
+
"""
|
|
277
|
+
url = f"{base_url.rstrip('/')}/api/admin/projects/{project_id}/cli-key"
|
|
278
|
+
body = _post(url, {"hostname": hostname or socket.gethostname()}, token=ermes_jwt)
|
|
279
|
+
return ProjectKey(
|
|
280
|
+
id=int(body["id"]),
|
|
281
|
+
name=str(body.get("name") or ""),
|
|
282
|
+
value=str(body.get("value") or ""),
|
|
283
|
+
scope=str(body.get("scope") or ""),
|
|
284
|
+
role=str(body.get("role") or ""),
|
|
285
|
+
)
|
ermes_cli/auth0.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Auth0 Device Authorization Flow (RFC 8628) for ermes-cli.
|
|
2
|
+
|
|
3
|
+
Same tenant as publinker-cli (`dev-hqiwr-re.us.auth0.com`) so the
|
|
4
|
+
authenticated user is the same identity across both tools. Different
|
|
5
|
+
native application + different API audience so tokens issued for
|
|
6
|
+
publinker can't be used against the Ermes API and vice versa.
|
|
7
|
+
|
|
8
|
+
Three operations:
|
|
9
|
+
- `device_login()` — full RFC 8628 flow end-to-end; returns a TokenBundle.
|
|
10
|
+
- `request_device_code()` / `poll_for_token()` — split for granular tests.
|
|
11
|
+
- `refresh_access_token(refresh_token)` — exchange a refresh token for a new
|
|
12
|
+
access token (Auth0 rotates the refresh token; we carry the old one
|
|
13
|
+
forward if the response omits a new one).
|
|
14
|
+
|
|
15
|
+
All three are pure: no keychain writes. The caller decides what to persist.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
import webbrowser
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Callable, Optional
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
from ermes_cli.keyring_store import TokenBundle
|
|
28
|
+
|
|
29
|
+
# Static Auth0 config. These values are non-secret per OAuth spec — the
|
|
30
|
+
# client_id of a public Native client and the API audience are public.
|
|
31
|
+
# Same tenant as publinker-cli (one Auth0 user identity across both tools);
|
|
32
|
+
# different client_id + audience so Ermes and publinker can't cross-use tokens.
|
|
33
|
+
AUTH0_DOMAIN = "dev-hqiwr-re.us.auth0.com"
|
|
34
|
+
AUTH0_CLIENT_ID = "tV4eZi7ZGXbSdfanxWA9OyIDdYcDwpnv"
|
|
35
|
+
AUTH0_AUDIENCE = "https://ermes.publinker.com/api"
|
|
36
|
+
AUTH0_SCOPES = "openid profile email offline_access"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Auth0Config:
|
|
41
|
+
domain: str = AUTH0_DOMAIN
|
|
42
|
+
client_id: str = AUTH0_CLIENT_ID
|
|
43
|
+
audience: str = AUTH0_AUDIENCE
|
|
44
|
+
scopes: str = AUTH0_SCOPES
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def device_code_url(self) -> str:
|
|
48
|
+
return f"https://{self.domain}/oauth/device/code"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def token_url(self) -> str:
|
|
52
|
+
return f"https://{self.domain}/oauth/token"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def revoke_url(self) -> str:
|
|
56
|
+
return f"https://{self.domain}/oauth/revoke"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def auth0_config() -> Auth0Config:
|
|
60
|
+
return Auth0Config()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthError(Exception):
|
|
64
|
+
"""Raised when the Auth0 flow fails terminally (user denied, expired, server error)."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class DeviceCode:
|
|
69
|
+
device_code: str
|
|
70
|
+
user_code: str
|
|
71
|
+
verification_uri_complete: str
|
|
72
|
+
expires_in: int # seconds
|
|
73
|
+
interval: int # poll seconds
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def request_device_code(cfg: Optional[Auth0Config] = None) -> DeviceCode:
|
|
77
|
+
"""Step 1 of RFC 8628 — request a device code from Auth0."""
|
|
78
|
+
cfg = cfg or auth0_config()
|
|
79
|
+
resp = requests.post(
|
|
80
|
+
cfg.device_code_url,
|
|
81
|
+
data={
|
|
82
|
+
"client_id": cfg.client_id,
|
|
83
|
+
"scope": cfg.scopes,
|
|
84
|
+
"audience": cfg.audience,
|
|
85
|
+
},
|
|
86
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
87
|
+
timeout=10,
|
|
88
|
+
)
|
|
89
|
+
if resp.status_code != 200:
|
|
90
|
+
raise AuthError(
|
|
91
|
+
f"device code request failed: {resp.status_code} {resp.text}"
|
|
92
|
+
)
|
|
93
|
+
body = resp.json()
|
|
94
|
+
return DeviceCode(
|
|
95
|
+
device_code=body["device_code"],
|
|
96
|
+
user_code=body["user_code"],
|
|
97
|
+
verification_uri_complete=body["verification_uri_complete"],
|
|
98
|
+
expires_in=int(body["expires_in"]),
|
|
99
|
+
interval=int(body["interval"]),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def poll_for_token(
|
|
104
|
+
device_code: DeviceCode,
|
|
105
|
+
cfg: Optional[Auth0Config] = None,
|
|
106
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
107
|
+
now: Callable[[], float] = time.monotonic,
|
|
108
|
+
) -> TokenBundle:
|
|
109
|
+
"""Step 2 of RFC 8628 — poll the token endpoint until the user authorizes
|
|
110
|
+
or the device code expires.
|
|
111
|
+
|
|
112
|
+
`sleep` and `now` are injectable so tests can run without real-time waits.
|
|
113
|
+
"""
|
|
114
|
+
cfg = cfg or auth0_config()
|
|
115
|
+
interval = device_code.interval
|
|
116
|
+
deadline = now() + device_code.expires_in
|
|
117
|
+
|
|
118
|
+
while now() < deadline:
|
|
119
|
+
sleep(interval)
|
|
120
|
+
resp = requests.post(
|
|
121
|
+
cfg.token_url,
|
|
122
|
+
data={
|
|
123
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
124
|
+
"device_code": device_code.device_code,
|
|
125
|
+
"client_id": cfg.client_id,
|
|
126
|
+
},
|
|
127
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
128
|
+
timeout=10,
|
|
129
|
+
)
|
|
130
|
+
if resp.status_code == 200:
|
|
131
|
+
return TokenBundle.from_token_response(resp.json())
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
err = resp.json().get("error", "")
|
|
135
|
+
except ValueError:
|
|
136
|
+
err = ""
|
|
137
|
+
|
|
138
|
+
if err == "authorization_pending":
|
|
139
|
+
continue
|
|
140
|
+
if err == "slow_down":
|
|
141
|
+
interval += 5
|
|
142
|
+
continue
|
|
143
|
+
if err in {"access_denied", "expired_token"}:
|
|
144
|
+
raise AuthError(f"login failed: {err}")
|
|
145
|
+
raise AuthError(
|
|
146
|
+
f"unexpected token endpoint response {resp.status_code}: {resp.text}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
raise AuthError("device code expired before user completed login")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def refresh_access_token(
|
|
153
|
+
refresh_token: str, cfg: Optional[Auth0Config] = None
|
|
154
|
+
) -> TokenBundle:
|
|
155
|
+
"""Exchange a refresh token for a new access token (and rotated refresh token).
|
|
156
|
+
|
|
157
|
+
Auth0 rotates refresh tokens by default — the response may carry a NEW
|
|
158
|
+
refresh_token, or (rarely) reuse the old one. Carry forward the prior
|
|
159
|
+
refresh token if the response omits it, so the caller never loses the
|
|
160
|
+
ability to refresh again.
|
|
161
|
+
"""
|
|
162
|
+
cfg = cfg or auth0_config()
|
|
163
|
+
resp = requests.post(
|
|
164
|
+
cfg.token_url,
|
|
165
|
+
data={
|
|
166
|
+
"grant_type": "refresh_token",
|
|
167
|
+
"client_id": cfg.client_id,
|
|
168
|
+
"refresh_token": refresh_token,
|
|
169
|
+
},
|
|
170
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
171
|
+
timeout=10,
|
|
172
|
+
)
|
|
173
|
+
if resp.status_code != 200:
|
|
174
|
+
raise AuthError(f"refresh failed: {resp.status_code} {resp.text}")
|
|
175
|
+
body = resp.json()
|
|
176
|
+
if "refresh_token" not in body:
|
|
177
|
+
body["refresh_token"] = refresh_token
|
|
178
|
+
return TokenBundle.from_token_response(body)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def device_login(
|
|
182
|
+
cfg: Optional[Auth0Config] = None,
|
|
183
|
+
open_browser: bool = True,
|
|
184
|
+
on_user_code: Optional[Callable[[DeviceCode], None]] = None,
|
|
185
|
+
) -> TokenBundle:
|
|
186
|
+
"""Run the full RFC 8628 device flow end-to-end. Returns the new token bundle.
|
|
187
|
+
|
|
188
|
+
`on_user_code` is invoked with the device code so the caller can print the
|
|
189
|
+
user_code + verification URL. If `open_browser` is True the verification URL
|
|
190
|
+
is also opened in the default browser.
|
|
191
|
+
"""
|
|
192
|
+
code = request_device_code(cfg)
|
|
193
|
+
if on_user_code is not None:
|
|
194
|
+
on_user_code(code)
|
|
195
|
+
if open_browser:
|
|
196
|
+
try:
|
|
197
|
+
webbrowser.open(code.verification_uri_complete)
|
|
198
|
+
except Exception:
|
|
199
|
+
# Browser open is best-effort — headless/SSH sessions won't have one.
|
|
200
|
+
pass
|
|
201
|
+
return poll_for_token(code, cfg)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def revoke_refresh_token(
|
|
205
|
+
refresh_token: str, cfg: Optional[Auth0Config] = None
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Best-effort RFC 7009 revocation on logout, so a refresh token copied out of
|
|
208
|
+
the vault is dead server-side too. Never raises: logout must still clear the
|
|
209
|
+
local copy when Auth0 is unreachable."""
|
|
210
|
+
cfg = cfg or auth0_config()
|
|
211
|
+
try:
|
|
212
|
+
resp = requests.post(
|
|
213
|
+
cfg.revoke_url,
|
|
214
|
+
data={"client_id": cfg.client_id, "token": refresh_token},
|
|
215
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
216
|
+
timeout=10,
|
|
217
|
+
)
|
|
218
|
+
except requests.RequestException:
|
|
219
|
+
return False
|
|
220
|
+
return resp.status_code == 200
|
ermes_cli/client.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
# Re-export ErmesAPIError from errors.py so existing `from ermes_cli.client
|
|
8
|
+
# import ErmesAPIError` keeps working without touching every command file.
|
|
9
|
+
from ermes_cli.config import validate_api_key
|
|
10
|
+
from ermes_cli.errors import ErmesAPIError, WriteBlockedError # noqa: F401
|
|
11
|
+
from ermes_cli.write_mode import assert_writes_allowed
|
|
12
|
+
|
|
13
|
+
_ERROR_MESSAGES = {
|
|
14
|
+
401: "Invalid API key or expired token",
|
|
15
|
+
403: "Insufficient permissions",
|
|
16
|
+
404: "Resource not found",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErmesClient:
|
|
21
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0):
|
|
22
|
+
# Every key tier (env, projects.yml, config.yml) lands here: an invalid
|
|
23
|
+
# header value would otherwise surface as an httpx error echoing the key.
|
|
24
|
+
api_key = validate_api_key(api_key)
|
|
25
|
+
self._base_url = base_url.rstrip("/") + "/api/client"
|
|
26
|
+
self._client = httpx.Client(
|
|
27
|
+
base_url=self._base_url,
|
|
28
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
33
|
+
if response.is_success:
|
|
34
|
+
if response.status_code == 204:
|
|
35
|
+
return None
|
|
36
|
+
content_type = response.headers.get("content-type", "")
|
|
37
|
+
if "application/json" in content_type:
|
|
38
|
+
return response.json()
|
|
39
|
+
return response.text or None
|
|
40
|
+
status = response.status_code
|
|
41
|
+
if status in _ERROR_MESSAGES:
|
|
42
|
+
raise ErmesAPIError(_ERROR_MESSAGES[status], status)
|
|
43
|
+
if status == 422:
|
|
44
|
+
try:
|
|
45
|
+
body = response.json()
|
|
46
|
+
detail = body.get("error") or body.get("message") or str(body)
|
|
47
|
+
except Exception:
|
|
48
|
+
detail = response.text
|
|
49
|
+
raise ErmesAPIError(f"Validation error: {detail}", status)
|
|
50
|
+
raise ErmesAPIError(f"Server error: {status}", status)
|
|
51
|
+
|
|
52
|
+
def get(self, path: str, params: dict | None = None) -> Any:
|
|
53
|
+
response = self._client.get(path, params=params)
|
|
54
|
+
return self._handle_response(response)
|
|
55
|
+
|
|
56
|
+
def post(self, path: str, json: dict | None = None) -> Any:
|
|
57
|
+
assert_writes_allowed("POST", path)
|
|
58
|
+
response = self._client.post(path, json=json)
|
|
59
|
+
return self._handle_response(response)
|
|
60
|
+
|
|
61
|
+
def patch(self, path: str, json: dict | None = None) -> Any:
|
|
62
|
+
assert_writes_allowed("PATCH", path)
|
|
63
|
+
response = self._client.patch(path, json=json)
|
|
64
|
+
return self._handle_response(response)
|
|
65
|
+
|
|
66
|
+
def put(self, path: str, json: dict | None = None) -> Any:
|
|
67
|
+
assert_writes_allowed("PUT", path)
|
|
68
|
+
response = self._client.put(path, json=json)
|
|
69
|
+
return self._handle_response(response)
|
|
70
|
+
|
|
71
|
+
def delete(self, path: str) -> Any:
|
|
72
|
+
assert_writes_allowed("DELETE", path)
|
|
73
|
+
response = self._client.delete(path)
|
|
74
|
+
return self._handle_response(response)
|
|
75
|
+
|
|
76
|
+
MAX_PAGES = 500
|
|
77
|
+
|
|
78
|
+
def get_paginated(
|
|
79
|
+
self, path: str, params: dict | None = None, limit: int = 100
|
|
80
|
+
) -> list[dict]:
|
|
81
|
+
params = dict(params or {})
|
|
82
|
+
all_results: list[dict] = []
|
|
83
|
+
for _page in range(self.MAX_PAGES):
|
|
84
|
+
data = self.get(path, params=params)
|
|
85
|
+
if not isinstance(data, dict) or "results" not in data:
|
|
86
|
+
return [data] if data else []
|
|
87
|
+
all_results.extend(data["results"])
|
|
88
|
+
if limit > 0 and len(all_results) >= limit:
|
|
89
|
+
return all_results[:limit]
|
|
90
|
+
next_cursor = data.get("nextCursor")
|
|
91
|
+
if not next_cursor:
|
|
92
|
+
return all_results
|
|
93
|
+
params["cursor"] = next_cursor
|
|
94
|
+
params["page"] = "next"
|
|
95
|
+
import sys
|
|
96
|
+
print(f"Warning: get_paginated hit max page limit ({self.MAX_PAGES}) for {path}", file=sys.stderr)
|
|
97
|
+
return all_results
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
self._client.close()
|
|
File without changes
|