kctl-supa 0.6.3__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.
- kctl_supa/__init__.py +3 -0
- kctl_supa/__main__.py +5 -0
- kctl_supa/cli.py +169 -0
- kctl_supa/commands/__init__.py +1 -0
- kctl_supa/commands/advisors_cmd.py +239 -0
- kctl_supa/commands/auth_cmd.py +276 -0
- kctl_supa/commands/backup_cmd.py +126 -0
- kctl_supa/commands/config_cmd.py +225 -0
- kctl_supa/commands/cron_cmd.py +108 -0
- kctl_supa/commands/dashboard_cmd.py +101 -0
- kctl_supa/commands/db_cmd.py +229 -0
- kctl_supa/commands/deploy_cmd.py +123 -0
- kctl_supa/commands/doctor_cmd.py +289 -0
- kctl_supa/commands/functions_cmd.py +147 -0
- kctl_supa/commands/health_cmd.py +100 -0
- kctl_supa/commands/integrations_cmd.py +85 -0
- kctl_supa/commands/logs_cmd.py +115 -0
- kctl_supa/commands/maintenance_cmd.py +121 -0
- kctl_supa/commands/migrate_cmd.py +128 -0
- kctl_supa/commands/monitor_cmd.py +127 -0
- kctl_supa/commands/publications_cmd.py +113 -0
- kctl_supa/commands/queues_cmd.py +164 -0
- kctl_supa/commands/realtime_cmd.py +72 -0
- kctl_supa/commands/security_cmd.py +146 -0
- kctl_supa/commands/settings_cmd.py +114 -0
- kctl_supa/commands/skill_cmd.py +57 -0
- kctl_supa/commands/status_cmd.py +79 -0
- kctl_supa/commands/storage_cmd.py +255 -0
- kctl_supa/commands/upgrade_cmd.py +258 -0
- kctl_supa/commands/vault_cmd.py +101 -0
- kctl_supa/core/__init__.py +1 -0
- kctl_supa/core/callbacks.py +26 -0
- kctl_supa/core/client.py +203 -0
- kctl_supa/core/config.py +156 -0
- kctl_supa/core/docker.py +250 -0
- kctl_supa/core/exceptions.py +56 -0
- kctl_supa-0.6.3.dist-info/METADATA +18 -0
- kctl_supa-0.6.3.dist-info/RECORD +40 -0
- kctl_supa-0.6.3.dist-info/WHEEL +4 -0
- kctl_supa-0.6.3.dist-info/entry_points.txt +2 -0
kctl_supa/__init__.py
ADDED
kctl_supa/__main__.py
ADDED
kctl_supa/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-supa."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from kctl_lib import handle_cli_error
|
|
9
|
+
|
|
10
|
+
from kctl_supa import __version__
|
|
11
|
+
from kctl_supa.commands.advisors_cmd import app as advisors_app
|
|
12
|
+
from kctl_supa.commands.auth_cmd import app as auth_app
|
|
13
|
+
from kctl_supa.commands.backup_cmd import app as backup_app
|
|
14
|
+
from kctl_supa.commands.config_cmd import app as config_app
|
|
15
|
+
from kctl_supa.commands.cron_cmd import app as cron_app
|
|
16
|
+
from kctl_supa.commands.dashboard_cmd import app as dashboard_app
|
|
17
|
+
from kctl_supa.commands.db_cmd import app as db_app
|
|
18
|
+
from kctl_supa.commands.deploy_cmd import app as deploy_app
|
|
19
|
+
from kctl_supa.commands.doctor_cmd import app as doctor_app
|
|
20
|
+
from kctl_supa.commands.functions_cmd import app as functions_app
|
|
21
|
+
from kctl_supa.commands.health_cmd import app as health_app
|
|
22
|
+
from kctl_supa.commands.integrations_cmd import app as integrations_app
|
|
23
|
+
from kctl_supa.commands.logs_cmd import app as logs_app
|
|
24
|
+
from kctl_supa.commands.maintenance_cmd import app as maintenance_app
|
|
25
|
+
from kctl_supa.commands.migrate_cmd import app as migrate_app
|
|
26
|
+
from kctl_supa.commands.monitor_cmd import app as monitor_app
|
|
27
|
+
from kctl_supa.commands.publications_cmd import app as publications_app
|
|
28
|
+
from kctl_supa.commands.queues_cmd import app as queues_app
|
|
29
|
+
from kctl_supa.commands.realtime_cmd import app as realtime_app
|
|
30
|
+
from kctl_supa.commands.security_cmd import app as security_app
|
|
31
|
+
from kctl_supa.commands.settings_cmd import app as settings_app
|
|
32
|
+
from kctl_supa.commands.skill_cmd import app as skill_app
|
|
33
|
+
from kctl_supa.commands.status_cmd import app as status_app
|
|
34
|
+
from kctl_supa.commands.storage_cmd import app as storage_app
|
|
35
|
+
from kctl_supa.commands.upgrade_cmd import app as upgrade_app
|
|
36
|
+
from kctl_supa.commands.vault_cmd import app as vault_app
|
|
37
|
+
from kctl_supa.core.callbacks import AppContext
|
|
38
|
+
from kctl_supa.core.exceptions import SupabaseError
|
|
39
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
40
|
+
from kctl_lib.tui import add_tui_command
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def version_callback(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
typer.echo(f"kctl-supa {__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(
|
|
50
|
+
name="kctl-supa",
|
|
51
|
+
help="Kodemeio Supabase CLI - manage self-hosted Supabase instances.",
|
|
52
|
+
no_args_is_help=True,
|
|
53
|
+
rich_markup_mode="rich",
|
|
54
|
+
pretty_exceptions_enable=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.callback()
|
|
59
|
+
def main(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
62
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
63
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
64
|
+
url: Annotated[str | None, typer.Option("--url", help="Supabase URL override")] = None,
|
|
65
|
+
version: Annotated[
|
|
66
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
67
|
+
] = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Kodemeio Supabase CLI."""
|
|
70
|
+
ctx.ensure_object(dict)
|
|
71
|
+
ctx.obj = AppContext(
|
|
72
|
+
json_mode=json_output,
|
|
73
|
+
quiet=quiet,
|
|
74
|
+
profile=profile,
|
|
75
|
+
url_override=url,
|
|
76
|
+
)
|
|
77
|
+
notify_if_outdated(ctx.obj.output, "kctl-supa", __version__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_P_ADMIN = "Admin & Config"
|
|
81
|
+
app.add_typer(config_app, name="config", rich_help_panel=_P_ADMIN)
|
|
82
|
+
app.add_typer(security_app, name="security", rich_help_panel=_P_ADMIN)
|
|
83
|
+
app.add_typer(doctor_app, name="doctor", rich_help_panel=_P_ADMIN)
|
|
84
|
+
app.add_typer(vault_app, name="vault", rich_help_panel=_P_ADMIN)
|
|
85
|
+
app.add_typer(integrations_app, name="integrations", rich_help_panel=_P_ADMIN)
|
|
86
|
+
app.add_typer(settings_app, name="settings", rich_help_panel=_P_ADMIN)
|
|
87
|
+
|
|
88
|
+
_P_SERVICES = "Services"
|
|
89
|
+
app.add_typer(health_app, name="health", rich_help_panel=_P_SERVICES)
|
|
90
|
+
app.add_typer(status_app, name="status", rich_help_panel=_P_SERVICES)
|
|
91
|
+
app.add_typer(dashboard_app, name="dashboard", rich_help_panel=_P_SERVICES)
|
|
92
|
+
app.add_typer(monitor_app, name="monitor", rich_help_panel=_P_SERVICES)
|
|
93
|
+
app.add_typer(advisors_app, name="advisors", rich_help_panel=_P_SERVICES)
|
|
94
|
+
|
|
95
|
+
_P_DATABASE = "Database"
|
|
96
|
+
app.add_typer(db_app, name="db", rich_help_panel=_P_DATABASE)
|
|
97
|
+
app.add_typer(backup_app, name="backup", rich_help_panel=_P_DATABASE)
|
|
98
|
+
app.add_typer(maintenance_app, name="maintenance", rich_help_panel=_P_DATABASE)
|
|
99
|
+
app.add_typer(migrate_app, name="migrate", rich_help_panel=_P_DATABASE)
|
|
100
|
+
app.add_typer(cron_app, name="cron", rich_help_panel=_P_DATABASE)
|
|
101
|
+
app.add_typer(queues_app, name="queues", rich_help_panel=_P_DATABASE)
|
|
102
|
+
|
|
103
|
+
_P_AUTH = "Auth & Users"
|
|
104
|
+
app.add_typer(auth_app, name="auth", rich_help_panel=_P_AUTH)
|
|
105
|
+
|
|
106
|
+
_P_STORAGE = "Storage & Files"
|
|
107
|
+
app.add_typer(storage_app, name="storage", rich_help_panel=_P_STORAGE)
|
|
108
|
+
|
|
109
|
+
_P_RT = "Realtime & Functions"
|
|
110
|
+
app.add_typer(realtime_app, name="realtime", rich_help_panel=_P_RT)
|
|
111
|
+
app.add_typer(functions_app, name="functions", rich_help_panel=_P_RT)
|
|
112
|
+
app.add_typer(publications_app, name="publications", rich_help_panel=_P_RT)
|
|
113
|
+
|
|
114
|
+
_P_OPS = "Operations"
|
|
115
|
+
app.add_typer(logs_app, name="logs", rich_help_panel=_P_OPS)
|
|
116
|
+
app.add_typer(deploy_app, name="deploy", rich_help_panel=_P_OPS)
|
|
117
|
+
app.add_typer(upgrade_app, name="upgrade", rich_help_panel=_P_OPS)
|
|
118
|
+
|
|
119
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
120
|
+
add_tui_command(app, service_key="supa", version=__version__)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command("self-update")
|
|
124
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
125
|
+
"""Check for updates and upgrade kctl-supa."""
|
|
126
|
+
actx = ctx.obj
|
|
127
|
+
out = actx.output
|
|
128
|
+
from kctl_lib.self_update import check_update
|
|
129
|
+
from kctl_lib.self_update import update as do_update
|
|
130
|
+
|
|
131
|
+
latest = check_update("kctl-supa", __version__)
|
|
132
|
+
if latest:
|
|
133
|
+
out.info(f"Updating to {latest}...")
|
|
134
|
+
do_update("kctl-supa")
|
|
135
|
+
out.success(f"Updated to {latest}")
|
|
136
|
+
else:
|
|
137
|
+
out.success("Already up to date")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def completions(
|
|
142
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
143
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Generate or install shell completions."""
|
|
146
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
147
|
+
|
|
148
|
+
if install:
|
|
149
|
+
path = install_completions("kctl-supa", shell)
|
|
150
|
+
if path:
|
|
151
|
+
typer.echo(f"Completions installed to {path}")
|
|
152
|
+
else:
|
|
153
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
else:
|
|
156
|
+
script = get_completion_script("kctl-supa", shell)
|
|
157
|
+
typer.echo(script)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run() -> None:
|
|
161
|
+
"""Entry point with error handling."""
|
|
162
|
+
try:
|
|
163
|
+
app()
|
|
164
|
+
except SupabaseError as e:
|
|
165
|
+
handle_cli_error(e)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
_run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules for kctl-supa."""
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Security and performance advisor commands for kctl-supa."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from kctl_supa.core.callbacks import AppContext
|
|
10
|
+
from kctl_supa.core.docker import DockerOps
|
|
11
|
+
from kctl_supa.core.exceptions import DockerError
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Security and performance advisors.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_docker(actx: AppContext) -> DockerOps:
|
|
17
|
+
return DockerOps(actx.config)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_psql(actx: AppContext, query: str) -> str:
|
|
21
|
+
out = actx.output
|
|
22
|
+
try:
|
|
23
|
+
docker = _get_docker(actx)
|
|
24
|
+
result = docker.psql(query)
|
|
25
|
+
docker.close()
|
|
26
|
+
return result
|
|
27
|
+
except DockerError as exc:
|
|
28
|
+
out.error(str(exc))
|
|
29
|
+
raise typer.Exit(1) from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_last_value(raw: str) -> str:
|
|
33
|
+
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
|
34
|
+
return lines[-1] if lines else "N/A"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def security(ctx: typer.Context) -> None:
|
|
39
|
+
"""Security audit: tables without RLS, public schemas, superuser roles."""
|
|
40
|
+
actx: AppContext = ctx.obj
|
|
41
|
+
|
|
42
|
+
docker = _get_docker(actx)
|
|
43
|
+
checks: list[tuple[str, str, str]] = []
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
no_rls = docker.psql(
|
|
47
|
+
"SELECT count(*) FROM pg_tables "
|
|
48
|
+
"WHERE schemaname = 'public' AND tablename NOT IN ("
|
|
49
|
+
"SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public'"
|
|
50
|
+
")",
|
|
51
|
+
)
|
|
52
|
+
val = _extract_last_value(no_rls)
|
|
53
|
+
n = int(val) if val.isdigit() else 0
|
|
54
|
+
checks.append(("Tables without RLS", val, "[red]WARN[/red]" if n > 0 else "[green]OK[/green]"))
|
|
55
|
+
except DockerError:
|
|
56
|
+
checks.append(("Tables without RLS", "error", "[red]ERROR[/red]"))
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
superusers = docker.psql("SELECT count(*) FROM pg_roles WHERE rolsuper = true")
|
|
60
|
+
val = _extract_last_value(superusers)
|
|
61
|
+
checks.append(("Superuser roles", val, "[yellow]INFO[/yellow]"))
|
|
62
|
+
except DockerError:
|
|
63
|
+
checks.append(("Superuser roles", "error", "[red]ERROR[/red]"))
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
exposed = docker.psql(
|
|
67
|
+
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'",
|
|
68
|
+
)
|
|
69
|
+
val = _extract_last_value(exposed)
|
|
70
|
+
checks.append(("Public schema tables", val, "[yellow]INFO[/yellow]"))
|
|
71
|
+
except DockerError:
|
|
72
|
+
checks.append(("Public schema tables", "error", "[red]ERROR[/red]"))
|
|
73
|
+
|
|
74
|
+
docker.close()
|
|
75
|
+
|
|
76
|
+
table = Table(title="Security Advisor", show_header=True, header_style="bold cyan")
|
|
77
|
+
table.add_column("Check", style="cyan")
|
|
78
|
+
table.add_column("Value")
|
|
79
|
+
table.add_column("Status")
|
|
80
|
+
for check_name, value, status in checks:
|
|
81
|
+
table.add_row(check_name, value, status)
|
|
82
|
+
rprint(table)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def performance(ctx: typer.Context) -> None:
|
|
87
|
+
"""Performance advisor: cache hit ratio, unused indexes, table bloat."""
|
|
88
|
+
actx: AppContext = ctx.obj
|
|
89
|
+
|
|
90
|
+
docker = _get_docker(actx)
|
|
91
|
+
checks: list[tuple[str, str, str]] = []
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
cache = docker.psql(
|
|
95
|
+
"SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit) + sum(blks_read), 0), 2) FROM pg_stat_database",
|
|
96
|
+
)
|
|
97
|
+
val = _extract_last_value(cache)
|
|
98
|
+
try:
|
|
99
|
+
pct = float(val)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pct = 0
|
|
102
|
+
status = "[green]OK[/green]" if pct >= 99 else "[yellow]WARN[/yellow]" if pct >= 95 else "[red]LOW[/red]"
|
|
103
|
+
checks.append(("Cache hit ratio", f"{val}%", status))
|
|
104
|
+
except DockerError:
|
|
105
|
+
checks.append(("Cache hit ratio", "error", "[red]ERROR[/red]"))
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
unused = docker.psql(
|
|
109
|
+
"SELECT count(*) FROM pg_stat_user_indexes WHERE idx_scan = 0",
|
|
110
|
+
)
|
|
111
|
+
val = _extract_last_value(unused)
|
|
112
|
+
n = int(val) if val.isdigit() else 0
|
|
113
|
+
status = "[green]OK[/green]" if n == 0 else "[yellow]WARN[/yellow]"
|
|
114
|
+
checks.append(("Unused indexes", val, status))
|
|
115
|
+
except DockerError:
|
|
116
|
+
checks.append(("Unused indexes", "error", "[red]ERROR[/red]"))
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
dead = docker.psql(
|
|
120
|
+
"SELECT sum(n_dead_tup) FROM pg_stat_user_tables",
|
|
121
|
+
)
|
|
122
|
+
val = _extract_last_value(dead)
|
|
123
|
+
try:
|
|
124
|
+
n = int(val)
|
|
125
|
+
except ValueError:
|
|
126
|
+
n = 0
|
|
127
|
+
status = "[green]OK[/green]" if n < 10000 else "[yellow]WARN[/yellow]" if n < 100000 else "[red]HIGH[/red]"
|
|
128
|
+
checks.append(("Dead tuples", val, status))
|
|
129
|
+
except DockerError:
|
|
130
|
+
checks.append(("Dead tuples", "error", "[red]ERROR[/red]"))
|
|
131
|
+
|
|
132
|
+
docker.close()
|
|
133
|
+
|
|
134
|
+
table = Table(title="Performance Advisor", show_header=True, header_style="bold cyan")
|
|
135
|
+
table.add_column("Check", style="cyan")
|
|
136
|
+
table.add_column("Value")
|
|
137
|
+
table.add_column("Status")
|
|
138
|
+
for check_name, value, status in checks:
|
|
139
|
+
table.add_row(check_name, value, status)
|
|
140
|
+
rprint(table)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def queries(ctx: typer.Context) -> None:
|
|
145
|
+
"""Query performance: top queries by total time (requires pg_stat_statements)."""
|
|
146
|
+
actx: AppContext = ctx.obj
|
|
147
|
+
out = actx.output
|
|
148
|
+
check = _run_psql(actx, "SELECT count(*) FROM pg_extension WHERE extname = 'pg_stat_statements'")
|
|
149
|
+
if check.strip().splitlines()[-1].strip() == "0":
|
|
150
|
+
out.warn("pg_stat_statements not installed. Enable it: CREATE EXTENSION pg_stat_statements;")
|
|
151
|
+
return
|
|
152
|
+
result = _run_psql(
|
|
153
|
+
actx,
|
|
154
|
+
"SELECT calls, round(total_exec_time::numeric, 2) AS total_ms, "
|
|
155
|
+
"round(mean_exec_time::numeric, 2) AS mean_ms, "
|
|
156
|
+
"left(query, 80) AS query "
|
|
157
|
+
"FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20",
|
|
158
|
+
)
|
|
159
|
+
typer.echo(result)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command(name="indexes")
|
|
163
|
+
def index_advisor(ctx: typer.Context) -> None:
|
|
164
|
+
"""Index advisor: unused indexes and sequential scan ratios."""
|
|
165
|
+
actx: AppContext = ctx.obj
|
|
166
|
+
out = actx.output
|
|
167
|
+
|
|
168
|
+
out.info("Unused indexes (0 scans):")
|
|
169
|
+
unused = _run_psql(
|
|
170
|
+
actx,
|
|
171
|
+
"SELECT schemaname, relname AS table, indexrelname AS index, "
|
|
172
|
+
"pg_size_pretty(pg_relation_size(indexrelid)) AS size "
|
|
173
|
+
"FROM pg_stat_user_indexes WHERE idx_scan = 0 "
|
|
174
|
+
"ORDER BY pg_relation_size(indexrelid) DESC",
|
|
175
|
+
)
|
|
176
|
+
typer.echo(unused)
|
|
177
|
+
|
|
178
|
+
out.info("Tables with high sequential scan ratio:")
|
|
179
|
+
seq_ratio = _run_psql(
|
|
180
|
+
actx,
|
|
181
|
+
"SELECT schemaname, relname AS table, "
|
|
182
|
+
"seq_scan, idx_scan, "
|
|
183
|
+
"CASE WHEN seq_scan + idx_scan > 0 "
|
|
184
|
+
"THEN round(100.0 * seq_scan / (seq_scan + idx_scan), 1) ELSE 0 END AS seq_pct "
|
|
185
|
+
"FROM pg_stat_user_tables "
|
|
186
|
+
"WHERE seq_scan + idx_scan > 100 "
|
|
187
|
+
"ORDER BY seq_pct DESC LIMIT 20",
|
|
188
|
+
)
|
|
189
|
+
typer.echo(seq_ratio)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command(name="rls-audit")
|
|
193
|
+
def rls_audit(ctx: typer.Context) -> None:
|
|
194
|
+
"""RLS audit: tables without policies, permissive gaps, role coverage."""
|
|
195
|
+
actx: AppContext = ctx.obj
|
|
196
|
+
out = actx.output
|
|
197
|
+
|
|
198
|
+
docker = _get_docker(actx)
|
|
199
|
+
|
|
200
|
+
out.info("Tables WITHOUT any RLS policy (exposed to anon/authenticated):")
|
|
201
|
+
no_policy = docker.psql(
|
|
202
|
+
"SELECT schemaname, tablename, rowsecurity "
|
|
203
|
+
"FROM pg_tables "
|
|
204
|
+
"WHERE schemaname IN ('public', 'storage') "
|
|
205
|
+
"AND tablename NOT IN (SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = pg_tables.schemaname) "
|
|
206
|
+
"ORDER BY schemaname, tablename",
|
|
207
|
+
)
|
|
208
|
+
typer.echo(no_policy)
|
|
209
|
+
|
|
210
|
+
out.info("Tables with RLS DISABLED (bypasses all policies):")
|
|
211
|
+
rls_disabled = docker.psql(
|
|
212
|
+
"SELECT n.nspname AS schema, c.relname AS table "
|
|
213
|
+
"FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid "
|
|
214
|
+
"WHERE c.relkind = 'r' AND NOT c.relrowsecurity "
|
|
215
|
+
"AND n.nspname IN ('public', 'storage') "
|
|
216
|
+
"ORDER BY n.nspname, c.relname",
|
|
217
|
+
)
|
|
218
|
+
typer.echo(rls_disabled)
|
|
219
|
+
|
|
220
|
+
out.info("All RLS policies (schema, table, policy, roles, command):")
|
|
221
|
+
all_policies = docker.psql(
|
|
222
|
+
"SELECT schemaname, tablename, policyname, permissive, roles, cmd "
|
|
223
|
+
"FROM pg_policies "
|
|
224
|
+
"WHERE schemaname IN ('public', 'storage', 'auth') "
|
|
225
|
+
"ORDER BY schemaname, tablename, policyname",
|
|
226
|
+
)
|
|
227
|
+
typer.echo(all_policies)
|
|
228
|
+
|
|
229
|
+
out.info("Tables with FORCE ROW SECURITY (applies to table owner too):")
|
|
230
|
+
forced = docker.psql(
|
|
231
|
+
"SELECT n.nspname AS schema, c.relname AS table "
|
|
232
|
+
"FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid "
|
|
233
|
+
"WHERE c.relkind = 'r' AND c.relforcerowsecurity "
|
|
234
|
+
"AND n.nspname IN ('public', 'storage') "
|
|
235
|
+
"ORDER BY n.nspname, c.relname",
|
|
236
|
+
)
|
|
237
|
+
typer.echo(forced)
|
|
238
|
+
|
|
239
|
+
docker.close()
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Authentication and user management commands for kctl-supa.
|
|
2
|
+
|
|
3
|
+
Authentication and user management.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich import print as rprint
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from kctl_supa.core.callbacks import AppContext
|
|
15
|
+
from kctl_supa.core.client import SupabaseClient
|
|
16
|
+
from kctl_supa.core.docker import DockerOps
|
|
17
|
+
from kctl_supa.core.exceptions import DockerError, SupabaseError
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="Authentication and user management.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_client(actx: AppContext) -> SupabaseClient:
|
|
23
|
+
cfg = actx.config
|
|
24
|
+
return SupabaseClient(base_url=cfg.url, service_role_key=cfg.service_role_key, anon_key=cfg.anon_key)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_docker(actx: AppContext) -> DockerOps:
|
|
28
|
+
return DockerOps(actx.config)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command(name="list")
|
|
32
|
+
def list_users(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
page: Annotated[int, typer.Option("--page", help="Page number")] = 1,
|
|
35
|
+
per_page: Annotated[int, typer.Option("--per-page", help="Users per page")] = 50,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""List users."""
|
|
38
|
+
actx: AppContext = ctx.obj
|
|
39
|
+
out = actx.output
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
client = _get_client(actx)
|
|
43
|
+
result = client.auth_list_users(page=page, per_page=per_page)
|
|
44
|
+
client.close()
|
|
45
|
+
except SupabaseError as exc:
|
|
46
|
+
out.error(str(exc))
|
|
47
|
+
raise typer.Exit(1) from exc
|
|
48
|
+
|
|
49
|
+
users = result if isinstance(result, list) else result.get("users", [])
|
|
50
|
+
|
|
51
|
+
if not users:
|
|
52
|
+
out.warn("No users found.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
table = Table(title=f"Auth Users (page {page})", show_header=True, header_style="bold cyan")
|
|
56
|
+
table.add_column("ID", style="dim")
|
|
57
|
+
table.add_column("Email", style="cyan")
|
|
58
|
+
table.add_column("Created At")
|
|
59
|
+
table.add_column("Confirmed")
|
|
60
|
+
|
|
61
|
+
for user in users:
|
|
62
|
+
uid = user.get("id", "")
|
|
63
|
+
email = user.get("email", "")
|
|
64
|
+
created = user.get("created_at", "")[:19] if user.get("created_at") else ""
|
|
65
|
+
confirmed = "[green]yes[/green]" if user.get("email_confirmed_at") else "[red]no[/red]"
|
|
66
|
+
table.add_row(uid, email, created, confirmed)
|
|
67
|
+
|
|
68
|
+
rprint(table)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def get(
|
|
73
|
+
ctx: typer.Context,
|
|
74
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Get user details by ID."""
|
|
77
|
+
actx: AppContext = ctx.obj
|
|
78
|
+
out = actx.output
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
client = _get_client(actx)
|
|
82
|
+
user = client.auth_get_user(user_id)
|
|
83
|
+
client.close()
|
|
84
|
+
except SupabaseError as exc:
|
|
85
|
+
out.error(str(exc))
|
|
86
|
+
raise typer.Exit(1) from exc
|
|
87
|
+
|
|
88
|
+
out.kv("ID", user.get("id", ""))
|
|
89
|
+
out.kv("Email", user.get("email", ""))
|
|
90
|
+
out.kv("Phone", user.get("phone", "") or "[dim]not set[/dim]")
|
|
91
|
+
out.kv("Created", user.get("created_at", "")[:19])
|
|
92
|
+
out.kv("Updated", user.get("updated_at", "")[:19])
|
|
93
|
+
out.kv("Email confirmed", "yes" if user.get("email_confirmed_at") else "no")
|
|
94
|
+
out.kv("Role", user.get("role", ""))
|
|
95
|
+
ban_duration = user.get("ban_duration")
|
|
96
|
+
if ban_duration and ban_duration != "none":
|
|
97
|
+
out.kv("Banned", f"[red]yes ({ban_duration})[/red]")
|
|
98
|
+
else:
|
|
99
|
+
out.kv("Banned", "no")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def create(
|
|
104
|
+
ctx: typer.Context,
|
|
105
|
+
email: Annotated[str, typer.Option("--email", help="User email", prompt=True)],
|
|
106
|
+
password: Annotated[str, typer.Option("--password", help="User password", prompt=True, hide_input=True)],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Create a new user."""
|
|
109
|
+
actx: AppContext = ctx.obj
|
|
110
|
+
out = actx.output
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
client = _get_client(actx)
|
|
114
|
+
user = client.auth_create_user(email=email, password=password)
|
|
115
|
+
client.close()
|
|
116
|
+
except SupabaseError as exc:
|
|
117
|
+
out.error(str(exc))
|
|
118
|
+
raise typer.Exit(1) from exc
|
|
119
|
+
|
|
120
|
+
out.success(f"User created: {user.get('id')}")
|
|
121
|
+
out.kv("Email", email)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command()
|
|
125
|
+
def delete(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
user_id: Annotated[str, typer.Argument(help="User ID to delete")],
|
|
128
|
+
confirm: Annotated[bool, typer.Option("--confirm", help="Skip confirmation prompt")] = False,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Delete a user."""
|
|
131
|
+
actx: AppContext = ctx.obj
|
|
132
|
+
out = actx.output
|
|
133
|
+
|
|
134
|
+
if not confirm:
|
|
135
|
+
if not typer.confirm(f"Delete user '{user_id}'?"):
|
|
136
|
+
raise typer.Exit(0)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
client = _get_client(actx)
|
|
140
|
+
client.auth_delete_user(user_id)
|
|
141
|
+
client.close()
|
|
142
|
+
except SupabaseError as exc:
|
|
143
|
+
out.error(str(exc))
|
|
144
|
+
raise typer.Exit(1) from exc
|
|
145
|
+
|
|
146
|
+
out.success(f"User '{user_id}' deleted")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def ban(
|
|
151
|
+
ctx: typer.Context,
|
|
152
|
+
user_id: Annotated[str, typer.Argument(help="User ID to ban")],
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Ban a user."""
|
|
155
|
+
actx: AppContext = ctx.obj
|
|
156
|
+
out = actx.output
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
client = _get_client(actx)
|
|
160
|
+
client.auth_update_user(user_id, ban_duration="876600h")
|
|
161
|
+
client.close()
|
|
162
|
+
except SupabaseError as exc:
|
|
163
|
+
out.error(str(exc))
|
|
164
|
+
raise typer.Exit(1) from exc
|
|
165
|
+
|
|
166
|
+
out.success(f"User '{user_id}' banned")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def unban(
|
|
171
|
+
ctx: typer.Context,
|
|
172
|
+
user_id: Annotated[str, typer.Argument(help="User ID to unban")],
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Unban a user."""
|
|
175
|
+
actx: AppContext = ctx.obj
|
|
176
|
+
out = actx.output
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
client = _get_client(actx)
|
|
180
|
+
client.auth_update_user(user_id, ban_duration="none")
|
|
181
|
+
client.close()
|
|
182
|
+
except SupabaseError as exc:
|
|
183
|
+
out.error(str(exc))
|
|
184
|
+
raise typer.Exit(1) from exc
|
|
185
|
+
|
|
186
|
+
out.success(f"User '{user_id}' unbanned")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def stats(ctx: typer.Context) -> None:
|
|
191
|
+
"""Show auth user statistics."""
|
|
192
|
+
actx: AppContext = ctx.obj
|
|
193
|
+
out = actx.output
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
docker = _get_docker(actx)
|
|
197
|
+
total = docker.psql("SELECT count(*) FROM auth.users")
|
|
198
|
+
confirmed = docker.psql("SELECT count(*) FROM auth.users WHERE email_confirmed_at IS NOT NULL")
|
|
199
|
+
banned = docker.psql("SELECT count(*) FROM auth.users WHERE banned_until IS NOT NULL AND banned_until > now()")
|
|
200
|
+
docker.close()
|
|
201
|
+
except DockerError as exc:
|
|
202
|
+
out.error(str(exc))
|
|
203
|
+
raise typer.Exit(1) from exc
|
|
204
|
+
|
|
205
|
+
def _extract_count(raw: str) -> str:
|
|
206
|
+
for line in raw.splitlines():
|
|
207
|
+
line = line.strip()
|
|
208
|
+
if line.isdigit():
|
|
209
|
+
return line
|
|
210
|
+
return raw.strip()
|
|
211
|
+
|
|
212
|
+
out.kv("Total users", _extract_count(total))
|
|
213
|
+
out.kv("Confirmed", _extract_count(confirmed))
|
|
214
|
+
out.kv("Banned", _extract_count(banned))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def providers(ctx: typer.Context) -> None:
|
|
219
|
+
"""List configured auth providers."""
|
|
220
|
+
actx: AppContext = ctx.obj
|
|
221
|
+
out = actx.output
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
docker = _get_docker(actx)
|
|
225
|
+
result = docker.docker_exec(
|
|
226
|
+
"auth",
|
|
227
|
+
"env | grep -i 'GOTRUE_EXTERNAL_' | sort",
|
|
228
|
+
)
|
|
229
|
+
docker.close()
|
|
230
|
+
except DockerError as exc:
|
|
231
|
+
out.error(str(exc))
|
|
232
|
+
raise typer.Exit(1) from exc
|
|
233
|
+
|
|
234
|
+
if not result.strip():
|
|
235
|
+
out.warn("No external providers configured.")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
table = Table(title="Auth Providers", show_header=True, header_style="bold cyan")
|
|
239
|
+
table.add_column("Provider", style="cyan")
|
|
240
|
+
table.add_column("Status")
|
|
241
|
+
|
|
242
|
+
seen: set[str] = set()
|
|
243
|
+
for line in result.splitlines():
|
|
244
|
+
line = line.strip()
|
|
245
|
+
if "ENABLED" in line:
|
|
246
|
+
parts = line.split("=", 1)
|
|
247
|
+
var_name = parts[0]
|
|
248
|
+
enabled = parts[1].strip().lower() == "true" if len(parts) > 1 else False
|
|
249
|
+
provider = var_name.replace("GOTRUE_EXTERNAL_", "").replace("_ENABLED", "").lower()
|
|
250
|
+
if provider not in seen:
|
|
251
|
+
seen.add(provider)
|
|
252
|
+
status = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
|
|
253
|
+
table.add_row(provider, status)
|
|
254
|
+
|
|
255
|
+
rprint(table)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command()
|
|
259
|
+
def policies(ctx: typer.Context) -> None:
|
|
260
|
+
"""List RLS policies on auth schema tables."""
|
|
261
|
+
actx: AppContext = ctx.obj
|
|
262
|
+
out = actx.output
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
docker = _get_docker(actx)
|
|
266
|
+
result = docker.psql(
|
|
267
|
+
"SELECT tablename, policyname, permissive, roles, cmd, qual "
|
|
268
|
+
"FROM pg_policies WHERE schemaname = 'auth' "
|
|
269
|
+
"ORDER BY tablename, policyname",
|
|
270
|
+
)
|
|
271
|
+
docker.close()
|
|
272
|
+
except DockerError as exc:
|
|
273
|
+
out.error(str(exc))
|
|
274
|
+
raise typer.Exit(1) from exc
|
|
275
|
+
|
|
276
|
+
typer.echo(result)
|