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.
- clickhouse_users_cli/__init__.py +5 -0
- clickhouse_users_cli/app.py +543 -0
- clickhouse_users_cli/db.py +110 -0
- clickhouse_users_cli/sql_builder.py +116 -0
- clickhouse_users_cli/style.py +55 -0
- clickhouse_users_cli/validators.py +57 -0
- clickhouse_users_cli-0.1.0.dist-info/METADATA +117 -0
- clickhouse_users_cli-0.1.0.dist-info/RECORD +12 -0
- clickhouse_users_cli-0.1.0.dist-info/WHEEL +5 -0
- clickhouse_users_cli-0.1.0.dist-info/entry_points.txt +2 -0
- clickhouse_users_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- clickhouse_users_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|