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 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