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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cylera-cli
3
- Version: 1.2.2
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.2.0
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
- base_url = os.environ.get("CYLERA_BASE_URL")
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
- load_dotenv(Path.cwd() / ".env")
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.2.2
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.2.0
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,7 @@
1
1
  LICENSE
2
2
  README.md
3
3
  cylera.py
4
+ cylera_auth.py
4
5
  pyproject.toml
5
6
  cylera_cli.egg-info/PKG-INFO
6
7
  cylera_cli.egg-info/SOURCES.txt
@@ -1,4 +1,4 @@
1
- cylera-client>=1.2.0
1
+ cylera-client>=1.4.0
2
2
  python-dotenv>=1.2.1
3
3
  requests>=2.32.5
4
4
  typer>=0.21.1
@@ -0,0 +1,2 @@
1
+ cylera
2
+ cylera_auth
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cylera-cli"
3
- version = "1.2.2"
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.2.0",
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