fastapi-openbi 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-openbi
3
+ Version: 1.0.0
4
+ Summary: Embeddable, Secure, Self-Contained BI Dashboard Engine for FastAPI
5
+ Author: OpenBI Team
6
+ Project-URL: Homepage, https://github.com/open-bi/fastapi-openbi
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Framework :: FastAPI
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: fastapi>=0.95.0
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Requires-Dist: psycopg2-binary>=2.9.0
16
+ Requires-Dist: cryptography>=41.0.0
17
+ Requires-Dist: uvicorn>=0.20.0
18
+
19
+ # fastapi-openbi
20
+
21
+ > ๐Ÿš€ **Self-Contained, Enterprise-Grade, Zero-Leakage BI Dashboard Engine for FastAPI.**
22
+
23
+ `fastapi-openbi` is a lightweight, drop-in Python library for FastAPI that lets you build, manage, and embed interactive analytical dashboards (Charts, KPI Cards, Drill-Downs, Multi-Tab pages) with **zero database credentials or raw SQL exposed to clients**.
24
+
25
+ ---
26
+
27
+ ## ๐Ÿ”’ Core Security Highlights
28
+
29
+ 1. **Zero DB Credential Leakage**: Database URLs are stored exclusively on the server and encrypted with **AES-256-GCM**.
30
+ 2. **Zero SQL Exposed**: Client browsers and frontend routes only receive and send opaque `queryId` identifiers.
31
+ 3. **Anti-IDOR Protection**: Requests are cryptographically authorized via **Scoped Dashboard Access Tokens** stored in the database.
32
+ 4. **1-Click Kill Switch**: Admins can revoke or regenerate client access tokens in real-time.
33
+ 5. **Swagger / OpenAPI Encapsulation**: All internal OpenBI routes are hidden from `/docs` and `/redoc` (`include_in_schema=False`).
34
+ 6. **DoS & Pool Protection**: Includes an in-memory **LRU Query Result Cache** (0.1ms responses), dedicated sub-pool, and a hard 3-second `statement_timeout`.
35
+
36
+ ---
37
+
38
+ ## ๐Ÿ“ฆ Installation
39
+
40
+ ```bash
41
+ pip install ./packages/python-sdk
42
+ # Or install dependencies:
43
+ pip install fastapi psycopg2-binary cryptography uvicorn
44
+ ```
45
+
46
+ ---
47
+
48
+ ## โšก 1-Minute Quick Start (FastAPI)
49
+
50
+ ```python
51
+ import os
52
+ from fastapi import FastAPI
53
+ from open_bi import create_bi_router, BIEngineConfig
54
+
55
+ app = FastAPI(title="My Enterprise SaaS Application")
56
+
57
+ # 1. Configure the OpenBI Engine
58
+ bi_config = BIEngineConfig(
59
+ primary_db_url=os.getenv("DATABASE_URL", "postgresql://postgres:pass@localhost:5432/my_db"),
60
+ admin_password=os.getenv("BI_ADMIN_PASSWORD", "super_secret_admin_key"),
61
+ encryption_key=os.getenv("BI_ENCRYPTION_KEY", "vault_secret_32_bytes_long!"),
62
+ include_in_schema=False, # Hides all BI routes from public /docs
63
+ query_timeout_ms=3000, # Kills any query taking > 3 seconds
64
+ cache_ttl_seconds=30 # In-memory query result cache
65
+ )
66
+
67
+ # 2. Mount the OpenBI Router (1 line of code)
68
+ app.include_router(create_bi_router(bi_config), prefix="/api/bi")
69
+
70
+ # Your normal application routes:
71
+ @app.get("/")
72
+ def index():
73
+ return {"status": "running"}
74
+
75
+ if __name__ == "__main__":
76
+ import uvicorn
77
+ uvicorn.run(app, host="0.0.0.0", port=8000)
78
+ ```
79
+
80
+ ---
81
+
82
+ ## ๐Ÿ› ๏ธ How It Works (Two-State Architecture)
83
+
84
+ ### 1. Admin Studio Workflow (Creator Mode)
85
+ - Admin logs in with `BI_ADMIN_PASSWORD` (sent via `X-BI-Admin-Key` header).
86
+ - Admin can connect to databases, inspect schemas, write SQL queries, and configure charts.
87
+ - The engine auto-generates a unique `access_token` (e.g. `emb_live_8f9a2b7c4d1e...`) for the dashboard and saves the configuration in the primary database.
88
+
89
+ ### 2. Client Viewer Workflow (End-User Mode)
90
+ - The client frontend route calls:
91
+ `GET /api/bi/public/dashboards/{dashboard_id}` (Header: `X-Dashboard-Token: emb_live_...`)
92
+ `POST /api/bi/public/query/execute` (Header: `X-Dashboard-Token: emb_live_...`, Body: `{ "queryId": "q_123" }`)
93
+ - **What happens on the server**:
94
+ 1. Verifies the token against the database record.
95
+ 2. Verifies `q_123` belongs to this dashboard (Anti-IDOR).
96
+ 3. Checks the in-memory LRU cache.
97
+ 4. Runs the SQL query on the backend and returns **pure JSON data rows** to the browser.
98
+ - **Client DevTools Inspector**: Sees **zero DB strings, zero passwords, and zero SQL queries**.
99
+
100
+ ---
101
+
102
+ ## ๐Ÿงช Testing
103
+
104
+ Run the automated security test suite:
105
+ ```bash
106
+ python packages/python-sdk/tests/test_openbi.py
107
+ ```
108
+ *(All 9 security and functional tests verify AES-256 encryption, Anti-IDOR enforcement, SQL Guard, Token verification, and payload sanitization).*
@@ -0,0 +1,90 @@
1
+ # fastapi-openbi
2
+
3
+ > ๐Ÿš€ **Self-Contained, Enterprise-Grade, Zero-Leakage BI Dashboard Engine for FastAPI.**
4
+
5
+ `fastapi-openbi` is a lightweight, drop-in Python library for FastAPI that lets you build, manage, and embed interactive analytical dashboards (Charts, KPI Cards, Drill-Downs, Multi-Tab pages) with **zero database credentials or raw SQL exposed to clients**.
6
+
7
+ ---
8
+
9
+ ## ๐Ÿ”’ Core Security Highlights
10
+
11
+ 1. **Zero DB Credential Leakage**: Database URLs are stored exclusively on the server and encrypted with **AES-256-GCM**.
12
+ 2. **Zero SQL Exposed**: Client browsers and frontend routes only receive and send opaque `queryId` identifiers.
13
+ 3. **Anti-IDOR Protection**: Requests are cryptographically authorized via **Scoped Dashboard Access Tokens** stored in the database.
14
+ 4. **1-Click Kill Switch**: Admins can revoke or regenerate client access tokens in real-time.
15
+ 5. **Swagger / OpenAPI Encapsulation**: All internal OpenBI routes are hidden from `/docs` and `/redoc` (`include_in_schema=False`).
16
+ 6. **DoS & Pool Protection**: Includes an in-memory **LRU Query Result Cache** (0.1ms responses), dedicated sub-pool, and a hard 3-second `statement_timeout`.
17
+
18
+ ---
19
+
20
+ ## ๐Ÿ“ฆ Installation
21
+
22
+ ```bash
23
+ pip install ./packages/python-sdk
24
+ # Or install dependencies:
25
+ pip install fastapi psycopg2-binary cryptography uvicorn
26
+ ```
27
+
28
+ ---
29
+
30
+ ## โšก 1-Minute Quick Start (FastAPI)
31
+
32
+ ```python
33
+ import os
34
+ from fastapi import FastAPI
35
+ from open_bi import create_bi_router, BIEngineConfig
36
+
37
+ app = FastAPI(title="My Enterprise SaaS Application")
38
+
39
+ # 1. Configure the OpenBI Engine
40
+ bi_config = BIEngineConfig(
41
+ primary_db_url=os.getenv("DATABASE_URL", "postgresql://postgres:pass@localhost:5432/my_db"),
42
+ admin_password=os.getenv("BI_ADMIN_PASSWORD", "super_secret_admin_key"),
43
+ encryption_key=os.getenv("BI_ENCRYPTION_KEY", "vault_secret_32_bytes_long!"),
44
+ include_in_schema=False, # Hides all BI routes from public /docs
45
+ query_timeout_ms=3000, # Kills any query taking > 3 seconds
46
+ cache_ttl_seconds=30 # In-memory query result cache
47
+ )
48
+
49
+ # 2. Mount the OpenBI Router (1 line of code)
50
+ app.include_router(create_bi_router(bi_config), prefix="/api/bi")
51
+
52
+ # Your normal application routes:
53
+ @app.get("/")
54
+ def index():
55
+ return {"status": "running"}
56
+
57
+ if __name__ == "__main__":
58
+ import uvicorn
59
+ uvicorn.run(app, host="0.0.0.0", port=8000)
60
+ ```
61
+
62
+ ---
63
+
64
+ ## ๐Ÿ› ๏ธ How It Works (Two-State Architecture)
65
+
66
+ ### 1. Admin Studio Workflow (Creator Mode)
67
+ - Admin logs in with `BI_ADMIN_PASSWORD` (sent via `X-BI-Admin-Key` header).
68
+ - Admin can connect to databases, inspect schemas, write SQL queries, and configure charts.
69
+ - The engine auto-generates a unique `access_token` (e.g. `emb_live_8f9a2b7c4d1e...`) for the dashboard and saves the configuration in the primary database.
70
+
71
+ ### 2. Client Viewer Workflow (End-User Mode)
72
+ - The client frontend route calls:
73
+ `GET /api/bi/public/dashboards/{dashboard_id}` (Header: `X-Dashboard-Token: emb_live_...`)
74
+ `POST /api/bi/public/query/execute` (Header: `X-Dashboard-Token: emb_live_...`, Body: `{ "queryId": "q_123" }`)
75
+ - **What happens on the server**:
76
+ 1. Verifies the token against the database record.
77
+ 2. Verifies `q_123` belongs to this dashboard (Anti-IDOR).
78
+ 3. Checks the in-memory LRU cache.
79
+ 4. Runs the SQL query on the backend and returns **pure JSON data rows** to the browser.
80
+ - **Client DevTools Inspector**: Sees **zero DB strings, zero passwords, and zero SQL queries**.
81
+
82
+ ---
83
+
84
+ ## ๐Ÿงช Testing
85
+
86
+ Run the automated security test suite:
87
+ ```bash
88
+ python packages/python-sdk/tests/test_openbi.py
89
+ ```
90
+ *(All 9 security and functional tests verify AES-256 encryption, Anti-IDOR enforcement, SQL Guard, Token verification, and payload sanitization).*
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-openbi
3
+ Version: 1.0.0
4
+ Summary: Embeddable, Secure, Self-Contained BI Dashboard Engine for FastAPI
5
+ Author: OpenBI Team
6
+ Project-URL: Homepage, https://github.com/open-bi/fastapi-openbi
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Framework :: FastAPI
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: fastapi>=0.95.0
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Requires-Dist: psycopg2-binary>=2.9.0
16
+ Requires-Dist: cryptography>=41.0.0
17
+ Requires-Dist: uvicorn>=0.20.0
18
+
19
+ # fastapi-openbi
20
+
21
+ > ๐Ÿš€ **Self-Contained, Enterprise-Grade, Zero-Leakage BI Dashboard Engine for FastAPI.**
22
+
23
+ `fastapi-openbi` is a lightweight, drop-in Python library for FastAPI that lets you build, manage, and embed interactive analytical dashboards (Charts, KPI Cards, Drill-Downs, Multi-Tab pages) with **zero database credentials or raw SQL exposed to clients**.
24
+
25
+ ---
26
+
27
+ ## ๐Ÿ”’ Core Security Highlights
28
+
29
+ 1. **Zero DB Credential Leakage**: Database URLs are stored exclusively on the server and encrypted with **AES-256-GCM**.
30
+ 2. **Zero SQL Exposed**: Client browsers and frontend routes only receive and send opaque `queryId` identifiers.
31
+ 3. **Anti-IDOR Protection**: Requests are cryptographically authorized via **Scoped Dashboard Access Tokens** stored in the database.
32
+ 4. **1-Click Kill Switch**: Admins can revoke or regenerate client access tokens in real-time.
33
+ 5. **Swagger / OpenAPI Encapsulation**: All internal OpenBI routes are hidden from `/docs` and `/redoc` (`include_in_schema=False`).
34
+ 6. **DoS & Pool Protection**: Includes an in-memory **LRU Query Result Cache** (0.1ms responses), dedicated sub-pool, and a hard 3-second `statement_timeout`.
35
+
36
+ ---
37
+
38
+ ## ๐Ÿ“ฆ Installation
39
+
40
+ ```bash
41
+ pip install ./packages/python-sdk
42
+ # Or install dependencies:
43
+ pip install fastapi psycopg2-binary cryptography uvicorn
44
+ ```
45
+
46
+ ---
47
+
48
+ ## โšก 1-Minute Quick Start (FastAPI)
49
+
50
+ ```python
51
+ import os
52
+ from fastapi import FastAPI
53
+ from open_bi import create_bi_router, BIEngineConfig
54
+
55
+ app = FastAPI(title="My Enterprise SaaS Application")
56
+
57
+ # 1. Configure the OpenBI Engine
58
+ bi_config = BIEngineConfig(
59
+ primary_db_url=os.getenv("DATABASE_URL", "postgresql://postgres:pass@localhost:5432/my_db"),
60
+ admin_password=os.getenv("BI_ADMIN_PASSWORD", "super_secret_admin_key"),
61
+ encryption_key=os.getenv("BI_ENCRYPTION_KEY", "vault_secret_32_bytes_long!"),
62
+ include_in_schema=False, # Hides all BI routes from public /docs
63
+ query_timeout_ms=3000, # Kills any query taking > 3 seconds
64
+ cache_ttl_seconds=30 # In-memory query result cache
65
+ )
66
+
67
+ # 2. Mount the OpenBI Router (1 line of code)
68
+ app.include_router(create_bi_router(bi_config), prefix="/api/bi")
69
+
70
+ # Your normal application routes:
71
+ @app.get("/")
72
+ def index():
73
+ return {"status": "running"}
74
+
75
+ if __name__ == "__main__":
76
+ import uvicorn
77
+ uvicorn.run(app, host="0.0.0.0", port=8000)
78
+ ```
79
+
80
+ ---
81
+
82
+ ## ๐Ÿ› ๏ธ How It Works (Two-State Architecture)
83
+
84
+ ### 1. Admin Studio Workflow (Creator Mode)
85
+ - Admin logs in with `BI_ADMIN_PASSWORD` (sent via `X-BI-Admin-Key` header).
86
+ - Admin can connect to databases, inspect schemas, write SQL queries, and configure charts.
87
+ - The engine auto-generates a unique `access_token` (e.g. `emb_live_8f9a2b7c4d1e...`) for the dashboard and saves the configuration in the primary database.
88
+
89
+ ### 2. Client Viewer Workflow (End-User Mode)
90
+ - The client frontend route calls:
91
+ `GET /api/bi/public/dashboards/{dashboard_id}` (Header: `X-Dashboard-Token: emb_live_...`)
92
+ `POST /api/bi/public/query/execute` (Header: `X-Dashboard-Token: emb_live_...`, Body: `{ "queryId": "q_123" }`)
93
+ - **What happens on the server**:
94
+ 1. Verifies the token against the database record.
95
+ 2. Verifies `q_123` belongs to this dashboard (Anti-IDOR).
96
+ 3. Checks the in-memory LRU cache.
97
+ 4. Runs the SQL query on the backend and returns **pure JSON data rows** to the browser.
98
+ - **Client DevTools Inspector**: Sees **zero DB strings, zero passwords, and zero SQL queries**.
99
+
100
+ ---
101
+
102
+ ## ๐Ÿงช Testing
103
+
104
+ Run the automated security test suite:
105
+ ```bash
106
+ python packages/python-sdk/tests/test_openbi.py
107
+ ```
108
+ *(All 9 security and functional tests verify AES-256 encryption, Anti-IDOR enforcement, SQL Guard, Token verification, and payload sanitization).*
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ fastapi_openbi.egg-info/PKG-INFO
4
+ fastapi_openbi.egg-info/SOURCES.txt
5
+ fastapi_openbi.egg-info/dependency_links.txt
6
+ fastapi_openbi.egg-info/requires.txt
7
+ fastapi_openbi.egg-info/top_level.txt
8
+ open_bi/__init__.py
9
+ open_bi/cache.py
10
+ open_bi/config.py
11
+ open_bi/crypto.py
12
+ open_bi/db.py
13
+ open_bi/guard.py
14
+ open_bi/router.py
15
+ tests/test_openbi.py
@@ -0,0 +1,5 @@
1
+ fastapi>=0.95.0
2
+ pydantic>=2.0.0
3
+ psycopg2-binary>=2.9.0
4
+ cryptography>=41.0.0
5
+ uvicorn>=0.20.0
@@ -0,0 +1,24 @@
1
+ """
2
+ OpenBI - Secure, Self-Contained BI Dashboard Engine for FastAPI.
3
+ """
4
+
5
+ from .config import BIEngineConfig, generate_dashboard_token
6
+ from .crypto import encrypt_string, decrypt_string, secure_compare
7
+ from .db import DatabaseManager
8
+ from .guard import validate_safe_query
9
+ from .cache import QueryResultCache
10
+ from .router import create_bi_router
11
+
12
+ __version__ = "1.0.0"
13
+
14
+ __all__ = [
15
+ "create_bi_router",
16
+ "BIEngineConfig",
17
+ "generate_dashboard_token",
18
+ "DatabaseManager",
19
+ "validate_safe_query",
20
+ "QueryResultCache",
21
+ "encrypt_string",
22
+ "decrypt_string",
23
+ "secure_compare",
24
+ ]
@@ -0,0 +1,41 @@
1
+ import time
2
+ import hashlib
3
+ import json
4
+ import threading
5
+ from typing import Any, Optional, Dict
6
+
7
+ class QueryResultCache:
8
+ def __init__(self, default_ttl_seconds: int = 30):
9
+ self.default_ttl = default_ttl_seconds
10
+ self._cache: Dict[str, Dict[str, Any]] = {}
11
+ self._lock = threading.Lock()
12
+
13
+ def _generate_key(self, query_id: str, params: Optional[Dict[str, Any]]) -> str:
14
+ param_str = json.dumps(params or {}, sort_keys=True)
15
+ raw = f"{query_id}:{param_str}"
16
+ return hashlib.sha256(raw.encode('utf-8')).hexdigest()
17
+
18
+ def get(self, query_id: str, params: Optional[Dict[str, Any]]) -> Optional[Any]:
19
+ key = self._generate_key(query_id, params)
20
+ now = time.time()
21
+ with self._lock:
22
+ if key in self._cache:
23
+ entry = self._cache[key]
24
+ if now < entry["expires_at"]:
25
+ return entry["data"]
26
+ else:
27
+ del self._cache[key]
28
+ return None
29
+
30
+ def set(self, query_id: str, params: Optional[Dict[str, Any]], data: Any, ttl_seconds: Optional[int] = None):
31
+ key = self._generate_key(query_id, params)
32
+ ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl
33
+ with self._lock:
34
+ self._cache[key] = {
35
+ "data": data,
36
+ "expires_at": time.time() + ttl,
37
+ }
38
+
39
+ def clear(self):
40
+ with self._lock:
41
+ self._cache.clear()
@@ -0,0 +1,24 @@
1
+ import secrets
2
+ import os
3
+ from typing import Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+ class BIEngineConfig(BaseModel):
7
+ primary_db_url: str = Field(
8
+ default_factory=lambda: os.getenv("DATABASE_URL", "postgresql://postgres:phemon420@localhost:5432/dashboard_generator")
9
+ )
10
+ admin_password: str = Field(
11
+ default_factory=lambda: os.getenv("BI_ADMIN_PASSWORD", "admin123")
12
+ )
13
+ encryption_key: Optional[str] = Field(
14
+ default_factory=lambda: os.getenv("BI_ENCRYPTION_KEY", "openbi_master_vault_key_2026_secure")
15
+ )
16
+ query_timeout_ms: int = Field(default=3000)
17
+ cache_ttl_seconds: int = Field(default=30)
18
+ pool_min_size: int = Field(default=2)
19
+ pool_max_size: int = Field(default=10)
20
+ include_in_schema: bool = Field(default=False)
21
+
22
+ def generate_dashboard_token() -> str:
23
+ """Generate a high-entropy cryptographically secure access token."""
24
+ return f"emb_live_{secrets.token_urlsafe(32)}"
@@ -0,0 +1,55 @@
1
+ import base64
2
+ import os
3
+ import secrets
4
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
5
+ from cryptography.hazmat.primitives import hashes
6
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
7
+
8
+ def _derive_key(passphrase: str, salt: bytes) -> bytes:
9
+ kdf = PBKDF2HMAC(
10
+ algorithm=hashes.SHA256(),
11
+ length=32,
12
+ salt=salt,
13
+ iterations=100000,
14
+ )
15
+ return kdf.derive(passphrase.encode('utf-8'))
16
+
17
+ def encrypt_string(plaintext: str, passphrase: str) -> str:
18
+ """Encrypt a string using AES-256-GCM with salt and IV."""
19
+ if not plaintext:
20
+ return ""
21
+ salt = os.urandom(16)
22
+ key = _derive_key(passphrase, salt)
23
+ aesgcm = AESGCM(key)
24
+ nonce = os.urandom(12)
25
+ ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
26
+
27
+ # Pack salt + nonce + ciphertext
28
+ packed = salt + nonce + ciphertext
29
+ return base64.b64encode(packed).decode('utf-8')
30
+
31
+ def decrypt_string(encrypted_b64: str, passphrase: str) -> str:
32
+ """Decrypt an AES-256-GCM encrypted base64 string."""
33
+ if not encrypted_b64:
34
+ return ""
35
+ try:
36
+ data = base64.b64decode(encrypted_b64.encode('utf-8'))
37
+ if len(data) < 28: # 16 salt + 12 nonce
38
+ return encrypted_b64 # fallback if unencrypted
39
+ salt = data[:16]
40
+ nonce = data[16:28]
41
+ ciphertext = data[28:]
42
+
43
+ key = _derive_key(passphrase, salt)
44
+ aesgcm = AESGCM(key)
45
+ decrypted = aesgcm.decrypt(nonce, ciphertext, None)
46
+ return decrypted.decode('utf-8')
47
+ except Exception:
48
+ # If decryption fails (e.g. was plain string), return as is
49
+ return encrypted_b64
50
+
51
+ def secure_compare(a: str, b: str) -> bool:
52
+ """Constant-time string comparison against timing attacks."""
53
+ if not a or not b:
54
+ return False
55
+ return secrets.compare_digest(a, b)
@@ -0,0 +1,187 @@
1
+ import json
2
+ import time
3
+ import psycopg2
4
+ from psycopg2.pool import ThreadedConnectionPool
5
+ from psycopg2.extras import RealDictCursor
6
+ from typing import Dict, Any, List, Optional, Tuple
7
+ from .guard import validate_safe_query
8
+
9
+ class DatabaseManager:
10
+ def __init__(self, primary_db_url: str, min_conn: int = 2, max_conn: int = 10, timeout_ms: int = 3000):
11
+ self.primary_db_url = primary_db_url
12
+ self.timeout_ms = timeout_ms
13
+ self.pools: Dict[str, ThreadedConnectionPool] = {}
14
+ self._init_primary_pool(min_conn, max_conn)
15
+ self.run_system_migrations()
16
+
17
+ def _init_primary_pool(self, min_conn: int, max_conn: int):
18
+ try:
19
+ pool = ThreadedConnectionPool(min_conn, max_conn, self.primary_db_url)
20
+ self.pools[self.primary_db_url] = pool
21
+ except Exception as e:
22
+ print(f"[OpenBI] Warning: Could not initialize primary DB pool immediately: {e}")
23
+
24
+ def get_pool(self, db_url: str) -> ThreadedConnectionPool:
25
+ target_url = db_url or self.primary_db_url
26
+ if target_url not in self.pools:
27
+ self.pools[target_url] = ThreadedConnectionPool(1, 5, target_url)
28
+ return self.pools[target_url]
29
+
30
+ def run_system_migrations(self):
31
+ """Auto-create _bi_dashboards, _bi_queries, _bi_widgets system tables."""
32
+ try:
33
+ pool = self.get_pool(self.primary_db_url)
34
+ conn = pool.getconn()
35
+ try:
36
+ with conn.cursor() as cur:
37
+ # 1. Dashboards Table
38
+ cur.execute("""
39
+ CREATE TABLE IF NOT EXISTS _bi_dashboards (
40
+ id VARCHAR(64) PRIMARY KEY,
41
+ title VARCHAR(255) NOT NULL,
42
+ description TEXT,
43
+ db_connection_url TEXT,
44
+ db_type VARCHAR(32) DEFAULT 'postgresql',
45
+ theme_config JSONB DEFAULT '{"mode": "dark", "accentColor": "#6366F1"}'::jsonb,
46
+ layout_config JSONB DEFAULT '[]'::jsonb,
47
+ access_token VARCHAR(255),
48
+ is_active BOOLEAN DEFAULT TRUE,
49
+ allowed_queries JSONB DEFAULT '[]'::jsonb,
50
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
51
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
52
+ );
53
+ """)
54
+
55
+ # 2. Queries Table
56
+ cur.execute("""
57
+ CREATE TABLE IF NOT EXISTS _bi_queries (
58
+ id VARCHAR(64) PRIMARY KEY,
59
+ dashboard_id VARCHAR(64) REFERENCES _bi_dashboards(id) ON DELETE CASCADE,
60
+ name VARCHAR(255) NOT NULL,
61
+ sql_text TEXT NOT NULL,
62
+ data_source_url TEXT,
63
+ refresh_interval_sec INTEGER DEFAULT 0,
64
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
65
+ );
66
+ """)
67
+
68
+ # 3. Widgets Table
69
+ cur.execute("""
70
+ CREATE TABLE IF NOT EXISTS _bi_widgets (
71
+ id VARCHAR(64) PRIMARY KEY,
72
+ dashboard_id VARCHAR(64) REFERENCES _bi_dashboards(id) ON DELETE CASCADE,
73
+ query_id VARCHAR(64) REFERENCES _bi_queries(id) ON DELETE SET NULL,
74
+ widget_type VARCHAR(32) NOT NULL,
75
+ title VARCHAR(255) NOT NULL,
76
+ subtitle TEXT,
77
+ mapping_config JSONB NOT NULL DEFAULT '{}'::jsonb,
78
+ display_config JSONB NOT NULL DEFAULT '{}'::jsonb,
79
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
80
+ );
81
+ """)
82
+
83
+ # Ensure access_token column exists if upgraded
84
+ cur.execute("""
85
+ DO $$
86
+ BEGIN
87
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='_bi_dashboards' AND column_name='access_token') THEN
88
+ ALTER TABLE _bi_dashboards ADD COLUMN access_token VARCHAR(255);
89
+ END IF;
90
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='_bi_dashboards' AND column_name='is_active') THEN
91
+ ALTER TABLE _bi_dashboards ADD COLUMN is_active BOOLEAN DEFAULT TRUE;
92
+ END IF;
93
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='_bi_dashboards' AND column_name='allowed_queries') THEN
94
+ ALTER TABLE _bi_dashboards ADD COLUMN allowed_queries JSONB DEFAULT '[]'::jsonb;
95
+ END IF;
96
+ END $$;
97
+ """)
98
+
99
+ conn.commit()
100
+ finally:
101
+ pool.putconn(conn)
102
+ except Exception as e:
103
+ print(f"[OpenBI] System migrations notice: {e}")
104
+
105
+ def execute_query(self, sql: str, db_url: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
106
+ """Execute a read-only SQL query with statement timeout."""
107
+ is_safe, error_msg = validate_safe_query(sql)
108
+ if not is_safe:
109
+ raise ValueError(error_msg)
110
+
111
+ target_url = db_url or self.primary_db_url
112
+ pool = self.get_pool(target_url)
113
+ conn = pool.getconn()
114
+
115
+ start_time = time.time()
116
+ try:
117
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
118
+ # Set hard statement timeout
119
+ cur.execute(f"SET statement_timeout = '{self.timeout_ms}ms';")
120
+
121
+ # Replace :named params with %(named)s for psycopg2
122
+ formatted_sql = sql
123
+ if params:
124
+ for k in params.keys():
125
+ formatted_sql = formatted_sql.replace(f":{k}", f"%({k})s")
126
+ cur.execute(formatted_sql, params)
127
+ else:
128
+ cur.execute(formatted_sql)
129
+
130
+ rows = cur.fetchall() if cur.description else []
131
+ execution_time_ms = round((time.time() - start_time) * 1000, 2)
132
+
133
+ columns = []
134
+ if cur.description:
135
+ for col in cur.description:
136
+ columns.append({
137
+ "name": col.name,
138
+ "dataType": "text"
139
+ })
140
+
141
+ # Format rows into standard JSON-serializable list
142
+ serialized_rows = []
143
+ for r in rows:
144
+ clean_row = {}
145
+ for k, v in dict(r).items():
146
+ clean_row[k] = str(v) if not isinstance(v, (int, float, bool, type(None))) else v
147
+ serialized_rows.append(clean_row)
148
+
149
+ return {
150
+ "columns": columns,
151
+ "rows": serialized_rows,
152
+ "rowCount": len(serialized_rows),
153
+ "executionTimeMs": execution_time_ms,
154
+ }
155
+ finally:
156
+ pool.putconn(conn)
157
+
158
+ def introspect_schema(self, db_url: Optional[str] = None) -> Dict[str, Any]:
159
+ """Get tables and columns schema."""
160
+ target_url = db_url or self.primary_db_url
161
+ pool = self.get_pool(target_url)
162
+ conn = pool.getconn()
163
+ try:
164
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
165
+ cur.execute("""
166
+ SELECT table_name, column_name, data_type, is_nullable
167
+ FROM information_schema.columns
168
+ WHERE table_schema = 'public' AND table_name NOT LIKE '\\_bi\\_%' ESCAPE '\\'
169
+ ORDER BY table_name, ordinal_position;
170
+ """)
171
+ rows = cur.fetchall()
172
+
173
+ tables_map: Dict[str, List[Dict[str, Any]]] = {}
174
+ for r in rows:
175
+ t_name = r["table_name"]
176
+ if t_name not in tables_map:
177
+ tables_map[t_name] = []
178
+ tables_map[t_name].append({
179
+ "name": r["column_name"],
180
+ "dataType": r["data_type"],
181
+ "isNullable": r["is_nullable"] == "YES"
182
+ })
183
+
184
+ tables = [{"name": name, "columns": cols} for name, cols in tables_map.items()]
185
+ return {"tables": tables, "dbType": "postgresql"}
186
+ finally:
187
+ pool.putconn(conn)
@@ -0,0 +1,52 @@
1
+ import re
2
+ from typing import Tuple
3
+
4
+ BLOCKED_PATTERNS = [
5
+ r'\bINSERT\b',
6
+ r'\bUPDATE\b',
7
+ r'\bDELETE\b',
8
+ r'\bDROP\b',
9
+ r'\bALTER\b',
10
+ r'\bCREATE\b',
11
+ r'\bTRUNCATE\b',
12
+ r'\bGRANT\b',
13
+ r'\bREVOKE\b',
14
+ r'\bEXEC\b',
15
+ r'\bEXECUTE\b',
16
+ r'\bVACUUM\b',
17
+ r'\bCOPY\b',
18
+ r'\bCALL\b',
19
+ r'\bDO\b',
20
+ ]
21
+
22
+ def sanitize_sql(sql: str) -> str:
23
+ """Remove comments and normalize whitespace."""
24
+ # Remove block comments
25
+ sql = re.sub(r'/\*.*?\*/', '', sql, flags=re.DOTALL)
26
+ # Remove single line comments
27
+ sql = re.sub(r'--.*$', '', sql, flags=re.MULTILINE)
28
+ return sql.strip()
29
+
30
+ def validate_safe_query(sql: str) -> Tuple[bool, str]:
31
+ """Verify that a query is strictly read-only and free of destructive statements."""
32
+ if not sql or not sql.strip():
33
+ return False, "Query string cannot be empty."
34
+
35
+ cleaned = sanitize_sql(sql)
36
+ normalized = cleaned.strip()
37
+
38
+ # Check that it starts with read-only keywords
39
+ if not re.match(r'^(SELECT|WITH|EXPLAIN)\b', normalized, re.IGNORECASE):
40
+ return False, "Security Violation: Query must start with SELECT, WITH, or EXPLAIN."
41
+
42
+ # Check for blocked DDL / DML keywords
43
+ for pattern in BLOCKED_PATTERNS:
44
+ if re.search(pattern, normalized, re.IGNORECASE):
45
+ return False, f"Security Violation: Destructive command matching '{pattern}' is strictly forbidden."
46
+
47
+ # Check for multiple stacked queries (semicolon chaining)
48
+ statements = [s.strip() for s in normalized.split(';') if s.strip()]
49
+ if len(statements) > 1:
50
+ return False, "Security Violation: Multiple stacked SQL statements are forbidden."
51
+
52
+ return True, ""
@@ -0,0 +1,424 @@
1
+ import json
2
+ import uuid
3
+ from typing import Dict, Any, Optional, List
4
+ from fastapi import APIRouter, HTTPException, Header, Depends, Query, Request, status
5
+ from fastapi.responses import HTMLResponse, JSONResponse
6
+ from pydantic import BaseModel, Field
7
+
8
+ from .config import BIEngineConfig, generate_dashboard_token
9
+ from .crypto import encrypt_string, decrypt_string, secure_compare
10
+ from .db import DatabaseManager
11
+ from .cache import QueryResultCache
12
+
13
+ # --- Request Models ---
14
+ class ExecuteQueryRequest(BaseModel):
15
+ sql: str
16
+ url: Optional[str] = None
17
+ params: Optional[Dict[str, Any]] = None
18
+
19
+ class PublicQueryExecuteRequest(BaseModel):
20
+ queryId: str
21
+ params: Optional[Dict[str, Any]] = None
22
+ token: Optional[str] = None
23
+
24
+ class DashboardSaveRequest(BaseModel):
25
+ id: Optional[str] = None
26
+ title: str
27
+ description: Optional[str] = ""
28
+ dbConnectionUrl: Optional[str] = None
29
+ dbType: Optional[str] = "postgresql"
30
+ theme: Optional[Dict[str, Any]] = None
31
+ layout: Optional[List[Dict[str, Any]]] = None
32
+
33
+ class QuerySaveRequest(BaseModel):
34
+ id: Optional[str] = None
35
+ dashboardId: str
36
+ name: str
37
+ sqlText: str
38
+ refreshIntervalSec: Optional[int] = 0
39
+
40
+ class WidgetSaveRequest(BaseModel):
41
+ id: Optional[str] = None
42
+ dashboardId: str
43
+ queryId: Optional[str] = None
44
+ widgetType: str
45
+ title: str
46
+ subtitle: Optional[str] = ""
47
+ mapping: Optional[Dict[str, Any]] = None
48
+ display: Optional[Dict[str, Any]] = None
49
+
50
+ def create_bi_router(config: Optional[BIEngineConfig] = None) -> APIRouter:
51
+ cfg = config or BIEngineConfig()
52
+ db_mgr = DatabaseManager(
53
+ primary_db_url=cfg.primary_db_url,
54
+ min_conn=cfg.pool_min_size,
55
+ max_conn=cfg.pool_max_size,
56
+ timeout_ms=cfg.query_timeout_ms
57
+ )
58
+ cache = QueryResultCache(default_ttl_seconds=cfg.cache_ttl_seconds)
59
+
60
+ router = APIRouter(include_in_schema=cfg.include_in_schema)
61
+
62
+ # Admin Authentication Dependency
63
+ def verify_admin(
64
+ x_bi_admin_key: Optional[str] = Header(None, alias="X-BI-Admin-Key"),
65
+ admin_key: Optional[str] = Query(None)
66
+ ):
67
+ token = x_bi_admin_key or admin_key
68
+ if not token or not secure_compare(token, cfg.admin_password):
69
+ raise HTTPException(
70
+ status_code=status.HTTP_401_UNAUTHORIZED,
71
+ detail="Unauthorized: Valid X-BI-Admin-Key header required."
72
+ )
73
+ return True
74
+
75
+ # Helper: Get Dashboard Record from DB
76
+ def _get_raw_dashboard(dashboard_id: str) -> Optional[Dict[str, Any]]:
77
+ pool = db_mgr.get_pool(cfg.primary_db_url)
78
+ conn = pool.getconn()
79
+ try:
80
+ with conn.cursor() as cur:
81
+ cur.execute("""
82
+ SELECT id, title, description, db_connection_url, db_type,
83
+ theme_config, layout_config, access_token, is_active, allowed_queries
84
+ FROM _bi_dashboards WHERE id = %s
85
+ """, (dashboard_id,))
86
+ row = cur.fetchone()
87
+ if not row:
88
+ return None
89
+ return {
90
+ "id": row[0],
91
+ "title": row[1],
92
+ "description": row[2],
93
+ "db_connection_url": row[3],
94
+ "db_type": row[4],
95
+ "theme_config": row[5] or {},
96
+ "layout_config": row[6] or [],
97
+ "access_token": row[7],
98
+ "is_active": row[8],
99
+ "allowed_queries": row[9] or []
100
+ }
101
+ finally:
102
+ pool.putconn(conn)
103
+
104
+ # Helper: Get Full Dashboard with Queries & Widgets (Sanitized for Public vs Admin)
105
+ def _build_dashboard_response(dashboard_id: str, is_admin: bool = False) -> Dict[str, Any]:
106
+ raw = _get_raw_dashboard(dashboard_id)
107
+ if not raw:
108
+ raise HTTPException(status_code=404, detail="Dashboard not found.")
109
+
110
+ pool = db_mgr.get_pool(cfg.primary_db_url)
111
+ conn = pool.getconn()
112
+ try:
113
+ with conn.cursor() as cur:
114
+ # Queries
115
+ cur.execute("SELECT id, name, sql_text, refresh_interval_sec FROM _bi_queries WHERE dashboard_id = %s", (dashboard_id,))
116
+ queries = []
117
+ for q in cur.fetchall():
118
+ queries.append({
119
+ "id": q[0],
120
+ "name": q[1],
121
+ "sqlText": q[2] if is_admin else "", # Zero SQL text in public payload!
122
+ "refreshIntervalSec": q[3]
123
+ })
124
+
125
+ # Widgets
126
+ cur.execute("SELECT id, query_id, widget_type, title, subtitle, mapping_config, display_config FROM _bi_widgets WHERE dashboard_id = %s", (dashboard_id,))
127
+ widgets = []
128
+ for w in cur.fetchall():
129
+ widgets.append({
130
+ "id": w[0],
131
+ "dashboardId": dashboard_id,
132
+ "queryId": w[1],
133
+ "widgetType": w[2],
134
+ "title": w[3],
135
+ "subtitle": w[4],
136
+ "mapping": w[5] or {},
137
+ "display": w[6] or {}
138
+ })
139
+
140
+ # Decrypt DB URL only for admin
141
+ db_url = None
142
+ if is_admin and raw["db_connection_url"]:
143
+ db_url = decrypt_string(raw["db_connection_url"], cfg.encryption_key or "default_key")
144
+
145
+ return {
146
+ "id": raw["id"],
147
+ "title": raw["title"],
148
+ "description": raw["description"],
149
+ "dbConnectionUrl": db_url, # Stripped/None for public!
150
+ "dbType": raw["db_type"],
151
+ "theme": raw["theme_config"],
152
+ "layout": raw["layout_config"],
153
+ "accessToken": raw["access_token"] if is_admin else None,
154
+ "isActive": raw["is_active"],
155
+ "queries": queries,
156
+ "widgets": widgets
157
+ }
158
+ finally:
159
+ pool.putconn(conn)
160
+
161
+ # =========================================================================
162
+ # ๐Ÿ”’ ADMIN ROUTES (Protected by BI_ADMIN_PASSWORD)
163
+ # =========================================================================
164
+
165
+ @router.get("/admin/dashboards", dependencies=[Depends(verify_admin)])
166
+ def list_admin_dashboards():
167
+ pool = db_mgr.get_pool(cfg.primary_db_url)
168
+ conn = pool.getconn()
169
+ try:
170
+ with conn.cursor() as cur:
171
+ cur.execute("SELECT id, title, description, db_type, theme_config, layout_config, access_token, is_active FROM _bi_dashboards ORDER BY created_at DESC")
172
+ dashboards = []
173
+ for r in cur.fetchall():
174
+ dashboards.append({
175
+ "id": r[0],
176
+ "title": r[1],
177
+ "description": r[2],
178
+ "dbType": r[3],
179
+ "theme": r[4] or {},
180
+ "layout": r[5] or [],
181
+ "accessToken": r[6],
182
+ "isActive": r[7]
183
+ })
184
+ return {"success": True, "data": dashboards}
185
+ finally:
186
+ pool.putconn(conn)
187
+
188
+ @router.get("/admin/dashboards/{dashboard_id}", dependencies=[Depends(verify_admin)])
189
+ def get_admin_dashboard(dashboard_id: str):
190
+ dash = _build_dashboard_response(dashboard_id, is_admin=True)
191
+ return {"success": True, "data": dash}
192
+
193
+ @router.post("/admin/dashboards", dependencies=[Depends(verify_admin)])
194
+ def save_admin_dashboard(req: DashboardSaveRequest):
195
+ dash_id = req.id or f"dash_{uuid.uuid4().hex[:12]}"
196
+
197
+ # Encrypt connection string with AES-256 before saving to PostgreSQL
198
+ encrypted_url = ""
199
+ if req.dbConnectionUrl:
200
+ encrypted_url = encrypt_string(req.dbConnectionUrl, cfg.encryption_key or "default_key")
201
+
202
+ # Auto-generate access token if not present
203
+ token = generate_dashboard_token()
204
+
205
+ pool = db_mgr.get_pool(cfg.primary_db_url)
206
+ conn = pool.getconn()
207
+ try:
208
+ with conn.cursor() as cur:
209
+ cur.execute("""
210
+ INSERT INTO _bi_dashboards (id, title, description, db_connection_url, db_type, theme_config, layout_config, access_token, is_active)
211
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, TRUE)
212
+ ON CONFLICT (id) DO UPDATE SET
213
+ title = EXCLUDED.title,
214
+ description = EXCLUDED.description,
215
+ db_connection_url = CASE WHEN EXCLUDED.db_connection_url != '' THEN EXCLUDED.db_connection_url ELSE _bi_dashboards.db_connection_url END,
216
+ db_type = EXCLUDED.db_type,
217
+ theme_config = EXCLUDED.theme_config,
218
+ layout_config = EXCLUDED.layout_config,
219
+ access_token = COALESCE(_bi_dashboards.access_token, EXCLUDED.access_token),
220
+ updated_at = CURRENT_TIMESTAMP
221
+ RETURNING access_token;
222
+ """, (
223
+ dash_id,
224
+ req.title,
225
+ req.description,
226
+ encrypted_url,
227
+ req.dbType,
228
+ json.dumps(req.theme or {"mode": "dark"}),
229
+ json.dumps(req.layout or []),
230
+ token
231
+ ))
232
+ saved_token = cur.fetchone()[0]
233
+ conn.commit()
234
+ return {"success": True, "data": {"id": dash_id, "accessToken": saved_token}}
235
+ finally:
236
+ pool.putconn(conn)
237
+
238
+ @router.delete("/admin/dashboards/{dashboard_id}", dependencies=[Depends(verify_admin)])
239
+ def delete_admin_dashboard(dashboard_id: str):
240
+ pool = db_mgr.get_pool(cfg.primary_db_url)
241
+ conn = pool.getconn()
242
+ try:
243
+ with conn.cursor() as cur:
244
+ cur.execute("DELETE FROM _bi_dashboards WHERE id = %s", (dashboard_id,))
245
+ conn.commit()
246
+ return {"success": True, "message": "Dashboard deleted"}
247
+ finally:
248
+ pool.putconn(conn)
249
+
250
+ @router.post("/admin/dashboards/{dashboard_id}/tokens/regenerate", dependencies=[Depends(verify_admin)])
251
+ def regenerate_token(dashboard_id: str):
252
+ new_token = generate_dashboard_token()
253
+ pool = db_mgr.get_pool(cfg.primary_db_url)
254
+ conn = pool.getconn()
255
+ try:
256
+ with conn.cursor() as cur:
257
+ cur.execute("UPDATE _bi_dashboards SET access_token = %s, is_active = TRUE WHERE id = %s", (new_token, dashboard_id))
258
+ conn.commit()
259
+ return {"success": True, "data": {"accessToken": new_token}}
260
+ finally:
261
+ pool.putconn(conn)
262
+
263
+ @router.post("/admin/dashboards/{dashboard_id}/tokens/revoke", dependencies=[Depends(verify_admin)])
264
+ def revoke_token(dashboard_id: str):
265
+ pool = db_mgr.get_pool(cfg.primary_url if hasattr(db_mgr, 'primary_url') else cfg.primary_db_url)
266
+ conn = pool.getconn()
267
+ try:
268
+ with conn.cursor() as cur:
269
+ cur.execute("UPDATE _bi_dashboards SET is_active = FALSE WHERE id = %s", (dashboard_id,))
270
+ conn.commit()
271
+ return {"success": True, "message": "Dashboard access token revoked."}
272
+ finally:
273
+ pool.putconn(conn)
274
+
275
+ @router.post("/admin/queries", dependencies=[Depends(verify_admin)])
276
+ def save_admin_query(req: QuerySaveRequest):
277
+ q_id = req.id or f"q_{uuid.uuid4().hex[:12]}"
278
+ pool = db_mgr.get_pool(cfg.primary_db_url)
279
+ conn = pool.getconn()
280
+ try:
281
+ with conn.cursor() as cur:
282
+ cur.execute("""
283
+ INSERT INTO _bi_queries (id, dashboard_id, name, sql_text, refresh_interval_sec)
284
+ VALUES (%s, %s, %s, %s, %s)
285
+ ON CONFLICT (id) DO UPDATE SET
286
+ name = EXCLUDED.name,
287
+ sql_text = EXCLUDED.sql_text,
288
+ refresh_interval_sec = EXCLUDED.refresh_interval_sec
289
+ """, (q_id, req.dashboardId, req.name, req.sqlText, req.refreshIntervalSec))
290
+ conn.commit()
291
+ return {"success": True, "data": {"id": q_id, "name": req.name}}
292
+ finally:
293
+ pool.putconn(conn)
294
+
295
+ @router.post("/admin/widgets", dependencies=[Depends(verify_admin)])
296
+ def save_admin_widget(req: WidgetSaveRequest):
297
+ w_id = req.id or f"w_{uuid.uuid4().hex[:12]}"
298
+ pool = db_mgr.get_pool(cfg.primary_db_url)
299
+ conn = pool.getconn()
300
+ try:
301
+ with conn.cursor() as cur:
302
+ cur.execute("""
303
+ INSERT INTO _bi_widgets (id, dashboard_id, query_id, widget_type, title, subtitle, mapping_config, display_config)
304
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
305
+ ON CONFLICT (id) DO UPDATE SET
306
+ query_id = EXCLUDED.query_id,
307
+ widget_type = EXCLUDED.widget_type,
308
+ title = EXCLUDED.title,
309
+ subtitle = EXCLUDED.subtitle,
310
+ mapping_config = EXCLUDED.mapping_config,
311
+ display_config = EXCLUDED.display_config
312
+ """, (
313
+ w_id,
314
+ req.dashboardId,
315
+ req.queryId,
316
+ req.widgetType,
317
+ req.title,
318
+ req.subtitle,
319
+ json.dumps(req.mapping or {}),
320
+ json.dumps(req.display or {})
321
+ ))
322
+ conn.commit()
323
+ return {"success": True, "data": {"id": w_id, "title": req.title}}
324
+ finally:
325
+ pool.putconn(conn)
326
+
327
+ @router.post("/admin/query/test", dependencies=[Depends(verify_admin)])
328
+ def test_admin_query(req: ExecuteQueryRequest):
329
+ try:
330
+ result = db_mgr.execute_query(req.sql, req.url, req.params)
331
+ return {"success": True, "data": result}
332
+ except Exception as e:
333
+ raise HTTPException(status_code=400, detail=str(e))
334
+
335
+ @router.get("/admin/db/introspect", dependencies=[Depends(verify_admin)])
336
+ def introspect_admin_db(url: Optional[str] = None):
337
+ try:
338
+ res = db_mgr.introspect_schema(url)
339
+ return {"success": True, "data": res}
340
+ except Exception as e:
341
+ raise HTTPException(status_code=400, detail=str(e))
342
+
343
+ # =========================================================================
344
+ # ๐ŸŒ PUBLIC CLIENT ROUTES (Protected by Scoped Dashboard Access Token)
345
+ # =========================================================================
346
+
347
+ @router.get("/public/dashboards/{dashboard_id}")
348
+ def get_public_dashboard(
349
+ dashboard_id: str,
350
+ x_dashboard_token: Optional[str] = Header(None, alias="X-Dashboard-Token"),
351
+ token: Optional[str] = Query(None)
352
+ ):
353
+ access_token = x_dashboard_token or token
354
+ raw = _get_raw_dashboard(dashboard_id)
355
+ if not raw:
356
+ raise HTTPException(status_code=404, detail="Dashboard not found.")
357
+
358
+ if not raw["is_active"]:
359
+ raise HTTPException(status_code=401, detail="Dashboard access has been revoked.")
360
+
361
+ if not access_token or not secure_compare(raw["access_token"], access_token):
362
+ raise HTTPException(status_code=401, detail="Invalid or missing X-Dashboard-Token.")
363
+
364
+ # Return sanitized dashboard (Zero DB URLs, Zero raw SQL text)
365
+ dash = _build_dashboard_response(dashboard_id, is_admin=False)
366
+ return {"success": True, "data": dash}
367
+
368
+ @router.post("/public/query/execute")
369
+ def execute_public_query(
370
+ req: PublicQueryExecuteRequest,
371
+ x_dashboard_token: Optional[str] = Header(None, alias="X-Dashboard-Token")
372
+ ):
373
+ token = x_dashboard_token or req.token
374
+ if not token:
375
+ raise HTTPException(status_code=401, detail="Missing X-Dashboard-Token header.")
376
+
377
+ # Look up query and parent dashboard
378
+ pool = db_mgr.get_pool(cfg.primary_db_url)
379
+ conn = pool.getconn()
380
+ try:
381
+ with conn.cursor() as cur:
382
+ cur.execute("""
383
+ SELECT q.sql_text, d.id, d.access_token, d.is_active, d.db_connection_url
384
+ FROM _bi_queries q
385
+ JOIN _bi_dashboards d ON q.dashboard_id = d.id
386
+ WHERE q.id = %s
387
+ """, (req.queryId,))
388
+ row = cur.fetchone()
389
+ if not row:
390
+ raise HTTPException(status_code=404, detail="Query not found.")
391
+
392
+ sql_text, dash_id, stored_token, is_active, encrypted_db_url = row
393
+
394
+ if not is_active:
395
+ raise HTTPException(status_code=401, detail="Dashboard access has been revoked.")
396
+
397
+ # Anti-IDOR Token verification: Ensure token matches THIS query's dashboard!
398
+ if not secure_compare(stored_token, token):
399
+ raise HTTPException(status_code=403, detail="Forbidden: Token does not have permission to execute this query.")
400
+
401
+ # Check in-memory LRU cache (only after token authorization is verified!)
402
+ cached = cache.get(req.queryId, req.params)
403
+ if cached:
404
+ return {"success": True, "data": cached, "cached": True}
405
+
406
+ # Decrypt target DB URL in RAM
407
+ target_db_url = cfg.primary_db_url
408
+ if encrypted_db_url:
409
+ target_db_url = decrypt_string(encrypted_db_url, cfg.encryption_key or "default_key")
410
+
411
+ # Execute query safely
412
+ res = db_mgr.execute_query(sql_text, target_db_url, req.params)
413
+
414
+ # Store in LRU cache
415
+ cache.set(req.queryId, req.params, res)
416
+ return {"success": True, "data": res}
417
+ except HTTPException:
418
+ raise
419
+ except Exception as e:
420
+ raise HTTPException(status_code=400, detail=f"Query execution error: {str(e)}")
421
+ finally:
422
+ pool.putconn(conn)
423
+
424
+ return router
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fastapi-openbi"
7
+ version = "1.0.0"
8
+ authors = [
9
+ { name = "OpenBI Team" },
10
+ ]
11
+ description = "Embeddable, Secure, Self-Contained BI Dashboard Engine for FastAPI"
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Framework :: FastAPI",
19
+ ]
20
+ dependencies = [
21
+ "fastapi>=0.95.0",
22
+ "pydantic>=2.0.0",
23
+ "psycopg2-binary>=2.9.0",
24
+ "cryptography>=41.0.0",
25
+ "uvicorn>=0.20.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ "Homepage" = "https://github.com/open-bi/fastapi-openbi"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,124 @@
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+ from fastapi import FastAPI
4
+ import sys
5
+ import os
6
+
7
+ # Add package to sys.path
8
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
9
+
10
+ from open_bi import create_bi_router, BIEngineConfig, encrypt_string, decrypt_string, validate_safe_query, generate_dashboard_token
11
+
12
+ def test_crypto_aes256():
13
+ secret = "my_super_secret_master_key_123"
14
+ raw_db_url = "postgresql://postgres:secretpassword@prod.db.internal:5432/finance"
15
+
16
+ encrypted = encrypt_string(raw_db_url, secret)
17
+ assert encrypted != raw_db_url
18
+ assert len(encrypted) > 20
19
+
20
+ decrypted = decrypt_string(encrypted, secret)
21
+ assert decrypted == raw_db_url
22
+
23
+ def test_sql_guard_security():
24
+ # Safe queries
25
+ assert validate_safe_query("SELECT * FROM sales;")[0] == True
26
+ assert validate_safe_query("WITH summary AS (SELECT category, sum(amount) FROM sales GROUP BY 1) SELECT * FROM summary")[0] == True
27
+
28
+ # Destructive blocked queries
29
+ assert validate_safe_query("DROP TABLE users;")[0] == False
30
+ assert validate_safe_query("DELETE FROM orders;")[0] == False
31
+ assert validate_safe_query("INSERT INTO accounts VALUES (1, 'hack');")[0] == False
32
+ assert validate_safe_query("UPDATE users SET role = 'admin';")[0] == False
33
+ assert validate_safe_query("SELECT 1; DROP TABLE users;")[0] == False
34
+
35
+ def test_token_generation():
36
+ token1 = generate_dashboard_token()
37
+ token2 = generate_dashboard_token()
38
+ assert token1.startswith("emb_live_")
39
+ assert token2.startswith("emb_live_")
40
+ assert token1 != token2
41
+ assert len(token1) >= 40
42
+
43
+ def test_fastapi_endpoints():
44
+ app = FastAPI()
45
+ cfg = BIEngineConfig(
46
+ primary_db_url="postgresql://postgres:phemon420@localhost:5432/dashboard_generator",
47
+ admin_password="test_admin_pass_999",
48
+ encryption_key="test_vault_key",
49
+ include_in_schema=False
50
+ )
51
+ app.include_router(create_bi_router(cfg), prefix="/api/bi")
52
+ client = TestClient(app)
53
+
54
+ # 1. Admin route with wrong password should fail 401
55
+ res = client.get("/api/bi/admin/dashboards", headers={"X-BI-Admin-Key": "wrong_password"})
56
+ assert res.status_code == 401
57
+
58
+ # 2. Admin route with correct password succeeds 200
59
+ res = client.get("/api/bi/admin/dashboards", headers={"X-BI-Admin-Key": "test_admin_pass_999"})
60
+ assert res.status_code == 200
61
+ data = res.json()
62
+ assert data["success"] == True
63
+
64
+ # 3. Create a test dashboard with encrypted DB URL
65
+ create_res = client.post("/api/bi/admin/dashboards", headers={"X-BI-Admin-Key": "test_admin_pass_999"}, json={
66
+ "id": "dash_test_security_1",
67
+ "title": "Security Test Dashboard",
68
+ "description": "Anti-IDOR & Vault Verification",
69
+ "dbConnectionUrl": "postgresql://postgres:phemon420@localhost:5432/dashboard_generator",
70
+ "layout": []
71
+ })
72
+ assert create_res.status_code == 200
73
+ token = create_res.json()["data"]["accessToken"]
74
+ assert token is not None
75
+
76
+ # 4. Save a query
77
+ q_res = client.post("/api/bi/admin/queries", headers={"X-BI-Admin-Key": "test_admin_pass_999"}, json={
78
+ "id": "q_test_sec_1",
79
+ "dashboardId": "dash_test_security_1",
80
+ "name": "Category Sales Test",
81
+ "sqlText": "SELECT 'Laptops' AS product_category, 150000 AS total_rev UNION ALL SELECT 'Smartphones', 220000;"
82
+ })
83
+ assert q_res.status_code == 200
84
+
85
+ # 5. Public endpoint with INVALID token fails 401
86
+ pub_fail = client.get("/api/bi/public/dashboards/dash_test_security_1", headers={"X-Dashboard-Token": "invalid_fake_token"})
87
+ assert pub_fail.status_code == 401
88
+
89
+ # 6. Public endpoint with VALID token succeeds & is SANITIZED (zero DB url, zero raw SQL)
90
+ pub_ok = client.get("/api/bi/public/dashboards/dash_test_security_1", headers={"X-Dashboard-Token": token})
91
+ assert pub_ok.status_code == 200
92
+ pub_data = pub_ok.json()["data"]
93
+ assert pub_data["title"] == "Security Test Dashboard"
94
+ # CRITICAL: dbConnectionUrl MUST be None/stripped for public!
95
+ assert pub_data["dbConnectionUrl"] is None
96
+ # CRITICAL: raw sqlText MUST be empty in public payload!
97
+ assert pub_data["queries"][0]["sqlText"] == ""
98
+
99
+ # 7. Execute query by ID through public API using token
100
+ exec_res = client.post("/api/bi/public/query/execute", headers={"X-Dashboard-Token": token}, json={
101
+ "queryId": "q_test_sec_1"
102
+ })
103
+ assert exec_res.status_code == 200
104
+ rows = exec_res.json()["data"]["rows"]
105
+ assert len(rows) > 0
106
+ assert "product_category" in rows[0]
107
+
108
+ # 8. Anti-IDOR Test: Another fake token cannot run this query!
109
+ idor_res = client.post("/api/bi/public/query/execute", headers={"X-Dashboard-Token": "emb_live_other_client_token"}, json={
110
+ "queryId": "q_test_sec_1"
111
+ })
112
+ assert idor_res.status_code == 403 # Forbidden!
113
+
114
+ # 9. Clean up test dashboard
115
+ del_res = client.delete("/api/bi/admin/dashboards/dash_test_security_1", headers={"X-BI-Admin-Key": "test_admin_pass_999"})
116
+ assert del_res.status_code == 200
117
+
118
+ print("\n[PASS] All 9 Python FastAPI OpenBI Security Tests Passed Successfully!")
119
+
120
+ if __name__ == "__main__":
121
+ test_crypto_aes256()
122
+ test_sql_guard_security()
123
+ test_token_generation()
124
+ test_fastapi_endpoints()