clickhouse-users-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.
@@ -0,0 +1,5 @@
1
+ """ClickHouse Users CLI — crie usuários com escopo mínimo (least privilege)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,543 @@
1
+ """Fluxo interativo — cada função = 1 etapa, 1 widget ideal para o tipo de dado."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import questionary
8
+ from rich.syntax import Syntax
9
+ from rich.table import Table
10
+
11
+ from clickhouse_users_cli.db import SYSTEM_DATABASES, ClickHouseAdmin, ConnectionInfo
12
+ from clickhouse_users_cli.sql_builder import (
13
+ PROFILE_LABELS,
14
+ PROFILE_PRIVILEGES,
15
+ GrantScope,
16
+ UserSpec,
17
+ build_activate_user_sql,
18
+ build_all_statements,
19
+ build_deactivate_user_sql,
20
+ build_drop_user_sql,
21
+ )
22
+ from clickhouse_users_cli.style import APP_STYLE, banner, console, error, info, step, success
23
+ from clickhouse_users_cli.validators import (
24
+ validate_at_least_one,
25
+ validate_host,
26
+ validate_new_password,
27
+ validate_port,
28
+ validate_required,
29
+ validate_username,
30
+ )
31
+
32
+ Q = questionary # alias curto
33
+
34
+
35
+ def _ask_or_abort(prompt):
36
+ """questionary retorna None no Ctrl+C — converte em saída limpa."""
37
+ answer = prompt.ask()
38
+ if answer is None:
39
+ console.print("\n[dim]Operação cancelada pelo usuário.[/]")
40
+ raise SystemExit(0)
41
+ return answer
42
+
43
+
44
+ # ── Etapa 1: conexão ──────────────────────────────────────────────────────────
45
+
46
+ def ask_connection() -> ConnectionInfo:
47
+ step("1/6 · Conexão com o ClickHouse", "Use uma conta admin (ex.: default). Nada é salvo em disco.")
48
+
49
+ host = _ask_or_abort(
50
+ Q.text("Host do servidor:", default="localhost", validate=validate_host, style=APP_STYLE, qmark="›")
51
+ ).strip()
52
+
53
+ protocol = _ask_or_abort(
54
+ Q.select(
55
+ "Protocolo:",
56
+ choices=[
57
+ Q.Choice("http — porta 8123 (rede local / sem TLS)", value=False),
58
+ Q.Choice("https — porta 8443 (TLS, recomendado)", value=True),
59
+ ],
60
+ default=False,
61
+ style=APP_STYLE, qmark="›",
62
+ instruction="(↑↓ navega · enter confirma)",
63
+ )
64
+ )
65
+ default_port = "8443" if protocol else "8123"
66
+
67
+ port = int(
68
+ _ask_or_abort(
69
+ Q.text(f"Porta HTTP(S) [{default_port}]:", default=default_port, validate=validate_port, style=APP_STYLE, qmark="›")
70
+ ).strip()
71
+ or default_port
72
+ )
73
+
74
+ admin_user = _ask_or_abort(
75
+ Q.text("Usuário admin:", default="default", validate=validate_required, style=APP_STYLE, qmark="›")
76
+ ).strip()
77
+
78
+ admin_pass = _ask_or_abort(Q.password("Senha do admin (enter = vazia):", style=APP_STYLE, qmark="›"))
79
+
80
+ return ConnectionInfo(host=host, port=port, secure=protocol, username=admin_user, password=admin_pass)
81
+
82
+
83
+ def connect_with_retry(conn: ConnectionInfo) -> ClickHouseAdmin:
84
+ while True:
85
+ with console.status(f"[cyan]Conectando em {conn.protocol}://{conn.host}:{conn.port}…[/]", spinner="dots"):
86
+ try:
87
+ admin = ClickHouseAdmin(conn).connect()
88
+ success(f"Conectado como '{conn.username}' em {conn.host}:{conn.port}")
89
+ return admin
90
+ except RuntimeError as e:
91
+ error(str(e))
92
+ action = _ask_or_abort(
93
+ Q.select(
94
+ "Não foi possível conectar. O que fazer?",
95
+ choices=["Tentar de novo", "Editar credenciais", "Sair"],
96
+ style=APP_STYLE, qmark="›",
97
+ )
98
+ )
99
+ if action == "Tentar de novo":
100
+ continue
101
+ if action == "Editar credenciais":
102
+ return connect_with_retry(ask_connection())
103
+ raise SystemExit(1)
104
+
105
+
106
+ # ── Etapa 2: perfil do novo usuário ───────────────────────────────────────────
107
+
108
+ def ask_profile() -> str:
109
+ step("2/6 · Tipo de usuário", "O perfil define os privilégios padrão (least privilege primeiro).")
110
+ return _ask_or_abort(
111
+ Q.select(
112
+ "Que tipo de usuário criar?",
113
+ choices=[Q.Choice(PROFILE_LABELS[k], value=k) for k in ("readonly", "readwrite", "admin", "custom")],
114
+ default="readonly",
115
+ style=APP_STYLE, qmark="›",
116
+ instruction="(↑↓ navega · enter confirma)",
117
+ )
118
+ )
119
+
120
+
121
+ # ── Etapa 3: credenciais do novo usuário ──────────────────────────────────────
122
+
123
+ def ask_new_credentials() -> tuple[str, str]:
124
+ step("3/6 · Novo usuário", "Nome segue regra do ClickHouse: letras, números e _ .")
125
+ username = _ask_or_abort(
126
+ Q.text("Nome do novo usuário:", validate=validate_username, style=APP_STYLE, qmark="›")
127
+ ).strip()
128
+
129
+ while True:
130
+ pwd = _ask_or_abort(
131
+ Q.password("Senha do novo usuário (mín. 8 chars):", validate=validate_new_password, style=APP_STYLE, qmark="›")
132
+ )
133
+ confirm = _ask_or_abort(Q.password("Confirme a senha:", style=APP_STYLE, qmark="›"))
134
+ if pwd != confirm:
135
+ error("Senhas não conferem — tente de novo.")
136
+ continue
137
+ return username, pwd
138
+
139
+
140
+ # ── Etapa 4: escopo (bancos → tabelas) ────────────────────────────────────────
141
+
142
+ def ask_databases(admin: ClickHouseAdmin) -> list[str]:
143
+ step("4/6 · Escopo — bancos", "Espaço marca · A confirma · Ctrl+A inverte a seleção.")
144
+ with console.status("[cyan]Listando bancos…[/]", spinner="dots"):
145
+ try:
146
+ databases = admin.list_databases()
147
+ except Exception as e:
148
+ error(f"Falha ao listar bancos: {e}")
149
+ raise SystemExit(1)
150
+
151
+ if not databases:
152
+ error("Nenhum banco encontrado.")
153
+ raise SystemExit(1)
154
+
155
+ choices = [
156
+ Q.Choice(
157
+ title=f"{db} (sistema)" if db in SYSTEM_DATABASES else db,
158
+ value=db,
159
+ # UX: sistema desmarcado por padrão para evitar GRANT acidental.
160
+ checked=db not in SYSTEM_DATABASES,
161
+ )
162
+ for db in databases
163
+ ]
164
+ selected = _ask_or_abort(
165
+ Q.checkbox(
166
+ "Em quais bancos o usuário terá acesso?",
167
+ choices=choices,
168
+ validate=validate_at_least_one,
169
+ style=APP_STYLE, qmark="›",
170
+ instruction="(espaço marca · enter confirma)",
171
+ )
172
+ )
173
+ return selected
174
+
175
+
176
+ def ask_tables(admin: ClickHouseAdmin, databases: list[str]) -> list[GrantScope]:
177
+ step("4/6 · Escopo — tabelas", "Escolha '*' para o banco inteiro ou marque tabelas específicas.")
178
+ scopes: list[GrantScope] = []
179
+ for db in databases:
180
+ try:
181
+ tables = admin.list_tables(db)
182
+ except Exception as e:
183
+ error(f"Falha ao listar tabelas de '{db}': {e} — usando '{db}.*'.")
184
+ scopes.append(GrantScope(database=db, table=None))
185
+ continue
186
+
187
+ if not tables:
188
+ info(f"Banco '{db}' sem tabelas — será concedido '{db}.*'.")
189
+ scopes.append(GrantScope(database=db, table=None))
190
+ continue
191
+
192
+ choices = [Q.Choice(title=f"{db}.* — todas as tabelas (atuais e futuras)", value="__ALL__")] + [
193
+ Q.Choice(title=f"{db}.{t}", value=t) for t in tables
194
+ ]
195
+ picked: list[str] = _ask_or_abort(
196
+ Q.checkbox(
197
+ f"Tabelas de '{db}':",
198
+ choices=choices,
199
+ validate=validate_at_least_one,
200
+ style=APP_STYLE, qmark="›",
201
+ instruction="(espaço marca · enter confirma)",
202
+ )
203
+ )
204
+ if "__ALL__" in picked:
205
+ scopes.append(GrantScope(database=db, table=None))
206
+ else:
207
+ scopes.extend(GrantScope(database=db, table=t) for t in picked)
208
+ return scopes
209
+
210
+
211
+ # ── Etapa 5: privilégios + restrições ─────────────────────────────────────────
212
+
213
+ PRIVILEGE_CHOICES = [
214
+ ("SELECT", "Ler dados"),
215
+ ("SHOW", "Ver bancos/tabelas/colunas"),
216
+ ("INSERT", "Inserir dados"),
217
+ ("CREATE", "Criar bancos/tabelas/views"),
218
+ ("ALTER", "Alterar estrutura/config"),
219
+ ("DROP", "Excluir bancos/tabelas — perigoso!"),
220
+ ("TRUNCATE", "Apagar todos os dados da tabela — perigoso!"),
221
+ ("OPTIMIZE", "Otimizar partições"),
222
+ ("KILL QUERY", "Cancelar queries em execução"),
223
+ ]
224
+
225
+
226
+ def ask_privileges(profile: str) -> tuple[list[str], bool]:
227
+ """Retorna (privilégios, grant_option)."""
228
+ if profile != "custom":
229
+ grant_option = profile == "admin"
230
+ return list(PROFILE_PRIVILEGES[profile]), grant_option
231
+
232
+ step("5/6 · Privilégios personalizados", "Marque só o necessário — DROP/TRUNCATE exigem confirmação extra.")
233
+ privs: list[str] = _ask_or_abort(
234
+ Q.checkbox(
235
+ "Quais privilégios conceder?",
236
+ choices=[Q.Choice(f"{name:12} — {desc}", value=name) for name, desc in PRIVILEGE_CHOICES],
237
+ validate=validate_at_least_one,
238
+ style=APP_STYLE, qmark="›",
239
+ )
240
+ )
241
+ grant_option = False
242
+ if any(p in privs for p in ("DROP", "TRUNCATE")):
243
+ grant_option_confirm = _ask_or_abort(
244
+ Q.confirm("Você marcou privilégio destrutivo. Tem certeza?", default=False, style=APP_STYLE, qmark="›")
245
+ )
246
+ if not grant_option_confirm:
247
+ return ask_privileges("custom")
248
+ else:
249
+ grant_option = _ask_or_abort(
250
+ Q.confirm("Permitir repassar acessos (WITH GRANT OPTION)?", default=False, style=APP_STYLE, qmark="›")
251
+ )
252
+ return privs, grant_option
253
+
254
+
255
+ def ask_host_restriction() -> list[str]:
256
+ step("5/6 · Restrição de origem", "De onde esse usuário poderá conectar?")
257
+ mode = _ask_or_abort(
258
+ Q.select(
259
+ "Permitir conexão de:",
260
+ choices=[
261
+ Q.Choice("Qualquer host — HOST ANY (padrão, mais simples)", value="any"),
262
+ Q.Choice("Somente localhost — HOST LOCALHOST", value="localhost"),
263
+ Q.Choice("IPs/hosts específicos — ex.: 10.0.0.5", value="custom"),
264
+ ],
265
+ default="any",
266
+ style=APP_STYLE, qmark="›",
267
+ )
268
+ )
269
+ if mode == "any":
270
+ return []
271
+ if mode == "localhost":
272
+ return ["LOCALHOST"]
273
+ raw = _ask_or_abort(
274
+ Q.text("Hosts/IPs (separados por vírgula):", validate=validate_required, style=APP_STYLE, qmark="›")
275
+ )
276
+ return [h.strip() for h in raw.split(",") if h.strip()]
277
+
278
+
279
+ def ask_create_options() -> bool:
280
+ return _ask_or_abort(
281
+ Q.confirm("Usar IF NOT EXISTS (não falha se o usuário já existir)?", default=True, style=APP_STYLE, qmark="›")
282
+ )
283
+
284
+
285
+ # ── Etapa 6: revisão + execução ───────────────────────────────────────────────
286
+
287
+ def show_review(spec: UserSpec) -> None:
288
+ step("6/6 · Revisão", "Confira o resumo e o SQL exato que será executado.")
289
+ table = Table(title="Resumo do novo usuário", show_header=True, header_style="bold cyan")
290
+ table.add_column("Campo", style="dim")
291
+ table.add_column("Valor", style="bold")
292
+ table.add_row("Usuário", spec.username)
293
+ table.add_row("Privilégios", ", ".join(spec.privileges))
294
+ table.add_row("Escopo", ", ".join(s.on_clause() for s in spec.scopes))
295
+ table.add_row("Hosts", ", ".join(spec.hosts) if spec.hosts else "ANY (qualquer host)")
296
+ table.add_row("GRANT OPTION", "sim" if spec.grant_option else "não")
297
+ console.print(table)
298
+
299
+ console.print("[dim]SQL a executar:[/]")
300
+ from rich.panel import Panel as RichPanel
301
+
302
+ for sql in build_all_statements(spec):
303
+ console.print(RichPanel(Syntax(sql + ";", "sql", theme="monokai"), border_style="dim"))
304
+
305
+
306
+ def confirm_and_execute(admin: ClickHouseAdmin, spec: UserSpec) -> None:
307
+ show_review(spec)
308
+ go = _ask_or_abort(Q.confirm(f"Criar usuário '{spec.username}' agora?", default=True, style=APP_STYLE, qmark="›"))
309
+ if not go:
310
+ console.print("[dim]Nada foi executado.[/]")
311
+ raise SystemExit(0)
312
+
313
+ statements = build_all_statements(spec)
314
+ with console.status("[cyan]Executando DDL…[/]", spinner="dots"):
315
+ try:
316
+ admin.execute_statements(statements)
317
+ except Exception as e:
318
+ error(f"Falha ao criar usuário: {e}")
319
+ info("Dica: verifique se sua conta admin tem permissão CREATE USER + GRANT.")
320
+ raise SystemExit(1)
321
+ success(f"Usuário '{spec.username}' criado com {len(statements)} comando(s).")
322
+ info("Teste: clickhouse-client --user " + spec.username + " --password '***' --query 'SHOW TABLES'")
323
+
324
+
325
+
326
+
327
+ # ── Menu principal + listar/gerenciar ────────────────────────────────────────────
328
+
329
+ def ask_main_action() -> str:
330
+ return _ask_or_abort(
331
+ Q.select(
332
+ "O que deseja fazer?",
333
+ choices=[
334
+ Q.Choice("Criar usuário — com privilégio mínimo", value="create"),
335
+ Q.Choice("Listar usuários — SHOW USERS + grants", value="list"),
336
+ Q.Choice("Gerenciar usuário — desativar / reativar / excluir", value="manage"),
337
+ Q.Choice("Sair", value="exit"),
338
+ ],
339
+ default="create",
340
+ style=APP_STYLE, qmark="›",
341
+ instruction="(↑↓ navega · enter confirma)",
342
+ )
343
+ )
344
+
345
+
346
+ def _is_inactive(create_sql: str) -> bool:
347
+ return "HOST NONE" in (create_sql or "").upper()
348
+
349
+
350
+ def show_user_details(admin: ClickHouseAdmin, username: str) -> tuple[str, list[str]]:
351
+ """Busca CREATE + GRANTS e exibe. Retorna (create_sql, grants)."""
352
+ try:
353
+ create_sql = admin.show_create_user(username)
354
+ except Exception as e:
355
+ error(f"Falha ao ver detalhes de '{username}': {e}")
356
+ create_sql = ""
357
+ try:
358
+ grants = admin.show_grants(username)
359
+ except Exception as e:
360
+ error(f"Falha ao ver grants de '{username}': {e}")
361
+ grants = []
362
+
363
+ table = Table(title=f"Usuário {username}", show_header=True, header_style="bold cyan")
364
+ table.add_column("Campo", style="dim")
365
+ table.add_column("Valor", style="bold")
366
+ status = "inativo (HOST NONE)" if _is_inactive(create_sql) else "ativo"
367
+ table.add_row("Status", status)
368
+ table.add_row("Grants", "\n".join(grants) if grants else "(nenhum grant explícito)")
369
+ console.print(table)
370
+
371
+ if create_sql:
372
+ console.print("[dim]Definição atual:[/]")
373
+ from rich.panel import Panel as RichPanel
374
+
375
+ console.print(RichPanel(Syntax(create_sql + ";", "sql", theme="monokai"), border_style="dim"))
376
+ return create_sql, grants
377
+
378
+
379
+ def flow_list_users(admin: ClickHouseAdmin) -> None:
380
+ step("Usuários · Lista", "SHOW USERS — depois escolha um para ver grants.")
381
+ with console.status("[cyan]Listando usuários…[/]", spinner="dots"):
382
+ try:
383
+ users = admin.list_users()
384
+ except Exception as e:
385
+ error(f"Falha ao listar usuários: {e}")
386
+ info("Dica: sua conta precisa de SHOW USERS / acesso a system.users.")
387
+ return
388
+
389
+ if not users:
390
+ info("Nenhum usuário encontrado.")
391
+ return
392
+
393
+ table = Table(title=f"{len(users)} usuário(s)", show_header=True, header_style="bold cyan")
394
+ table.add_column("#", style="dim")
395
+ table.add_column("Usuário", style="bold")
396
+ for i, u in enumerate(users, 1):
397
+ table.add_row(str(i), u)
398
+ console.print(table)
399
+
400
+ see = _ask_or_abort(
401
+ Q.confirm("Ver detalhes (grants) de um usuário?", default=True, style=APP_STYLE, qmark="›")
402
+ )
403
+ if not see:
404
+ return
405
+ target = _ask_or_abort(
406
+ Q.select(
407
+ "Qual usuário?",
408
+ choices=users,
409
+ style=APP_STYLE, qmark="›",
410
+ instruction="(↑↓ navega · enter confirma)",
411
+ )
412
+ )
413
+ show_user_details(admin, target)
414
+
415
+
416
+ def flow_manage_users(admin: ClickHouseAdmin) -> None:
417
+ step("Usuários · Gerenciar", "Desativar = HOST NONE (reversível). Excluir = DROP USER (definitivo).")
418
+ with console.status("[cyan]Listando usuários…[/]", spinner="dots"):
419
+ try:
420
+ users = admin.list_users()
421
+ except Exception as e:
422
+ error(f"Falha ao listar usuários: {e}")
423
+ return
424
+
425
+ if not users:
426
+ info("Nenhum usuário encontrado.")
427
+ return
428
+
429
+ target = _ask_or_abort(
430
+ Q.select(
431
+ "Qual usuário gerenciar?",
432
+ choices=users,
433
+ style=APP_STYLE, qmark="›",
434
+ instruction="(↑↓ navega · enter confirma)",
435
+ )
436
+ )
437
+ create_sql, _grants = show_user_details(admin, target)
438
+ inactive = _is_inactive(create_sql)
439
+
440
+ if target == admin.conn.username:
441
+ error(f" Atenção: '{target}' é o usuário conectado agora. Desativar/excluir pode te derrubar.")
442
+
443
+ options = []
444
+ if inactive:
445
+ options.append(Q.Choice("Reativar — ALTER USER … HOST ANY", value="activate"))
446
+ else:
447
+ options.append(Q.Choice("Desativar — ALTER USER … HOST NONE (reversível)", value="deactivate"))
448
+ options += [
449
+ Q.Choice("Excluir — DROP USER (definitivo!)", value="drop"),
450
+ Q.Choice("Voltar ao menu", value="back"),
451
+ ]
452
+ action = _ask_or_abort(
453
+ Q.select("Ação:", choices=options, style=APP_STYLE, qmark="›")
454
+ )
455
+ if action == "back":
456
+ return
457
+
458
+ preview = {
459
+ "deactivate": build_deactivate_user_sql(target),
460
+ "activate": build_activate_user_sql(target),
461
+ "drop": build_drop_user_sql(target),
462
+ }[action]
463
+
464
+ console.print("[dim]SQL a executar:[/]")
465
+ from rich.panel import Panel as RichPanel
466
+
467
+ console.print(RichPanel(Syntax(preview + ";", "sql", theme="monokai"), border_style="dim"))
468
+
469
+ # Confirmação: default sempre o mais seguro (não executar).
470
+ label = {"deactivate": f"Desativar '{target}' agora?", "activate": f"Reativar '{target}' agora?", "drop": f"EXCLUIR '{target}' em definitivo?"}[action]
471
+ go = _ask_or_abort(Q.confirm(label, default=False, style=APP_STYLE, qmark="›"))
472
+ if not go:
473
+ console.print("[dim]Nada foi executado.[/]")
474
+ return
475
+
476
+ with console.status("[cyan]Executando…[/]", spinner="dots"):
477
+ try:
478
+ if action == "deactivate":
479
+ admin.deactivate_user(target)
480
+ elif action == "activate":
481
+ admin.activate_user(target)
482
+ else:
483
+ admin.drop_user(target)
484
+ except Exception as e:
485
+ error(f"Falha na operação: {e}")
486
+ info("Dica: verifique se sua conta tem ALTER USER / DROP USER.")
487
+ return
488
+ success({"deactivate": f"Usuário '{target}' desativado (HOST NONE).", "activate": f"Usuário '{target}' reativado (HOST ANY).", "drop": f"Usuário '{target}' excluído."}[action])
489
+
490
+
491
+ def flow_create_user(admin: ClickHouseAdmin) -> None:
492
+ profile = ask_profile()
493
+ username, password = ask_new_credentials()
494
+ databases = ask_databases(admin)
495
+ scopes = ask_tables(admin, databases)
496
+ privileges, grant_option = ask_privileges(profile)
497
+ if profile == "admin":
498
+ grant_option = True
499
+ hosts = ask_host_restriction()
500
+ if_not_exists = ask_create_options()
501
+
502
+ spec = UserSpec(
503
+ username=username,
504
+ password=password,
505
+ privileges=privileges,
506
+ scopes=scopes,
507
+ hosts=hosts,
508
+ if_not_exists=if_not_exists,
509
+ grant_option=grant_option,
510
+ )
511
+ confirm_and_execute(admin, spec)
512
+
513
+
514
+ # ── main ─────────────────────────────────────────────────────────────────────
515
+
516
+ def main() -> None:
517
+ try:
518
+ banner()
519
+ conn = ask_connection()
520
+ admin = connect_with_retry(conn)
521
+
522
+ while True:
523
+ action = ask_main_action()
524
+ if action == "create":
525
+ flow_create_user(admin)
526
+ elif action == "list":
527
+ flow_list_users(admin)
528
+ elif action == "manage":
529
+ flow_manage_users(admin)
530
+ else:
531
+ console.print("[dim]Até logo![/]")
532
+ return
533
+ except SystemExit:
534
+ raise
535
+ except KeyboardInterrupt:
536
+ console.print("\n[dim]Interrompido. Nada foi executado.[/]")
537
+ except Exception as e: # pragma: no cover — guarda-chuva final
538
+ error(f"Erro inesperado: {e}")
539
+ sys.exit(1)
540
+
541
+
542
+ if __name__ == "__main__":
543
+ main()
@@ -0,0 +1,110 @@
1
+ """Camada de acesso ao ClickHouse via clickhouse-connect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class ConnectionInfo:
10
+ host: str
11
+ port: int
12
+ secure: bool
13
+ username: str
14
+ password: str
15
+
16
+ @property
17
+ def protocol(self) -> str:
18
+ return "https" if self.secure else "http"
19
+
20
+
21
+ # Bancos internos que poluim a seleção — vão para o fim da lista, desmarcados.
22
+ SYSTEM_DATABASES = {"system", "information_schema", "INFORMATION_SCHEMA"}
23
+
24
+
25
+ class ClickHouseAdmin:
26
+ def __init__(self, conn: ConnectionInfo):
27
+ self.conn = conn
28
+ self._client = None
29
+
30
+ def connect(self):
31
+ """Cria cliente e valida com SELECT 1. Levanta exceção com msg amigável."""
32
+ try:
33
+ from clickhouse_connect import get_client
34
+ except ImportError as e: # pragma: no cover
35
+ raise RuntimeError(
36
+ "Dependência 'clickhouse-connect' não instalada. Rode: pip install -r requirements.txt"
37
+ ) from e
38
+
39
+ try:
40
+ self._client = get_client(
41
+ host=self.conn.host,
42
+ port=self.conn.port,
43
+ username=self.conn.username,
44
+ password=self.conn.password,
45
+ secure=self.conn.secure,
46
+ )
47
+ self._client.query("SELECT 1")
48
+ except Exception as e:
49
+ raise RuntimeError(f"Falha ao conectar em {self.conn.protocol}://{self.conn.host}:{self.conn.port}: {e}") from e
50
+ return self
51
+
52
+ @property
53
+ def client(self):
54
+ if self._client is None:
55
+ raise RuntimeError("Não conectado. Chame .connect() primeiro.")
56
+ return self._client
57
+
58
+ def list_databases(self) -> list[str]:
59
+ rows = self.client.query("SHOW DATABASES").result_rows
60
+ dbs = [r[0] for r in rows if r and r[0]]
61
+ # UX: bancos de usuário primeiro (alfabético), sistema por último.
62
+ user_dbs = sorted(d for d in dbs if d not in SYSTEM_DATABASES)
63
+ sys_dbs = sorted(d for d in dbs if d in SYSTEM_DATABASES)
64
+ return user_dbs + sys_dbs
65
+
66
+ def list_tables(self, database: str) -> list[str]:
67
+ # Escapa com backticks pois nome do banco vem do servidor, não do usuário.
68
+ rows = self.client.query(f"SHOW TABLES FROM `{database}`").result_rows
69
+ return sorted(r[0] for r in rows if r and r[0])
70
+
71
+ def list_users(self) -> list[str]:
72
+ rows = self.client.query("SHOW USERS").result_rows
73
+ return sorted(r[0] for r in rows if r and r[0])
74
+
75
+ def show_grants(self, username: str) -> list[str]:
76
+ from clickhouse_users_cli.sql_builder import build_show_grants_sql
77
+
78
+ rows = self.client.query(build_show_grants_sql(username)).result_rows
79
+ return [r[0] for r in rows if r and r[0]]
80
+
81
+ def show_create_user(self, username: str) -> str:
82
+ from clickhouse_users_cli.sql_builder import build_show_create_user_sql
83
+
84
+ rows = self.client.query(build_show_create_user_sql(username)).result_rows
85
+ return rows[0][0] if rows and rows[0] else ""
86
+
87
+ def deactivate_user(self, username: str) -> str:
88
+ from clickhouse_users_cli.sql_builder import build_deactivate_user_sql
89
+
90
+ sql = build_deactivate_user_sql(username)
91
+ self.client.command(sql)
92
+ return sql
93
+
94
+ def activate_user(self, username: str) -> str:
95
+ from clickhouse_users_cli.sql_builder import build_activate_user_sql
96
+
97
+ sql = build_activate_user_sql(username)
98
+ self.client.command(sql)
99
+ return sql
100
+
101
+ def drop_user(self, username: str) -> str:
102
+ from clickhouse_users_cli.sql_builder import build_drop_user_sql
103
+
104
+ sql = build_drop_user_sql(username)
105
+ self.client.command(sql)
106
+ return sql
107
+
108
+ def execute_statements(self, statements: list[str]) -> None:
109
+ for sql in statements:
110
+ self.client.command(sql)
@@ -0,0 +1,116 @@
1
+ """Construção do SQL DDL/DCL — separada do fluxo interativo para testar sem banco."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ def escape_ident(name: str) -> str:
9
+ """Escapa identificador com backticks: `nome`."""
10
+ return "`" + name.replace("`", "``") + "`"
11
+
12
+
13
+ def escape_string(value: str) -> str:
14
+ """Escapa literal de string com aspas simples."""
15
+ return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
16
+
17
+
18
+ def escape_host(value: str) -> str:
19
+ # HOST em CREATE USER aceita IP/hostname; aspas simples são o seguro.
20
+ return escape_string(value)
21
+
22
+
23
+ @dataclass
24
+ class GrantScope:
25
+ """Um alvo de GRANT: (database, table|None, table|*)."""
26
+
27
+ database: str # "*" = todas
28
+ table: str | None = None # None = db.* | "*" = *.* quando database == "*"
29
+
30
+ def on_clause(self) -> str:
31
+ if self.database == "*":
32
+ return "*.*"
33
+ if self.table in (None, "*"):
34
+ return f"{escape_ident(self.database)}.*"
35
+ return f"{escape_ident(self.database)}.{escape_ident(self.table)}"
36
+
37
+
38
+ @dataclass
39
+ class UserSpec:
40
+ username: str
41
+ password: str # plaintext na criação; ClickHouse guarda o hash
42
+ privileges: list[str] = field(default_factory=list) # ex.: ["SELECT", "SHOW"]
43
+ scopes: list[GrantScope] = field(default_factory=list)
44
+ hosts: list[str] = field(default_factory=list) # vazio = qualquer host
45
+ if_not_exists: bool = True
46
+ grant_option: bool = False # WITH GRANT OPTION (só admin)
47
+ # Quota futura: ex. "MAX QUERIES PER HOUR 1000" — mantido para evoluir sem quebrar API.
48
+
49
+
50
+ # Templates de perfil → privilégios padrão. "custom" deixa o usuário marcar.
51
+ PROFILE_PRIVILEGES: dict[str, list[str]] = {
52
+ "readonly": ["SHOW", "SELECT"],
53
+ "readwrite": ["SHOW", "SELECT", "INSERT"],
54
+ "admin": ["ALL"],
55
+ "custom": [],
56
+ }
57
+
58
+ PROFILE_LABELS: dict[str, str] = {
59
+ "readonly": "Somente leitura — SELECT + SHOW (analistas, BI)",
60
+ "readwrite": "Leitura + escrita — SELECT + SHOW + INSERT (engenheiros)",
61
+ "admin": "Administrador — ALL com GRANT OPTION (acesso total!)",
62
+ "custom": "Personalizado — eu escolho os privilégios",
63
+ }
64
+
65
+
66
+ def build_create_user_sql(spec: UserSpec) -> str:
67
+ ine = "IF NOT EXISTS " if spec.if_not_exists else ""
68
+ parts = [f"CREATE USER {ine}{escape_ident(spec.username)}"]
69
+ parts.append(f"IDENTIFIED WITH plaintext_password BY {escape_string(spec.password)}")
70
+ if spec.hosts:
71
+ hosts = ", ".join(f"HOST {escape_host(h)}" for h in spec.hosts)
72
+ parts.append(hosts)
73
+ else:
74
+ parts.append("HOST ANY")
75
+ return " ".join(parts)
76
+
77
+
78
+ def build_grant_sql(spec: UserSpec) -> list[str]:
79
+ if not spec.scopes:
80
+ raise ValueError("Nenhum escopo (banco/tabela) selecionado.")
81
+ if not spec.privileges:
82
+ raise ValueError("Nenhum privilégio selecionado.")
83
+ privs = ", ".join(spec.privileges)
84
+ stmts: list[str] = []
85
+ for scope in spec.scopes:
86
+ suffix = " WITH GRANT OPTION" if spec.grant_option else ""
87
+ stmts.append(f"GRANT {privs} ON {scope.on_clause()} TO {escape_ident(spec.username)}{suffix}")
88
+ return stmts
89
+
90
+
91
+ def build_all_statements(spec: UserSpec) -> list[str]:
92
+ return [build_create_user_sql(spec), *build_grant_sql(spec)]
93
+
94
+
95
+ def build_show_grants_sql(username: str) -> str:
96
+ return f"SHOW GRANTS FOR {escape_ident(username)}"
97
+
98
+
99
+ def build_show_create_user_sql(username: str) -> str:
100
+ return f"SHOW CREATE USER {escape_ident(username)}"
101
+
102
+
103
+ def build_deactivate_user_sql(username: str) -> str:
104
+ """Desativa o login sem excluir: bloqueia qualquer origem (reversível)."""
105
+ return f"ALTER USER {escape_ident(username)} HOST NONE"
106
+
107
+
108
+ def build_activate_user_sql(username: str) -> str:
109
+ """Reativa o login: permite qualquer origem."""
110
+ return f"ALTER USER {escape_ident(username)} HOST ANY"
111
+
112
+
113
+ def build_drop_user_sql(username: str, if_exists: bool = True) -> str:
114
+ """Exclui o usuário em definitivo."""
115
+ ine = "IF EXISTS " if if_exists else ""
116
+ return f"DROP USER {ine}{escape_ident(username)}"
@@ -0,0 +1,55 @@
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}[/]")
@@ -0,0 +1,57 @@
1
+ """Validadores semânticos para cada tipo de input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ USERNAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
8
+ HOST_RE = re.compile(
9
+ r"^([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*|localhost|\d{1,3}(\.\d{1,3}){3})$"
10
+ )
11
+
12
+
13
+ def validate_required(value: str) -> bool | str:
14
+ if value and value.strip():
15
+ return True
16
+ return "Valor obrigatório — digite algo para continuar."
17
+
18
+
19
+ def validate_host(value: str) -> bool | str:
20
+ if not value.strip():
21
+ return "Informe o host (ex.: localhost ou 10.0.0.5)."
22
+ if not HOST_RE.match(value.strip()):
23
+ return "Host inválido. Ex.: localhost, db.empresa.com, 10.0.0.5"
24
+ return True
25
+
26
+
27
+ def validate_port(value: str) -> bool | str:
28
+ v = value.strip()
29
+ if not v.isdigit():
30
+ return "Porta deve ser numérica. Ex.: 8123 (http) ou 8443 (https)."
31
+ n = int(v)
32
+ if not 1 <= n <= 65535:
33
+ return "Porta fora do intervalo 1–65535."
34
+ return True
35
+
36
+
37
+ def validate_username(value: str) -> bool | str:
38
+ v = value.strip()
39
+ if not USERNAME_RE.match(v):
40
+ return "Use letras, números e _ , começando com letra ou _. Ex.: analyst_julho"
41
+ if v.lower() in {"default", "root", "admin", "system"}:
42
+ return f"'{v}' é reservado — escolha outro nome."
43
+ return True
44
+
45
+
46
+ def validate_new_password(value: str) -> bool | str:
47
+ if len(value) < 8:
48
+ return "Mínimo 8 caracteres (recomendado 12+ com letras + números)."
49
+ if len(value) > 128:
50
+ return "Máximo 128 caracteres."
51
+ return True
52
+
53
+
54
+ def validate_at_least_one(selection: list) -> bool | str:
55
+ if selection:
56
+ return True
57
+ return "Selecione pelo menos 1 item (espaço marca, enter confirma)."
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: clickhouse-users-cli
3
+ Version: 0.1.0
4
+ Summary: CLI interativa para criar usuarios ClickHouse com privilegio minimo (least privilege)
5
+ License-Expression: MIT
6
+ Keywords: clickhouse,cli,users,access-control,least-privilege
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Database
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: questionary>=2.0.0
20
+ Requires-Dist: clickhouse-connect>=0.8.0
21
+ Requires-Dist: rich>=13.0.0
22
+ Dynamic: license-file
23
+
24
+ # ClickHouse Users CLI
25
+
26
+ CLI interativa em Python com [`questionary`](https://questionary.readthedocs.io/) para criar usuários no ClickHouse com **privilégio mínimo** (least privilege): pergunta credenciais, lista bancos, lista tabelas, define perfil e gera `CREATE USER` + `GRANT`s com preview antes de executar.
27
+
28
+ ## Instalar
29
+
30
+ ```powershell
31
+ pip install clickhouse-users-cli
32
+ ch-users
33
+ ```
34
+
35
+ ## Rodar do código-fonte
36
+
37
+ ```powershell
38
+ pip install -r requirements.txt
39
+ python main.py
40
+ ```
41
+
42
+ Requer uma conta admin (ex.: `default`) com permissão `CREATE USER` + `GRANT`.
43
+ Para listar/gerenciar também precisa de `SHOW USERS` + `ALTER USER` / `DROP USER`.
44
+
45
+ ## Menu
46
+
47
+ Após conectar, escolha: **Criar usuário** · **Listar usuários** · **Gerenciar usuário** · **Sair**.
48
+ O menu fica em loop — dá para criar, auditar e desativar vários sem reconectar.
49
+
50
+ ## Fluxo — criar (6 etapas)
51
+
52
+ 1. **Conexão** — host, protocolo, porta, usuário/senha admin. Testa com `SELECT 1` e permite tentar de novo / editar / sair.
53
+ 2. **Tipo de usuário** — perfil (`readonly`, `readwrite`, `admin`, `custom`).
54
+ 3. **Novo usuário** — nome validado + senha com confirmação.
55
+ 4. **Escopo** — `SHOW DATABASES` → checkbox de bancos → `SHOW TABLES FROM` por banco → checkbox de tabelas (com opção `banco.*`).
56
+ 5. **Privilégios + origem** — se `custom`, checkbox de privilégios; depois `HOST ANY | LOCALHOST | IPs`.
57
+ 6. **Revisão** — tabela resumo (Rich) + SQL com syntax highlight + confirmação final.
58
+
59
+ Exemplo do SQL gerado:
60
+
61
+ ```sql
62
+ CREATE USER IF NOT EXISTS `analyst_julho` IDENTIFIED WITH plaintext_password BY '***' HOST ANY;
63
+ GRANT SHOW, SELECT ON `vendas`.`pedidos` TO `analyst_julho`;
64
+ GRANT SHOW, SELECT ON `vendas`.`clientes` TO `analyst_julho`;
65
+ ```
66
+
67
+ ## Fluxo — listar / gerenciar
68
+
69
+ - **Listar** — `SHOW USERS` em tabela + opção de ver `SHOW CREATE USER` e `SHOW GRANTS FOR` de um usuário.
70
+ - **Gerenciar** — escolhe 1 usuário, vê status (`HOST NONE` = inativo) + grants, depois:
71
+ - **Desativar** → `ALTER USER \`nome\` HOST NONE` (reversível, bloqueia login);
72
+ - **Reativar** → `ALTER USER \`nome\` HOST ANY`;
73
+ - **Excluir** → `DROP USER IF EXISTS \`nome\`` (definitivo).
74
+ - Todo DDL mostra preview + confirmação com default `não`. Mexer no próprio usuário logado gera alerta extra.
75
+
76
+ ## Por que cada widget?
77
+
78
+ | Dado | Widget | Motivo semântico / UX |
79
+ |---|---|---|
80
+ | Host, porta, nomes, IPs | `text` + `validate` + `default` | Entrada livre com validação imediata e exemplo; `default` acelera o caminho feliz (localhost:8123) |
81
+ | Senhas (admin e nova) | `password` (mascarado) | Não ecoa segredo; com validação de tamanho e dupla confirmação no novo usuário |
82
+ | Protocolo (http/https) | `select` | Escolha única em lista pequena; descrição já indica a porta padrão |
83
+ | Perfil | `select` com descrição | 4 opções mutuamente exclusivas; descrição diz o privilégio e o público (analista, engenheiro…) |
84
+ | Bancos, tabelas, privilégios custom | `checkbox` + validação mín. 1 | Multi-seleção com `espaço`; instrução de teclado visível; `banco.*` evita marcar 100 tabelas |
85
+ | Confirmações (IF NOT EXISTS, GRANT OPTION, DROP, executar) | `confirm` com default seguro | Yes/No explícito; default é sempre a opção menos destrutiva |
86
+ | Progresso e erros | `rich.status` (spinner) + `Rule` por etapa | Feedback durante `SHOW DATABASES` / DDL; cabeçalho `1/6…6/6` orienta "onde estou" |
87
+ | Revisão | `rich.table` + `rich.syntax` | Resumo legível + SQL exato que será executado — sem "caixa-preta" |
88
+
89
+ Decisões de UX:
90
+
91
+ - **Sistemas por último e desmarcados** (`system`, `information_schema`) para evitar `GRANT` acidental.
92
+ - **Ctrl+C = saída limpa**, nada executa sem confirmação final.
93
+ - **SQL separado da UI** (`clickhouse_users_cli/sql_builder.py`) — dá para testar sem banco.
94
+ - **Escape correto** de identificadores (backticks) e strings (aspas simples).
95
+ - **Perfis com defaults seguros**: `readonly → SHOW+SELECT`, `readwrite → +INSERT`, `admin → ALL + GRANT OPTION`, `custom → você escolhe` (com confirmação extra para `DROP`/`TRUNCATE`).
96
+
97
+ ## Estrutura
98
+
99
+ ```
100
+ main.py # entrypoint (dev local)
101
+ requirements.txt
102
+ pyproject.toml # build + metadados PyPI (comando: ch-users)
103
+ clickhouse_users_cli/
104
+ __init__.py # __version__ (fonte única da versão)
105
+ app.py # fluxo questionary (6 etapas)
106
+ db.py # clickhouse-connect: connect, list_databases/tables, execute
107
+ sql_builder.py # CREATE USER / GRANT puros (testável)
108
+ validators.py # validações por tipo de input
109
+ style.py # Style questionary + banner/tabela Rich
110
+ ```
111
+
112
+ ## Perfis
113
+
114
+ - `readonly`: `SHOW, SELECT` — BI, analistas.
115
+ - `readwrite`: `SHOW, SELECT, INSERT` — engenheiros, ingestão.
116
+ - `admin`: `ALL ... WITH GRANT OPTION` em `*.*` — cuidado!
117
+ - `custom`: `SELECT/SHOW/INSERT/CREATE/ALTER/DROP/TRUNCATE/OPTIMIZE/KILL QUERY` à escolha.
@@ -0,0 +1,12 @@
1
+ clickhouse_users_cli/__init__.py,sha256=jxMQ7QW-Y91tK4ToZ0GFN6QLUNjjtsrZzC1jNa1ibkQ,134
2
+ clickhouse_users_cli/app.py,sha256=Dj258tLQnnW-2Pr2SansVORhARVhiFRiIHKq3-dgnok,21130
3
+ clickhouse_users_cli/db.py,sha256=3X6FmRSwdsJuGTPguQZEmEmI6LRHrjH6jsaWlFczFYo,3871
4
+ clickhouse_users_cli/sql_builder.py,sha256=fnHSwxOi45zZPEOYJQdqD2C92aWYVOmKuRluYmE39AI,4058
5
+ clickhouse_users_cli/style.py,sha256=aTjmQmXOmUy1uPkP2qSRfO5J7PhuQVuT997-qWQc5XI,1689
6
+ clickhouse_users_cli/validators.py,sha256=3A_FyOfQxJFkjtJl3rZIj1ZQT2rGUjs_7npPAEjIHtQ,1771
7
+ clickhouse_users_cli-0.1.0.dist-info/licenses/LICENSE,sha256=PB9Ow4AGAsl3OiFMkuVOfhtZxzUbzCvFRhLQaBwDdU4,1070
8
+ clickhouse_users_cli-0.1.0.dist-info/METADATA,sha256=wFb_IEg-rMNg5pfj4Q6kvqgi2u2FRfrz_rOm0ZFziwM,5976
9
+ clickhouse_users_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ clickhouse_users_cli-0.1.0.dist-info/entry_points.txt,sha256=Xc-CYZF_4Dq5ChZP_Cy0NE_R3g3QHvSwvfMBIPtgKtY,59
11
+ clickhouse_users_cli-0.1.0.dist-info/top_level.txt,sha256=CRnjGzZQwzXzoLn82wAgVaYiyaxKHPWRAzXRuBwXz4c,21
12
+ clickhouse_users_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ch-users = clickhouse_users_cli.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TODO-SEU-NOME
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ clickhouse_users_cli