clickhouse-users-cli 0.1.1__tar.gz → 0.1.3__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.
Files changed (19) hide show
  1. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/PKG-INFO +10 -1
  2. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/README.md +8 -0
  3. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli/__init__.py +1 -1
  4. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli/app.py +159 -15
  5. clickhouse_users_cli-0.1.3/clickhouse_users_cli/session.py +80 -0
  6. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli/sql_builder.py +13 -0
  7. clickhouse_users_cli-0.1.3/clickhouse_users_cli/style.py +114 -0
  8. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli.egg-info/PKG-INFO +10 -1
  9. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli.egg-info/SOURCES.txt +1 -0
  10. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli.egg-info/requires.txt +1 -0
  11. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/pyproject.toml +1 -0
  12. clickhouse_users_cli-0.1.1/clickhouse_users_cli/style.py +0 -55
  13. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/LICENSE +0 -0
  14. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli/db.py +0 -0
  15. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli/validators.py +0 -0
  16. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli.egg-info/dependency_links.txt +0 -0
  17. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli.egg-info/entry_points.txt +0 -0
  18. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/clickhouse_users_cli.egg-info/top_level.txt +0 -0
  19. {clickhouse_users_cli-0.1.1 → clickhouse_users_cli-0.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clickhouse-users-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: CLI interativa para criar usuarios ClickHouse com privilegio minimo (least privilege)
5
5
  License-Expression: MIT
6
6
  Keywords: clickhouse,cli,users,access-control,least-privilege
@@ -19,6 +19,7 @@ License-File: LICENSE
19
19
  Requires-Dist: questionary>=2.0.0
20
20
  Requires-Dist: clickhouse-connect>=0.8.0
21
21
  Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: pyyaml>=6.0
22
23
  Dynamic: license-file
23
24
 
24
25
  # ClickHouse Users CLI
@@ -47,6 +48,13 @@ Para listar/gerenciar também precisa de `SHOW USERS` + `ALTER USER` / `DROP USE
47
48
  Após conectar, escolha: **Criar usuário** · **Listar usuários** · **Gerenciar usuário** · **Sair**.
48
49
  O menu fica em loop — dá para criar, auditar e desativar vários sem reconectar.
49
50
 
51
+ ## Sessão salva (manter conectado)
52
+
53
+ - Na primeira conexão o CLI pergunta se quer **manter conectado**: salva `~/.ch-users/connection.yaml` com host, porta, protocolo, usuário e senha.
54
+ - Na próxima execução, oferece **usar a salva / nova conexão / apagar a salva**.
55
+ - No menu há **Esquecer sessão** (apaga o YAML, com confirmação).
56
+ - Segurança: a senha fica em **texto puro** no arquivo (permissão `0600` no Linux/mac; no Windows vale o aviso em tela). Só use em máquina confiável — nunca compartilhe o arquivo.
57
+
50
58
  ## Fluxo — criar (6 etapas)
51
59
 
52
60
  1. **Conexão** — host, protocolo, porta, usuário/senha admin. Testa com `SELECT 1` e permite tentar de novo / editar / sair.
@@ -68,6 +76,7 @@ GRANT SHOW, SELECT ON `vendas`.`clientes` TO `analyst_julho`;
68
76
 
69
77
  - **Listar** — `SHOW USERS` em tabela + opção de ver `SHOW CREATE USER` e `SHOW GRANTS FOR` de um usuário.
70
78
  - **Gerenciar** — escolhe 1 usuário, vê status (`HOST NONE` = inativo) + grants, depois:
79
+ - **Editar permissões** → pergunta perfil (tipo de acesso) + bancos/tabelas e aplica `REVOKE ALL ON *.*` + novos `GRANTs` (com preview e confirmação);
71
80
  - **Desativar** → `ALTER USER \`nome\` HOST NONE` (reversível, bloqueia login);
72
81
  - **Reativar** → `ALTER USER \`nome\` HOST ANY`;
73
82
  - **Excluir** → `DROP USER IF EXISTS \`nome\`` (definitivo).
@@ -24,6 +24,13 @@ Para listar/gerenciar também precisa de `SHOW USERS` + `ALTER USER` / `DROP USE
24
24
  Após conectar, escolha: **Criar usuário** · **Listar usuários** · **Gerenciar usuário** · **Sair**.
25
25
  O menu fica em loop — dá para criar, auditar e desativar vários sem reconectar.
26
26
 
27
+ ## Sessão salva (manter conectado)
28
+
29
+ - Na primeira conexão o CLI pergunta se quer **manter conectado**: salva `~/.ch-users/connection.yaml` com host, porta, protocolo, usuário e senha.
30
+ - Na próxima execução, oferece **usar a salva / nova conexão / apagar a salva**.
31
+ - No menu há **Esquecer sessão** (apaga o YAML, com confirmação).
32
+ - Segurança: a senha fica em **texto puro** no arquivo (permissão `0600` no Linux/mac; no Windows vale o aviso em tela). Só use em máquina confiável — nunca compartilhe o arquivo.
33
+
27
34
  ## Fluxo — criar (6 etapas)
28
35
 
29
36
  1. **Conexão** — host, protocolo, porta, usuário/senha admin. Testa com `SELECT 1` e permite tentar de novo / editar / sair.
@@ -45,6 +52,7 @@ GRANT SHOW, SELECT ON `vendas`.`clientes` TO `analyst_julho`;
45
52
 
46
53
  - **Listar** — `SHOW USERS` em tabela + opção de ver `SHOW CREATE USER` e `SHOW GRANTS FOR` de um usuário.
47
54
  - **Gerenciar** — escolhe 1 usuário, vê status (`HOST NONE` = inativo) + grants, depois:
55
+ - **Editar permissões** → pergunta perfil (tipo de acesso) + bancos/tabelas e aplica `REVOKE ALL ON *.*` + novos `GRANTs` (com preview e confirmação);
48
56
  - **Desativar** → `ALTER USER \`nome\` HOST NONE` (reversível, bloqueia login);
49
57
  - **Reativar** → `ALTER USER \`nome\` HOST ANY`;
50
58
  - **Excluir** → `DROP USER IF EXISTS \`nome\`` (definitivo).
@@ -1,5 +1,5 @@
1
1
  """ClickHouse Users CLI — crie usuários com escopo mínimo (least privilege)."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.3"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -6,7 +6,6 @@ import sys
6
6
 
7
7
  import questionary
8
8
  from rich.syntax import Syntax
9
- from rich.table import Table
10
9
 
11
10
  from clickhouse_users_cli.db import SYSTEM_DATABASES, ClickHouseAdmin, ConnectionInfo
12
11
  from clickhouse_users_cli.sql_builder import (
@@ -18,8 +17,10 @@ from clickhouse_users_cli.sql_builder import (
18
17
  build_all_statements,
19
18
  build_deactivate_user_sql,
20
19
  build_drop_user_sql,
20
+ build_edit_grants_statements,
21
21
  )
22
- from clickhouse_users_cli.style import APP_STYLE, banner, console, error, info, step, success
22
+ from clickhouse_users_cli.style import APP_STYLE, banner, console, error, info, make_table, step, success, warn
23
+ from clickhouse_users_cli.session import SESSION_FILE
23
24
  from clickhouse_users_cli.validators import (
24
25
  validate_at_least_one,
25
26
  validate_host,
@@ -288,7 +289,7 @@ def ask_create_options() -> bool:
288
289
 
289
290
  def show_review(spec: UserSpec) -> None:
290
291
  step("6/6 · Revisão", "Confira o resumo e o SQL exato que será executado.")
291
- table = Table(title="Resumo do novo usuário", show_header=True, header_style="bold cyan")
292
+ table = make_table("Resumo do novo usuário")
292
293
  table.add_column("Campo", style="dim")
293
294
  table.add_column("Valor", style="bold")
294
295
  table.add_row("Usuário", spec.username)
@@ -326,18 +327,107 @@ def confirm_and_execute(admin: ClickHouseAdmin, spec: UserSpec) -> None:
326
327
 
327
328
 
328
329
 
330
+ # ── Sessão salva (YAML local, opt-in) ────────────────────────────────────────────
331
+
332
+ def maybe_load_saved_session() -> ConnectionInfo | None:
333
+ """Se há YAML salvo, oferece usar / nova conexão / apagar. None = digitar."""
334
+ from clickhouse_users_cli.session import delete_session, has_saved_session, load_session
335
+
336
+ if not has_saved_session():
337
+ return None
338
+ step("Sessão salva", f"Arquivo: {SESSION_FILE} (senha em texto puro — nunca compartilhe)")
339
+ try:
340
+ saved = load_session()
341
+ except RuntimeError as e:
342
+ error(str(e))
343
+ fix = _ask_or_abort(
344
+ Q.select(
345
+ "A sessão salva está inválida. O que fazer?",
346
+ choices=[
347
+ Q.Choice("Apagar e digitar nova conexão", value="delete"),
348
+ Q.Choice("Digitar nova conexão (manter arquivo)", value="new"),
349
+ ],
350
+ style=APP_STYLE, qmark="›",
351
+ )
352
+ )
353
+ if fix == "delete":
354
+ delete_session()
355
+ success("Sessão apagada.")
356
+ return None
357
+
358
+ info(f"Conexão guardada: {saved.protocol}://{saved.host}:{saved.port} · usuário '{saved.username}'.")
359
+ choice = _ask_or_abort(
360
+ Q.select(
361
+ "Usar a conexão salva?",
362
+ choices=[
363
+ Q.Choice("Usar conexão salva", value="use"),
364
+ Q.Choice("Nova conexão (digitar de novo)", value="new"),
365
+ Q.Choice("Apagar conexão salva", value="delete"),
366
+ ],
367
+ default="use",
368
+ style=APP_STYLE, qmark="›",
369
+ instruction="(↑↓ navega · enter confirma)",
370
+ )
371
+ )
372
+ if choice == "delete":
373
+ delete_session()
374
+ success("Sessão apagada.")
375
+ return None
376
+ if choice == "new":
377
+ return None
378
+ return saved
379
+
380
+
381
+ def ask_remember(conn: ConnectionInfo) -> None:
382
+ """Opt-in: salva a conexão em YAML local (senha em texto puro)."""
383
+ from clickhouse_users_cli.session import save_session
384
+
385
+ warn("A senha será gravada em texto puro no arquivo — só use em máquina confiável.")
386
+ keep = _ask_or_abort(
387
+ Q.confirm("Manter conectado neste computador (salvar sessão)?", default=False, style=APP_STYLE, qmark="›")
388
+ )
389
+ if not keep:
390
+ return
391
+ try:
392
+ path = save_session(conn)
393
+ except RuntimeError as e:
394
+ error(str(e))
395
+ return
396
+ success(f"Sessão salva em {path}")
397
+
398
+
399
+ def flow_forget_session() -> None:
400
+ from clickhouse_users_cli.session import delete_session, has_saved_session
401
+
402
+ if not has_saved_session():
403
+ info("Nenhuma sessão salva.")
404
+ return
405
+ step("Esquecer sessão", f"Arquivo: {SESSION_FILE}")
406
+ go = _ask_or_abort(
407
+ Q.confirm("Apagar a sessão salva (vai pedir credenciais na próxima vez)?", default=False, style=APP_STYLE, qmark="›")
408
+ )
409
+ if not go:
410
+ console.print("[dim]Nada foi apagado.[/]")
411
+ return
412
+ delete_session()
413
+ success("Sessão apagada.")
414
+
415
+
329
416
  # ── Menu principal + listar/gerenciar ────────────────────────────────────────────
330
417
 
331
- def ask_main_action() -> str:
418
+ def ask_main_action(show_forget: bool = False) -> str:
419
+ choices = [
420
+ Q.Choice("Criar usuário — com privilégio mínimo", value="create"),
421
+ Q.Choice("Listar usuários — SHOW USERS + grants", value="list"),
422
+ Q.Choice("Gerenciar usuário — acessos, desativar / reativar / excluir", value="manage"),
423
+ ]
424
+ if show_forget:
425
+ choices.append(Q.Choice("Esquecer sessão — apagar YAML salvo", value="forget"))
426
+ choices.append(Q.Choice("Sair", value="exit"))
332
427
  return _ask_or_abort(
333
428
  Q.select(
334
429
  "O que deseja fazer?",
335
- choices=[
336
- Q.Choice("Criar usuário — com privilégio mínimo", value="create"),
337
- Q.Choice("Listar usuários — SHOW USERS + grants", value="list"),
338
- Q.Choice("Gerenciar usuário — desativar / reativar / excluir", value="manage"),
339
- Q.Choice("Sair", value="exit"),
340
- ],
430
+ choices=choices,
341
431
  default="create",
342
432
  style=APP_STYLE, qmark="›",
343
433
  instruction="(↑↓ navega · enter confirma)",
@@ -362,7 +452,7 @@ def show_user_details(admin: ClickHouseAdmin, username: str) -> tuple[str, list[
362
452
  error(f"Falha ao ver grants de '{username}': {e}")
363
453
  grants = []
364
454
 
365
- table = Table(title=f"Usuário {username}", show_header=True, header_style="bold cyan")
455
+ table = make_table(f"Usuário {username}")
366
456
  table.add_column("Campo", style="dim")
367
457
  table.add_column("Valor", style="bold")
368
458
  status = "inativo (HOST NONE)" if _is_inactive(create_sql) else "ativo"
@@ -392,7 +482,7 @@ def flow_list_users(admin: ClickHouseAdmin) -> None:
392
482
  info("Nenhum usuário encontrado.")
393
483
  return
394
484
 
395
- table = Table(title=f"{len(users)} usuário(s)", show_header=True, header_style="bold cyan")
485
+ table = make_table(f"{len(users)} usuário(s)")
396
486
  table.add_column("#", style="dim")
397
487
  table.add_column("Usuário", style="bold")
398
488
  for i, u in enumerate(users, 1):
@@ -415,6 +505,49 @@ def flow_list_users(admin: ClickHouseAdmin) -> None:
415
505
  show_user_details(admin, target)
416
506
 
417
507
 
508
+ def flow_edit_grants(admin: ClickHouseAdmin, username: str) -> None:
509
+ """Redefine os acessos: REVOKE ALL + GRANTs novos (perfil + escopo + hosts intactos)."""
510
+ step("Usuários · Editar permissões", "Os acessos atuais serão zerados e reaplicados. Nada muda sem confirmação.")
511
+ if username == admin.conn.username:
512
+ warn(f"'{username}' é o usuário conectado agora. Zerar os próprios acessos pode te derrubar.")
513
+
514
+ profile = ask_profile()
515
+ privileges, grant_option = ask_privileges(profile)
516
+ if profile == "admin":
517
+ grant_option = True
518
+ databases = ask_databases(admin)
519
+ scopes = ask_tables(admin, databases)
520
+
521
+ statements = build_edit_grants_statements(username, privileges, scopes, grant_option)
522
+ table = make_table(f"Novos acessos de '{username}'")
523
+ table.add_column("Privilégios", style="bold")
524
+ table.add_column("Escopo", style="bold")
525
+ table.add_column("Grant option", style="dim")
526
+ for s in scopes:
527
+ table.add_row(", ".join(privileges), s.on_clause(), "sim" if grant_option else "não")
528
+ console.print(table)
529
+
530
+ console.print("[dim]SQL a executar (ordem importa: primeiro zera, depois concede):[/]")
531
+ from rich.panel import Panel as RichPanel
532
+
533
+ for sql in statements:
534
+ console.print(RichPanel(Syntax(sql + ";", "sql", theme="monokai"), border_style="dim"))
535
+
536
+ go = _ask_or_abort(Q.confirm(f"Aplicar novos acessos a '{username}' agora?", default=False, style=APP_STYLE, qmark="›"))
537
+ if not go:
538
+ console.print("[dim]Nada foi executado.[/]")
539
+ return
540
+
541
+ with console.status("[cyan]Aplicando acessos…[/]", spinner="dots"):
542
+ try:
543
+ admin.execute_statements(statements)
544
+ except Exception as e:
545
+ error(f"Falha ao editar acessos: {e}")
546
+ info("Dica: a conta precisa de GRANT/REVOKE nos escopos envolvidos. Os acessos antigos podem ter sido zerados — confira com Listar.")
547
+ return
548
+ success(f"Acessos de '{username}' atualizados ({len(statements)} comando(s)).")
549
+
550
+
418
551
  def flow_manage_users(admin: ClickHouseAdmin) -> None:
419
552
  step("Usuários · Gerenciar", "Desativar = HOST NONE (reversível). Excluir = DROP USER (definitivo).")
420
553
  with console.status("[cyan]Listando usuários…[/]", spinner="dots"):
@@ -440,7 +573,7 @@ def flow_manage_users(admin: ClickHouseAdmin) -> None:
440
573
  inactive = _is_inactive(create_sql)
441
574
 
442
575
  if target == admin.conn.username:
443
- error(f" Atenção: '{target}' é o usuário conectado agora. Desativar/excluir pode te derrubar.")
576
+ warn(f"'{target}' é o usuário conectado agora. Desativar/excluir pode te derrubar.")
444
577
 
445
578
  options = []
446
579
  if inactive:
@@ -448,6 +581,7 @@ def flow_manage_users(admin: ClickHouseAdmin) -> None:
448
581
  else:
449
582
  options.append(Q.Choice("Desativar — ALTER USER … HOST NONE (reversível)", value="deactivate"))
450
583
  options += [
584
+ Q.Choice("Editar permissões — bancos/tabelas + tipo de acesso", value="edit"),
451
585
  Q.Choice("Excluir — DROP USER (definitivo!)", value="drop"),
452
586
  Q.Choice("Voltar ao menu", value="back"),
453
587
  ]
@@ -456,6 +590,9 @@ def flow_manage_users(admin: ClickHouseAdmin) -> None:
456
590
  )
457
591
  if action == "back":
458
592
  return
593
+ if action == "edit":
594
+ flow_edit_grants(admin, target)
595
+ return
459
596
 
460
597
  preview = {
461
598
  "deactivate": build_deactivate_user_sql(target),
@@ -518,17 +655,24 @@ def flow_create_user(admin: ClickHouseAdmin) -> None:
518
655
  def main() -> None:
519
656
  try:
520
657
  banner()
521
- conn = ask_connection()
658
+ from clickhouse_users_cli.session import has_saved_session
659
+
660
+ saved_conn = maybe_load_saved_session()
661
+ conn = saved_conn or ask_connection()
522
662
  admin = connect_with_retry(conn)
663
+ if saved_conn is None:
664
+ ask_remember(conn)
523
665
 
524
666
  while True:
525
- action = ask_main_action()
667
+ action = ask_main_action(show_forget=has_saved_session())
526
668
  if action == "create":
527
669
  flow_create_user(admin)
528
670
  elif action == "list":
529
671
  flow_list_users(admin)
530
672
  elif action == "manage":
531
673
  flow_manage_users(admin)
674
+ elif action == "forget":
675
+ flow_forget_session()
532
676
  else:
533
677
  console.print("[dim]Até logo![/]")
534
678
  return
@@ -0,0 +1,80 @@
1
+ """Sessão local opcional — salva a conexão admin em YAML para reutilizar.
2
+
3
+ Segurança: a senha fica em texto puro no arquivo. O arquivo é criado com
4
+ permissão restrita (0600 no POSIX) e o salvamento é sempre opt-in explícito.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from clickhouse_users_cli.db import ConnectionInfo
13
+
14
+ SESSION_DIR = Path.home() / ".ch-users"
15
+ SESSION_FILE = SESSION_DIR / "connection.yaml"
16
+
17
+
18
+ def has_saved_session() -> bool:
19
+ return SESSION_FILE.is_file()
20
+
21
+
22
+ def describe_session(data: dict) -> str:
23
+ proto = "https" if data.get("secure") else "http"
24
+ return f"{proto}://{data.get('host')}:{data.get('port')} · usuário '{data.get('username')}'"
25
+
26
+
27
+ def save_session(conn: ConnectionInfo) -> Path:
28
+ """Grava a conexão em YAML com permissão restrita. Retorna o caminho."""
29
+ try:
30
+ import yaml
31
+ except ImportError as e: # pragma: no cover
32
+ raise RuntimeError("Dependência 'pyyaml' não instalada. Rode: pip install pyyaml") from e
33
+
34
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
35
+ payload = {
36
+ "host": conn.host,
37
+ "port": conn.port,
38
+ "secure": conn.secure,
39
+ "username": conn.username,
40
+ "password": conn.password,
41
+ }
42
+ with open(SESSION_FILE, "w", encoding="utf-8") as f:
43
+ yaml.safe_dump(payload, f, allow_unicode=True, sort_keys=False)
44
+ try:
45
+ os.chmod(SESSION_FILE, 0o600)
46
+ except OSError:
47
+ pass # Windows: melhor esforço; o aviso em tela cobre o risco
48
+ return SESSION_FILE
49
+
50
+
51
+ def load_session() -> ConnectionInfo:
52
+ """Lê o YAML e devolve ConnectionInfo. Erro amigável se ausente/corrompido."""
53
+ try:
54
+ import yaml
55
+ except ImportError as e: # pragma: no cover
56
+ raise RuntimeError("Dependência 'pyyaml' não instalada. Rode: pip install pyyaml") from e
57
+
58
+ try:
59
+ with open(SESSION_FILE, encoding="utf-8") as f:
60
+ data = yaml.safe_load(f) or {}
61
+ return ConnectionInfo(
62
+ host=str(data["host"]),
63
+ port=int(data["port"]),
64
+ secure=bool(data["secure"]),
65
+ username=str(data["username"]),
66
+ password=str(data.get("password") or ""),
67
+ )
68
+ except KeyError as e:
69
+ raise RuntimeError(f"Arquivo de sessão incompleto (falta {e}). Apague e salve de novo.") from e
70
+ except (OSError, ValueError, TypeError) as e:
71
+ raise RuntimeError(f"Não foi possível ler a sessão salva: {e}") from e
72
+
73
+
74
+ def delete_session() -> bool:
75
+ """Apaga o YAML. Retorna True se algo foi removido."""
76
+ try:
77
+ SESSION_FILE.unlink()
78
+ return True
79
+ except FileNotFoundError:
80
+ return False
@@ -114,3 +114,16 @@ def build_drop_user_sql(username: str, if_exists: bool = True) -> str:
114
114
  """Exclui o usuário em definitivo."""
115
115
  ine = "IF EXISTS " if if_exists else ""
116
116
  return f"DROP USER {ine}{escape_ident(username)}"
117
+
118
+
119
+ def build_revoke_all_sql(username: str) -> str:
120
+ """Remove todos os privilégios em todos os escopos (zera antes de reaplicar)."""
121
+ return f"REVOKE ALL ON *.* FROM {escape_ident(username)}"
122
+
123
+
124
+ def build_edit_grants_statements(
125
+ username: str, privileges: list[str], scopes: list[GrantScope], grant_option: bool = False
126
+ ) -> list[str]:
127
+ """Edição de acessos: REVOKE ALL + GRANTs novos (com preview + confirmação no CLI)."""
128
+ spec = UserSpec(username=username, password="", privileges=privileges, scopes=scopes, grant_option=grant_option)
129
+ return [build_revoke_all_sql(username), *build_grant_sql(spec)]
@@ -0,0 +1,114 @@
1
+ """Estilo centralizado (UI) + helpers de apresentação com Rich."""
2
+
3
+ from questionary import Style
4
+ from rich.align import Align
5
+ from rich.box import DOUBLE, ROUNDED
6
+ from rich.console import Console, Group
7
+ from rich.panel import Panel
8
+ from rich.rule import Rule
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from clickhouse_users_cli import __version__
13
+
14
+ console = Console()
15
+
16
+ CYAN = "#00d7ff"
17
+ MAGENTA = "#c792ea"
18
+ GREEN = "#00ff87"
19
+ RED = "#ff5555"
20
+ AMBER = "#ffcb6b"
21
+ MUTED = "#6c7086"
22
+ BORDER = "#3a3f4b"
23
+
24
+ # Estilo questionary: alto contraste, foco visível, perigo em vermelho.
25
+ # Mantém a navegação por teclado óbvia (setas + espaço + enter).
26
+ APP_STYLE = Style(
27
+ [
28
+ ("qmark", "fg:#00d7ff bold"), # "?" das perguntas
29
+ ("question", "fg:#ffffff bold"), # texto da pergunta
30
+ ("answer", "fg:#00ff87 bold"), # resposta escolhida
31
+ ("pointer", "fg:#00d7ff bold"), # "»" do item focado
32
+ ("highlighted", "fg:#00d7ff bold"), # item focado
33
+ ("selected", "fg:#00ff87"), # checkbox marcado "●"
34
+ ("separator", "fg:#6c7086"),
35
+ ("instruction", "fg:#a6adc8 italic"),
36
+ ("text", "fg:#ffffff"),
37
+ ("disabled", "fg:#6c7086 italic"),
38
+ ]
39
+ )
40
+
41
+
42
+ def _logo_title() -> Text:
43
+ """Título em degradê ciano → magenta."""
44
+ t = Text(no_wrap=True)
45
+ t.append("⬢ ", style=f"bold {CYAN}")
46
+ t.append("ClickHouse", style=f"bold {CYAN}")
47
+ t.append(" ", style="bold")
48
+ t.append("Users CLI", style=f"bold {MAGENTA}")
49
+ return t
50
+
51
+
52
+ def banner() -> None:
53
+ mark = Text("╭──────────╮\n│ ▓▓▓▓▓▓▓▓ │\n│ │\n╰──────────╯", style=f"bold {CYAN}", justify="center")
54
+ body = Group(
55
+ Align.center(mark),
56
+ Align.center(_logo_title()),
57
+ Align.center(Text("Crie usuários com escopo mínimo necessário", style="dim")),
58
+ Align.center(Text("criar ◆ listar ◆ desativar ◆ auditar", style=f"bold {CYAN}")),
59
+ )
60
+ console.print(
61
+ Panel(
62
+ body,
63
+ box=DOUBLE,
64
+ border_style=CYAN,
65
+ title=f"[black on {CYAN}] v{__version__} ",
66
+ subtitle="[dim]least privilege por padrão[/]",
67
+ padding=(1, 4),
68
+ )
69
+ )
70
+
71
+
72
+ def step(title: str, subtitle: str = "") -> None:
73
+ """Cabeçalho de etapa — orienta o usuário sobre 'onde estou' no fluxo."""
74
+ console.print()
75
+ console.print(Rule(f"[black on {CYAN}] {title} [/]", style=CYAN))
76
+ if subtitle:
77
+ console.print(f" [dim]{subtitle}[/]")
78
+
79
+
80
+ def make_table(title: str) -> Table:
81
+ """Tabela padrão: bordas arredondadas, cabeçalho ciano."""
82
+ return Table(
83
+ title=f"[bold white]{title}[/]",
84
+ show_header=True,
85
+ header_style=f"bold {CYAN}",
86
+ border_style=BORDER,
87
+ box=ROUNDED,
88
+ show_lines=False,
89
+ padding=(0, 1),
90
+ )
91
+
92
+
93
+ def success(msg: str) -> None:
94
+ console.print(f"[black on {GREEN}] ✔ [/] [bold {GREEN}]{msg}[/]")
95
+
96
+
97
+ def error(msg: str) -> None:
98
+ console.print(
99
+ Panel(
100
+ f"[bold {RED}]{msg}[/]",
101
+ title=f"[bold {RED}]✘ Erro[/]",
102
+ border_style=RED,
103
+ box=ROUNDED,
104
+ padding=(0, 1),
105
+ )
106
+ )
107
+
108
+
109
+ def warn(msg: str) -> None:
110
+ console.print(f"[black on {AMBER}] ! [/] [{AMBER}]{msg}[/]")
111
+
112
+
113
+ def info(msg: str) -> None:
114
+ console.print(f"[dim]▸ {msg}[/]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clickhouse-users-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: CLI interativa para criar usuarios ClickHouse com privilegio minimo (least privilege)
5
5
  License-Expression: MIT
6
6
  Keywords: clickhouse,cli,users,access-control,least-privilege
@@ -19,6 +19,7 @@ License-File: LICENSE
19
19
  Requires-Dist: questionary>=2.0.0
20
20
  Requires-Dist: clickhouse-connect>=0.8.0
21
21
  Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: pyyaml>=6.0
22
23
  Dynamic: license-file
23
24
 
24
25
  # ClickHouse Users CLI
@@ -47,6 +48,13 @@ Para listar/gerenciar também precisa de `SHOW USERS` + `ALTER USER` / `DROP USE
47
48
  Após conectar, escolha: **Criar usuário** · **Listar usuários** · **Gerenciar usuário** · **Sair**.
48
49
  O menu fica em loop — dá para criar, auditar e desativar vários sem reconectar.
49
50
 
51
+ ## Sessão salva (manter conectado)
52
+
53
+ - Na primeira conexão o CLI pergunta se quer **manter conectado**: salva `~/.ch-users/connection.yaml` com host, porta, protocolo, usuário e senha.
54
+ - Na próxima execução, oferece **usar a salva / nova conexão / apagar a salva**.
55
+ - No menu há **Esquecer sessão** (apaga o YAML, com confirmação).
56
+ - Segurança: a senha fica em **texto puro** no arquivo (permissão `0600` no Linux/mac; no Windows vale o aviso em tela). Só use em máquina confiável — nunca compartilhe o arquivo.
57
+
50
58
  ## Fluxo — criar (6 etapas)
51
59
 
52
60
  1. **Conexão** — host, protocolo, porta, usuário/senha admin. Testa com `SELECT 1` e permite tentar de novo / editar / sair.
@@ -68,6 +76,7 @@ GRANT SHOW, SELECT ON `vendas`.`clientes` TO `analyst_julho`;
68
76
 
69
77
  - **Listar** — `SHOW USERS` em tabela + opção de ver `SHOW CREATE USER` e `SHOW GRANTS FOR` de um usuário.
70
78
  - **Gerenciar** — escolhe 1 usuário, vê status (`HOST NONE` = inativo) + grants, depois:
79
+ - **Editar permissões** → pergunta perfil (tipo de acesso) + bancos/tabelas e aplica `REVOKE ALL ON *.*` + novos `GRANTs` (com preview e confirmação);
71
80
  - **Desativar** → `ALTER USER \`nome\` HOST NONE` (reversível, bloqueia login);
72
81
  - **Reativar** → `ALTER USER \`nome\` HOST ANY`;
73
82
  - **Excluir** → `DROP USER IF EXISTS \`nome\`` (definitivo).
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  clickhouse_users_cli/__init__.py
5
5
  clickhouse_users_cli/app.py
6
6
  clickhouse_users_cli/db.py
7
+ clickhouse_users_cli/session.py
7
8
  clickhouse_users_cli/sql_builder.py
8
9
  clickhouse_users_cli/style.py
9
10
  clickhouse_users_cli/validators.py
@@ -1,3 +1,4 @@
1
1
  questionary>=2.0.0
2
2
  clickhouse-connect>=0.8.0
3
3
  rich>=13.0.0
4
+ pyyaml>=6.0
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "questionary>=2.0.0",
26
26
  "clickhouse-connect>=0.8.0",
27
27
  "rich>=13.0.0",
28
+ "pyyaml>=6.0",
28
29
  ]
29
30
 
30
31
  [project.scripts]
@@ -1,55 +0,0 @@
1
- """Estilo centralizado (UI) + helpers de apresentação com Rich."""
2
-
3
- from questionary import Style
4
- from rich.console import Console
5
- from rich.panel import Panel
6
- from rich.rule import Rule
7
-
8
- console = Console()
9
-
10
- # Estilo questionary: alto contraste, foco visível, perigo em vermelho.
11
- # Mantém a navegação por teclado óbvia (setas + espaço + enter).
12
- APP_STYLE = Style(
13
- [
14
- ("qmark", "fg:#00d7ff bold"), # "?" das perguntas
15
- ("question", "fg:#ffffff bold"), # texto da pergunta
16
- ("answer", "fg:#00ff87 bold"), # resposta escolhida
17
- ("pointer", "fg:#00d7ff bold"), # "»" do item focado
18
- ("highlighted", "fg:#00d7ff bold"), # item focado
19
- ("selected", "fg:#00ff87"), # checkbox marcado "●"
20
- ("separator", "fg:#6c7086"),
21
- ("instruction", "fg:#a6adc8 italic"),
22
- ("text", "fg:#ffffff"),
23
- ("disabled", "fg:#6c7086 italic"),
24
- ]
25
- )
26
-
27
-
28
- def banner() -> None:
29
- console.print(
30
- Panel.fit(
31
- "[bold cyan]ClickHouse Users CLI[/]\n"
32
- "[dim]Crie usuários com escopo mínimo necessário (least privilege)[/dim]",
33
- border_style="cyan",
34
- )
35
- )
36
-
37
-
38
- def step(title: str, subtitle: str = "") -> None:
39
- """Cabeçalho de etapa — orienta o usuário sobre 'onde estou' no fluxo."""
40
- console.print()
41
- console.print(Rule(f"[bold cyan]{title}[/]", style="cyan"))
42
- if subtitle:
43
- console.print(f"[dim]{subtitle}[/]")
44
-
45
-
46
- def success(msg: str) -> None:
47
- console.print(f"[bold green]✔ {msg}[/]")
48
-
49
-
50
- def error(msg: str) -> None:
51
- console.print(f"[bold red]✘ {msg}[/]")
52
-
53
-
54
- def info(msg: str) -> None:
55
- console.print(f"[dim]{msg}[/]")