cylera-cli 1.2.2__tar.gz → 1.3.0__tar.gz
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.
- {cylera_cli-1.2.2/cylera_cli.egg-info → cylera_cli-1.3.0}/PKG-INFO +2 -2
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/cylera.py +104 -4
- cylera_cli-1.3.0/cylera_auth.py +188 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0/cylera_cli.egg-info}/PKG-INFO +2 -2
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/cylera_cli.egg-info/SOURCES.txt +1 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/cylera_cli.egg-info/requires.txt +1 -1
- cylera_cli-1.3.0/cylera_cli.egg-info/top_level.txt +2 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/pyproject.toml +3 -3
- cylera_cli-1.2.2/cylera_cli.egg-info/top_level.txt +0 -1
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/LICENSE +0 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/README.md +0 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/cylera_cli.egg-info/dependency_links.txt +0 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/cylera_cli.egg-info/entry_points.txt +0 -0
- {cylera_cli-1.2.2 → cylera_cli-1.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cylera-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Command-line interface for the Cylera Partner API
|
|
5
5
|
Author-email: Cylera <support@cylera.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: System :: Systems Administration
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
-
Requires-Dist: cylera-client>=1.
|
|
25
|
+
Requires-Dist: cylera-client>=1.4.0
|
|
26
26
|
Requires-Dist: python-dotenv>=1.2.1
|
|
27
27
|
Requires-Dist: requests>=2.32.5
|
|
28
28
|
Requires-Dist: typer>=0.21.1
|
|
@@ -31,6 +31,9 @@ from cylera_client import (
|
|
|
31
31
|
CyleraAuthError,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
import cylera_auth
|
|
35
|
+
from cylera_auth import CyleraLoginError
|
|
36
|
+
|
|
34
37
|
# Available Cylera API endpoints
|
|
35
38
|
CYLERA_URLS = [
|
|
36
39
|
"https://partner.us1.cylera.com/",
|
|
@@ -51,12 +54,30 @@ def print_json(data: Any) -> None:
|
|
|
51
54
|
print(json.dumps(data, indent=2))
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def base_url_setting() -> Optional[str]:
|
|
58
|
+
"""CYLERA_BASE_URL when set, else the API last signed in to."""
|
|
59
|
+
load_dotenv(Path.cwd() / ".env")
|
|
60
|
+
return os.environ.get("CYLERA_BASE_URL") or cylera_auth.default_base_url()
|
|
61
|
+
|
|
62
|
+
|
|
54
63
|
def get_client() -> CyleraClient:
|
|
55
|
-
"""Create and return a CyleraClient using environment variables.
|
|
56
|
-
|
|
64
|
+
"""Create and return a CyleraClient using environment variables.
|
|
65
|
+
|
|
66
|
+
A username and password, when set, win; otherwise the client uses the
|
|
67
|
+
WorkOS sign-in stored by 'cylera auth login'.
|
|
68
|
+
"""
|
|
69
|
+
base_url = base_url_setting()
|
|
57
70
|
username = os.environ.get("CYLERA_USERNAME")
|
|
58
71
|
password = os.environ.get("CYLERA_PASSWORD")
|
|
59
72
|
|
|
73
|
+
if base_url and not (username or password) and cylera_auth.stored_login(base_url):
|
|
74
|
+
try:
|
|
75
|
+
token = cylera_auth.cylera_token(base_url)
|
|
76
|
+
except CyleraLoginError as e:
|
|
77
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
return CyleraClient(token=token, base_url=base_url)
|
|
80
|
+
|
|
60
81
|
if not base_url or not username or not password:
|
|
61
82
|
print(
|
|
62
83
|
"Error: Missing required environment variables.\n"
|
|
@@ -71,10 +92,11 @@ def get_client() -> CyleraClient:
|
|
|
71
92
|
|
|
72
93
|
def check_environment() -> bool:
|
|
73
94
|
"""Check if required environment variables are set."""
|
|
74
|
-
|
|
75
|
-
base_url = os.environ.get("CYLERA_BASE_URL")
|
|
95
|
+
base_url = base_url_setting()
|
|
76
96
|
username = os.environ.get("CYLERA_USERNAME")
|
|
77
97
|
password = os.environ.get("CYLERA_PASSWORD")
|
|
98
|
+
if base_url and not (username or password):
|
|
99
|
+
return cylera_auth.stored_login(base_url) is not None
|
|
78
100
|
return bool(base_url and username and password)
|
|
79
101
|
|
|
80
102
|
|
|
@@ -227,6 +249,84 @@ def init() -> None:
|
|
|
227
249
|
print(" cylera devices --page-size 5")
|
|
228
250
|
|
|
229
251
|
|
|
252
|
+
# Hidden until the Partner API has POST /auth/token_exchange: for now
|
|
253
|
+
# these work only against the identity management prototype.
|
|
254
|
+
auth_app = typer.Typer(
|
|
255
|
+
help="Sign in to Cylera through WorkOS.", no_args_is_help=True)
|
|
256
|
+
app.add_typer(auth_app, name="auth", hidden=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _require_base_url() -> str:
|
|
260
|
+
base_url = base_url_setting()
|
|
261
|
+
if not base_url:
|
|
262
|
+
print("Not signed in. Run 'cylera auth login'.", file=sys.stderr)
|
|
263
|
+
raise typer.Exit(1)
|
|
264
|
+
return base_url
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@auth_app.command("login")
|
|
268
|
+
def auth_login(
|
|
269
|
+
base_url: Annotated[Optional[str], typer.Option(
|
|
270
|
+
"--base-url", help="Cylera API endpoint, instead of choosing one")] = None,
|
|
271
|
+
no_browser: Annotated[bool, typer.Option(
|
|
272
|
+
"--no-browser", help="Print the sign-in link without opening a browser")] = False,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Sign in through your browser."""
|
|
275
|
+
load_dotenv(Path.cwd() / ".env")
|
|
276
|
+
client_id = os.environ.get("CYLERA_WORKOS_CLIENT_ID")
|
|
277
|
+
if not client_id:
|
|
278
|
+
print("Error: Set CYLERA_WORKOS_CLIENT_ID.", file=sys.stderr)
|
|
279
|
+
raise typer.Exit(1)
|
|
280
|
+
base_url = base_url or os.environ.get("CYLERA_BASE_URL")
|
|
281
|
+
if not base_url:
|
|
282
|
+
base_url = _prompt_base_url()
|
|
283
|
+
print()
|
|
284
|
+
try:
|
|
285
|
+
authentication = cylera_auth.device_login(
|
|
286
|
+
client_id, show=lambda line: print(line, file=sys.stderr),
|
|
287
|
+
open_browser=not no_browser)
|
|
288
|
+
# Exchange before storing anything, so an account Cylera won't
|
|
289
|
+
# accept isn't left half signed in.
|
|
290
|
+
token = cylera_auth.exchange_token(base_url, authentication["access_token"])
|
|
291
|
+
except CyleraLoginError as e:
|
|
292
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
cylera_auth.save_login(base_url, client_id, authentication)
|
|
295
|
+
|
|
296
|
+
with CyleraClient(token=token, base_url=base_url) as client:
|
|
297
|
+
current = Organization(client).get_organization()
|
|
298
|
+
print(
|
|
299
|
+
f"Signed in as {authentication['user']['email']} "
|
|
300
|
+
f"({current.get('name', 'unknown organization')}) on {base_url}",
|
|
301
|
+
file=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@auth_app.command("status")
|
|
306
|
+
def auth_status() -> None:
|
|
307
|
+
"""Show how the CLI signs in to Cylera."""
|
|
308
|
+
base_url = _require_base_url()
|
|
309
|
+
username = os.environ.get("CYLERA_USERNAME")
|
|
310
|
+
if username:
|
|
311
|
+
print(f"Using the password for {username} on {base_url}")
|
|
312
|
+
return
|
|
313
|
+
login = cylera_auth.stored_login(base_url)
|
|
314
|
+
if login is None:
|
|
315
|
+
print(f"Not signed in on {base_url}. Run 'cylera auth login'.")
|
|
316
|
+
raise typer.Exit(1)
|
|
317
|
+
print(f"Signed in through WorkOS as {login['email']} on {base_url}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@auth_app.command("logout")
|
|
321
|
+
def auth_logout() -> None:
|
|
322
|
+
"""Forget the WorkOS sign-in for the current Cylera API endpoint."""
|
|
323
|
+
base_url = _require_base_url()
|
|
324
|
+
if cylera_auth.forget_login(base_url):
|
|
325
|
+
print(f"Signed out on {base_url}")
|
|
326
|
+
else:
|
|
327
|
+
print(f"Not signed in on {base_url}")
|
|
328
|
+
|
|
329
|
+
|
|
230
330
|
@app.command()
|
|
231
331
|
def organization() -> None:
|
|
232
332
|
"""Get the organization associated with the current credentials."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WorkOS sign-in for the Cylera CLI (`cylera auth login`).
|
|
3
|
+
|
|
4
|
+
Uses the WorkOS CLI Auth flow (the OAuth 2.0 Device Authorization Grant,
|
|
5
|
+
RFC 8628) as a public client: only a client ID, no API key or secret. The
|
|
6
|
+
WorkOS refresh token is stored per Partner API URL; each command refreshes
|
|
7
|
+
it for a WorkOS access token and exchanges that at the Partner API's
|
|
8
|
+
POST /auth/token_exchange for a Cylera token. The Partner API URL last
|
|
9
|
+
signed in to becomes the default, so commands work from any directory.
|
|
10
|
+
|
|
11
|
+
Prototype: the WorkOS client ID comes from CYLERA_WORKOS_CLIENT_ID rather
|
|
12
|
+
than being discovered from the Partner API.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import webbrowser
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
WORKOS_API = "https://api.workos.com/user_management"
|
|
25
|
+
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
26
|
+
CREDENTIALS_PATH = Path.home() / ".config" / "cylera" / "credentials.json"
|
|
27
|
+
CONFIG_PATH = Path.home() / ".config" / "cylera" / "config.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CyleraLoginError(Exception):
|
|
31
|
+
"""Sign-in failed, or the stored sign-in no longer works."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _key(base_url: str) -> str:
|
|
35
|
+
return base_url.rstrip("/")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_credentials() -> dict[str, Any]:
|
|
39
|
+
if not CREDENTIALS_PATH.exists():
|
|
40
|
+
return {}
|
|
41
|
+
return json.loads(CREDENTIALS_PATH.read_text())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_credentials(credentials: dict[str, Any]) -> None:
|
|
45
|
+
CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
# Created owner-only: the refresh token works on its own.
|
|
47
|
+
fd = os.open(CREDENTIALS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
48
|
+
with os.fdopen(fd, "w") as f:
|
|
49
|
+
json.dump(credentials, f, indent=2)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_base_url() -> Optional[str]:
|
|
53
|
+
"""The Partner API URL last signed in to, or None."""
|
|
54
|
+
if not CONFIG_PATH.exists():
|
|
55
|
+
return None
|
|
56
|
+
return json.loads(CONFIG_PATH.read_text()).get("base_url")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _set_default_base_url(base_url: Optional[str]) -> None:
|
|
60
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
CONFIG_PATH.write_text(json.dumps({"base_url": base_url}, indent=2))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stored_login(base_url: str) -> Optional[dict[str, Any]]:
|
|
65
|
+
"""The stored sign-in for this Partner API, or None."""
|
|
66
|
+
return _read_credentials().get(_key(base_url))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _store_login(base_url: str, login: dict[str, Any]) -> None:
|
|
70
|
+
credentials = _read_credentials()
|
|
71
|
+
credentials[_key(base_url)] = login
|
|
72
|
+
_write_credentials(credentials)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def forget_login(base_url: str) -> bool:
|
|
76
|
+
"""Delete the stored sign-in for this Partner API. True if there was one."""
|
|
77
|
+
credentials = _read_credentials()
|
|
78
|
+
if credentials.pop(_key(base_url), None) is None:
|
|
79
|
+
return False
|
|
80
|
+
_write_credentials(credentials)
|
|
81
|
+
if default_base_url() == base_url:
|
|
82
|
+
_set_default_base_url(None)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _post(path: str, data: dict[str, str]) -> requests.Response:
|
|
87
|
+
return requests.post(f"{WORKOS_API}/{path}", data=data, timeout=30)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def device_login(
|
|
91
|
+
client_id: str,
|
|
92
|
+
show: Callable[[str], None] = print,
|
|
93
|
+
open_browser: bool = True,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Run the device flow until the user confirms in the browser.
|
|
96
|
+
|
|
97
|
+
Returns WorkOS's authentication response (user, access_token,
|
|
98
|
+
refresh_token, ...). Raises CyleraLoginError if the user declines or
|
|
99
|
+
the code expires.
|
|
100
|
+
"""
|
|
101
|
+
response = _post("authorize/device", {"client_id": client_id})
|
|
102
|
+
if not response.ok:
|
|
103
|
+
raise CyleraLoginError(f"WorkOS refused the sign-in request: {response.text}")
|
|
104
|
+
device = response.json()
|
|
105
|
+
|
|
106
|
+
url = device["verification_uri_complete"]
|
|
107
|
+
show(f"Open this page to sign in: {url}")
|
|
108
|
+
show(f"Confirm this code matches: {device['user_code']}")
|
|
109
|
+
if open_browser:
|
|
110
|
+
webbrowser.open(url)
|
|
111
|
+
|
|
112
|
+
interval = device.get("interval", 5)
|
|
113
|
+
deadline = time.monotonic() + device.get("expires_in", 300)
|
|
114
|
+
while time.monotonic() < deadline:
|
|
115
|
+
time.sleep(interval)
|
|
116
|
+
response = _post("authenticate", {
|
|
117
|
+
"grant_type": DEVICE_CODE_GRANT,
|
|
118
|
+
"device_code": device["device_code"],
|
|
119
|
+
"client_id": client_id,
|
|
120
|
+
})
|
|
121
|
+
if response.ok:
|
|
122
|
+
return response.json()
|
|
123
|
+
error = response.json().get("error")
|
|
124
|
+
if error == "authorization_pending":
|
|
125
|
+
continue
|
|
126
|
+
if error == "slow_down":
|
|
127
|
+
interval += 5
|
|
128
|
+
continue
|
|
129
|
+
if error == "access_denied":
|
|
130
|
+
raise CyleraLoginError("Sign-in was declined.")
|
|
131
|
+
if error == "expired_token":
|
|
132
|
+
break
|
|
133
|
+
raise CyleraLoginError(f"WorkOS sign-in failed: {response.text}")
|
|
134
|
+
raise CyleraLoginError("The sign-in code expired. Run 'cylera auth login' again.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def exchange_token(base_url: str, workos_access_token: str) -> str:
|
|
138
|
+
"""Exchange a WorkOS access token for a Cylera token."""
|
|
139
|
+
response = requests.post(
|
|
140
|
+
f"{_key(base_url)}/auth/token_exchange",
|
|
141
|
+
json={"token": workos_access_token},
|
|
142
|
+
timeout=30,
|
|
143
|
+
)
|
|
144
|
+
if response.status_code == 404:
|
|
145
|
+
raise CyleraLoginError(
|
|
146
|
+
"This Cylera API doesn't support signing in through WorkOS yet.")
|
|
147
|
+
if response.status_code == 401:
|
|
148
|
+
raise CyleraLoginError(
|
|
149
|
+
"Cylera refused your WorkOS sign-in. Sign in to the Cylera Portal "
|
|
150
|
+
"once to move your account to WorkOS, then try again."
|
|
151
|
+
)
|
|
152
|
+
response.raise_for_status()
|
|
153
|
+
return response.json()["token"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def save_login(base_url: str, client_id: str, authentication: dict[str, Any]) -> None:
|
|
157
|
+
"""Store a fresh sign-in's refresh token, and make its Partner API the default."""
|
|
158
|
+
_store_login(base_url, {
|
|
159
|
+
"client_id": client_id,
|
|
160
|
+
"email": authentication["user"]["email"],
|
|
161
|
+
"refresh_token": authentication["refresh_token"],
|
|
162
|
+
})
|
|
163
|
+
_set_default_base_url(base_url)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cylera_token(base_url: str) -> str:
|
|
167
|
+
"""A Cylera token from the stored sign-in.
|
|
168
|
+
|
|
169
|
+
Refreshes the WorkOS session (saving the rotated refresh token) and
|
|
170
|
+
exchanges the new access token. Raises CyleraLoginError if there's no
|
|
171
|
+
stored sign-in, or WorkOS no longer accepts it.
|
|
172
|
+
"""
|
|
173
|
+
login = stored_login(base_url)
|
|
174
|
+
if login is None:
|
|
175
|
+
raise CyleraLoginError("Not signed in. Run 'cylera auth login'.")
|
|
176
|
+
response = _post("authenticate", {
|
|
177
|
+
"grant_type": "refresh_token",
|
|
178
|
+
"refresh_token": login["refresh_token"],
|
|
179
|
+
"client_id": login["client_id"],
|
|
180
|
+
})
|
|
181
|
+
if not response.ok:
|
|
182
|
+
if response.json().get("error") == "invalid_grant":
|
|
183
|
+
forget_login(base_url)
|
|
184
|
+
raise CyleraLoginError("Your sign-in has expired. Run 'cylera auth login'.")
|
|
185
|
+
raise CyleraLoginError(f"WorkOS refresh failed: {response.text}")
|
|
186
|
+
authentication = response.json()
|
|
187
|
+
_store_login(base_url, {**login, "refresh_token": authentication["refresh_token"]})
|
|
188
|
+
return exchange_token(base_url, authentication["access_token"])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cylera-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Command-line interface for the Cylera Partner API
|
|
5
5
|
Author-email: Cylera <support@cylera.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: System :: Systems Administration
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
-
Requires-Dist: cylera-client>=1.
|
|
25
|
+
Requires-Dist: cylera-client>=1.4.0
|
|
26
26
|
Requires-Dist: python-dotenv>=1.2.1
|
|
27
27
|
Requires-Dist: requests>=2.32.5
|
|
28
28
|
Requires-Dist: typer>=0.21.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cylera-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0"
|
|
4
4
|
description = "Command-line interface for the Cylera Partner API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
]
|
|
24
24
|
requires-python = ">=3.10"
|
|
25
25
|
dependencies = [
|
|
26
|
-
"cylera-client>=1.
|
|
26
|
+
"cylera-client>=1.4.0",
|
|
27
27
|
"python-dotenv>=1.2.1",
|
|
28
28
|
"requests>=2.32.5",
|
|
29
29
|
"typer>=0.21.1",
|
|
@@ -42,7 +42,7 @@ requires = ["setuptools>=61.0"]
|
|
|
42
42
|
build-backend = "setuptools.build_meta"
|
|
43
43
|
|
|
44
44
|
[tool.setuptools]
|
|
45
|
-
py-modules = ["cylera"]
|
|
45
|
+
py-modules = ["cylera", "cylera_auth"]
|
|
46
46
|
|
|
47
47
|
[dependency-groups]
|
|
48
48
|
dev = [
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
cylera
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|