snowglobe-cli 0.1.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.
Files changed (71) hide show
  1. snowglobe/__init__.py +6 -0
  2. snowglobe/__main__.py +3 -0
  3. snowglobe/cli/__init__.py +0 -0
  4. snowglobe/cli/access.py +197 -0
  5. snowglobe/cli/app.py +148 -0
  6. snowglobe/cli/context.py +48 -0
  7. snowglobe/cli/cost.py +291 -0
  8. snowglobe/cli/debug.py +265 -0
  9. snowglobe/cli/diff.py +34 -0
  10. snowglobe/cli/optimizer.py +91 -0
  11. snowglobe/cli/prompts.py +161 -0
  12. snowglobe/cli/report.py +91 -0
  13. snowglobe/cli/shell.py +1437 -0
  14. snowglobe/cli/shell_completer.py +128 -0
  15. snowglobe/collectors/access.py +882 -0
  16. snowglobe/collectors/query_history.py +46 -0
  17. snowglobe/collectors/query_profile.py +101 -0
  18. snowglobe/config/loader.py +42 -0
  19. snowglobe/core/access_service.py +721 -0
  20. snowglobe/core/cost_service.py +929 -0
  21. snowglobe/core/optimizer.py +92 -0
  22. snowglobe/core/query_service.py +48 -0
  23. snowglobe/core/report_service.py +110 -0
  24. snowglobe/core/risk_service.py +358 -0
  25. snowglobe/engines/access/__init__.py +0 -0
  26. snowglobe/engines/access/explainer.py +113 -0
  27. snowglobe/engines/access/resolver.py +199 -0
  28. snowglobe/engines/ai/cortex_optimizer.py +69 -0
  29. snowglobe/engines/optimizer/query_optimizer.py +326 -0
  30. snowglobe/graphs/__init__.py +0 -0
  31. snowglobe/graphs/role_graph.py +140 -0
  32. snowglobe/graphs/user_graph.py +64 -0
  33. snowglobe/models/__init__.py +0 -0
  34. snowglobe/models/access.py +65 -0
  35. snowglobe/models/access_path.py +15 -0
  36. snowglobe/models/object_ref.py +11 -0
  37. snowglobe/models/object_type.py +50 -0
  38. snowglobe/models/optimizer.py +15 -0
  39. snowglobe/models/privilege.py +78 -0
  40. snowglobe/models/query.py +59 -0
  41. snowglobe/output/__init__.py +0 -0
  42. snowglobe/output/cli.py +413 -0
  43. snowglobe/queries/__init__.py +0 -0
  44. snowglobe/queries/query_history.py +37 -0
  45. snowglobe/snowflake/connection.py +75 -0
  46. snowglobe/state/db.py +559 -0
  47. snowglobe/state/state.py +60 -0
  48. snowglobe/templates/report.md.j2 +55 -0
  49. snowglobe/tests/access_tests.py +5 -0
  50. snowglobe/tui/__init__.py +1 -0
  51. snowglobe/tui/__main__.py +3 -0
  52. snowglobe/tui/app.py +299 -0
  53. snowglobe/tui/screens/__init__.py +0 -0
  54. snowglobe/tui/screens/access.py +627 -0
  55. snowglobe/tui/screens/cost.py +831 -0
  56. snowglobe/tui/screens/home.py +222 -0
  57. snowglobe/tui/screens/refresh.py +222 -0
  58. snowglobe/tui/screens/reports.py +252 -0
  59. snowglobe/tui/screens/risk.py +417 -0
  60. snowglobe/tui/screens/tune.py +254 -0
  61. snowglobe/tui/widgets/__init__.py +0 -0
  62. snowglobe/tui/widgets/access_paths.py +63 -0
  63. snowglobe/tui/widgets/cache_badge.py +28 -0
  64. snowglobe/tui/widgets/header.py +21 -0
  65. snowglobe/tui/widgets/nav.py +32 -0
  66. snowglobe_cli-0.1.0.dist-info/METADATA +368 -0
  67. snowglobe_cli-0.1.0.dist-info/RECORD +71 -0
  68. snowglobe_cli-0.1.0.dist-info/WHEEL +5 -0
  69. snowglobe_cli-0.1.0.dist-info/entry_points.txt +2 -0
  70. snowglobe_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  71. snowglobe_cli-0.1.0.dist-info/top_level.txt +1 -0
snowglobe/cli/shell.py ADDED
@@ -0,0 +1,1437 @@
1
+ import typer
2
+ import pandas as pd
3
+ from prompt_toolkit import PromptSession
4
+ from prompt_toolkit.completion import FuzzyCompleter
5
+ from snowglobe.cli.shell_completer import SnowglobeCompleter
6
+ from snowglobe.cli.context import SnowglobeContext
7
+ from snowglobe.core.access_service import AccessService
8
+ from snowglobe.core.optimizer import QueryOptimizerService
9
+ from snowglobe.output import cli
10
+
11
+
12
+ def start_shell(ctx: SnowglobeContext):
13
+ """Start the interactive Snowglobe shell."""
14
+
15
+ # Preload access graphs for completions and stateful queries
16
+ access_service = AccessService(ctx)
17
+ ctx.user_graph, ctx.role_graph, ctx.object_index = access_service.get_graphs()
18
+
19
+ session = PromptSession(
20
+ completer=FuzzyCompleter(SnowglobeCompleter(ctx))
21
+ )
22
+
23
+ typer.echo("Snowglobe Interactive Shell")
24
+ typer.echo("Type 'check' to get started, or 'help' for all commands.\n")
25
+
26
+ while True:
27
+ try:
28
+ active = ctx.target_role or ctx.username or ""
29
+ prompt_label = f"snowglobe[{active}]> " if active else "snowglobe> "
30
+ text = session.prompt(prompt_label).strip()
31
+
32
+ if not text:
33
+ continue
34
+
35
+ if text in {"exit", "quit"}:
36
+ break
37
+
38
+ _dispatch(text, ctx)
39
+
40
+ except KeyboardInterrupt:
41
+ continue
42
+ except EOFError:
43
+ break
44
+
45
+
46
+ def _dispatch(text: str, ctx: SnowglobeContext):
47
+ """Route shell input to the appropriate handler."""
48
+ parts = text.split()
49
+ cmd = parts[0]
50
+ args = parts[1:]
51
+
52
+ handlers = {
53
+ "check": _cmd_check,
54
+ "roles": _cmd_roles,
55
+ "members": _cmd_members,
56
+ "path": _cmd_path,
57
+ "escalation": _cmd_escalation,
58
+ "scan": _cmd_scan,
59
+ "use": _cmd_use,
60
+ "set": _cmd_set,
61
+ "access": _cmd_access,
62
+ "whoaccess": _cmd_whoaccess,
63
+ "create": _cmd_create,
64
+ "cost": _cmd_cost,
65
+ "optimize": _cmd_optimize,
66
+ "drift": _cmd_drift,
67
+ "unused": _cmd_unused,
68
+ "report": _cmd_report,
69
+ "refresh": _cmd_refresh,
70
+ "status": _cmd_status,
71
+ "debug": _cmd_debug,
72
+ "help": _cmd_help,
73
+ "?": _cmd_help,
74
+ }
75
+
76
+ handler = handlers.get(cmd)
77
+ if handler:
78
+ handler(ctx, args)
79
+ else:
80
+ typer.secho(f"Unknown command: {cmd}. Type 'help' for available commands.", fg=typer.colors.YELLOW)
81
+
82
+
83
+ # --- Shell commands ---
84
+
85
+ def _cmd_check(ctx: SnowglobeContext, args: list):
86
+ """Guided wizard for access and privilege checks."""
87
+ from prompt_toolkit import PromptSession
88
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
89
+
90
+ typer.echo("")
91
+ typer.secho("What would you like to check?", fg=typer.colors.CYAN, bold=True)
92
+ typer.echo(" [1] Can a user/role access an object?")
93
+ typer.echo(" [2] Who can access an object?")
94
+ typer.echo(" [3] Where can a role create objects?")
95
+ typer.echo(" [4] What roles does a user have?")
96
+ typer.echo(" [5] Who has a specific role?")
97
+ typer.echo(" [6] Does one role inherit from another?")
98
+ typer.echo(" [7] Can a role escalate to admin privileges?")
99
+ typer.echo("")
100
+
101
+ session = PromptSession()
102
+ choice = session.prompt(
103
+ "Choice (1-7): ",
104
+ completer=WordCompleter(["1", "2", "3", "4", "5", "6", "7"]),
105
+ ).strip()
106
+
107
+ if choice == "1":
108
+ # Clear object state so user gets prompted fresh
109
+ ctx.object_type = None
110
+ ctx.object_name = None
111
+ ctx.privilege = None
112
+ _cmd_access(ctx, args)
113
+ elif choice == "2":
114
+ _cmd_whoaccess(ctx, args)
115
+ elif choice == "3":
116
+ _cmd_create(ctx, args)
117
+ elif choice == "4":
118
+ _cmd_roles(ctx, args)
119
+ elif choice == "5":
120
+ _cmd_members(ctx, args)
121
+ elif choice == "6":
122
+ _cmd_path(ctx, args)
123
+ elif choice == "7":
124
+ _cmd_escalation(ctx, args)
125
+ else:
126
+ typer.secho("Invalid choice. Please enter 1-7.", fg=typer.colors.YELLOW)
127
+
128
+ def _cmd_use(ctx: SnowglobeContext, args: list):
129
+ """Set the active user or role for subsequent queries."""
130
+ if len(args) < 2:
131
+ typer.echo("Usage: use role <name> | use user <name>")
132
+ return
133
+
134
+ kind, name = args[0], args[1]
135
+
136
+ if kind == "role":
137
+ ctx.target_role = name
138
+ ctx.username = None
139
+ typer.secho(f"Using role: {name}", fg=typer.colors.GREEN)
140
+ elif kind == "user":
141
+ ctx.username = name
142
+ ctx.target_role = None
143
+ typer.secho(f"Using user: {name}", fg=typer.colors.GREEN)
144
+ else:
145
+ typer.echo("Usage: use role <name> | use user <name>")
146
+
147
+
148
+ def _cmd_set(ctx: SnowglobeContext, args: list):
149
+ """Set a working state field."""
150
+ if len(args) < 2:
151
+ typer.echo("Usage: set <field> <value>")
152
+ typer.echo("Fields: object_type, object_name, privilege")
153
+ return
154
+
155
+ field, value = args[0], " ".join(args[1:])
156
+ valid_fields = {"object_type", "object_name", "privilege"}
157
+
158
+ if field not in valid_fields:
159
+ typer.secho(f"Unknown field: {field}. Valid: {', '.join(valid_fields)}", fg=typer.colors.YELLOW)
160
+ return
161
+
162
+ setattr(ctx, field, value)
163
+ typer.secho(f"{field} = {value}", fg=typer.colors.GREEN)
164
+
165
+
166
+ def _cmd_access(ctx: SnowglobeContext, args: list):
167
+ """Run access check using current shell state. Prompts for missing fields."""
168
+ from snowglobe.cli.prompts import resolve_access_inputs
169
+
170
+ # Resolve missing inputs with interactive prompts + fuzzy completion
171
+ resolved = resolve_access_inputs(
172
+ username=ctx.username,
173
+ role=ctx.target_role,
174
+ object_type=ctx.object_type,
175
+ object_name=ctx.object_name,
176
+ privilege=ctx.privilege,
177
+ user_graph=ctx.user_graph,
178
+ role_graph=ctx.role_graph,
179
+ grants=[],
180
+ object_index=ctx.object_index,
181
+ )
182
+
183
+ # Update context state with resolved values for next time
184
+ ctx.username = resolved.get("username")
185
+ ctx.target_role = resolved.get("role")
186
+ ctx.object_type = resolved.get("object_type")
187
+ ctx.object_name = resolved.get("object_name")
188
+ ctx.privilege = resolved.get("privilege")
189
+
190
+ access_service = AccessService(ctx)
191
+
192
+ try:
193
+ result = access_service.inspect_access(
194
+ username=resolved["username"],
195
+ role=resolved["role"],
196
+ object_type=resolved["object_type"],
197
+ object_name=resolved["object_name"],
198
+ privilege=resolved["privilege"],
199
+ ignore_excluded_roles=False,
200
+ refresh_state=False,
201
+ )
202
+ typer.echo(cli.format_access_text(result))
203
+ except SystemExit:
204
+ pass
205
+ except Exception as e:
206
+ typer.secho(f"Error: {e}", fg=typer.colors.RED)
207
+
208
+
209
+ def _cmd_whoaccess(ctx: SnowglobeContext, args: list):
210
+ """Reverse lookup: who can access this object? Prompts for object details."""
211
+ from prompt_toolkit import PromptSession
212
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
213
+ from snowglobe.models.object_type import ObjectType
214
+
215
+ session = PromptSession()
216
+
217
+ # Parse args: whoaccess [--privilege PRIV]
218
+ privilege = None
219
+ for i, arg in enumerate(args):
220
+ if arg == "--privilege" and i + 1 < len(args):
221
+ privilege = args[i + 1].upper()
222
+
223
+ # Prompt for object type
224
+ object_type = ctx.object_type
225
+ if not object_type:
226
+ items = [ot.value for ot in ObjectType]
227
+ word = WordCompleter(items, ignore_case=True)
228
+ fuzzy = FuzzyCompleter(word)
229
+ object_type = session.prompt(
230
+ "Object type: ",
231
+ completer=fuzzy,
232
+ complete_while_typing=True,
233
+ ).strip().upper()
234
+
235
+ if not object_type:
236
+ typer.secho("Object type required.", fg=typer.colors.RED)
237
+ return
238
+
239
+ # Prompt for object name with FQN completions
240
+ object_name = ctx.object_name
241
+ if not object_name:
242
+ obj_items = []
243
+ if ctx.object_index and object_type in ctx.object_index:
244
+ obj_items = ctx.object_index[object_type]
245
+ word = WordCompleter(obj_items, ignore_case=True, sentence=True)
246
+ fuzzy = FuzzyCompleter(word)
247
+ object_name = session.prompt(
248
+ "Object name (FQN): ",
249
+ completer=fuzzy,
250
+ complete_while_typing=True,
251
+ ).strip()
252
+
253
+ if not object_name:
254
+ typer.secho("Object name required.", fg=typer.colors.RED)
255
+ return
256
+
257
+ # Run the reverse lookup
258
+ access_service = AccessService(ctx)
259
+ try:
260
+ result = access_service.inspect_reverse(
261
+ object_type=object_type,
262
+ object_name=object_name,
263
+ privilege=privilege,
264
+ )
265
+ typer.echo(cli.format_reverse_text(result))
266
+
267
+ # Drill-down: select a role to see its full grants
268
+ privileges_result = result.get("privileges", {})
269
+ if privileges_result:
270
+ all_roles = set()
271
+ for priv_info in privileges_result.values():
272
+ all_roles.update(priv_info.get("direct_roles", []))
273
+ all_roles = sorted(all_roles)
274
+ if all_roles:
275
+ selected = _drill_down_prompt(all_roles, "Inspect role's grants")
276
+ if selected:
277
+ typer.echo(f"\n Fetching grants for {selected}...")
278
+ grant_rows = access_service.db.query_grants_by_grantees({selected})
279
+ if grant_rows:
280
+ import pandas as pd_local
281
+ grant_df = pd_local.DataFrame(grant_rows)[["privilege", "granted_on", "fqn"]]
282
+ grant_df.columns = ["PRIVILEGE", "OBJECT_TYPE", "OBJECT"]
283
+ cli.print_table(grant_df, title=f"Grants for {selected}")
284
+ else:
285
+ typer.echo(f" No grants found for {selected}.")
286
+ except Exception as e:
287
+ typer.secho(f"Error: {e}", fg=typer.colors.RED)
288
+
289
+
290
+ def _cmd_create(ctx: SnowglobeContext, args: list):
291
+ """Check CREATE privileges for the active role/user."""
292
+ from prompt_toolkit import PromptSession
293
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
294
+
295
+ CREATE_PRIVILEGES = [
296
+ "CREATE TABLE", "CREATE VIEW", "CREATE SCHEMA", "CREATE DATABASE",
297
+ "CREATE DYNAMIC TABLE", "CREATE STREAMLIT", "CREATE NOTEBOOK",
298
+ "CREATE STAGE", "CREATE STREAM", "CREATE PIPE", "CREATE TASK",
299
+ "CREATE FUNCTION", "CREATE PROCEDURE", "CREATE ALERT",
300
+ "CREATE FILE FORMAT", "CREATE SEQUENCE", "CREATE TAG",
301
+ "CREATE SECRET", "CREATE WAREHOUSE", "CREATE ROLE",
302
+ "CREATE MATERIALIZED VIEW", "CREATE EXTERNAL TABLE",
303
+ "CREATE ICEBERG TABLE", "CREATE MODEL", "CREATE AGENT",
304
+ ]
305
+
306
+ session = PromptSession()
307
+
308
+ # Resolve role/user — use current context or prompt
309
+ role = ctx.target_role
310
+ username = ctx.username
311
+
312
+ if not role and not username:
313
+ # Prompt for inspect type
314
+ items = list(ctx.role_graph.roles.keys()) if ctx.role_graph else []
315
+ word = WordCompleter(items, ignore_case=True)
316
+ fuzzy = FuzzyCompleter(word)
317
+ role = session.prompt(
318
+ "Role: ",
319
+ completer=fuzzy,
320
+ complete_while_typing=True,
321
+ ).strip()
322
+ if not role:
323
+ typer.secho("Role required.", fg=typer.colors.RED)
324
+ return
325
+
326
+ # Prompt for CREATE privilege
327
+ privilege = None
328
+ if args:
329
+ privilege = " ".join(args).upper()
330
+
331
+ if not privilege:
332
+ word = WordCompleter(CREATE_PRIVILEGES, ignore_case=True)
333
+ fuzzy = FuzzyCompleter(word)
334
+ privilege = session.prompt(
335
+ "CREATE privilege: ",
336
+ completer=fuzzy,
337
+ complete_while_typing=True,
338
+ ).strip().upper()
339
+
340
+ if not privilege:
341
+ typer.secho("Privilege required.", fg=typer.colors.RED)
342
+ return
343
+
344
+ # Optional scope — offer databases and schemas from the object index
345
+ scope_items = []
346
+ if ctx.object_index:
347
+ scope_items.extend(ctx.object_index.get("DATABASE", []))
348
+ scope_items.extend(ctx.object_index.get("SCHEMA", []))
349
+ scope_word = WordCompleter(sorted(set(scope_items)), ignore_case=True)
350
+ scope_fuzzy = FuzzyCompleter(scope_word)
351
+ scope = session.prompt(
352
+ "Scope (DB or DB.SCHEMA, blank for all): ",
353
+ completer=scope_fuzzy,
354
+ complete_while_typing=True,
355
+ ).strip() or None
356
+
357
+ # Run the check
358
+ access_service = AccessService(ctx)
359
+ try:
360
+ result = access_service.inspect_create(
361
+ username=username,
362
+ role=role,
363
+ privilege=privilege,
364
+ scope=scope,
365
+ )
366
+ typer.echo(cli.format_create_text(result))
367
+ except SystemExit:
368
+ pass
369
+ except Exception as e:
370
+ typer.secho(f"Error: {e}", fg=typer.colors.RED)
371
+
372
+
373
+ def _cmd_roles(ctx: SnowglobeContext, args: list):
374
+ """Show all roles a user has (direct + inherited)."""
375
+ from prompt_toolkit import PromptSession
376
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
377
+
378
+ session = PromptSession()
379
+
380
+ # Prompt for username
381
+ username = args[0] if args else None
382
+ if not username:
383
+ items = list(ctx.user_graph.assigned_roles.keys())
384
+ word = WordCompleter(items, ignore_case=True)
385
+ fuzzy = FuzzyCompleter(word)
386
+ username = session.prompt(
387
+ "User: ",
388
+ completer=fuzzy,
389
+ complete_while_typing=True,
390
+ ).strip()
391
+
392
+ if not username:
393
+ typer.secho("Username required.", fg=typer.colors.RED)
394
+ return
395
+
396
+ if username not in ctx.user_graph.assigned_roles:
397
+ typer.secho(f"User '{username}' not found.", fg=typer.colors.RED)
398
+ return
399
+
400
+ # Direct roles
401
+ direct_roles, excluded = ctx.user_graph.roles_of(username)
402
+ effective = ctx.user_graph.effective_roles(username, ctx.role_graph)
403
+
404
+ typer.echo("")
405
+ typer.secho(f"Roles for user: {username}", fg=typer.colors.CYAN, bold=True)
406
+ typer.echo("")
407
+
408
+ typer.secho(f" Direct roles ({len(direct_roles)}):", fg=typer.colors.GREEN)
409
+ for r in sorted(direct_roles):
410
+ typer.echo(f" {r}")
411
+
412
+ if excluded:
413
+ typer.secho(f"\n Excluded roles ({len(excluded)}):", fg=typer.colors.YELLOW)
414
+ for r in sorted(excluded):
415
+ typer.echo(f" {r}")
416
+
417
+ inherited = effective - set(direct_roles) - set(excluded)
418
+ if inherited:
419
+ typer.secho(f"\n Inherited roles ({len(inherited)}):", fg=typer.colors.GREEN)
420
+ for r in sorted(inherited)[:30]:
421
+ typer.echo(f" {r}")
422
+ if len(inherited) > 30:
423
+ typer.echo(f" ... and {len(inherited) - 30} more")
424
+
425
+ typer.echo(f"\n Total effective: {len(effective)} roles")
426
+
427
+
428
+ def _cmd_members(ctx: SnowglobeContext, args: list):
429
+ """Show all users who have a specific role (direct assignment)."""
430
+ from prompt_toolkit import PromptSession
431
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
432
+
433
+ session = PromptSession()
434
+
435
+ # Prompt for role
436
+ role = args[0] if args else None
437
+ if not role:
438
+ items = list(ctx.role_graph.roles.keys())
439
+ word = WordCompleter(items, ignore_case=True)
440
+ fuzzy = FuzzyCompleter(word)
441
+ role = session.prompt(
442
+ "Role: ",
443
+ completer=fuzzy,
444
+ complete_while_typing=True,
445
+ ).strip()
446
+
447
+ if not role:
448
+ typer.secho("Role required.", fg=typer.colors.RED)
449
+ return
450
+
451
+ # Find users with this role (directly assigned)
452
+ direct_users = []
453
+ for user, assigned in ctx.user_graph.assigned_roles.items():
454
+ if role in assigned:
455
+ direct_users.append(user)
456
+
457
+ # Find users who inherit this role
458
+ inherited_users = []
459
+ for user, assigned in ctx.user_graph.assigned_roles.items():
460
+ if user in direct_users:
461
+ continue
462
+ effective = set(assigned)
463
+ for r in assigned:
464
+ effective |= ctx.role_graph.all_ancestors(r)
465
+ if role in effective:
466
+ inherited_users.append(user)
467
+
468
+ typer.echo("")
469
+ typer.secho(f"Users with role: {role}", fg=typer.colors.CYAN, bold=True)
470
+ typer.echo("")
471
+
472
+ if direct_users:
473
+ typer.secho(f" Directly assigned ({len(direct_users)}):", fg=typer.colors.GREEN)
474
+ for u in sorted(direct_users):
475
+ typer.echo(f" {u}")
476
+ else:
477
+ typer.echo(" No users directly assigned.")
478
+
479
+ if inherited_users:
480
+ typer.secho(f"\n Inherited ({len(inherited_users)}):", fg=typer.colors.GREEN)
481
+ for u in sorted(inherited_users)[:30]:
482
+ typer.echo(f" {u}")
483
+ if len(inherited_users) > 30:
484
+ typer.echo(f" ... and {len(inherited_users) - 30} more")
485
+
486
+ total = len(direct_users) + len(inherited_users)
487
+ typer.echo(f"\n Total: {total} users")
488
+
489
+
490
+ def _cmd_path(ctx: SnowglobeContext, args: list):
491
+ """Check if one role inherits from another and show the path."""
492
+ from prompt_toolkit import PromptSession
493
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
494
+
495
+ session = PromptSession()
496
+ items = list(ctx.role_graph.roles.keys())
497
+ word = WordCompleter(items, ignore_case=True)
498
+ fuzzy = FuzzyCompleter(word)
499
+
500
+ # Prompt for source role
501
+ from_role = args[0] if len(args) > 0 else None
502
+ if not from_role:
503
+ from_role = session.prompt(
504
+ "From role: ",
505
+ completer=fuzzy,
506
+ complete_while_typing=True,
507
+ ).strip()
508
+
509
+ if not from_role:
510
+ typer.secho("From role required.", fg=typer.colors.RED)
511
+ return
512
+
513
+ # Prompt for target role
514
+ to_role = args[1] if len(args) > 1 else None
515
+ if not to_role:
516
+ to_role = session.prompt(
517
+ "To role: ",
518
+ completer=fuzzy,
519
+ complete_while_typing=True,
520
+ ).strip()
521
+
522
+ if not to_role:
523
+ typer.secho("To role required.", fg=typer.colors.RED)
524
+ return
525
+
526
+ # Check if to_role is in from_role's ancestors
527
+ ancestors = ctx.role_graph.all_ancestors(from_role)
528
+ if to_role not in ancestors:
529
+ typer.echo("")
530
+ typer.secho(f" {from_role} does NOT inherit from {to_role}", fg=typer.colors.RED)
531
+ return
532
+
533
+ # Find all paths
534
+ paths = ctx.role_graph.all_paths(from_role, to_role)
535
+
536
+ typer.echo("")
537
+ typer.secho(f" {from_role} DOES inherit from {to_role}", fg=typer.colors.GREEN, bold=True)
538
+ typer.echo("")
539
+
540
+ if paths:
541
+ typer.secho(f" Inheritance paths ({len(paths)}):", fg=typer.colors.CYAN)
542
+ for i, path in enumerate(paths[:10], 1):
543
+ typer.echo(f" Path {i}: {' -> '.join(path)}")
544
+ if len(paths) > 10:
545
+ typer.echo(f" ... and {len(paths) - 10} more paths")
546
+
547
+
548
+ def _cmd_escalation(ctx: SnowglobeContext, args: list):
549
+ """Check if a role can reach admin privileges via inheritance."""
550
+ from prompt_toolkit import PromptSession
551
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
552
+ from snowglobe.core.risk_service import RiskService
553
+
554
+ session = PromptSession()
555
+
556
+ role = args[0] if args else None
557
+ if not role:
558
+ items = list(ctx.role_graph.roles.keys())
559
+ word = WordCompleter(items, ignore_case=True)
560
+ fuzzy = FuzzyCompleter(word)
561
+ role = session.prompt(
562
+ "Role to check: ",
563
+ completer=fuzzy,
564
+ complete_while_typing=True,
565
+ ).strip()
566
+
567
+ if not role:
568
+ typer.secho("Role required.", fg=typer.colors.RED)
569
+ return
570
+
571
+ result = RiskService(ctx).check_escalation(role, ctx.role_graph, ctx.user_graph)
572
+
573
+ typer.echo("")
574
+
575
+ if result["is_privileged"]:
576
+ typer.secho(f" {role} IS a privileged role.", fg=typer.colors.YELLOW, bold=True)
577
+ return
578
+
579
+ reachable = result["reachable_targets"]
580
+ if not reachable:
581
+ typer.secho(f" {role} has NO escalation path to admin roles.", fg=typer.colors.GREEN, bold=True)
582
+ typer.echo(" This role cannot reach ACCOUNTADMIN, SYSADMIN, SECURITYADMIN, or any role with MANAGE GRANTS.")
583
+ return
584
+
585
+ typer.secho(f" {role} can reach {len(reachable)} privileged role(s):", fg=typer.colors.RED, bold=True)
586
+ typer.echo("")
587
+
588
+ for entry in reachable:
589
+ hops = entry["hops"]
590
+ color = typer.colors.RED if hops <= 3 else typer.colors.YELLOW
591
+ typer.secho(f" → {entry['target']} ({hops} hops)", fg=color)
592
+ typer.echo(f" {' → '.join(entry['path'])}")
593
+ typer.echo("")
594
+
595
+ direct_users = result["affected_users"]["direct"]
596
+ inherited_users = result["affected_users"]["inherited"]
597
+ total_users = len(direct_users) + len(inherited_users)
598
+ if total_users > 0:
599
+ typer.secho(f" Users who can escalate via this role ({total_users}):", fg=typer.colors.CYAN)
600
+ for u in direct_users:
601
+ typer.echo(f" {u} (directly assigned)")
602
+ for u in inherited_users[:20]:
603
+ typer.echo(f" {u} (inherited)")
604
+ if len(inherited_users) > 20:
605
+ typer.echo(f" ... and {len(inherited_users) - 20} more")
606
+ else:
607
+ typer.echo(" No users currently hold this role.")
608
+
609
+
610
+ def _cmd_scan(ctx: SnowglobeContext, args: list):
611
+ """Scan all roles for privilege escalation paths to admin roles."""
612
+ from prompt_toolkit import PromptSession
613
+ from prompt_toolkit.completion import WordCompleter
614
+ from snowglobe.core.risk_service import RiskService
615
+
616
+ # Parse flags
617
+ csv_path = None
618
+ json_path = None
619
+ for i, a in enumerate(args):
620
+ if a == "--csv" and i + 1 < len(args):
621
+ csv_path = args[i + 1]
622
+ elif a == "--json" and i + 1 < len(args):
623
+ json_path = args[i + 1]
624
+
625
+ risk_service = RiskService(ctx)
626
+
627
+ n_roles = len(ctx.role_graph.all_roles())
628
+ typer.echo("")
629
+ typer.secho("Privilege Escalation Scan", fg=typer.colors.CYAN, bold=True)
630
+ typer.echo("─" * 50)
631
+ typer.echo(f"Scanning {n_roles} roles...")
632
+ typer.echo("")
633
+
634
+ result = risk_service.run_scan(ctx.role_graph, ctx.user_graph)
635
+
636
+ # --- Diff with previous scan ---
637
+ diff = result["diff"]
638
+ if diff is not None:
639
+ if diff["new"] or diff["resolved"]:
640
+ parts = []
641
+ if diff["new"]:
642
+ parts.append(f"+{len(diff['new'])} new")
643
+ if diff["resolved"]:
644
+ parts.append(f"-{len(diff['resolved'])} resolved")
645
+ typer.secho(f" Changes since last scan: {', '.join(parts)}", fg=typer.colors.MAGENTA, bold=True)
646
+ if diff.get("new_details"):
647
+ typer.echo("")
648
+ typer.secho(" NEW risks:", fg=typer.colors.RED)
649
+ for e in diff["new_details"][:5]:
650
+ typer.echo(f" {e['role']} → {e['target']} (score={e['risk_score']})")
651
+ typer.echo("")
652
+ else:
653
+ typer.secho(" No changes since last scan.", fg=typer.colors.GREEN)
654
+ typer.echo("")
655
+
656
+ # --- Direct privilege risks ---
657
+ dangerous_grants = result["dangerous_grants"]
658
+ if dangerous_grants:
659
+ typer.secho(f"Direct Privilege Risks ({len(dangerous_grants)} grants):", fg=typer.colors.RED, bold=True)
660
+ typer.echo("")
661
+ for g in dangerous_grants[:15]:
662
+ typer.echo(f" {g['ROLE']:<40} {g['PRIVILEGE']:<20} {g['OBJECT_TYPE']:<12} {g['OBJECT']}")
663
+ if len(dangerous_grants) > 15:
664
+ typer.echo(f" ... and {len(dangerous_grants) - 15} more")
665
+ typer.echo("")
666
+
667
+ # --- Flagged roles by risk score ---
668
+ high_risk = result["high_risk"]
669
+ medium_risk = result["medium_risk"]
670
+
671
+ if high_risk:
672
+ typer.secho(f"HIGH RISK — {len(high_risk)} roles (score ≥ 10):", fg=typer.colors.RED, bold=True)
673
+ typer.echo("")
674
+ for i, entry in enumerate(high_risk, 1):
675
+ typer.secho(
676
+ f" [{i}] {entry['role']} → {entry['target']} "
677
+ f"(score={entry['risk_score']}, {entry['hops']} hops, {entry['user_count']} users)",
678
+ fg=typer.colors.RED,
679
+ )
680
+ typer.echo(f" {' → '.join(entry['path'])}")
681
+ typer.echo("")
682
+
683
+ if medium_risk:
684
+ offset = len(high_risk)
685
+ typer.secho(f"MEDIUM — {len(medium_risk)} roles (score 5-10):", fg=typer.colors.YELLOW)
686
+ typer.echo("")
687
+ for i, entry in enumerate(medium_risk, offset + 1):
688
+ typer.echo(
689
+ f" [{i}] {entry['role']} → {entry['target']} "
690
+ f"(score={entry['risk_score']}, {entry['hops']} hops, {entry['user_count']} users)"
691
+ )
692
+ typer.echo("")
693
+
694
+ # --- Dormant users ---
695
+ dormant_users = result["dormant_users"]
696
+ if dormant_users:
697
+ seen = set()
698
+ unique_dormant = []
699
+ for d in sorted(dormant_users, key=lambda x: x["risk_score"], reverse=True):
700
+ if d["user"] not in seen:
701
+ seen.add(d["user"])
702
+ unique_dormant.append(d)
703
+
704
+ typer.secho(f"Dormant Escalation Risks ({len(unique_dormant)} users inactive >90 days):", fg=typer.colors.MAGENTA, bold=True)
705
+ typer.echo("")
706
+ for d in unique_dormant[:10]:
707
+ typer.echo(f" {d['user']:<40} via {d['role']} (score={d['risk_score']})")
708
+ if len(unique_dormant) > 10:
709
+ typer.echo(f" ... and {len(unique_dormant) - 10} more")
710
+ typer.echo("")
711
+
712
+ # --- Summary ---
713
+ s = result["summary"]
714
+ typer.secho("Summary:", fg=typer.colors.CYAN)
715
+ typer.echo(f" Privileged roles (admin): {s['admin_roles']}")
716
+ typer.echo(f" High risk (score ≥ 10): {s['high_risk']}")
717
+ typer.echo(f" Medium risk (score 5-10): {s['medium_risk']}")
718
+ typer.echo(f" Low risk (score < 5): {s['low_risk']}")
719
+ typer.echo(f" No escalation path: {s['no_path']}")
720
+ typer.echo(f" Direct privilege risks: {s['direct_privilege_risks']}")
721
+ if s["dormant_with_risk"]:
722
+ typer.echo(f" Dormant users with risk: {s['dormant_with_risk']}")
723
+ typer.echo(f" Total roles scanned: {s['total_scanned']}")
724
+
725
+ # --- Export ---
726
+ if csv_path:
727
+ risk_service.export_scan_csv(result["flagged"], csv_path)
728
+ typer.secho(f"\n Exported CSV: {csv_path}", fg=typer.colors.GREEN)
729
+
730
+ if json_path:
731
+ risk_service.export_scan_json(result, json_path)
732
+ typer.secho(f" Exported JSON: {json_path}", fg=typer.colors.GREEN)
733
+
734
+ # --- Drill-down ---
735
+ if high_risk or medium_risk:
736
+ typer.echo("")
737
+ session = PromptSession()
738
+ choices = [str(i) for i in range(1, len(high_risk) + len(medium_risk) + 1)]
739
+ choice = session.prompt(
740
+ "Drill into a role (number) or press Enter to skip: ",
741
+ completer=WordCompleter(choices),
742
+ ).strip()
743
+
744
+ if choice and choice.isdigit():
745
+ idx = int(choice) - 1
746
+ all_displayed = high_risk + medium_risk
747
+ if 0 <= idx < len(all_displayed):
748
+ selected_role = all_displayed[idx]["role"]
749
+ typer.echo("")
750
+ _cmd_escalation(ctx, [selected_role])
751
+
752
+
753
+ def _cmd_cost(ctx: SnowglobeContext, args: list):
754
+ """Cost analysis wizard — or use subcommands: cost summary|warehouses|users|ai|ai-users|services|queries"""
755
+ from prompt_toolkit import PromptSession
756
+ from prompt_toolkit.completion import WordCompleter
757
+ from snowglobe.core.cost_service import CostService
758
+
759
+ cost_service = CostService(ctx)
760
+
761
+ # Parse common flags from args
762
+ days = 30
763
+ csv_path = None
764
+ refresh = False
765
+ sub_args = args[1:] if args else []
766
+ for i, a in enumerate(sub_args):
767
+ if a == "--days" and i + 1 < len(sub_args):
768
+ days = int(sub_args[i + 1])
769
+ elif a == "--csv" and i + 1 < len(sub_args):
770
+ csv_path = sub_args[i + 1]
771
+ elif a == "--refresh":
772
+ refresh = True
773
+
774
+ # If a subcommand is given directly, route to it
775
+ if args:
776
+ sub = args[0].lower()
777
+
778
+ if sub == "summary":
779
+ _cost_summary(cost_service, days, csv_path, refresh)
780
+ elif sub == "warehouses":
781
+ _cost_warehouses(cost_service, days, csv_path, refresh)
782
+ elif sub == "users":
783
+ _cost_users(cost_service, min(days, 7), csv_path, refresh)
784
+ elif sub == "ai":
785
+ _cost_ai(cost_service, days, csv_path, refresh)
786
+ elif sub == "ai-users":
787
+ _cost_ai_users(cost_service, days, csv_path, refresh)
788
+ elif sub == "services":
789
+ _cost_services(cost_service, days, csv_path, refresh)
790
+ elif sub == "queries":
791
+ _cost_queries(cost_service, min(days, 7), csv_path, refresh)
792
+ elif sub == "trend":
793
+ _cost_trend(cost_service, days, csv_path, refresh)
794
+ elif sub == "storage":
795
+ _cost_storage(cost_service, days, csv_path, refresh)
796
+ elif sub == "budget":
797
+ _cost_budget(cost_service, csv_path)
798
+ elif sub == "replication":
799
+ _cost_replication(cost_service, days, csv_path, refresh)
800
+ elif sub in ("materialized-views", "mv"):
801
+ _cost_materialized_views(cost_service, days, csv_path, refresh)
802
+ else:
803
+ typer.secho(f"Unknown subcommand: {sub}. Use: summary, warehouses, users, ai, ai-users, services, queries, trend, storage, budget, replication, mv", fg=typer.colors.YELLOW)
804
+ return
805
+
806
+ # Interactive wizard
807
+ typer.echo("")
808
+ typer.secho("Cost Analysis", fg=typer.colors.CYAN, bold=True)
809
+ typer.echo(" [1] Account summary — total spend by service type")
810
+ typer.echo(" [2] Warehouse breakdown — cost per warehouse")
811
+ typer.echo(" [3] User breakdown — all costs per user (warehouse + AI)")
812
+ typer.echo(" [4] AI services — token costs by service type")
813
+ typer.echo(" [5] AI by user — token costs per user per service")
814
+ typer.echo(" [6] Services breakdown — pipes, tasks, SPCS, clustering")
815
+ typer.echo(" [7] Top expensive queries")
816
+ typer.echo(" [8] Daily trend — day-over-day spend with rolling average")
817
+ typer.echo(" [9] Storage — per-database storage breakdown")
818
+ typer.echo(" [10] Budget — Snowflake budget status & projected spend")
819
+ typer.echo(" [11] Replication — cross-region replication costs")
820
+ typer.echo(" [12] Materialized views — MV refresh costs")
821
+ typer.echo("")
822
+
823
+ session = PromptSession()
824
+ choice = session.prompt(
825
+ "Choice (1-12): ",
826
+ completer=WordCompleter(["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]),
827
+ ).strip()
828
+
829
+ if choice == "1":
830
+ _cost_summary(cost_service, 30, None, False)
831
+ elif choice == "2":
832
+ _cost_warehouses(cost_service, 30, None, False)
833
+ elif choice == "3":
834
+ _cost_users(cost_service, 7, None, False)
835
+ elif choice == "4":
836
+ _cost_ai(cost_service, 30, None, False)
837
+ elif choice == "5":
838
+ _cost_ai_users(cost_service, 30, None, False)
839
+ elif choice == "6":
840
+ _cost_services(cost_service, 30, None, False)
841
+ elif choice == "7":
842
+ _cost_queries(cost_service, 7, None, False)
843
+ elif choice == "8":
844
+ _cost_trend(cost_service, 30, None, False)
845
+ elif choice == "9":
846
+ _cost_storage(cost_service, 30, None, False)
847
+ elif choice == "10":
848
+ _cost_budget(cost_service, None)
849
+ elif choice == "11":
850
+ _cost_replication(cost_service, 30, None, False)
851
+ elif choice == "12":
852
+ _cost_materialized_views(cost_service, 30, None, False)
853
+ else:
854
+ typer.secho("Invalid choice.", fg=typer.colors.YELLOW)
855
+
856
+
857
+ def _export_csv(df, csv_path: str | None):
858
+ """Export DataFrame to CSV if path is given. Returns True if exported."""
859
+ if csv_path:
860
+ df.to_csv(csv_path, index=False)
861
+ typer.secho(f" Exported to: {csv_path}", fg=typer.colors.GREEN)
862
+ return True
863
+ return False
864
+
865
+
866
+ def _cache_indicator(cache_age: int | None) -> str:
867
+ """Return a cache status string for display."""
868
+ if cache_age is None:
869
+ return ""
870
+ return f" (cached {cache_age} min ago)"
871
+
872
+
873
+ def _drill_down_prompt(items: list[str], label: str = "Drill down") -> str | None:
874
+ """
875
+ Show a numbered selection prompt after displaying results.
876
+ Returns the selected item string, or None if user skips.
877
+ """
878
+ import sys
879
+ if not sys.stdin.isatty() or not items:
880
+ return None
881
+
882
+ from prompt_toolkit import PromptSession
883
+ from prompt_toolkit.completion import WordCompleter
884
+
885
+ typer.echo("")
886
+ choices = [str(i + 1) for i in range(len(items))] + ["q"]
887
+ session = PromptSession()
888
+ selection = session.prompt(
889
+ f" {label} (1-{len(items)}, q to skip): ",
890
+ completer=WordCompleter(choices),
891
+ ).strip()
892
+
893
+ if not selection or selection.lower() == "q":
894
+ return None
895
+ try:
896
+ idx = int(selection) - 1
897
+ if 0 <= idx < len(items):
898
+ return items[idx]
899
+ except ValueError:
900
+ pass
901
+ return None
902
+
903
+
904
+ def _display_daily_trend(df, title: str):
905
+ """Render a daily trend DataFrame as a sparkline table."""
906
+ if df.empty:
907
+ typer.echo(" No data for this selection.")
908
+ return
909
+ typer.echo("")
910
+ typer.secho(f" {title}", fg=typer.colors.CYAN, bold=True)
911
+ typer.echo(f" {'DATE':<12} {'CREDITS':>10} {'TREND'}")
912
+ typer.echo(f" {'─' * 12} {'─' * 10} {'─' * 20}")
913
+ max_credits = df["CREDITS"].max() if not df.empty else 1
914
+ for _, row in df.iterrows():
915
+ bar_len = int((row["CREDITS"] / max_credits) * 20) if max_credits > 0 else 0
916
+ bar = "▓" * bar_len
917
+ typer.echo(f" {str(row['DATE']):<12} {row['CREDITS']:>10,.2f} {bar}")
918
+ typer.echo("")
919
+
920
+
921
+ def _cost_summary(cost_service, days: int, csv_path: str | None, refresh: bool):
922
+ """Display account cost summary by service type."""
923
+ typer.echo(f"\nFetching account summary (last {days} days)...")
924
+ df, cache_age = cost_service.get_account_summary(days, refresh=refresh)
925
+ if df.empty:
926
+ typer.echo(" No cost data found.")
927
+ return
928
+
929
+ if _export_csv(df, csv_path):
930
+ return
931
+
932
+ typer.echo("")
933
+ total = df["CREDITS"].sum()
934
+ typer.secho(f" Total: {total:,.2f} credits ({days} days){_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
935
+ typer.echo("")
936
+ for i, (_, row) in enumerate(df.iterrows(), 1):
937
+ bar_len = int(row["PCT"] / 2)
938
+ bar = "█" * bar_len
939
+ typer.echo(f" [{i:>2}] {row['SERVICE_TYPE']:<38} {row['CREDITS']:>10,.2f} {row['PCT']:>5.1f}% {bar}")
940
+ typer.echo("")
941
+
942
+ # Drill-down
943
+ service_types = df["SERVICE_TYPE"].tolist()
944
+ selected = _drill_down_prompt(service_types, "View daily trend for service")
945
+ if selected:
946
+ typer.echo(f"\n Fetching daily trend for {selected}...")
947
+ detail_df = cost_service.get_service_daily_trend(selected, days)
948
+ _display_daily_trend(detail_df, f"Daily Trend: {selected} ({days} days)")
949
+
950
+
951
+ def _cost_warehouses(cost_service, days: int, csv_path: str | None, refresh: bool):
952
+ """Display warehouse cost breakdown."""
953
+ typer.echo(f"\nFetching warehouse costs (last {days} days)...")
954
+ df, cache_age = cost_service.get_warehouse_breakdown(days, refresh=refresh)
955
+ if df.empty:
956
+ typer.echo(" No warehouse data found.")
957
+ return
958
+
959
+ if _export_csv(df, csv_path):
960
+ return
961
+
962
+ typer.echo("")
963
+ if cache_age is not None:
964
+ typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
965
+ cli.print_table(df, title=f"Warehouse Costs ({days} days)")
966
+
967
+ # Drill-down
968
+ if not df.empty and "WAREHOUSE_NAME" in df.columns:
969
+ warehouses = df["WAREHOUSE_NAME"].tolist()
970
+ selected = _drill_down_prompt(warehouses, "View daily trend for warehouse")
971
+ if selected:
972
+ typer.echo(f"\n Fetching daily trend for {selected}...")
973
+ detail_df = cost_service.get_warehouse_daily_trend(selected, days)
974
+ _display_daily_trend(detail_df, f"Daily Trend: {selected} ({days} days)")
975
+
976
+
977
+ def _cost_users(cost_service, days: int, csv_path: str | None, refresh: bool):
978
+ """Display complete cost per user — warehouse + all AI services."""
979
+ typer.echo(f"\nFetching user costs (last {days} days)...")
980
+ df, cache_age, note = cost_service.get_user_breakdown(days, refresh=refresh)
981
+ if df.empty:
982
+ typer.echo(" No user data found.")
983
+ return
984
+
985
+ if _export_csv(df, csv_path):
986
+ return
987
+
988
+ typer.echo("")
989
+ if note:
990
+ typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
991
+ if cache_age is not None:
992
+ typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
993
+ cli.print_table(df, title=f"User Cost Attribution ({days} days)")
994
+
995
+ # Drill-down
996
+ if not df.empty and "USER_NAME" in df.columns:
997
+ users = df["USER_NAME"].tolist()
998
+ selected = _drill_down_prompt(users, "View warehouse breakdown for user")
999
+ if selected:
1000
+ typer.echo(f"\n Fetching detail for {selected}...")
1001
+ detail_df, detail_note = cost_service.get_user_detail(selected, days)
1002
+ if detail_note:
1003
+ typer.secho(f" ⚠ {detail_note}", fg=typer.colors.YELLOW)
1004
+ if detail_df.empty:
1005
+ typer.echo(" No query attribution data for this user.")
1006
+ else:
1007
+ cli.print_table(detail_df, title=f"Warehouse Breakdown: {selected} ({days} days)")
1008
+
1009
+
1010
+ def _cost_ai(cost_service, days: int, csv_path: str | None, refresh: bool):
1011
+ """Display AI/ML token costs by service type."""
1012
+ typer.echo(f"\nFetching AI costs (last {days} days)...")
1013
+ df, cache_age, note = cost_service.get_ai_costs(days, refresh=refresh)
1014
+ if df.empty:
1015
+ typer.echo(" No AI usage found.")
1016
+ return
1017
+
1018
+ if _export_csv(df, csv_path):
1019
+ return
1020
+
1021
+ typer.echo("")
1022
+ if note:
1023
+ typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
1024
+ total = df["TOTAL_CREDITS"].astype(float).sum()
1025
+ typer.secho(f" Total AI credits: {total:,.2f} ({days} days){_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
1026
+ typer.echo("")
1027
+ for _, row in df.iterrows():
1028
+ bar_len = int(row["PCT"] / 2)
1029
+ bar = "█" * bar_len
1030
+ typer.echo(f" {row['SERVICE']:<30} {float(row['TOTAL_CREDITS']):>10,.2f} {row['PCT']:>5.1f}% {bar}")
1031
+ typer.echo("")
1032
+
1033
+
1034
+ def _cost_ai_users(cost_service, days: int, csv_path: str | None, refresh: bool):
1035
+ """Display AI/ML token costs per user with service breakdown."""
1036
+ typer.echo(f"\nFetching AI costs by user (last {days} days)...")
1037
+ df, cache_age, note = cost_service.get_ai_costs_by_user(days, refresh=refresh)
1038
+ if df.empty:
1039
+ typer.echo(" No AI usage found.")
1040
+ return
1041
+
1042
+ if _export_csv(df, csv_path):
1043
+ return
1044
+
1045
+ typer.echo("")
1046
+ if note:
1047
+ typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
1048
+ if cache_age is not None:
1049
+ typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
1050
+ cli.print_table(df, title=f"AI Token Costs by User ({days} days)")
1051
+
1052
+
1053
+ def _cost_services(cost_service, days: int, csv_path: str | None, refresh: bool):
1054
+ """Display non-warehouse service costs (pipes, tasks, SPCS, clustering)."""
1055
+ typer.echo(f"\nFetching service costs (last {days} days)...")
1056
+ df, cache_age = cost_service.get_service_breakdown(days, refresh=refresh)
1057
+ if df.empty:
1058
+ typer.echo(" No service cost data found.")
1059
+ return
1060
+
1061
+ if _export_csv(df, csv_path):
1062
+ return
1063
+
1064
+ typer.echo("")
1065
+ # Group totals by service type
1066
+ totals = df.groupby("SERVICE")["CREDITS"].sum().sort_values(ascending=False)
1067
+ typer.secho(f" Service totals:{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
1068
+ for svc, credits in totals.items():
1069
+ typer.echo(f" {svc:<25} {credits:>10,.2f} credits")
1070
+ typer.echo("")
1071
+ cli.print_table(df, title=f"Service Resource Costs ({days} days)")
1072
+
1073
+
1074
+ def _cost_queries(cost_service, days: int, csv_path: str | None, refresh: bool):
1075
+ """Display top expensive queries."""
1076
+ typer.echo(f"\nFetching top queries (last {days} days)...")
1077
+ df, cache_age, note = cost_service.get_top_queries(days, refresh=refresh)
1078
+ if df.empty:
1079
+ typer.echo(" No query data found.")
1080
+ return
1081
+
1082
+ if _export_csv(df, csv_path):
1083
+ return
1084
+
1085
+ typer.echo("")
1086
+ if note:
1087
+ typer.secho(f" ⚠ {note}", fg=typer.colors.YELLOW)
1088
+ cli.print_table(df, title=f"Top Expensive Queries ({days} days)")
1089
+
1090
+
1091
+ def _cost_trend(cost_service, days: int, csv_path: str | None, refresh: bool):
1092
+ """Display daily cost trend with day-over-day change and rolling 7-day average."""
1093
+ typer.echo(f"\nFetching daily trend (last {days} days)...")
1094
+ df, cache_age = cost_service.get_daily_trend(days, refresh=refresh)
1095
+ if df.empty:
1096
+ typer.echo(" No trend data found.")
1097
+ return
1098
+
1099
+ if _export_csv(df, csv_path):
1100
+ return
1101
+
1102
+ typer.echo("")
1103
+ total = df["CREDITS"].sum()
1104
+ avg_daily = df["CREDITS"].mean()
1105
+ typer.secho(f" Total: {total:,.2f} credits | Avg daily: {avg_daily:,.2f}{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
1106
+ typer.echo("")
1107
+
1108
+ # Sparkline-style table showing date, credits, delta, and rolling avg
1109
+ typer.echo(f" {'DATE':<12} {'CREDITS':>10} {'DELTA %':>9} {'7D AVG':>10} {'TREND'}")
1110
+ typer.echo(f" {'─' * 12} {'─' * 10} {'─' * 9} {'─' * 10} {'─' * 20}")
1111
+ max_credits = df["CREDITS"].max() if not df.empty else 1
1112
+ for _, row in df.iterrows():
1113
+ bar_len = int((row["CREDITS"] / max_credits) * 20) if max_credits > 0 else 0
1114
+ bar = "▓" * bar_len
1115
+ delta_str = f"{row['DELTA_PCT']:+.1f}%" if pd.notna(row["DELTA_PCT"]) else " —"
1116
+ avg_str = f"{row['ROLLING_7D_AVG']:,.2f}" if pd.notna(row["ROLLING_7D_AVG"]) else "—"
1117
+ typer.echo(f" {str(row['DATE']):<12} {row['CREDITS']:>10,.2f} {delta_str:>9} {avg_str:>10} {bar}")
1118
+ typer.echo("")
1119
+
1120
+
1121
+ def _cost_storage(cost_service, days: int, csv_path: str | None, refresh: bool):
1122
+ """Display per-database storage breakdown with estimated monthly cost."""
1123
+ typer.echo(f"\nFetching storage usage (avg over last {days} days)...")
1124
+ df, cache_age = cost_service.get_storage_usage(days, refresh=refresh)
1125
+ if df.empty:
1126
+ typer.echo(" No storage data found.")
1127
+ return
1128
+
1129
+ if _export_csv(df, csv_path):
1130
+ return
1131
+
1132
+ typer.echo("")
1133
+ total_tb = df["TOTAL_TB"].sum()
1134
+ total_cost = df["EST_MONTHLY_COST"].sum()
1135
+ rate = cost_service.get_storage_rate()
1136
+ typer.secho(f" Total storage: {total_tb:,.4f} TB | Est. monthly: ${total_cost:,.2f}{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
1137
+ rate_source = "contracted rate" if rate != 23.0 else "on-demand default"
1138
+ typer.echo(f" (Estimated at ${rate:.2f}/TB/month — {rate_source})")
1139
+ typer.echo("")
1140
+
1141
+ # Display table with human-readable sizes
1142
+ display_df = df[["DATABASE_NAME", "TOTAL_TB", "EST_MONTHLY_COST"]].copy()
1143
+ display_df = display_df[display_df["TOTAL_TB"] > 0]
1144
+ if not display_df.empty:
1145
+ cli.print_table(display_df, title=f"Storage by Database ({days}-day avg)")
1146
+
1147
+
1148
+ def _cost_budget(cost_service, csv_path: str | None):
1149
+ """Display Snowflake native budget status."""
1150
+ typer.echo("\nFetching budget status...")
1151
+ df, error = cost_service.get_budget_status()
1152
+ if error:
1153
+ typer.secho(f" {error}", fg=typer.colors.YELLOW)
1154
+ return
1155
+
1156
+ if df.empty:
1157
+ typer.echo(" No budget spending history found.")
1158
+ return
1159
+
1160
+ if _export_csv(df, csv_path):
1161
+ return
1162
+
1163
+ typer.echo("")
1164
+ typer.secho(" Snowflake Budget — Spending History", fg=typer.colors.CYAN, bold=True)
1165
+ typer.echo("")
1166
+ cli.print_table(df, title="Budget Spending History")
1167
+
1168
+
1169
+ def _cost_replication(cost_service, days: int, csv_path: str | None, refresh: bool):
1170
+ """Display replication costs by group or daily."""
1171
+ typer.echo(f"\nFetching replication costs (last {days} days)...")
1172
+ df, cache_age = cost_service.get_replication_costs(days, refresh=refresh)
1173
+ if df.empty:
1174
+ typer.echo(" No replication cost data found.")
1175
+ return
1176
+
1177
+ if _export_csv(df, csv_path):
1178
+ return
1179
+
1180
+ typer.echo("")
1181
+ if cache_age is not None:
1182
+ typer.secho(f" {_cache_indicator(cache_age).strip()}", fg=typer.colors.BRIGHT_BLACK)
1183
+ cli.print_table(df, title=f"Replication Costs ({days} days)")
1184
+
1185
+
1186
+ def _cost_materialized_views(cost_service, days: int, csv_path: str | None, refresh: bool):
1187
+ """Display materialized view refresh costs."""
1188
+ typer.echo(f"\nFetching materialized view costs (last {days} days)...")
1189
+ df, cache_age = cost_service.get_materialized_view_costs(days, refresh=refresh)
1190
+ if df.empty:
1191
+ typer.echo(" No materialized view cost data found.")
1192
+ return
1193
+
1194
+ if _export_csv(df, csv_path):
1195
+ return
1196
+
1197
+ typer.echo("")
1198
+ total = df["CREDITS"].sum() if "CREDITS" in df.columns else 0
1199
+ typer.secho(f" Total MV refresh credits: {total:,.2f}{_cache_indicator(cache_age)}", fg=typer.colors.GREEN, bold=True)
1200
+ typer.echo("")
1201
+ cli.print_table(df, title=f"Materialized View Costs ({days} days)")
1202
+
1203
+
1204
+ def _cmd_drift(ctx: SnowglobeContext, args: list):
1205
+ """Show access changes since last refresh or --days N."""
1206
+ days = None
1207
+ for i, a in enumerate(args):
1208
+ if a == "--days" and i + 1 < len(args):
1209
+ days = int(args[i + 1])
1210
+
1211
+ access_service = AccessService(ctx)
1212
+ result = access_service.detect_drift(days=days)
1213
+ typer.echo(cli.format_drift_text(result))
1214
+
1215
+
1216
+ def _cmd_unused(ctx: SnowglobeContext, args: list):
1217
+ """Find roles with granted privileges but no query activity."""
1218
+ days = 90
1219
+ for i, a in enumerate(args):
1220
+ if a == "--days" and i + 1 < len(args):
1221
+ days = int(args[i + 1])
1222
+
1223
+ typer.echo(f"\nChecking for unused privileges (inactive >{days} days)...")
1224
+ access_service = AccessService(ctx)
1225
+ df, error = access_service.detect_unused_privileges(days=days)
1226
+ if error:
1227
+ typer.secho(f" {error}", fg=typer.colors.YELLOW)
1228
+ return
1229
+ if df.empty:
1230
+ typer.secho(" All roles with data grants are active.", fg=typer.colors.GREEN)
1231
+ return
1232
+ typer.echo("")
1233
+ cli.print_table(df, title=f"Roles with Unused Privileges (>{days} days inactive)")
1234
+
1235
+
1236
+ def _cmd_report(ctx: SnowglobeContext, args: list):
1237
+ """Generate reports. Usage: report <username> | report full | report cost"""
1238
+ if not args:
1239
+ typer.echo("Usage:")
1240
+ typer.echo(" report <username> Full access report for a user")
1241
+ typer.echo(" report full Cost + AI + storage + queries report (saves .md)")
1242
+ typer.echo(" report cost Cost-only report (saves .md)")
1243
+ return
1244
+
1245
+ sub = args[0].lower()
1246
+
1247
+ if sub == "full":
1248
+ from snowglobe.core.report_service import ReportService
1249
+ days = 30
1250
+ for i, a in enumerate(args[1:]):
1251
+ if a == "--days" and i + 2 <= len(args[1:]):
1252
+ days = int(args[i + 2])
1253
+
1254
+ typer.echo(f"\nGenerating full report ({days} days)...")
1255
+ service = ReportService(ctx)
1256
+ output_path = f"snowglobe_report_{__import__('datetime').date.today().isoformat()}.md"
1257
+ _, data = service.generate_and_save(output_path, days=days)
1258
+ typer.echo(service.terminal_summary(data))
1259
+ typer.secho(f" Report saved: {output_path}", fg=typer.colors.GREEN, bold=True)
1260
+ typer.echo("")
1261
+
1262
+ elif sub == "cost":
1263
+ from snowglobe.core.report_service import ReportService
1264
+ days = 30
1265
+ for i, a in enumerate(args[1:]):
1266
+ if a == "--days" and i + 2 <= len(args[1:]):
1267
+ days = int(args[i + 2])
1268
+
1269
+ typer.echo(f"\nGenerating cost report ({days} days)...")
1270
+ service = ReportService(ctx)
1271
+ data = service.generate_full_report(days=days, top_n=0)
1272
+ data["top_queries"] = []
1273
+ markdown = service.render_markdown(data)
1274
+ output_path = f"snowglobe_cost_{__import__('datetime').date.today().isoformat()}.md"
1275
+ from pathlib import Path
1276
+ Path(output_path).write_text(markdown)
1277
+ typer.echo(service.terminal_summary(data))
1278
+ typer.secho(f" Report saved: {output_path}", fg=typer.colors.GREEN, bold=True)
1279
+ typer.echo("")
1280
+
1281
+ else:
1282
+ # Treat as username for access report
1283
+ username = args[0].upper()
1284
+ typer.echo(f"\nGenerating access report for {username}...")
1285
+ access_service = AccessService(ctx)
1286
+ try:
1287
+ result = access_service.inspect_user_report(username)
1288
+ typer.echo(cli.format_user_report(result))
1289
+ except Exception as e:
1290
+ typer.secho(f" Error: {e}", fg=typer.colors.RED)
1291
+
1292
+
1293
+ def _cmd_optimize(ctx: SnowglobeContext, args: list):
1294
+ """Analyze a query by ID."""
1295
+ if not args:
1296
+ typer.echo("Usage: optimize <query_id>")
1297
+ return
1298
+
1299
+ query_id = args[0]
1300
+ optimizer_service = QueryOptimizerService(ctx)
1301
+ optimizer_service.collect_query_profile(query_id)
1302
+ optimizer_service.analyze_query()
1303
+
1304
+ # Snowflake-native insights first
1305
+ insights = optimizer_service.collect_insights()
1306
+ if insights:
1307
+ typer.echo(cli.format_query_insights(query_id, insights))
1308
+
1309
+ # Local rule-based suggestions
1310
+ opt_suggestions = optimizer_service.suggestions()
1311
+ typer.echo(cli.format_optimizer_suggestions(query_id, opt_suggestions.suggestions))
1312
+
1313
+ tree = optimizer_service.build_operator_tree()
1314
+ scores = optimizer_service.score()
1315
+ cli.print_operator_tree(tree, scores)
1316
+
1317
+ opt_cost_attribution = optimizer_service.cost_attribution()
1318
+ typer.echo(cli.format_cost_attribution(opt_cost_attribution))
1319
+
1320
+ opt_exp = optimizer_service.expensive_operators()
1321
+ typer.echo(cli.format_expensive_operators(opt_exp))
1322
+
1323
+
1324
+ def _cmd_status(ctx: SnowglobeContext, args: list):
1325
+ """Show current shell working state."""
1326
+ from snowglobe.state.db import StateDB
1327
+ from datetime import datetime, timezone
1328
+
1329
+ typer.echo("Current state:")
1330
+ typer.echo(f" user: {ctx.username or '(not set)'}")
1331
+ typer.echo(f" role: {ctx.target_role or '(not set)'}")
1332
+ typer.echo(f" object_type: {ctx.object_type or '(not set)'}")
1333
+ typer.echo(f" object_name: {ctx.object_name or '(not set)'}")
1334
+ typer.echo(f" privilege: {ctx.privilege or '(not set)'}")
1335
+
1336
+ # Show cache age
1337
+ db = StateDB()
1338
+ refreshed_at = db.get_refreshed_at()
1339
+ if refreshed_at:
1340
+ try:
1341
+ refreshed = datetime.fromisoformat(refreshed_at)
1342
+ age = datetime.now(timezone.utc) - refreshed
1343
+ hours = age.total_seconds() / 3600
1344
+ if hours < 1:
1345
+ age_str = f"{int(age.total_seconds() / 60)} minutes ago"
1346
+ elif hours < 24:
1347
+ age_str = f"{int(hours)} hours ago"
1348
+ else:
1349
+ age_str = f"{int(hours // 24)} days ago"
1350
+ typer.echo(f" cache: refreshed {age_str}")
1351
+ except (ValueError, TypeError):
1352
+ typer.echo(" cache: unknown age")
1353
+ else:
1354
+ typer.echo(" cache: not populated")
1355
+
1356
+
1357
+ def _cmd_debug(ctx: SnowglobeContext, args: list):
1358
+ """Run connection diagnostics."""
1359
+ from snowglobe.cli.debug import run_diagnostics
1360
+ run_diagnostics(profile_name=ctx.profile_name, verbose=ctx.verbose)
1361
+
1362
+
1363
+ def _cmd_refresh(ctx: SnowglobeContext, args: list):
1364
+ """Refresh cached state from Snowflake. Use 'refresh --full' for complete reload."""
1365
+ full = "--full" in args
1366
+
1367
+ access_service = AccessService(ctx)
1368
+ access_service.setup_state()
1369
+
1370
+ access_service.refresh_state(full=full)
1371
+
1372
+ # Update context with fresh data
1373
+ ctx.user_graph = access_service.user_graph
1374
+ ctx.role_graph = access_service.role_graph
1375
+ ctx.object_index = access_service.object_index
1376
+
1377
+ typer.secho(f" Users: {len(ctx.user_graph.assigned_roles)}", fg=typer.colors.GREEN)
1378
+ typer.secho(f" Roles: {len(ctx.role_graph.parents)}", fg=typer.colors.GREEN)
1379
+ total_objects = sum(len(v) for v in ctx.object_index.values())
1380
+ typer.secho(f" Object index: {total_objects} FQNs", fg=typer.colors.GREEN)
1381
+ typer.secho("Done.", fg=typer.colors.GREEN, bold=True)
1382
+
1383
+
1384
+ def _cmd_help(ctx: SnowglobeContext, args: list):
1385
+ """Show available shell commands."""
1386
+ typer.echo("")
1387
+ typer.secho("Commands:", fg=typer.colors.CYAN, bold=True)
1388
+ typer.echo(" check Guided access & privilege checks (start here)")
1389
+ typer.echo(" roles <user> What roles does a user have?")
1390
+ typer.echo(" members <role> Who has this role?")
1391
+ typer.echo(" path <from> <to> Does one role inherit from another?")
1392
+ typer.echo(" escalation <role> Can this role reach admin privileges?")
1393
+ typer.echo(" scan Escalation scan with risk scoring (--csv, --json)")
1394
+ typer.echo(" cost Cost analysis wizard")
1395
+ typer.echo(" cost summary Account spend by service type")
1396
+ typer.echo(" cost warehouses Cost per warehouse")
1397
+ typer.echo(" cost users Cost per user")
1398
+ typer.echo(" cost ai AI token costs by service")
1399
+ typer.echo(" cost queries Top expensive queries")
1400
+ typer.echo(" cost trend Daily spend trend with rolling avg")
1401
+ typer.echo(" cost storage Per-database storage breakdown")
1402
+ typer.echo(" cost budget Snowflake budget status")
1403
+ typer.echo(" cost replication Replication costs by group")
1404
+ typer.echo(" cost mv Materialized view refresh costs")
1405
+ typer.echo(" optimize <id> Analyze a specific query")
1406
+ typer.echo(" drift Show access changes since last refresh")
1407
+ typer.echo(" unused Find roles with unused privileges")
1408
+ typer.echo(" report <user> Full access report for a user")
1409
+ typer.echo(" report full Cost/AI/storage/queries report (saves .md)")
1410
+ typer.echo(" report cost Cost-only report (saves .md)")
1411
+ typer.echo(" refresh Refresh cached state from Snowflake")
1412
+ typer.echo(" status Show current working state")
1413
+ typer.echo(" debug Run connection diagnostics")
1414
+ typer.echo(" help / ? Show this help")
1415
+ typer.echo(" exit Exit the shell")
1416
+ typer.echo("")
1417
+ typer.secho("Shortcuts:", fg=typer.colors.CYAN)
1418
+ typer.echo(" use role <name> Set active role")
1419
+ typer.echo(" use user <name> Set active user")
1420
+ typer.echo(" access Direct: can user/role access object?")
1421
+ typer.echo(" whoaccess Direct: who can access object?")
1422
+ typer.echo(" create Direct: where can role create objects?")
1423
+ typer.echo("")
1424
+
1425
+
1426
+ # --- Typer registration ---
1427
+
1428
+ shell_app = typer.Typer(
1429
+ help="Interactive Snowglobe shell",
1430
+ no_args_is_help=True,
1431
+ )
1432
+
1433
+
1434
+ @shell_app.command()
1435
+ def shell(ctx: typer.Context):
1436
+ """Launch the interactive Snowglobe shell."""
1437
+ start_shell(ctx.obj)