firebird-admin 1.0.1__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.
- fbadmin/__init__.py +7 -0
- fbadmin/__main__.py +54 -0
- fbadmin/ai/__init__.py +41 -0
- fbadmin/ai/providers.py +72 -0
- fbadmin/api/__init__.py +43 -0
- fbadmin/api/admin.py +84 -0
- fbadmin/api/ai.py +117 -0
- fbadmin/api/auth.py +215 -0
- fbadmin/api/databases.py +305 -0
- fbadmin/api/deps.py +40 -0
- fbadmin/api/fbsecurity.py +261 -0
- fbadmin/api/jobs.py +17 -0
- fbadmin/api/maintenance.py +661 -0
- fbadmin/api/metrics.py +56 -0
- fbadmin/api/monitoring.py +276 -0
- fbadmin/api/object_dialogs.py +132 -0
- fbadmin/api/query.py +159 -0
- fbadmin/api/replication.py +155 -0
- fbadmin/api/schedules.py +103 -0
- fbadmin/api/schema.py +422 -0
- fbadmin/api/servers.py +317 -0
- fbadmin/api/trace.py +121 -0
- fbadmin/api/version.py +142 -0
- fbadmin/app.py +126 -0
- fbadmin/config.py +34 -0
- fbadmin/connections.py +376 -0
- fbadmin/desktop.py +122 -0
- fbadmin/driver_patches.py +137 -0
- fbadmin/jobs.py +78 -0
- fbadmin/limits.py +147 -0
- fbadmin/logsetup.py +122 -0
- fbadmin/maintenance/__init__.py +1 -0
- fbadmin/maintenance/archive.py +73 -0
- fbadmin/maintenance/cron.py +119 -0
- fbadmin/maintenance/gstat.py +233 -0
- fbadmin/maintenance/ops.py +406 -0
- fbadmin/maintenance/scheduler.py +239 -0
- fbadmin/maintenance/svc.py +358 -0
- fbadmin/maintenance/trace.py +298 -0
- fbadmin/metadata/__init__.py +1 -0
- fbadmin/metadata/catalog.py +1166 -0
- fbadmin/metadata/catalog_extras.py +449 -0
- fbadmin/metadata/ddlgen.py +1020 -0
- fbadmin/metadata/designer.py +375 -0
- fbadmin/metadata/types.py +92 -0
- fbadmin/middleware.py +16 -0
- fbadmin/monitoring/__init__.py +1 -0
- fbadmin/monitoring/advisor.py +305 -0
- fbadmin/monitoring/history.py +286 -0
- fbadmin/monitoring/journal.py +107 -0
- fbadmin/monitoring/metrics.py +66 -0
- fbadmin/monitoring/probes.py +374 -0
- fbadmin/monitoring/profiler.py +239 -0
- fbadmin/monitoring/replication.py +186 -0
- fbadmin/native.py +208 -0
- fbadmin/plugins.py +73 -0
- fbadmin/preconfig.py +118 -0
- fbadmin/query.py +373 -0
- fbadmin/schema/__init__.py +2 -0
- fbadmin/schema/copydata.py +144 -0
- fbadmin/schema/dataio.py +657 -0
- fbadmin/schema/diff.py +534 -0
- fbadmin/schema/extract.py +202 -0
- fbadmin/schema/generator.py +273 -0
- fbadmin/schema/model.py +503 -0
- fbadmin/schema/report.py +72 -0
- fbadmin/schema/search.py +113 -0
- fbadmin/schema_ctx.py +88 -0
- fbadmin/security/__init__.py +60 -0
- fbadmin/security/audit.py +96 -0
- fbadmin/security/core.py +374 -0
- fbadmin/security/crypto.py +147 -0
- fbadmin/security/db.py +205 -0
- fbadmin/security/errors.py +30 -0
- fbadmin/security/fbservices.py +147 -0
- fbadmin/security/fbsql.py +281 -0
- fbadmin/security/ldap.py +90 -0
- fbadmin/security/middleware.py +396 -0
- fbadmin/security/oidc.py +116 -0
- fbadmin/security/proxy.py +20 -0
- fbadmin/security/readonly.py +94 -0
- fbadmin/security/servers.py +419 -0
- fbadmin/security/sessions.py +154 -0
- fbadmin/security/settings.py +235 -0
- fbadmin/security/wirecompat.py +71 -0
- fbadmin/serialize.py +53 -0
- fbadmin/sqlsplit.py +277 -0
- fbadmin/static/assets/DataView-C2gZQZsl.js +1 -0
- fbadmin/static/assets/Jobs-BbRKWT35.js +2 -0
- fbadmin/static/assets/Monitor-BjEKPBW_.js +1 -0
- fbadmin/static/assets/QueryTool-DKWEk7JA.js +30 -0
- fbadmin/static/assets/codemirror-CQtTaafu.js +12 -0
- fbadmin/static/assets/index-DVXUQWWb.css +1 -0
- fbadmin/static/assets/index-n-ToRVCh.js +122 -0
- fbadmin/static/assets/react-D1VdbdIl.js +40 -0
- fbadmin/static/assets/sql-formatter-BMeiBS2F.js +19 -0
- fbadmin/static/index.html +17 -0
- fbadmin/store.py +166 -0
- fbadmin/updater.py +234 -0
- firebird_admin-1.0.1.dist-info/METADATA +65 -0
- firebird_admin-1.0.1.dist-info/RECORD +104 -0
- firebird_admin-1.0.1.dist-info/WHEEL +5 -0
- firebird_admin-1.0.1.dist-info/entry_points.txt +3 -0
- firebird_admin-1.0.1.dist-info/top_level.txt +1 -0
fbadmin/__init__.py
ADDED
fbadmin/__main__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Command line entry point: ``fbadmin`` or ``python -m fbadmin``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import webbrowser
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from .config import settings
|
|
13
|
+
from .logsetup import LEVELS, configure_logging
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
p = argparse.ArgumentParser(prog="fbadmin", description="Web administration tool for Firebird SQL")
|
|
18
|
+
p.add_argument("--host", default=settings.host, help="bind address (default %(default)s)")
|
|
19
|
+
p.add_argument("--port", type=int, default=settings.port, help="port (default %(default)s)")
|
|
20
|
+
p.add_argument("--no-browser", action="store_true", help="do not open a browser window")
|
|
21
|
+
p.add_argument("--reload", action="store_true", help="auto-reload on code changes (development)")
|
|
22
|
+
p.add_argument("--log-level", choices=LEVELS, default=None,
|
|
23
|
+
help="log level (default $FBADMIN_LOG_LEVEL or info)")
|
|
24
|
+
p.add_argument("--log-format", choices=("text", "json"), default=None,
|
|
25
|
+
help="log output format; json writes one object per line (default $FBADMIN_LOG_FORMAT or text)")
|
|
26
|
+
# --- security / server mode (docs/SECURITY.md)
|
|
27
|
+
p.add_argument("--server-mode", action="store_true",
|
|
28
|
+
help="multi-user server mode with logins (same as FBADMIN_SERVER_MODE=1)")
|
|
29
|
+
p.add_argument("--ssl-certfile", help="serve HTTPS with this certificate (PEM)")
|
|
30
|
+
p.add_argument("--ssl-keyfile", help="private key for --ssl-certfile")
|
|
31
|
+
p.add_argument("--proxy-headers", action="store_true",
|
|
32
|
+
help="trust X-Forwarded-Proto/For from --forwarded-allow-ips (behind a reverse proxy)")
|
|
33
|
+
p.add_argument("--forwarded-allow-ips", default=os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1"),
|
|
34
|
+
help="proxy addresses trusted for X-Forwarded-* headers (default %(default)s)")
|
|
35
|
+
args = p.parse_args()
|
|
36
|
+
log_config = configure_logging(args.log_level, args.log_format)
|
|
37
|
+
if args.server_mode:
|
|
38
|
+
os.environ["FBADMIN_SERVER_MODE"] = "1"
|
|
39
|
+
https = bool(args.ssl_certfile)
|
|
40
|
+
if not args.no_browser:
|
|
41
|
+
host = "localhost" if args.host in ("0.0.0.0", "127.0.0.1") else args.host
|
|
42
|
+
url = f"{'https' if https else 'http'}://{host}:{args.port}/"
|
|
43
|
+
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
|
|
44
|
+
extra = {}
|
|
45
|
+
if https:
|
|
46
|
+
extra.update(ssl_certfile=args.ssl_certfile, ssl_keyfile=args.ssl_keyfile)
|
|
47
|
+
if args.proxy_headers:
|
|
48
|
+
extra.update(proxy_headers=True, forwarded_allow_ips=args.forwarded_allow_ips)
|
|
49
|
+
uvicorn.run("fbadmin.app:app", host=args.host, port=args.port, reload=args.reload,
|
|
50
|
+
log_config=log_config, access_log=False, **extra)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
fbadmin/ai/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Opt-in AI assistant: explain plans, suggest indices, convert SQL from other dialects.
|
|
2
|
+
|
|
3
|
+
Disabled unless ``FBADMIN_AI_PROVIDER`` is set. Only SQL text, plans and table DDL are
|
|
4
|
+
sent to the provider — never table data. Providers are pluggable: register a class with
|
|
5
|
+
``register_provider`` or via the ``fbadmin.ai_providers`` entry point group.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from importlib.metadata import entry_points
|
|
12
|
+
|
|
13
|
+
from .providers import AIProvider, AnthropicProvider, AIUnavailable
|
|
14
|
+
|
|
15
|
+
_PROVIDERS: dict[str, type[AIProvider]] = {"anthropic": AnthropicProvider}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_provider(name: str, cls: type[AIProvider]) -> None:
|
|
19
|
+
_PROVIDERS[name] = cls
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_entry_points() -> None:
|
|
23
|
+
try:
|
|
24
|
+
for ep in entry_points(group="fbadmin.ai_providers"):
|
|
25
|
+
_PROVIDERS.setdefault(ep.name, ep.load())
|
|
26
|
+
except Exception: # noqa: BLE001 - a broken plugin must not break the app
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_provider() -> AIProvider | None:
|
|
31
|
+
name = os.environ.get("FBADMIN_AI_PROVIDER", "").strip().lower()
|
|
32
|
+
if not name or name == "none":
|
|
33
|
+
return None
|
|
34
|
+
_load_entry_points()
|
|
35
|
+
cls = _PROVIDERS.get(name)
|
|
36
|
+
if cls is None:
|
|
37
|
+
raise AIUnavailable(f"Unknown AI provider {name!r}; available: {', '.join(sorted(_PROVIDERS))}")
|
|
38
|
+
return cls()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["AIProvider", "AIUnavailable", "AnthropicProvider", "get_provider", "register_provider"]
|
fbadmin/ai/providers.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AIUnavailable(Exception):
|
|
7
|
+
"""The assistant is not configured or the provider failed."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AIProvider:
|
|
11
|
+
"""Interface for assistant backends."""
|
|
12
|
+
|
|
13
|
+
name = "base"
|
|
14
|
+
|
|
15
|
+
def complete(self, system: str, prompt: str) -> str: # pragma: no cover - interface
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
def describe(self) -> dict:
|
|
19
|
+
return {"provider": self.name}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AnthropicProvider(AIProvider):
|
|
23
|
+
"""Claude via the official Anthropic SDK (``pip install firebird-admin[ai]``).
|
|
24
|
+
|
|
25
|
+
Credentials come from the SDK's normal resolution (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
|
|
26
|
+
an ``ant auth login`` profile, ...). Model: FBADMIN_AI_MODEL (default claude-opus-5-5).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name = "anthropic"
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
try:
|
|
33
|
+
import anthropic
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise AIUnavailable("The 'anthropic' package is not installed: pip install 'firebird-admin[ai]'") from exc
|
|
36
|
+
self._anthropic = anthropic
|
|
37
|
+
self.model = os.environ.get("FBADMIN_AI_MODEL", "claude-opus-5-5")
|
|
38
|
+
self.effort = os.environ.get("FBADMIN_AI_EFFORT", "high")
|
|
39
|
+
self.client = anthropic.Anthropic()
|
|
40
|
+
|
|
41
|
+
def describe(self) -> dict:
|
|
42
|
+
return {"provider": self.name, "model": self.model}
|
|
43
|
+
|
|
44
|
+
def complete(self, system: str, prompt: str) -> str:
|
|
45
|
+
a = self._anthropic
|
|
46
|
+
try:
|
|
47
|
+
# Server-side fallback: if a safety classifier declines, the API re-runs the
|
|
48
|
+
# request on a fallback model inside the same call.
|
|
49
|
+
with self.client.beta.messages.stream(
|
|
50
|
+
model=self.model,
|
|
51
|
+
max_tokens=16000,
|
|
52
|
+
system=system,
|
|
53
|
+
output_config={"effort": self.effort},
|
|
54
|
+
betas=["server-side-fallback-2026-07-01"],
|
|
55
|
+
fallbacks="default",
|
|
56
|
+
messages=[{"role": "user", "content": prompt}],
|
|
57
|
+
) as stream:
|
|
58
|
+
msg = stream.get_final_message()
|
|
59
|
+
except a.AuthenticationError as exc:
|
|
60
|
+
raise AIUnavailable("Anthropic credentials are missing or invalid (set ANTHROPIC_API_KEY)") from exc
|
|
61
|
+
except a.RateLimitError as exc:
|
|
62
|
+
raise AIUnavailable("Rate limited by the Anthropic API; try again shortly") from exc
|
|
63
|
+
except a.APIStatusError as exc:
|
|
64
|
+
raise AIUnavailable(f"Anthropic API error {exc.status_code}: {exc.message}") from exc
|
|
65
|
+
except a.APIConnectionError as exc:
|
|
66
|
+
raise AIUnavailable("Cannot reach the Anthropic API (network)") from exc
|
|
67
|
+
if msg.stop_reason == "refusal":
|
|
68
|
+
raise AIUnavailable("The model declined this request")
|
|
69
|
+
text = "".join(b.text for b in msg.content if b.type == "text").strip()
|
|
70
|
+
if msg.stop_reason == "max_tokens":
|
|
71
|
+
text += "\n\n(answer truncated)"
|
|
72
|
+
return text
|
fbadmin/api/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""API routers. Feature modules add themselves to ROUTERS (one line each, to merge cleanly)."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from . import ai # noqa: I001 - one import per line keeps parallel branches mergeable
|
|
6
|
+
from . import databases
|
|
7
|
+
from . import jobs
|
|
8
|
+
from . import maintenance
|
|
9
|
+
from . import query
|
|
10
|
+
from . import schedules
|
|
11
|
+
from . import servers
|
|
12
|
+
from . import trace
|
|
13
|
+
from . import metrics
|
|
14
|
+
from . import monitoring
|
|
15
|
+
from . import replication
|
|
16
|
+
from . import version as version_api
|
|
17
|
+
from . import object_dialogs
|
|
18
|
+
from . import schema
|
|
19
|
+
from . import admin
|
|
20
|
+
from . import auth
|
|
21
|
+
from . import fbsecurity
|
|
22
|
+
|
|
23
|
+
ROUTERS: list[APIRouter] = [
|
|
24
|
+
servers.router,
|
|
25
|
+
databases.router,
|
|
26
|
+
query.router,
|
|
27
|
+
jobs.router,
|
|
28
|
+
ai.router,
|
|
29
|
+
maintenance.router,
|
|
30
|
+
trace.router,
|
|
31
|
+
schedules.router,
|
|
32
|
+
monitoring.router,
|
|
33
|
+
monitoring.settings_router,
|
|
34
|
+
metrics.router,
|
|
35
|
+
replication.router,
|
|
36
|
+
replication.conf_router,
|
|
37
|
+
version_api.router,
|
|
38
|
+
object_dialogs.router,
|
|
39
|
+
schema.router,
|
|
40
|
+
auth.router,
|
|
41
|
+
admin.router,
|
|
42
|
+
fbsecurity.router,
|
|
43
|
+
]
|
fbadmin/api/admin.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""fbadmin user administration and the audit log viewer (administrators only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Query
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from ..security.core import require_admin
|
|
9
|
+
from ..security.errors import SecurityError
|
|
10
|
+
from .deps import get_state
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UserIn(BaseModel):
|
|
16
|
+
username: str
|
|
17
|
+
email: str | None = None
|
|
18
|
+
password: str | None = None
|
|
19
|
+
role: str = "user"
|
|
20
|
+
active: bool = True
|
|
21
|
+
auth_source: str = "internal"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserUpdate(BaseModel):
|
|
25
|
+
email: str | None = None
|
|
26
|
+
password: str | None = None
|
|
27
|
+
role: str | None = None
|
|
28
|
+
active: bool | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _server_mode_admin():
|
|
32
|
+
p = require_admin()
|
|
33
|
+
if p is None:
|
|
34
|
+
raise SecurityError(400, "User accounts exist only in server mode", "desktop_mode")
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/users")
|
|
39
|
+
def list_users():
|
|
40
|
+
_server_mode_admin()
|
|
41
|
+
return get_state().security.db.list_users()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.post("/users", status_code=201)
|
|
45
|
+
def create_user(body: UserIn):
|
|
46
|
+
_server_mode_admin()
|
|
47
|
+
sec = get_state().security
|
|
48
|
+
if body.auth_source not in ("internal", "ldap", "proxy") and not body.auth_source.startswith("oidc:"):
|
|
49
|
+
raise SecurityError(400, f"Unknown authentication source {body.auth_source!r}", "bad_source")
|
|
50
|
+
user = sec.create_user(body.username, email=body.email, password=body.password, role=body.role,
|
|
51
|
+
auth_source=body.auth_source, active=body.active)
|
|
52
|
+
return {k: v for k, v in user.items() if k not in ("password_hash", "vault")}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.put("/users/{user_id}")
|
|
56
|
+
def update_user(user_id: int, body: UserUpdate):
|
|
57
|
+
p = _server_mode_admin()
|
|
58
|
+
user = get_state().security.update_user(user_id, body.model_dump(exclude_unset=True), p)
|
|
59
|
+
return {k: v for k, v in user.items() if k not in ("password_hash", "vault")}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.delete("/users/{user_id}", status_code=204)
|
|
63
|
+
def delete_user(user_id: int):
|
|
64
|
+
p = _server_mode_admin()
|
|
65
|
+
get_state().security.delete_user(user_id, p)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/audit")
|
|
69
|
+
def audit(
|
|
70
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
71
|
+
offset: int = Query(0, ge=0),
|
|
72
|
+
username: str | None = None,
|
|
73
|
+
server: str | None = None,
|
|
74
|
+
q: str | None = None,
|
|
75
|
+
success: bool | None = None,
|
|
76
|
+
since: float | None = None,
|
|
77
|
+
until: float | None = None,
|
|
78
|
+
category: str | None = None,
|
|
79
|
+
):
|
|
80
|
+
require_admin()
|
|
81
|
+
sec = get_state().security
|
|
82
|
+
out = sec.db.query_audit(limit=limit, offset=offset, username=username, server=server, text=q,
|
|
83
|
+
success=success, since=since, until=until, category=category) if sec.db.exists else {"total": 0, "rows": []}
|
|
84
|
+
return {**out, "enabled": sec.audit.enabled}
|
fbadmin/api/ai.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""AI assistant endpoints (opt-in, see fbadmin/ai)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from ..ai import AIUnavailable, get_provider
|
|
12
|
+
from ..metadata import Catalog
|
|
13
|
+
from ..query import explain
|
|
14
|
+
from ..sqlsplit import bind_params
|
|
15
|
+
from .deps import get_state
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
|
18
|
+
|
|
19
|
+
SYSTEM = (
|
|
20
|
+
"You are a Firebird SQL expert embedded in fbadmin, a database administration tool. "
|
|
21
|
+
"Answer for Firebird {version} specifically (its SQL dialect, PSQL, plans and index behaviour). "
|
|
22
|
+
"Be concise and practical: short explanation, then concrete SQL in ```sql blocks when relevant. "
|
|
23
|
+
"Never invent tables or columns that are not in the provided DDL; say what is missing instead."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
TASKS = {
|
|
27
|
+
"explain_plan": "Explain this query's execution plan in plain language. Point out full table scans "
|
|
28
|
+
"(NATURAL / Full Scan), sorts and joins that could be slow, and why the optimizer chose them.",
|
|
29
|
+
"suggest_indexes": "Suggest indices (CREATE INDEX statements) that would make this query faster, given the plan "
|
|
30
|
+
"and the table definitions. Mention selectivity concerns and existing indices that already cover it. "
|
|
31
|
+
"If no index would help, say so.",
|
|
32
|
+
"convert_sql": "Convert the following SQL from {dialect} to Firebird SQL. Output the converted SQL in one ```sql block, "
|
|
33
|
+
"then list the changes you made (e.g. LIMIT -> FETCH FIRST/ROWS, AUTO_INCREMENT -> IDENTITY, "
|
|
34
|
+
"string concatenation, date functions, data types).",
|
|
35
|
+
"explain_error": "Explain this Firebird error for the given SQL and show the corrected SQL.",
|
|
36
|
+
"ask": "Answer the user's question about this SQL / database.",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AskIn(BaseModel):
|
|
41
|
+
task: str
|
|
42
|
+
sql: str = ""
|
|
43
|
+
question: str = ""
|
|
44
|
+
error: str = ""
|
|
45
|
+
dialect: str = "MySQL"
|
|
46
|
+
server_id: str | None = None
|
|
47
|
+
database_id: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/status")
|
|
51
|
+
def status():
|
|
52
|
+
try:
|
|
53
|
+
p = get_provider()
|
|
54
|
+
except AIUnavailable as exc:
|
|
55
|
+
return {"enabled": False, "reason": str(exc)}
|
|
56
|
+
if p is None:
|
|
57
|
+
return {"enabled": False, "reason": "Set FBADMIN_AI_PROVIDER=anthropic to enable the assistant"}
|
|
58
|
+
return {"enabled": True, **p.describe()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _context(server_id: str, db_id: str, sql: str, with_plan: bool) -> tuple[str, str]:
|
|
62
|
+
"""Plan + DDL of the tables the statement touches; and the engine version."""
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
with get_state().manager.meta(server_id, db_id) as conn:
|
|
65
|
+
cat = Catalog(conn)
|
|
66
|
+
version = cat.engine_version
|
|
67
|
+
names: list[str] = []
|
|
68
|
+
if with_plan and sql.strip():
|
|
69
|
+
bound, _ = bind_params(sql.strip().rstrip(";"), [0])
|
|
70
|
+
try:
|
|
71
|
+
plan = explain(conn, bound)
|
|
72
|
+
detailed = explain(conn, bound, detailed=True)
|
|
73
|
+
parts.append(f"Plan:\n{plan}\n\nDetailed plan:\n{detailed}")
|
|
74
|
+
names += re.findall(r'Table (?:"[^"]+"\.)?"([^"]+)"', detailed or "") # FB6: "SCHEMA"."T"
|
|
75
|
+
except Exception as exc: # noqa: BLE001
|
|
76
|
+
parts.append(f"(could not prepare the statement: {exc})")
|
|
77
|
+
tables = {r["name"] for r in cat.list_objects("tables")} | {r["name"] for r in cat.list_objects("views")}
|
|
78
|
+
names += [w for w in re.findall(r'[A-Za-z_][A-Za-z0-9_$]*|"[^"]+"', sql) if w.strip('"').upper() in tables or w.strip('"') in tables]
|
|
79
|
+
seen = []
|
|
80
|
+
for n in names:
|
|
81
|
+
n = n.strip('"')
|
|
82
|
+
n = n if n in tables else n.upper()
|
|
83
|
+
if n in tables and n not in seen:
|
|
84
|
+
seen.append(n)
|
|
85
|
+
for n in seen[:12]:
|
|
86
|
+
otype = "tables" if n in {r["name"] for r in cat.list_objects("tables")} else "views"
|
|
87
|
+
try:
|
|
88
|
+
parts.append(cat.get_object(otype, n)["ddl"])
|
|
89
|
+
except Exception: # noqa: BLE001
|
|
90
|
+
pass
|
|
91
|
+
return "\n\n".join(parts), version
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.post("/ask")
|
|
95
|
+
def ask(body: AskIn):
|
|
96
|
+
try:
|
|
97
|
+
provider = get_provider()
|
|
98
|
+
if provider is None:
|
|
99
|
+
return JSONResponse(status_code=400, content={"detail": "AI assistant is disabled (FBADMIN_AI_PROVIDER not set)"})
|
|
100
|
+
if body.task not in TASKS:
|
|
101
|
+
return JSONResponse(status_code=400, content={"detail": f"Unknown task {body.task!r}"})
|
|
102
|
+
ctx, version = "", "3.0+"
|
|
103
|
+
if body.server_id and body.database_id and body.task != "convert_sql":
|
|
104
|
+
ctx, version = _context(body.server_id, body.database_id, body.sql,
|
|
105
|
+
with_plan=body.task in ("explain_plan", "suggest_indexes"))
|
|
106
|
+
prompt = TASKS[body.task].format(dialect=body.dialect)
|
|
107
|
+
if body.question:
|
|
108
|
+
prompt += f"\n\nQuestion: {body.question}"
|
|
109
|
+
if body.error:
|
|
110
|
+
prompt += f"\n\nError:\n{body.error}"
|
|
111
|
+
prompt += f"\n\nSQL:\n```sql\n{body.sql}\n```"
|
|
112
|
+
if ctx:
|
|
113
|
+
prompt += f"\n\nContext from the database:\n{ctx}"
|
|
114
|
+
answer = provider.complete(SYSTEM.format(version=version or "3.0+"), prompt)
|
|
115
|
+
return {"answer": answer, **provider.describe()}
|
|
116
|
+
except AIUnavailable as exc:
|
|
117
|
+
return JSONResponse(status_code=503, content={"detail": str(exc)})
|
fbadmin/api/auth.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Login, logout, first-run setup, OAuth2/OIDC and the master password (vault)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.parse
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request, Response
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from ..security.errors import LoginRequired, SecurityError
|
|
12
|
+
from ..security.middleware import SESSION_COOKIE, clear_cookies, is_secure, session_cookies
|
|
13
|
+
from ..security.oidc import identity
|
|
14
|
+
from ..security.sessions import current_principal
|
|
15
|
+
from .deps import get_state
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoginIn(BaseModel):
|
|
21
|
+
username: str
|
|
22
|
+
password: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SetupIn(BaseModel):
|
|
26
|
+
username: str
|
|
27
|
+
email: str | None = None
|
|
28
|
+
password: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PasswordChangeIn(BaseModel):
|
|
32
|
+
old_password: str
|
|
33
|
+
new_password: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VaultIn(BaseModel):
|
|
37
|
+
password: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VaultChangeIn(BaseModel):
|
|
41
|
+
old_password: str
|
|
42
|
+
new_password: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _set_cookies(response: Response, headers: list[tuple[bytes, bytes]]) -> None:
|
|
46
|
+
for k, v in headers:
|
|
47
|
+
response.headers.append(k.decode(), v.decode())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _status(request: Request) -> dict:
|
|
51
|
+
sec = get_state().security
|
|
52
|
+
p = current_principal.get()
|
|
53
|
+
out: dict = {
|
|
54
|
+
"server_mode": sec.server_mode,
|
|
55
|
+
"authenticated": (p is not None) or not sec.server_mode,
|
|
56
|
+
"needs_setup": sec.needs_setup(),
|
|
57
|
+
"auth_sources": [],
|
|
58
|
+
"user": None,
|
|
59
|
+
"audit": sec.audit.enabled,
|
|
60
|
+
}
|
|
61
|
+
if sec.server_mode:
|
|
62
|
+
srcs = sec.settings.auth_sources
|
|
63
|
+
out["auth_sources"] = [{"type": "password", "name": s} for s in srcs if s in ("internal", "ldap")]
|
|
64
|
+
if "oidc" in srcs:
|
|
65
|
+
out["auth_sources"] += [{"type": "oidc", "name": o.name, "title": o.title} for o in sec.settings.oidc]
|
|
66
|
+
if "proxy" in srcs:
|
|
67
|
+
out["auth_sources"].append({"type": "proxy", "name": "proxy"})
|
|
68
|
+
if p is not None:
|
|
69
|
+
user = sec.db.get_user(p.user_id) or {}
|
|
70
|
+
out["user"] = {"id": p.user_id, "username": p.username, "role": p.role, "email": user.get("email"),
|
|
71
|
+
"auth_source": p.auth_source}
|
|
72
|
+
elif not sec.server_mode:
|
|
73
|
+
out["user"] = {"id": 0, "username": "desktop", "role": "admin", "email": None, "auth_source": "desktop"}
|
|
74
|
+
if out["authenticated"]:
|
|
75
|
+
try:
|
|
76
|
+
out["vault"] = sec.vault_status()
|
|
77
|
+
except SecurityError:
|
|
78
|
+
out["vault"] = None
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/status")
|
|
83
|
+
def status(request: Request):
|
|
84
|
+
return _status(request)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _login_response(request: Request, response: Response, principal) -> dict:
|
|
88
|
+
sec = get_state().security
|
|
89
|
+
old = request.cookies.get(SESSION_COOKIE)
|
|
90
|
+
if old:
|
|
91
|
+
sec.end_session(old)
|
|
92
|
+
session = sec.start_session(principal)
|
|
93
|
+
_set_cookies(response, session_cookies(sec, session, is_secure(sec, request.url.scheme)))
|
|
94
|
+
token = current_principal.set(principal)
|
|
95
|
+
try:
|
|
96
|
+
return {**_status(request), "csrf_token": session.csrf}
|
|
97
|
+
finally:
|
|
98
|
+
current_principal.reset(token)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.post("/login")
|
|
102
|
+
def login(body: LoginIn, request: Request, response: Response):
|
|
103
|
+
sec = get_state().security
|
|
104
|
+
if not sec.server_mode:
|
|
105
|
+
raise SecurityError(400, "fbadmin runs in desktop mode (no login)", "desktop_mode")
|
|
106
|
+
if sec.needs_setup():
|
|
107
|
+
raise SecurityError(409, "Create the first administrator first", "needs_setup")
|
|
108
|
+
srcs = sec.settings.auth_sources
|
|
109
|
+
if "internal" not in srcs and "ldap" not in srcs:
|
|
110
|
+
raise SecurityError(400, "Password login is disabled", "password_login_disabled")
|
|
111
|
+
p = sec.login_password(body.username, body.password, request.client.host if request.client else None)
|
|
112
|
+
return _login_response(request, response, p)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post("/setup")
|
|
116
|
+
def setup(body: SetupIn, request: Request, response: Response):
|
|
117
|
+
"""First run in server mode: create the initial administrator."""
|
|
118
|
+
sec = get_state().security
|
|
119
|
+
if not sec.needs_setup():
|
|
120
|
+
raise SecurityError(409, "Setup has already been completed", "setup_done")
|
|
121
|
+
sec.create_user(body.username, email=body.email, password=body.password, role="admin")
|
|
122
|
+
p = sec.login_password(body.username, body.password, request.client.host if request.client else None)
|
|
123
|
+
return _login_response(request, response, p)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.post("/logout")
|
|
127
|
+
def logout(request: Request, response: Response):
|
|
128
|
+
sec = get_state().security
|
|
129
|
+
sec.end_session(request.cookies.get(SESSION_COOKIE))
|
|
130
|
+
_set_cookies(response, clear_cookies(is_secure(sec, request.url.scheme)))
|
|
131
|
+
return {"ok": True}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.post("/password")
|
|
135
|
+
def change_password(body: PasswordChangeIn):
|
|
136
|
+
sec = get_state().security
|
|
137
|
+
p = current_principal.get()
|
|
138
|
+
if p is None:
|
|
139
|
+
raise LoginRequired()
|
|
140
|
+
sec.change_password(p, body.old_password, body.new_password)
|
|
141
|
+
return {"ok": True}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------- OAuth2 / OIDC
|
|
145
|
+
def _redirect_uri(request: Request, provider: str) -> str:
|
|
146
|
+
sec = get_state().security
|
|
147
|
+
base = sec.settings.public_url or str(request.base_url).rstrip("/")
|
|
148
|
+
return f"{base}/api/auth/oidc/{urllib.parse.quote(provider)}/callback"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@router.get("/oidc/{provider}/login")
|
|
152
|
+
def oidc_login(provider: str, request: Request):
|
|
153
|
+
sec = get_state().security
|
|
154
|
+
if not sec.server_mode or "oidc" not in sec.settings.auth_sources:
|
|
155
|
+
raise SecurityError(400, "OAuth2/OIDC login is not enabled", "oidc_disabled")
|
|
156
|
+
p = sec.settings.oidc_provider(provider)
|
|
157
|
+
return RedirectResponse(sec.oidc.start(p, _redirect_uri(request, provider)), status_code=302)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.get("/oidc/{provider}/callback")
|
|
161
|
+
def oidc_callback(provider: str, request: Request, code: str = "", state: str = "", error: str = ""):
|
|
162
|
+
sec = get_state().security
|
|
163
|
+
if not sec.server_mode or "oidc" not in sec.settings.auth_sources:
|
|
164
|
+
raise SecurityError(400, "OAuth2/OIDC login is not enabled", "oidc_disabled")
|
|
165
|
+
if error or not code:
|
|
166
|
+
return RedirectResponse("/?login_error=" + urllib.parse.quote(error or "no code"), status_code=302)
|
|
167
|
+
p = sec.settings.oidc_provider(provider)
|
|
168
|
+
try:
|
|
169
|
+
info = identity(p, sec.oidc.finish(p, code, state))
|
|
170
|
+
principal = sec.login_external(info, f"oidc:{provider}")
|
|
171
|
+
except SecurityError as exc:
|
|
172
|
+
return RedirectResponse("/?login_error=" + urllib.parse.quote(exc.detail), status_code=302)
|
|
173
|
+
resp = RedirectResponse("/", status_code=302)
|
|
174
|
+
old = request.cookies.get(SESSION_COOKIE)
|
|
175
|
+
if old:
|
|
176
|
+
sec.end_session(old)
|
|
177
|
+
session = sec.start_session(principal)
|
|
178
|
+
_set_cookies(resp, session_cookies(sec, session, is_secure(sec, request.url.scheme)))
|
|
179
|
+
return resp
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------- master password
|
|
183
|
+
@router.get("/vault")
|
|
184
|
+
def vault_status():
|
|
185
|
+
return get_state().security.vault_status()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.post("/vault/setup")
|
|
189
|
+
def vault_setup(body: VaultIn):
|
|
190
|
+
return get_state().security.vault_setup(body.password)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.post("/vault/unlock")
|
|
194
|
+
def vault_unlock(body: VaultIn):
|
|
195
|
+
return get_state().security.vault_unlock(body.password)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.post("/vault/lock")
|
|
199
|
+
def vault_lock():
|
|
200
|
+
return get_state().security.vault_lock()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.post("/vault/change")
|
|
204
|
+
def vault_change(body: VaultChangeIn):
|
|
205
|
+
return get_state().security.vault_change(body.old_password, body.new_password)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.post("/vault/disable")
|
|
209
|
+
def vault_disable(body: VaultIn):
|
|
210
|
+
return get_state().security.vault_disable(body.password)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.post("/vault/reset")
|
|
214
|
+
def vault_reset():
|
|
215
|
+
return get_state().security.vault_reset()
|