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.
Files changed (104) hide show
  1. fbadmin/__init__.py +7 -0
  2. fbadmin/__main__.py +54 -0
  3. fbadmin/ai/__init__.py +41 -0
  4. fbadmin/ai/providers.py +72 -0
  5. fbadmin/api/__init__.py +43 -0
  6. fbadmin/api/admin.py +84 -0
  7. fbadmin/api/ai.py +117 -0
  8. fbadmin/api/auth.py +215 -0
  9. fbadmin/api/databases.py +305 -0
  10. fbadmin/api/deps.py +40 -0
  11. fbadmin/api/fbsecurity.py +261 -0
  12. fbadmin/api/jobs.py +17 -0
  13. fbadmin/api/maintenance.py +661 -0
  14. fbadmin/api/metrics.py +56 -0
  15. fbadmin/api/monitoring.py +276 -0
  16. fbadmin/api/object_dialogs.py +132 -0
  17. fbadmin/api/query.py +159 -0
  18. fbadmin/api/replication.py +155 -0
  19. fbadmin/api/schedules.py +103 -0
  20. fbadmin/api/schema.py +422 -0
  21. fbadmin/api/servers.py +317 -0
  22. fbadmin/api/trace.py +121 -0
  23. fbadmin/api/version.py +142 -0
  24. fbadmin/app.py +126 -0
  25. fbadmin/config.py +34 -0
  26. fbadmin/connections.py +376 -0
  27. fbadmin/desktop.py +122 -0
  28. fbadmin/driver_patches.py +137 -0
  29. fbadmin/jobs.py +78 -0
  30. fbadmin/limits.py +147 -0
  31. fbadmin/logsetup.py +122 -0
  32. fbadmin/maintenance/__init__.py +1 -0
  33. fbadmin/maintenance/archive.py +73 -0
  34. fbadmin/maintenance/cron.py +119 -0
  35. fbadmin/maintenance/gstat.py +233 -0
  36. fbadmin/maintenance/ops.py +406 -0
  37. fbadmin/maintenance/scheduler.py +239 -0
  38. fbadmin/maintenance/svc.py +358 -0
  39. fbadmin/maintenance/trace.py +298 -0
  40. fbadmin/metadata/__init__.py +1 -0
  41. fbadmin/metadata/catalog.py +1166 -0
  42. fbadmin/metadata/catalog_extras.py +449 -0
  43. fbadmin/metadata/ddlgen.py +1020 -0
  44. fbadmin/metadata/designer.py +375 -0
  45. fbadmin/metadata/types.py +92 -0
  46. fbadmin/middleware.py +16 -0
  47. fbadmin/monitoring/__init__.py +1 -0
  48. fbadmin/monitoring/advisor.py +305 -0
  49. fbadmin/monitoring/history.py +286 -0
  50. fbadmin/monitoring/journal.py +107 -0
  51. fbadmin/monitoring/metrics.py +66 -0
  52. fbadmin/monitoring/probes.py +374 -0
  53. fbadmin/monitoring/profiler.py +239 -0
  54. fbadmin/monitoring/replication.py +186 -0
  55. fbadmin/native.py +208 -0
  56. fbadmin/plugins.py +73 -0
  57. fbadmin/preconfig.py +118 -0
  58. fbadmin/query.py +373 -0
  59. fbadmin/schema/__init__.py +2 -0
  60. fbadmin/schema/copydata.py +144 -0
  61. fbadmin/schema/dataio.py +657 -0
  62. fbadmin/schema/diff.py +534 -0
  63. fbadmin/schema/extract.py +202 -0
  64. fbadmin/schema/generator.py +273 -0
  65. fbadmin/schema/model.py +503 -0
  66. fbadmin/schema/report.py +72 -0
  67. fbadmin/schema/search.py +113 -0
  68. fbadmin/schema_ctx.py +88 -0
  69. fbadmin/security/__init__.py +60 -0
  70. fbadmin/security/audit.py +96 -0
  71. fbadmin/security/core.py +374 -0
  72. fbadmin/security/crypto.py +147 -0
  73. fbadmin/security/db.py +205 -0
  74. fbadmin/security/errors.py +30 -0
  75. fbadmin/security/fbservices.py +147 -0
  76. fbadmin/security/fbsql.py +281 -0
  77. fbadmin/security/ldap.py +90 -0
  78. fbadmin/security/middleware.py +396 -0
  79. fbadmin/security/oidc.py +116 -0
  80. fbadmin/security/proxy.py +20 -0
  81. fbadmin/security/readonly.py +94 -0
  82. fbadmin/security/servers.py +419 -0
  83. fbadmin/security/sessions.py +154 -0
  84. fbadmin/security/settings.py +235 -0
  85. fbadmin/security/wirecompat.py +71 -0
  86. fbadmin/serialize.py +53 -0
  87. fbadmin/sqlsplit.py +277 -0
  88. fbadmin/static/assets/DataView-C2gZQZsl.js +1 -0
  89. fbadmin/static/assets/Jobs-BbRKWT35.js +2 -0
  90. fbadmin/static/assets/Monitor-BjEKPBW_.js +1 -0
  91. fbadmin/static/assets/QueryTool-DKWEk7JA.js +30 -0
  92. fbadmin/static/assets/codemirror-CQtTaafu.js +12 -0
  93. fbadmin/static/assets/index-DVXUQWWb.css +1 -0
  94. fbadmin/static/assets/index-n-ToRVCh.js +122 -0
  95. fbadmin/static/assets/react-D1VdbdIl.js +40 -0
  96. fbadmin/static/assets/sql-formatter-BMeiBS2F.js +19 -0
  97. fbadmin/static/index.html +17 -0
  98. fbadmin/store.py +166 -0
  99. fbadmin/updater.py +234 -0
  100. firebird_admin-1.0.1.dist-info/METADATA +65 -0
  101. firebird_admin-1.0.1.dist-info/RECORD +104 -0
  102. firebird_admin-1.0.1.dist-info/WHEEL +5 -0
  103. firebird_admin-1.0.1.dist-info/entry_points.txt +3 -0
  104. firebird_admin-1.0.1.dist-info/top_level.txt +1 -0
fbadmin/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """fbadmin - a pgAdmin-style administration tool for Firebird SQL."""
2
+
3
+ __version__ = "1.0.1"
4
+
5
+ from . import driver_patches as _driver_patches # noqa: E402
6
+
7
+ _driver_patches.apply()
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"]
@@ -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
@@ -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()