sql-data-guard 0.1.9__tar.gz → 0.2.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.
- {sql_data_guard-0.1.9/src/sql_data_guard.egg-info → sql_data_guard-0.2.0}/PKG-INFO +58 -20
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/README.md +49 -16
- sql_data_guard-0.2.0/pyproject.toml +50 -0
- sql_data_guard-0.2.0/src/sql_data_guard/__init__.py +4 -0
- sql_data_guard-0.2.0/src/sql_data_guard/column_masking.py +127 -0
- sql_data_guard-0.2.0/src/sql_data_guard/config_suggestion.py +169 -0
- sql_data_guard-0.2.0/src/sql_data_guard/mcpwrapper/mcp_wrapper.py +331 -0
- sql_data_guard-0.2.0/src/sql_data_guard/rest/__init__.py +3 -0
- sql_data_guard-0.2.0/src/sql_data_guard/rest/gunicorn.conf.py +20 -0
- sql_data_guard-0.2.0/src/sql_data_guard/rest/sql_data_guard_rest.py +87 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/src/sql_data_guard/restriction_validation.py +45 -5
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/src/sql_data_guard/restriction_verification.py +60 -30
- sql_data_guard-0.2.0/src/sql_data_guard/sql_data_guard.py +729 -0
- sql_data_guard-0.2.0/src/sql_data_guard/verification_context.py +127 -0
- sql_data_guard-0.2.0/src/sql_data_guard/verification_utils.py +66 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0/src/sql_data_guard.egg-info}/PKG-INFO +58 -20
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/src/sql_data_guard.egg-info/SOURCES.txt +14 -0
- sql_data_guard-0.2.0/src/sql_data_guard.egg-info/requires.txt +1 -0
- sql_data_guard-0.2.0/test/test_bypass_unit.py +790 -0
- sql_data_guard-0.2.0/test/test_column_masking_unit.py +241 -0
- sql_data_guard-0.2.0/test/test_config_suggestion_unit.py +267 -0
- sql_data_guard-0.2.0/test/test_correlated_unit.py +96 -0
- sql_data_guard-0.2.0/test/test_cte_scope_unit.py +66 -0
- sql_data_guard-0.2.0/test/test_dify_plugin_unit.py +136 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_duckdb_unit.py +4 -3
- sql_data_guard-0.2.0/test/test_function_policy_unit.py +156 -0
- sql_data_guard-0.2.0/test/test_identifier_case_unit.py +59 -0
- sql_data_guard-0.2.0/test/test_limit_enforcement_unit.py +167 -0
- sql_data_guard-0.2.0/test/test_mcp_wrapper_unit.py +430 -0
- sql_data_guard-0.2.0/test/test_rest_api_unit.py +240 -0
- sql_data_guard-0.2.0/test/test_security_unit.py +249 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_sql_guard_curr_unit.py +2 -6
- sql_data_guard-0.2.0/test/test_sql_guard_joins_unit.py +385 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_sql_guard_llm.py +2 -2
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_sql_guard_unit.py +41 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_sql_guard_updates_unit.py +23 -15
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_utils.py +12 -7
- sql_data_guard-0.1.9/pyproject.toml +0 -30
- sql_data_guard-0.1.9/src/sql_data_guard/__init__.py +0 -1
- sql_data_guard-0.1.9/src/sql_data_guard/mcpwrapper/mcp_wrapper.py +0 -129
- sql_data_guard-0.1.9/src/sql_data_guard/rest/__init__.py +0 -1
- sql_data_guard-0.1.9/src/sql_data_guard/rest/sql_data_guard_rest.py +0 -39
- sql_data_guard-0.1.9/src/sql_data_guard/sql_data_guard.py +0 -295
- sql_data_guard-0.1.9/src/sql_data_guard/verification_context.py +0 -63
- sql_data_guard-0.1.9/src/sql_data_guard/verification_utils.py +0 -18
- sql_data_guard-0.1.9/src/sql_data_guard.egg-info/requires.txt +0 -1
- sql_data_guard-0.1.9/test/test_rest_api_unit.py +0 -76
- sql_data_guard-0.1.9/test/test_sql_guard_joins_unit.py +0 -300
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/LICENSE.md +0 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/setup.cfg +0 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/src/sql_data_guard.egg-info/dependency_links.txt +0 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/src/sql_data_guard.egg-info/top_level.txt +0 -0
- {sql_data_guard-0.1.9 → sql_data_guard-0.2.0}/test/test_sql_guard_validation_unit.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-data-guard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Safety Layer for LLM Database Interactions
|
|
5
|
-
Author-email: Imperva - Threat
|
|
5
|
+
Author-email: Imperva - Threat Research Infra <ww.dis.imperva.threat-research-infra@thalesgroup.com>
|
|
6
6
|
License: MIT License
|
|
7
7
|
|
|
8
8
|
Copyright (c) 2025 Imperva
|
|
@@ -15,11 +15,16 @@ License: MIT License
|
|
|
15
15
|
Project-URL: Homepage, https://github.com/ThalesGroup/sql-data-guard
|
|
16
16
|
Project-URL: Issues, https://github.com/ThalesGroup/sql-data-guard/issues
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
23
|
Classifier: Operating System :: OS Independent
|
|
19
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.10
|
|
20
25
|
Description-Content-Type: text/markdown
|
|
21
26
|
License-File: LICENSE.md
|
|
22
|
-
Requires-Dist: sqlglot
|
|
27
|
+
Requires-Dist: sqlglot<31,>=25.20.1
|
|
23
28
|
Dynamic: license-file
|
|
24
29
|
|
|
25
30
|
|
|
@@ -32,7 +37,7 @@ Dynamic: license-file
|
|
|
32
37
|
SQL is the go-to language for performing queries on databases, and for a good reason - it’s well known, easy to use and pretty simple. However, it seems that it’s as easy to use as it is to exploit, and SQL injection is still one of the most targeted vulnerabilities - especially nowadays with the proliferation of “natural language queries” harnessing Large Language Models (LLMs) power to generate and run SQL queries.
|
|
33
38
|
|
|
34
39
|
|
|
35
|
-
To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard
|
|
40
|
+
To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard also detects "always-true" (static) expressions such as `OR 1 = 1`, a common injection payload, and removes them before query execution.
|
|
36
41
|
|
|
37
42
|
|
|
38
43
|
sql-data-guard is particularly useful when constructing SQL queries with LLMs, as such queries can’t run as prepared statements. Prepared statements secure a query’s structure, but LLM-generated queries are dynamic and lack this fixed form, increasing SQL injection risk. sql-data-guard mitigates this by inspecting and validating the query's content.
|
|
@@ -45,14 +50,14 @@ Connecting LLMs to SQL databases without strict controls can risk accidental dat
|
|
|
45
50
|
|
|
46
51
|
## Why Use sql-data-guard?
|
|
47
52
|
|
|
48
|
-
Consider using sql-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
|
|
53
|
+
Consider using sql-data-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
|
|
49
54
|
- Your application generates complex SQL queries.
|
|
50
55
|
- Your application employs LLM (Large Language Models) to create SQL queries, making it difficult to fully control the queries.
|
|
51
56
|
- Different application users and roles should have different permissions, and you need to correlate an application user or role with fine-grained data access permission.
|
|
52
57
|
- In multi-tenant applications, you need to ensure that each tenant can access only their data, which requires row-level security and often cannot be done using the database permissions model.
|
|
53
58
|
|
|
54
|
-
sql-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
|
|
55
|
-
Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
|
|
59
|
+
sql-data-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
|
|
60
|
+
Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-data-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
|
|
56
61
|
|
|
57
62
|
## How It Works
|
|
58
63
|
|
|
@@ -73,7 +78,7 @@ from sql_data_guard import verify_sql
|
|
|
73
78
|
config = {
|
|
74
79
|
"tables": [
|
|
75
80
|
{
|
|
76
|
-
"
|
|
81
|
+
"table_name": "orders",
|
|
77
82
|
"columns": ["id", "product_name", "account_id"],
|
|
78
83
|
"restrictions": [{"column": "account_id", "value": 123}]
|
|
79
84
|
}
|
|
@@ -88,23 +93,32 @@ Output:
|
|
|
88
93
|
```json
|
|
89
94
|
{
|
|
90
95
|
"allowed": false,
|
|
91
|
-
"errors": [
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
"errors": [
|
|
97
|
+
"Column name is not allowed. Column removed from SELECT clause",
|
|
98
|
+
"Missing restriction for table: orders column: account_id value: 123",
|
|
99
|
+
"Static expression is not allowed: 1 = 1"
|
|
100
|
+
],
|
|
101
|
+
"fixed": "SELECT id FROM orders WHERE account_id = 123",
|
|
102
|
+
"risk": 0.53
|
|
94
103
|
}
|
|
95
104
|
```
|
|
96
|
-
|
|
105
|
+
`errors` is a sorted list. `risk` is between 0 (no risk) and 1 (high risk).
|
|
106
|
+
For more details on restriction rules and validation, see the [manual.](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/manual.md)
|
|
97
107
|
|
|
98
108
|
|
|
99
109
|
Here is a table with more examples of SQL queries and their corresponding JSON outputs:
|
|
100
110
|
|
|
101
|
-
| SQL Query
|
|
102
|
-
|
|
103
|
-
| SELECT id, product_name FROM orders WHERE account_id = 123 | { "allowed": true, "errors": [], "fixed": null }
|
|
104
|
-
| SELECT id FROM orders WHERE account_id = 456
|
|
105
|
-
| SELECT id, col FROM orders WHERE account_id = 123
|
|
106
|
-
| SELECT id FROM orders WHERE account_id = 123 OR 1 = 1 | { "allowed": false, "errors": ["
|
|
107
|
-
|SELECT * FROM orders WHERE account_id = 123| {"allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123"}
|
|
111
|
+
| SQL Query | JSON Output |
|
|
112
|
+
|-----------|-------------|
|
|
113
|
+
| `SELECT id, product_name FROM orders WHERE account_id = 123` | `{ "allowed": true, "errors": [], "fixed": null, "risk": 0 }` |
|
|
114
|
+
| `SELECT id FROM orders WHERE account_id = 456` | `{ "allowed": false, "errors": ["Missing restriction for table: orders column: account_id value: 123"], "fixed": "SELECT id FROM orders WHERE (account_id = 456) AND account_id = 123", "risk": 0.5 }` |
|
|
115
|
+
| `SELECT id, col FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["Column col is not allowed. Column removed from SELECT clause"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.3 }` |
|
|
116
|
+
| `SELECT id FROM orders WHERE account_id = 123 OR 1 = 1` | `{ "allowed": false, "errors": ["Static expression is not allowed: 1 = 1"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.8 }` |
|
|
117
|
+
| `SELECT * FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123", "risk": 0.1 }` |
|
|
118
|
+
| `SELECT id FROM orders WHERE account_id = 123 AND secret = 1` | `{ "allowed": false, "errors": ["Column secret is not allowed"], "fixed": null, "risk": 0.8 }` |
|
|
119
|
+
| `SELECT id FROM orders WHERE account_id = 123; DROP TABLE orders` | `{ "allowed": false, "errors": ["Multiple statements are not allowed"], "fixed": null, "risk": 1.0 }` |
|
|
120
|
+
|
|
121
|
+
When `fixed` is `null` the query cannot be repaired and must not be run.
|
|
108
122
|
|
|
109
123
|
This table provides a variety of SQL queries and their corresponding JSON outputs, demonstrating how `sql-data-guard` handles different scenarios.
|
|
110
124
|
|
|
@@ -127,6 +141,25 @@ To run the sql-data-guard Docker container, use the following command:
|
|
|
127
141
|
docker run -d --name sql-data-guard -p 5000:5000 ghcr.io/thalesgroup/sql-data-guard
|
|
128
142
|
```
|
|
129
143
|
|
|
144
|
+
The image runs the service with [gunicorn](https://gunicorn.org/) (2 worker processes with 4 threads each), not the Flask development server, and installs `sql-data-guard` from PyPI. Release images are tagged with the version (for example `0.1.10`) and `latest`, and contain exactly that release. It runs as a non-root user and listens on port 5000.
|
|
145
|
+
|
|
146
|
+
Settings (environment variables):
|
|
147
|
+
|
|
148
|
+
| Variable | Default | Meaning |
|
|
149
|
+
|----------|---------|---------|
|
|
150
|
+
| `APP_API_TOKEN` | not set | When set, every request must send `Authorization: Bearer <token>`. **Set it unless the service is only reachable by trusted callers.** |
|
|
151
|
+
| `APP_PORT` | `5000` | Port to listen on. |
|
|
152
|
+
| `APP_HOST` | `127.0.0.1` (`0.0.0.0` in the image) | Address to bind. |
|
|
153
|
+
| `APP_MAX_REQUEST_BYTES` | `1000000` | Maximum request size. |
|
|
154
|
+
| `WEB_CONCURRENCY` | `2` | gunicorn worker processes (image only). |
|
|
155
|
+
| `APP_THREADS` | `4` | Threads per worker (image only). |
|
|
156
|
+
|
|
157
|
+
The caller sends the policy (`config`) with every request, so the service is not an enforcement point against callers who must not be able to choose their own policy.
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
docker run -d --name sql-data-guard -p 5000:5000 -e APP_API_TOKEN=change-me ghcr.io/thalesgroup/sql-data-guard
|
|
161
|
+
```
|
|
162
|
+
|
|
130
163
|
### Calling the Docker Container Using REST API
|
|
131
164
|
|
|
132
165
|
Once the `sql-data-guard` Docker container is running, you can interact with it using its REST API. Below is an example of how to verify an SQL query using `curl`:
|
|
@@ -134,6 +167,7 @@ Once the `sql-data-guard` Docker container is running, you can interact with it
|
|
|
134
167
|
```bash
|
|
135
168
|
curl -X POST http://localhost:5000/verify-sql \
|
|
136
169
|
-H "Content-Type: application/json" \
|
|
170
|
+
-H "Authorization: Bearer change-me" \
|
|
137
171
|
-d '{
|
|
138
172
|
"sql": "SELECT * FROM orders WHERE account_id = 123",
|
|
139
173
|
"config": {
|
|
@@ -148,6 +182,10 @@ curl -X POST http://localhost:5000/verify-sql \
|
|
|
148
182
|
}'
|
|
149
183
|
```
|
|
150
184
|
|
|
185
|
+
## MCP Wrapper
|
|
186
|
+
|
|
187
|
+
sql-data-guard can also protect an MCP server that talks to a database: the wrapper verifies the SQL of every tool call before the server sees it, and refuses tools that could run SQL without the guard. See [the MCP wrapper guide](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/mcp-wrapper.md).
|
|
188
|
+
|
|
151
189
|
## Contributing
|
|
152
190
|
We welcome contributions! Please see our [CONTRIBUTING.md](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/CONTRIBUTING.md) for more details.
|
|
153
191
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
SQL is the go-to language for performing queries on databases, and for a good reason - it’s well known, easy to use and pretty simple. However, it seems that it’s as easy to use as it is to exploit, and SQL injection is still one of the most targeted vulnerabilities - especially nowadays with the proliferation of “natural language queries” harnessing Large Language Models (LLMs) power to generate and run SQL queries.
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard
|
|
11
|
+
To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard also detects "always-true" (static) expressions such as `OR 1 = 1`, a common injection payload, and removes them before query execution.
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
sql-data-guard is particularly useful when constructing SQL queries with LLMs, as such queries can’t run as prepared statements. Prepared statements secure a query’s structure, but LLM-generated queries are dynamic and lack this fixed form, increasing SQL injection risk. sql-data-guard mitigates this by inspecting and validating the query's content.
|
|
@@ -21,14 +21,14 @@ Connecting LLMs to SQL databases without strict controls can risk accidental dat
|
|
|
21
21
|
|
|
22
22
|
## Why Use sql-data-guard?
|
|
23
23
|
|
|
24
|
-
Consider using sql-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
|
|
24
|
+
Consider using sql-data-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
|
|
25
25
|
- Your application generates complex SQL queries.
|
|
26
26
|
- Your application employs LLM (Large Language Models) to create SQL queries, making it difficult to fully control the queries.
|
|
27
27
|
- Different application users and roles should have different permissions, and you need to correlate an application user or role with fine-grained data access permission.
|
|
28
28
|
- In multi-tenant applications, you need to ensure that each tenant can access only their data, which requires row-level security and often cannot be done using the database permissions model.
|
|
29
29
|
|
|
30
|
-
sql-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
|
|
31
|
-
Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
|
|
30
|
+
sql-data-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
|
|
31
|
+
Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-data-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
|
|
32
32
|
|
|
33
33
|
## How It Works
|
|
34
34
|
|
|
@@ -49,7 +49,7 @@ from sql_data_guard import verify_sql
|
|
|
49
49
|
config = {
|
|
50
50
|
"tables": [
|
|
51
51
|
{
|
|
52
|
-
"
|
|
52
|
+
"table_name": "orders",
|
|
53
53
|
"columns": ["id", "product_name", "account_id"],
|
|
54
54
|
"restrictions": [{"column": "account_id", "value": 123}]
|
|
55
55
|
}
|
|
@@ -64,23 +64,32 @@ Output:
|
|
|
64
64
|
```json
|
|
65
65
|
{
|
|
66
66
|
"allowed": false,
|
|
67
|
-
"errors": [
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
"errors": [
|
|
68
|
+
"Column name is not allowed. Column removed from SELECT clause",
|
|
69
|
+
"Missing restriction for table: orders column: account_id value: 123",
|
|
70
|
+
"Static expression is not allowed: 1 = 1"
|
|
71
|
+
],
|
|
72
|
+
"fixed": "SELECT id FROM orders WHERE account_id = 123",
|
|
73
|
+
"risk": 0.53
|
|
70
74
|
}
|
|
71
75
|
```
|
|
72
|
-
|
|
76
|
+
`errors` is a sorted list. `risk` is between 0 (no risk) and 1 (high risk).
|
|
77
|
+
For more details on restriction rules and validation, see the [manual.](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/manual.md)
|
|
73
78
|
|
|
74
79
|
|
|
75
80
|
Here is a table with more examples of SQL queries and their corresponding JSON outputs:
|
|
76
81
|
|
|
77
|
-
| SQL Query
|
|
78
|
-
|
|
79
|
-
| SELECT id, product_name FROM orders WHERE account_id = 123 | { "allowed": true, "errors": [], "fixed": null }
|
|
80
|
-
| SELECT id FROM orders WHERE account_id = 456
|
|
81
|
-
| SELECT id, col FROM orders WHERE account_id = 123
|
|
82
|
-
| SELECT id FROM orders WHERE account_id = 123 OR 1 = 1 | { "allowed": false, "errors": ["
|
|
83
|
-
|SELECT * FROM orders WHERE account_id = 123| {"allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123"}
|
|
82
|
+
| SQL Query | JSON Output |
|
|
83
|
+
|-----------|-------------|
|
|
84
|
+
| `SELECT id, product_name FROM orders WHERE account_id = 123` | `{ "allowed": true, "errors": [], "fixed": null, "risk": 0 }` |
|
|
85
|
+
| `SELECT id FROM orders WHERE account_id = 456` | `{ "allowed": false, "errors": ["Missing restriction for table: orders column: account_id value: 123"], "fixed": "SELECT id FROM orders WHERE (account_id = 456) AND account_id = 123", "risk": 0.5 }` |
|
|
86
|
+
| `SELECT id, col FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["Column col is not allowed. Column removed from SELECT clause"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.3 }` |
|
|
87
|
+
| `SELECT id FROM orders WHERE account_id = 123 OR 1 = 1` | `{ "allowed": false, "errors": ["Static expression is not allowed: 1 = 1"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.8 }` |
|
|
88
|
+
| `SELECT * FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123", "risk": 0.1 }` |
|
|
89
|
+
| `SELECT id FROM orders WHERE account_id = 123 AND secret = 1` | `{ "allowed": false, "errors": ["Column secret is not allowed"], "fixed": null, "risk": 0.8 }` |
|
|
90
|
+
| `SELECT id FROM orders WHERE account_id = 123; DROP TABLE orders` | `{ "allowed": false, "errors": ["Multiple statements are not allowed"], "fixed": null, "risk": 1.0 }` |
|
|
91
|
+
|
|
92
|
+
When `fixed` is `null` the query cannot be repaired and must not be run.
|
|
84
93
|
|
|
85
94
|
This table provides a variety of SQL queries and their corresponding JSON outputs, demonstrating how `sql-data-guard` handles different scenarios.
|
|
86
95
|
|
|
@@ -103,6 +112,25 @@ To run the sql-data-guard Docker container, use the following command:
|
|
|
103
112
|
docker run -d --name sql-data-guard -p 5000:5000 ghcr.io/thalesgroup/sql-data-guard
|
|
104
113
|
```
|
|
105
114
|
|
|
115
|
+
The image runs the service with [gunicorn](https://gunicorn.org/) (2 worker processes with 4 threads each), not the Flask development server, and installs `sql-data-guard` from PyPI. Release images are tagged with the version (for example `0.1.10`) and `latest`, and contain exactly that release. It runs as a non-root user and listens on port 5000.
|
|
116
|
+
|
|
117
|
+
Settings (environment variables):
|
|
118
|
+
|
|
119
|
+
| Variable | Default | Meaning |
|
|
120
|
+
|----------|---------|---------|
|
|
121
|
+
| `APP_API_TOKEN` | not set | When set, every request must send `Authorization: Bearer <token>`. **Set it unless the service is only reachable by trusted callers.** |
|
|
122
|
+
| `APP_PORT` | `5000` | Port to listen on. |
|
|
123
|
+
| `APP_HOST` | `127.0.0.1` (`0.0.0.0` in the image) | Address to bind. |
|
|
124
|
+
| `APP_MAX_REQUEST_BYTES` | `1000000` | Maximum request size. |
|
|
125
|
+
| `WEB_CONCURRENCY` | `2` | gunicorn worker processes (image only). |
|
|
126
|
+
| `APP_THREADS` | `4` | Threads per worker (image only). |
|
|
127
|
+
|
|
128
|
+
The caller sends the policy (`config`) with every request, so the service is not an enforcement point against callers who must not be able to choose their own policy.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
docker run -d --name sql-data-guard -p 5000:5000 -e APP_API_TOKEN=change-me ghcr.io/thalesgroup/sql-data-guard
|
|
132
|
+
```
|
|
133
|
+
|
|
106
134
|
### Calling the Docker Container Using REST API
|
|
107
135
|
|
|
108
136
|
Once the `sql-data-guard` Docker container is running, you can interact with it using its REST API. Below is an example of how to verify an SQL query using `curl`:
|
|
@@ -110,6 +138,7 @@ Once the `sql-data-guard` Docker container is running, you can interact with it
|
|
|
110
138
|
```bash
|
|
111
139
|
curl -X POST http://localhost:5000/verify-sql \
|
|
112
140
|
-H "Content-Type: application/json" \
|
|
141
|
+
-H "Authorization: Bearer change-me" \
|
|
113
142
|
-d '{
|
|
114
143
|
"sql": "SELECT * FROM orders WHERE account_id = 123",
|
|
115
144
|
"config": {
|
|
@@ -124,6 +153,10 @@ curl -X POST http://localhost:5000/verify-sql \
|
|
|
124
153
|
}'
|
|
125
154
|
```
|
|
126
155
|
|
|
156
|
+
## MCP Wrapper
|
|
157
|
+
|
|
158
|
+
sql-data-guard can also protect an MCP server that talks to a database: the wrapper verifies the SQL of every tool call before the server sees it, and refuses tools that could run SQL without the guard. See [the MCP wrapper guide](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/mcp-wrapper.md).
|
|
159
|
+
|
|
127
160
|
## Contributing
|
|
128
161
|
We welcome contributions! Please see our [CONTRIBUTING.md](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/CONTRIBUTING.md) for more details.
|
|
129
162
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools]
|
|
6
|
+
include-package-data = false
|
|
7
|
+
package-dir = {"" = "src"}
|
|
8
|
+
[tool.pip]
|
|
9
|
+
extra-index-url = ["https://pypi.org/simple"]
|
|
10
|
+
[project]
|
|
11
|
+
name = "sql-data-guard"
|
|
12
|
+
version = "0.2.0"
|
|
13
|
+
dependencies = [
|
|
14
|
+
# lower bound: oldest version the test-suite passes on; upper bound: next untested major
|
|
15
|
+
"sqlglot>=25.20.1,<31"
|
|
16
|
+
]
|
|
17
|
+
authors = [
|
|
18
|
+
{ name="Imperva - Threat Research Infra", email="ww.dis.imperva.threat-research-infra@thalesgroup.com" },
|
|
19
|
+
]
|
|
20
|
+
description = "Safety Layer for LLM Database Interactions"
|
|
21
|
+
readme = "README.md"
|
|
22
|
+
requires-python = ">=3.10"
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
]
|
|
32
|
+
license = {file = "LICENSE.md"}
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ThalesGroup/sql-data-guard"
|
|
36
|
+
Issues = "https://github.com/ThalesGroup/sql-data-guard/issues"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["F", "E9"] # pyflakes + syntax errors
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
files = ["src/sql_data_guard"]
|
|
43
|
+
ignore_missing_imports = true
|
|
44
|
+
|
|
45
|
+
[tool.coverage.run]
|
|
46
|
+
source = ["sql_data_guard"]
|
|
47
|
+
|
|
48
|
+
[tool.coverage.report]
|
|
49
|
+
fail_under = 90
|
|
50
|
+
show_missing = true
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Column masking.
|
|
3
|
+
|
|
4
|
+
Instead of removing a sensitive column from the SELECT list (which changes the shape of the
|
|
5
|
+
result and can break the caller), a table can declare it as *masked*: the column is still
|
|
6
|
+
returned under the same name, but its value is replaced, so the real value never leaves the
|
|
7
|
+
database.
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"table_name": "users",
|
|
11
|
+
"columns": ["id", "name", "ssn"],
|
|
12
|
+
"column_masks": [{"column": "ssn", "policy": "redact", "replacement": "****"}]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
A masked column can only be selected directly (`ssn`, `u.ssn`, `ssn AS x`, or through `SELECT *`).
|
|
16
|
+
Any other use (WHERE, JOIN, GROUP BY, ORDER BY, inside a function or expression...) would let a
|
|
17
|
+
caller learn the real value one comparison at a time, and cannot be masked, so it is rejected.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
import sqlglot.expressions as expr
|
|
23
|
+
|
|
24
|
+
from .verification_utils import names_equal, same_name, table_matches
|
|
25
|
+
|
|
26
|
+
# policy name -> builds the replacement expression of a mask. More policies (for example a
|
|
27
|
+
# partial mask) need functions whose names differ per database, and can be registered here.
|
|
28
|
+
_DEFAULT_REPLACEMENT = "****"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _redact(mask: dict) -> expr.Expression:
|
|
32
|
+
return expr.Literal.string(mask.get("replacement", _DEFAULT_REPLACEMENT))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
MASK_POLICIES = {"redact": _redact}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_column_masks(config: dict) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Validates the `column_masks` of every table.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: if a mask is malformed, uses an unknown policy, masks a column that is not in
|
|
44
|
+
the table's `columns`, masks a column twice, or masks a column used by a restriction.
|
|
45
|
+
"""
|
|
46
|
+
for table in config.get("tables", []):
|
|
47
|
+
if "column_masks" not in table:
|
|
48
|
+
continue
|
|
49
|
+
name = table.get("table_name")
|
|
50
|
+
masks = table["column_masks"]
|
|
51
|
+
if not isinstance(masks, list):
|
|
52
|
+
raise ValueError(f"'column_masks' of table '{name}' must be a list.")
|
|
53
|
+
allowed = [str(c).lower() for c in table.get("columns", [])]
|
|
54
|
+
restricted = [str(r.get("column")).lower() for r in table.get("restrictions") or []]
|
|
55
|
+
seen = set()
|
|
56
|
+
for mask in masks:
|
|
57
|
+
if not isinstance(mask, dict) or not isinstance(mask.get("column"), str) or not mask["column"]:
|
|
58
|
+
raise ValueError(f"Each column mask of table '{name}' must be an object with a 'column'.")
|
|
59
|
+
column = mask["column"].lower()
|
|
60
|
+
if column not in allowed:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Masked column '{mask['column']}' must also be in the 'columns' of table '{name}'."
|
|
63
|
+
)
|
|
64
|
+
if column in seen:
|
|
65
|
+
raise ValueError(f"Column '{mask['column']}' of table '{name}' is masked more than once.")
|
|
66
|
+
seen.add(column)
|
|
67
|
+
if column in restricted:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Column '{mask['column']}' of table '{name}' cannot be both masked and restricted."
|
|
70
|
+
)
|
|
71
|
+
policy = mask.get("policy")
|
|
72
|
+
if policy not in MASK_POLICIES:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Unsupported mask policy {policy!r} for column '{mask['column']}'. "
|
|
75
|
+
f"Supported policies: {sorted(MASK_POLICIES)}."
|
|
76
|
+
)
|
|
77
|
+
replacement = mask.get("replacement", _DEFAULT_REPLACEMENT)
|
|
78
|
+
if not isinstance(replacement, str):
|
|
79
|
+
raise ValueError(f"'replacement' for column '{mask['column']}' must be a string.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_mask(col: expr.Column, scopes: List[List[expr.Table]], config: dict) -> Optional[dict]:
|
|
83
|
+
"""
|
|
84
|
+
The mask that applies to a column reference, or None.
|
|
85
|
+
|
|
86
|
+
`scopes` are the tables of the query being verified and of its enclosing queries, innermost
|
|
87
|
+
first, so a correlated sub-query that refers to a table of the outer query is found too. A
|
|
88
|
+
qualified column is resolved to the innermost table with that name or alias; an unqualified
|
|
89
|
+
column to the innermost query that has a configured table with that column. If several tables
|
|
90
|
+
of that query could provide it, it is masked when any of them masks it (the safe choice).
|
|
91
|
+
"""
|
|
92
|
+
for tables in scopes:
|
|
93
|
+
candidates = []
|
|
94
|
+
for table in tables:
|
|
95
|
+
if col.table and not same_name(col.table, table.alias_or_name):
|
|
96
|
+
continue
|
|
97
|
+
for config_table in config["tables"]:
|
|
98
|
+
if table_matches(table, config_table) and (
|
|
99
|
+
col.table or any(names_equal(col, c) for c in config_table["columns"])
|
|
100
|
+
):
|
|
101
|
+
candidates.append(config_table)
|
|
102
|
+
if candidates:
|
|
103
|
+
return next(
|
|
104
|
+
(
|
|
105
|
+
mask
|
|
106
|
+
for config_table in candidates
|
|
107
|
+
for mask in config_table.get("column_masks") or []
|
|
108
|
+
if names_equal(col, mask["column"])
|
|
109
|
+
),
|
|
110
|
+
None,
|
|
111
|
+
)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mask_value(mask: dict) -> expr.Expression:
|
|
116
|
+
"""The expression that replaces the value of a masked column."""
|
|
117
|
+
return MASK_POLICIES[mask["policy"]](mask)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def masked_select_item(mask: dict, name: str) -> expr.Expression:
|
|
121
|
+
"""`<mask value> AS <name>`: what a masked column is rewritten to in the SELECT list."""
|
|
122
|
+
return expr.alias_(mask_value(mask), expr.to_identifier(name))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def table_masks(config_table: dict) -> Dict[str, dict]:
|
|
126
|
+
"""Masks of a configured table, by lower case column name."""
|
|
127
|
+
return {m["column"].lower(): m for m in config_table.get("column_masks") or []}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Suggesting and applying allow-list changes to a configuration.
|
|
3
|
+
|
|
4
|
+
``suggest_config`` answers "what would this query need to be allowed?" and ``merge_config``
|
|
5
|
+
applies such an answer to a configuration. Both only deal with the table/column allow-list
|
|
6
|
+
(``tables``); restrictions, masks, function lists and limits are never touched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import logging
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .sql_data_guard import _verify_sql
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Every configured table needs at least one column, so a table that is being added is seeded
|
|
18
|
+
# with this name to let the verification go on and find the columns the query really uses.
|
|
19
|
+
_PLACEHOLDER_COLUMN = "__sql_data_guard_placeholder__"
|
|
20
|
+
_MAX_ROUNDS = 25
|
|
21
|
+
_DELTA_TABLE_KEYS = {"table_name", "database_name", "columns"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _identity(table_name: str, database_name: Optional[str]) -> tuple:
|
|
25
|
+
return str(table_name).lower(), str(database_name or "").lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _find_entry(tables: List[dict], table_name: str, database_name: Optional[str]) -> Optional[dict]:
|
|
29
|
+
"""The configured table a query reference refers to (same rule as table_matches)."""
|
|
30
|
+
for entry in tables:
|
|
31
|
+
if str(entry["table_name"]).lower() != str(table_name).lower():
|
|
32
|
+
continue
|
|
33
|
+
configured_db = entry.get("database_name")
|
|
34
|
+
if database_name and configured_db and str(database_name).lower() != str(configured_db).lower():
|
|
35
|
+
continue
|
|
36
|
+
return entry
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _has_column(entry: dict, column: str) -> bool:
|
|
41
|
+
return str(column).lower() in {str(c).lower() for c in entry["columns"]}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_delta(delta: dict) -> None:
|
|
45
|
+
"""Raises ValueError unless ``delta`` has the shape produced by ``suggest_config``."""
|
|
46
|
+
if not isinstance(delta, dict) or set(delta) != {"tables"} or not isinstance(delta["tables"], list):
|
|
47
|
+
raise ValueError("A config delta must be an object with a single 'tables' list.")
|
|
48
|
+
for table in delta["tables"]:
|
|
49
|
+
if not isinstance(table, dict) or not set(table) <= _DELTA_TABLE_KEYS:
|
|
50
|
+
raise ValueError(f"A delta table may only have the keys {sorted(_DELTA_TABLE_KEYS)}.")
|
|
51
|
+
if not isinstance(table.get("table_name"), str) or not table["table_name"]:
|
|
52
|
+
raise ValueError("Each delta table must have a 'table_name'.")
|
|
53
|
+
db = table.get("database_name")
|
|
54
|
+
if db is not None and not isinstance(db, str):
|
|
55
|
+
raise ValueError("'database_name' must be a string.")
|
|
56
|
+
columns = table.get("columns")
|
|
57
|
+
if not isinstance(columns, list) or not columns or not all(isinstance(c, str) and c for c in columns):
|
|
58
|
+
raise ValueError("Each delta table must have a non-empty 'columns' list of names.")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def merge_config(config: dict, delta: dict) -> dict:
|
|
62
|
+
"""
|
|
63
|
+
Returns a copy of ``config`` with the tables and columns of ``delta`` added.
|
|
64
|
+
|
|
65
|
+
A delta table with the same name (and database) as a configured one adds its missing columns
|
|
66
|
+
to it; any other delta table is appended. Nothing else in the configuration changes, and the
|
|
67
|
+
arguments are not modified. Raises ValueError if ``delta`` is malformed.
|
|
68
|
+
"""
|
|
69
|
+
validate_delta(delta)
|
|
70
|
+
merged = copy.deepcopy(config)
|
|
71
|
+
tables = merged.setdefault("tables", [])
|
|
72
|
+
for table in delta["tables"]:
|
|
73
|
+
existing = next(
|
|
74
|
+
(
|
|
75
|
+
t
|
|
76
|
+
for t in tables
|
|
77
|
+
if _identity(t["table_name"], t.get("database_name"))
|
|
78
|
+
== _identity(table["table_name"], table.get("database_name"))
|
|
79
|
+
),
|
|
80
|
+
None,
|
|
81
|
+
)
|
|
82
|
+
if existing is None:
|
|
83
|
+
tables.append(copy.deepcopy(table))
|
|
84
|
+
continue
|
|
85
|
+
for column in table["columns"]:
|
|
86
|
+
if not _has_column(existing, column):
|
|
87
|
+
existing["columns"].append(column)
|
|
88
|
+
return merged
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def suggest_config(sql: str, config: dict, dialect: Optional[str] = None) -> Optional[dict]:
|
|
92
|
+
"""
|
|
93
|
+
Suggests the smallest allow-list addition that lets ``sql`` run as written.
|
|
94
|
+
|
|
95
|
+
Returns ``{"tables": [{"table_name", "database_name"?, "columns": [...]}, ...]}``, to be
|
|
96
|
+
applied with ``merge_config``; ``{"tables": []}`` if the query needs nothing added. Returns
|
|
97
|
+
None when widening the allow-list cannot make the query acceptable:
|
|
98
|
+
|
|
99
|
+
- the query is rejected for another reason (DML/DDL, several statements, a blocked function,
|
|
100
|
+
a column that cannot be attributed to one table, ...);
|
|
101
|
+
- it needs a table without naming any column (``SELECT *`` or ``COUNT(*)`` on a table the
|
|
102
|
+
configuration does not know), because a configured table must list its columns.
|
|
103
|
+
|
|
104
|
+
The suggestion is always checked: ``verify_sql`` on ``merge_config(config, delta)`` accepts
|
|
105
|
+
the query (allowed, or fixable). It never raises.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
return _suggest(sql, config, dialect)
|
|
109
|
+
except Exception as e: # noqa: BLE001 - never suggest anything on unexpected errors
|
|
110
|
+
logger.error(f"Unexpected error while suggesting a config ({type(e).__name__})")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
|
|
115
|
+
if not isinstance(config, dict) or not isinstance(config.get("tables"), list):
|
|
116
|
+
return None
|
|
117
|
+
working = copy.deepcopy(config)
|
|
118
|
+
tables = working["tables"]
|
|
119
|
+
delta_tables: dict = {} # identity -> delta table
|
|
120
|
+
|
|
121
|
+
def delta_for(entry: dict) -> dict:
|
|
122
|
+
key = _identity(entry["table_name"], entry.get("database_name"))
|
|
123
|
+
if key not in delta_tables:
|
|
124
|
+
table = {"table_name": entry["table_name"], "columns": []}
|
|
125
|
+
if entry.get("database_name"):
|
|
126
|
+
table["database_name"] = entry["database_name"]
|
|
127
|
+
delta_tables[key] = table
|
|
128
|
+
return delta_tables[key]
|
|
129
|
+
|
|
130
|
+
for _ in range(_MAX_ROUNDS):
|
|
131
|
+
violations: List[dict] = []
|
|
132
|
+
_verify_sql(sql, working, dialect, violations)
|
|
133
|
+
progress = False
|
|
134
|
+
for v in violations:
|
|
135
|
+
if v["table_name"] is None:
|
|
136
|
+
return None # a column that belongs to no single table: nothing to suggest
|
|
137
|
+
entry = _find_entry(tables, v["table_name"], v["database_name"])
|
|
138
|
+
if v["type"] == "table":
|
|
139
|
+
if entry is None:
|
|
140
|
+
entry = {
|
|
141
|
+
"table_name": v["table_name"],
|
|
142
|
+
**({"database_name": v["database_name"]} if v["database_name"] else {}),
|
|
143
|
+
"columns": [_PLACEHOLDER_COLUMN],
|
|
144
|
+
}
|
|
145
|
+
tables.append(entry)
|
|
146
|
+
delta_for(entry) # listed even if no real column is found (rejected below)
|
|
147
|
+
progress = True
|
|
148
|
+
else:
|
|
149
|
+
if entry is None:
|
|
150
|
+
return None
|
|
151
|
+
if not _has_column(entry, v["column"]):
|
|
152
|
+
entry["columns"].append(v["column"])
|
|
153
|
+
delta_for(entry)["columns"].append(v["column"])
|
|
154
|
+
progress = True
|
|
155
|
+
if not progress:
|
|
156
|
+
break
|
|
157
|
+
else:
|
|
158
|
+
return None # did not settle
|
|
159
|
+
|
|
160
|
+
# a table can only be configured with columns: drop what was found for none
|
|
161
|
+
result = [t for t in delta_tables.values() if t["columns"]]
|
|
162
|
+
if len(result) != len(delta_tables):
|
|
163
|
+
return None
|
|
164
|
+
delta = {"tables": result}
|
|
165
|
+
merged = merge_config(config, delta)
|
|
166
|
+
verdict = _verify_sql(sql, merged, dialect)
|
|
167
|
+
if not verdict["allowed"] and verdict["fixed"] is None:
|
|
168
|
+
return None
|
|
169
|
+
return delta
|