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.
- fastapi_openbi-1.0.0/PKG-INFO +108 -0
- fastapi_openbi-1.0.0/README.md +90 -0
- fastapi_openbi-1.0.0/fastapi_openbi.egg-info/PKG-INFO +108 -0
- fastapi_openbi-1.0.0/fastapi_openbi.egg-info/SOURCES.txt +15 -0
- fastapi_openbi-1.0.0/fastapi_openbi.egg-info/dependency_links.txt +1 -0
- fastapi_openbi-1.0.0/fastapi_openbi.egg-info/requires.txt +5 -0
- fastapi_openbi-1.0.0/fastapi_openbi.egg-info/top_level.txt +1 -0
- fastapi_openbi-1.0.0/open_bi/__init__.py +24 -0
- fastapi_openbi-1.0.0/open_bi/cache.py +41 -0
- fastapi_openbi-1.0.0/open_bi/config.py +24 -0
- fastapi_openbi-1.0.0/open_bi/crypto.py +55 -0
- fastapi_openbi-1.0.0/open_bi/db.py +187 -0
- fastapi_openbi-1.0.0/open_bi/guard.py +52 -0
- fastapi_openbi-1.0.0/open_bi/router.py +424 -0
- fastapi_openbi-1.0.0/pyproject.toml +29 -0
- fastapi_openbi-1.0.0/setup.cfg +4 -0
- fastapi_openbi-1.0.0/tests/test_openbi.py +124 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
open_bi
|
|
@@ -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,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()
|