lumos-identity-cli 0.0.0.dev1__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.
lumos/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ import importlib.metadata
2
+
3
+ __app_name__ = "lumos"
4
+ __version__ = importlib.metadata.version("lumos-identity-cli")
lumos/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from lumos.cli import lumos
2
+
3
+
4
+ def main():
5
+ lumos()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
lumos/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ import os
2
+
3
+ from click_extra import Context, echo, group, option, pass_context
4
+
5
+ from lumos import __version__
6
+ from lumos.common.client import ApiClient
7
+ from lumos.common.helpers import authenticate
8
+ from lumos.common.helpers import login as _login
9
+ from lumos.common.helpers import logout as _logout
10
+ from lumos.common.helpers import setup as _setup
11
+ from lumos.common.logging import logdebug
12
+
13
+ client = ApiClient()
14
+
15
+
16
+ @group(
17
+ context_settings={"help_option_names": ["-h", "--help"]},
18
+ version=__version__,
19
+ )
20
+ @option("--debug", is_flag=True, help="Enable debug mode", hidden=True)
21
+ @pass_context
22
+ def lumos(ctx: Context, debug: bool) -> None:
23
+ """Lumos CLI - Command line interface for Lumos"""
24
+ if debug:
25
+ os.environ["DEBUG"] = "1"
26
+ logdebug("🐞 Debug mode enabled")
27
+
28
+
29
+ @lumos.command("whoami", help="Show information about the currently logged in user.")
30
+ @option("--username", is_flag=True, help="Show the current user's username only")
31
+ @option("--id", "show_id", is_flag=True, help="Show the current user's ID only")
32
+ @authenticate
33
+ def whoami(username: bool, show_id: bool) -> None:
34
+ user = client.get_current_user()
35
+ if username:
36
+ echo(user.email)
37
+ return
38
+ if show_id:
39
+ echo(user.id)
40
+ return
41
+ msg = f" 💻 Logged in as {user.given_name} {user.family_name} ({user.email})"
42
+ if scope := os.environ.get("SCOPE"):
43
+ msg += f" as {scope}"
44
+ echo(msg)
45
+ echo(f"Your ID is {user.id}, if you need to reference it")
46
+
47
+
48
+ @lumos.command(
49
+ "setup",
50
+ help="Setup your Lumos CLI. Can be used to login or change your authentication method.",
51
+ )
52
+ def setup():
53
+ _setup(show_overwrite_prompt=True)
54
+ # Call whoami command programmatically
55
+ ctx = Context(whoami)
56
+ ctx.invoke(whoami, username=False, show_id=False)
57
+
58
+
59
+ @lumos.command(
60
+ "login",
61
+ help="Login to your Lumos account via OAuth. You must be logged in to Lumos on your browser.",
62
+ )
63
+ @option(
64
+ "--admin",
65
+ is_flag=True,
66
+ help="Log in as an admin, if you have the permission to do so",
67
+ )
68
+ def login(admin: bool):
69
+ _logout()
70
+ _login(admin)
71
+ # Call whoami command programmatically
72
+ ctx = Context(whoami)
73
+ ctx.invoke(whoami, username=False, show_id=False)
74
+
75
+
76
+ @lumos.command("logout", help="Logout of your Lumos account.")
77
+ def logout():
78
+ _logout()
79
+ echo(" 👋 Logged out!")
80
+
81
+
82
+ # Import and register subcommands
83
+ def register_subcommands():
84
+ """Register all subcommands after the main CLI group is defined"""
85
+ from lumos.list_collections.cli import list_group
86
+ from lumos.request.cli import request
87
+
88
+ lumos.add_command(request)
89
+ lumos.add_command(list_group, name="list")
90
+
91
+
92
+ # Register subcommands at module load time
93
+ register_subcommands()
94
+
95
+ if __name__ == "__main__":
96
+ lumos()
@@ -0,0 +1 @@
1
+ # Common utilities for Lumos CLI
lumos/common/client.py ADDED
@@ -0,0 +1,479 @@
1
+ import os
2
+ import time
3
+ import webbrowser
4
+ from abc import abstractmethod
5
+ from typing import Any, ClassVar
6
+ from uuid import UUID
7
+
8
+ import requests
9
+ from click_extra import confirm, echo
10
+
11
+ from lumos import __version__
12
+ from lumos.common.client_helpers import check_version_header
13
+ from lumos.common.keyhelpers import write_key
14
+ from lumos.common.logging import logdebug_request, logdebug_response
15
+ from lumos.common.models import (
16
+ AccessRequest,
17
+ App,
18
+ AppSetting,
19
+ Group,
20
+ Permission,
21
+ SupportRequestStatus,
22
+ User,
23
+ )
24
+
25
+
26
+ class BaseClient:
27
+ url: str
28
+
29
+ def __init__(self, url: str):
30
+ self.url = url
31
+
32
+ def get(self, endpoint: str, params: dict | None = None):
33
+ """Function to call an API endpoint and return the response."""
34
+ if params is None:
35
+ params = {}
36
+ return self._send_request("GET", endpoint, params=params)
37
+
38
+ def get_paged(
39
+ self,
40
+ endpoint: str,
41
+ params: dict | None = None,
42
+ page_size: int = 100,
43
+ page: int = 1,
44
+ ) -> tuple[list[dict[str, Any]], int, int, int, int]:
45
+ """Function to call an API endpoint and return the paged response."""
46
+ if params is None:
47
+ params = {}
48
+ params = {**params, "size": page_size, "page": page}
49
+ response = self.get(endpoint, params=params)
50
+ if response:
51
+ return (
52
+ response["items"],
53
+ len(response["items"]),
54
+ int(response["total"]),
55
+ int(response["page"]),
56
+ int(response["pages"]),
57
+ )
58
+ return [], 0, 0, 0, 0
59
+
60
+ def get_all(
61
+ self,
62
+ endpoint: str,
63
+ params: dict | None = None,
64
+ ) -> tuple[list[dict[str, Any]], int, int, int, int]:
65
+ """Function to call an API endpoint and return all the results."""
66
+ if params is None:
67
+ params = {}
68
+ all_results: list[Any] = []
69
+ page = 1
70
+ while True:
71
+ results, _, total, _, pages = self.get_paged(endpoint, params=params, page=page, page_size=100)
72
+ if total == 0:
73
+ break
74
+ all_results.extend(results)
75
+ if page == pages:
76
+ break
77
+ if (
78
+ page == 1
79
+ and total > 5000
80
+ and not confirm(
81
+ f"Warning: {total} results found. This may take a while. Do you want to continue?",
82
+ default=True,
83
+ )
84
+ ):
85
+ raise SystemExit(1)
86
+ page += 1
87
+ time.sleep(0.15)
88
+ count = len(all_results)
89
+ return all_results, count, count, 1, 1
90
+
91
+ def get_all_or_paged(
92
+ self,
93
+ endpoint: str,
94
+ params: dict | None = None,
95
+ all: bool = False,
96
+ page_size: int = 100,
97
+ page: int = 1,
98
+ ) -> tuple[list[dict[str, Any]], int, int, int, int]:
99
+ if params is None:
100
+ params = {}
101
+ """Function to call an API endpoint and return all the results."""
102
+ if all:
103
+ return self.get_all(endpoint, params=params)
104
+ return self.get_paged(endpoint, params=params, page_size=page_size, page=page)
105
+
106
+ def post(self, endpoint: str, body: dict[str, Any]):
107
+ """Function to call an API endpoint and return the response."""
108
+ return self._send_request("POST", endpoint, body=body)
109
+
110
+ def delete(self, endpoint: str, params: dict | None = None):
111
+ if params is None:
112
+ params = {}
113
+ return self._send_request("DELETE", endpoint, params=params)
114
+
115
+ def _send_request(
116
+ self,
117
+ method: str,
118
+ endpoint: str,
119
+ body: dict | None = None,
120
+ params: dict | None = None,
121
+ retry: int = 0,
122
+ ):
123
+ if params is None:
124
+ params = {}
125
+ if retry > 3:
126
+ echo("Too many retries. Exiting.", err=True)
127
+ raise SystemExit(1)
128
+ url, headers = self._get_url_and_headers(endpoint)
129
+ logdebug_request(url, headers, body, params, method)
130
+
131
+ response = requests.request(method, url, headers=headers, json=body, params=params)
132
+
133
+ logdebug_response(response)
134
+
135
+ if not check_version_header(response):
136
+ raise SystemExit(1)
137
+
138
+ if response.ok:
139
+ if response.status_code == 204:
140
+ return None
141
+ return response.json()
142
+ if response.status_code == 429:
143
+ echo("We're being rate limited. Waiting a sec.", err=True)
144
+ retry += 1
145
+ time.sleep(retry)
146
+ return self._send_request(method, endpoint, body, params, retry)
147
+ if response.status_code == 401:
148
+ if retry > 1 or not (scope := os.environ.get("SCOPE")):
149
+ echo(
150
+ "Something went wrong with authorization. Try logging in again.",
151
+ err=True,
152
+ )
153
+ raise SystemExit(1)
154
+ echo(
155
+ "Something went wrong with authorization. Trying to log in again.",
156
+ err=True,
157
+ )
158
+ AuthClient().authenticate(scope == "admin")
159
+ return self._send_request(method, endpoint, body, params, retry + 1)
160
+ if response.status_code == 403:
161
+ echo("You don't have permission to do that.", err=True)
162
+ raise SystemExit(1)
163
+
164
+ if response.status_code == 404:
165
+ echo("Not found.", err=True)
166
+ raise SystemExit(1)
167
+
168
+ echo(f"An error occurred (status code {response.status_code})", err=True)
169
+ if response.status_code == 409 or response.status_code == 422:
170
+ response_json = response.json()
171
+ if detail := response_json.get("detail"):
172
+ echo(detail, err=True)
173
+
174
+ raise SystemExit(1)
175
+
176
+ @abstractmethod
177
+ def _get_url_and_headers(self, endpoint: str) -> tuple[str, dict[str, str]]:
178
+ pass
179
+
180
+
181
+ class AuthClient(BaseClient):
182
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
183
+ HEADERS: ClassVar[dict[str, str]] = {
184
+ "content-type": "application/x-www-form-urlencoded",
185
+ "User-Agent": f"lumos-cli/{__version__}",
186
+ }
187
+
188
+ def __init__(self):
189
+ auth_url: str | None = None
190
+ if os.environ.get("DEV_MODE"):
191
+ auth_url = os.environ.get("AUTH_URL")
192
+ super().__init__(auth_url or "https://b.app.lumosidentity.com")
193
+
194
+ def _get_url_and_headers(self, endpoint: str) -> tuple[str, dict[str, str]]:
195
+ return f"{self.url}/b/oauth/{endpoint}", self.HEADERS
196
+
197
+ def _get_client_id(self) -> str:
198
+ if auth_url := os.environ.get("AUTH_URL"):
199
+ if "localhost" in auth_url:
200
+ return "IHcwQtVXJH8RVrPag3Tqi17KDdI6X9Ja"
201
+ if "qa" in auth_url:
202
+ return "37o8paGpj1BvV6NOFmVO21cG8L9ePSCs"
203
+ if "staging" in auth_url:
204
+ return "tLPbUcyJZZyEvuxTo5deyFQXkO9iXZyx"
205
+ return "XfsajAwB6pl2XyNYwzrVkTI15ISbQ2dR"
206
+
207
+ def authenticate(self, admin: bool = False):
208
+ url, headers = self._get_url_and_headers("device/code")
209
+ client_id = self._get_client_id()
210
+ scope = "admin" if admin else "user"
211
+ params = {
212
+ "client_id": client_id,
213
+ "scope": scope,
214
+ }
215
+ logdebug_request(url, headers, None, params, "POST")
216
+
217
+ response = requests.post(url, headers=headers, params=params)
218
+ if not check_version_header(response):
219
+ raise SystemExit(1)
220
+
221
+ if not response.ok:
222
+ echo("Something went wrong.")
223
+ logdebug_response(response)
224
+ raise SystemExit(1)
225
+
226
+ device_auth_data = response.json()
227
+
228
+ verification_uri_complete = device_auth_data["verification_uri_complete"]
229
+ if os.environ.get("DEV_MODE"):
230
+ if url.startswith("http://"):
231
+ verification_uri_complete = verification_uri_complete.replace("https://", "http://")
232
+ if "localhost:3000" in verification_uri_complete:
233
+ verification_uri_complete = verification_uri_complete.replace("localhost:3000", "localhost:8080")
234
+
235
+ webbrowser.open(verification_uri_complete)
236
+
237
+ token_data = {
238
+ "client_id": client_id,
239
+ "device_code": device_auth_data["device_code"],
240
+ "grant_type": self.GRANT_TYPE,
241
+ }
242
+ token_url, _ = self._get_url_and_headers("token")
243
+ echo(
244
+ f" 🔑 Please go to {verification_uri_complete} to authenticate. You must already be logged in to Lumos on your browser."
245
+ )
246
+
247
+ logdebug_request(token_url, headers, token_data, None, "POST")
248
+
249
+ wait = 0
250
+ while True:
251
+ print(
252
+ " ⏰ Waiting" + ("." * (wait % 10)) + (" " * (10 - (wait % 10))),
253
+ end="\r",
254
+ )
255
+ token_response = requests.post(token_url, headers=headers, data=token_data)
256
+ if token_response.status_code == 400:
257
+ echo(f"Bad request: {token_response.json()}")
258
+ raise SystemExit(1)
259
+ logdebug_response(token_response)
260
+ if token_response.status_code != 200:
261
+ time.sleep(1)
262
+ wait += 1
263
+ else:
264
+ token_data = token_response.json()
265
+ token = token_data["access_token"]
266
+ break
267
+ if wait > 60:
268
+ echo("Timed out waiting for authentication.")
269
+ raise SystemExit(1)
270
+ echo(" ✅ Authenticated!")
271
+ write_key(token, scope)
272
+
273
+
274
+ class ApiClient(BaseClient):
275
+ def __init__(self):
276
+ api_url: str | None = None
277
+ if os.environ.get("DEV_MODE"):
278
+ api_url = os.environ.get("API_URL")
279
+ super().__init__(api_url or "https://api.lumos.com")
280
+
281
+ def _get_url_and_headers(self, endpoint: str) -> tuple[str, dict[str, str]]:
282
+ api_key = os.environ.get("API_KEY")
283
+ headers = {
284
+ "Authorization": f"Bearer {api_key}",
285
+ "Accept": "application/json",
286
+ "User-Agent": f"lumos-cli/{__version__}",
287
+ }
288
+ return f"{self.url}/{endpoint}", headers
289
+
290
+ def get_current_user_id(self) -> UUID:
291
+ if not os.environ.get("USER_ID"):
292
+ user = self.get_current_user()
293
+ os.environ["USER_ID"] = str(user.id)
294
+ return user.id
295
+ return UUID(os.environ["USER_ID"])
296
+
297
+ def get_current_user(self) -> User:
298
+ user = self.get("users/current")
299
+ if user:
300
+ os.environ["USER_ID"] = user["id"]
301
+ return User(**user)
302
+ echo("You are not logged in", err=True)
303
+ raise SystemExit(1)
304
+
305
+ def get_appstore_app(self, id: UUID) -> App | None:
306
+ raw_app = self.get(f"appstore/apps/{id}")
307
+ if raw_app:
308
+ return App(**raw_app)
309
+ return None
310
+
311
+ def get_appstore_app_setting(self, id: UUID) -> AppSetting:
312
+ raw_setting = self.get(f"appstore/apps/{id}/settings")
313
+ return AppSetting(**raw_setting)
314
+
315
+ def get_request_status(self, id: UUID) -> AccessRequest | None:
316
+ raw_request = self.get(f"appstore/access_requests/{id}")
317
+ return self._create_access_request(raw_request)
318
+
319
+ def cancel_access_request(self, id: UUID, reason: str | None) -> None:
320
+ params = {}
321
+ if reason:
322
+ params["reason"] = reason
323
+ self.delete(f"appstore/access_requests/{id}", params)
324
+
325
+ def _create_access_request(self, raw_request: dict | None) -> AccessRequest | None:
326
+ if not raw_request:
327
+ return None
328
+ return AccessRequest(**raw_request)
329
+
330
+ def get_appstore_apps(
331
+ self,
332
+ name_search: str | None = None,
333
+ all: bool = False,
334
+ page_size: int = 100,
335
+ page: int = 1,
336
+ ) -> tuple[list[App], int, int]:
337
+ endpoint = "appstore/apps"
338
+ params: dict[str, Any] = {}
339
+ if name_search:
340
+ params["name_search"] = name_search
341
+ raw_apps, count, total, _, _ = self.get_all_or_paged(
342
+ endpoint, params=params, all=all, page_size=page_size, page=page
343
+ )
344
+ apps: list[App] = []
345
+ for item in raw_apps:
346
+ apps.append(App(**item))
347
+ return apps, count, total
348
+
349
+ def get_my_apps(self, for_user: UUID | None = None) -> list[App]:
350
+ user = for_user or self.get_current_user_id()
351
+ statuses = SupportRequestStatus.PENDING_STATUSES + SupportRequestStatus.SUCCESS_STATUSES
352
+ access_requests, _, _, _, _ = self.get_access_requests(
353
+ target_user_id=user,
354
+ status=statuses,
355
+ all=True,
356
+ )
357
+ return access_requests
358
+
359
+ def get_access_requests(
360
+ self,
361
+ target_user_id: UUID | None = None,
362
+ status: list[str] | None = None,
363
+ all: bool = False,
364
+ page_size: int = 100,
365
+ page: int = 1,
366
+ ) -> tuple[list[AccessRequest], int, int, int, int]:
367
+ params: dict[str, Any] = {"sort": "desc"}
368
+ if target_user_id:
369
+ params["target_user_id"] = str(target_user_id)
370
+ if status and len(status) > 0:
371
+ params["statuses"] = [str(s) for s in status]
372
+
373
+ endpoint = "appstore/access_requests"
374
+ raw_access_requests, count, total, page, pages = self.get_all_or_paged(
375
+ endpoint, params=params, all=all, page=page, page_size=page_size
376
+ )
377
+ access_requests: list[AccessRequest] = []
378
+ for item in raw_access_requests:
379
+ access_request = self._create_access_request(item)
380
+ if access_request:
381
+ access_requests.append(access_request)
382
+ return access_requests, count, total, page, pages
383
+
384
+ def get_users(
385
+ self,
386
+ like: str | None = None,
387
+ all: bool = False,
388
+ page_size: int = 100,
389
+ page: int = 1,
390
+ ) -> tuple[list[User], int, int]:
391
+ endpoint = "users"
392
+ params: dict[str, Any] = {}
393
+ if like:
394
+ params["search_term"] = like
395
+ raw_users, count, total, _, _ = self.get_all_or_paged(
396
+ endpoint, params=params, all=all, page_size=page_size, page=page
397
+ )
398
+
399
+ users: list[User] = []
400
+ for item in raw_users:
401
+ users.append(User(**item))
402
+ return users, count, total
403
+
404
+ def get_app_requestable_permissions(
405
+ self,
406
+ app_id: UUID,
407
+ search_term: str | None = None,
408
+ all: bool = False,
409
+ page_size: int = 100,
410
+ page: int = 1,
411
+ ) -> tuple[list[Permission], int, int]:
412
+ endpoint = "appstore/requestable_permissions"
413
+ params: dict[str, Any] = {
414
+ "app_id": str(app_id),
415
+ "in_app_store": True,
416
+ }
417
+ if search_term:
418
+ params["search_term"] = search_term
419
+
420
+ raw_permissions, count, total, _, _ = self.get_all_or_paged(
421
+ endpoint, params=params, all=all, page_size=page_size, page=page
422
+ )
423
+
424
+ return [self._create_permission(item) for item in raw_permissions], count, total
425
+
426
+ def get_groups(
427
+ self,
428
+ app_id: UUID | None,
429
+ search_term: str | None = None,
430
+ all: bool = False,
431
+ page_size: int = 25,
432
+ page: int = 1,
433
+ ) -> tuple[list[Group], int, int]:
434
+ endpoint = "groups"
435
+ params: dict[str, Any] = {}
436
+ if app_id:
437
+ params["app_id"] = str(app_id)
438
+ if search_term:
439
+ params["name"] = search_term
440
+
441
+ raw_permissions, count, total, _, _ = self.get_all_or_paged(
442
+ endpoint, params=params, all=all, page_size=page_size, page=page
443
+ )
444
+
445
+ return [Group(**item) for item in raw_permissions], count, total
446
+
447
+ def get_app_requestable_permission(self, permission_id: UUID) -> Permission | None:
448
+ item = self.get(f"appstore/requestable_permissions/{permission_id}")
449
+ if not item:
450
+ return None
451
+ return self._create_permission(item)
452
+
453
+ def _create_permission(self, raw_permission: dict) -> Permission:
454
+ permission = Permission(**raw_permission)
455
+ durations = raw_permission["request_config"]["request_fulfillment_config"]["time_based_access"]
456
+ permission.duration_options = durations
457
+ return permission
458
+
459
+ def create_access_request(
460
+ self,
461
+ app_id: UUID,
462
+ note: str,
463
+ expiration_in_seconds: int | None,
464
+ permission_ids: list[UUID] | None = None,
465
+ target_user_id: UUID | None = None,
466
+ ) -> AccessRequest | None:
467
+ body: dict[str, Any] = {
468
+ "app_id": str(app_id),
469
+ "note": note,
470
+ }
471
+ if permission_ids:
472
+ body["requestable_permission_ids"] = [str(p) for p in permission_ids]
473
+ if expiration_in_seconds:
474
+ body["expiration_in_seconds"] = expiration_in_seconds
475
+ if target_user_id:
476
+ body["target_user_id"] = str(target_user_id)
477
+ response = self.post("appstore/access_request", body)
478
+
479
+ return AccessRequest(**response[0]) if response else None
@@ -0,0 +1,40 @@
1
+ import os
2
+
3
+ import requests
4
+ from click_extra import secho
5
+
6
+ from lumos import __version__
7
+
8
+
9
+ def check_version_header(response: requests.Response) -> bool:
10
+ if os.environ.get("WARNED"):
11
+ return True
12
+ if not (version_header_string := response.headers.get("X-CLI-Version")):
13
+ return True
14
+
15
+ current_version = parse_version(__version__)
16
+ header_version = parse_version(version_header_string)
17
+ cont, error_message = check_version(current_version, header_version)
18
+
19
+ if error_message:
20
+ secho(error_message, fg="cyan" if cont else "red")
21
+ return cont
22
+
23
+
24
+ def check_version(current_version: list[int], header_version: list[int]) -> tuple[bool, str | None]:
25
+ if current_version[0] < header_version[0]:
26
+ return (
27
+ False,
28
+ "A new version of the CLI is available. Please run `brew upgrade lumos` to proceed.",
29
+ )
30
+ if current_version[0] == header_version[0] and current_version[1] < header_version[1]:
31
+ os.environ["WARNED"] = "1"
32
+ return (
33
+ True,
34
+ "There's an update available to the CLI. Please run `brew upgrade lumos`.",
35
+ )
36
+ return True, None
37
+
38
+
39
+ def parse_version(version: str) -> list[int]:
40
+ return [int(v) for v in version.split(".")]