sql-data-guard 0.2.0__tar.gz → 0.2.1__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.2.0/src/sql_data_guard.egg-info → sql_data_guard-0.2.1}/PKG-INFO +1 -1
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/pyproject.toml +1 -1
- sql_data_guard-0.2.1/src/sql_data_guard/__init__.py +4 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/config_suggestion.py +46 -12
- sql_data_guard-0.2.1/src/sql_data_guard/restriction_validation.py +260 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/restriction_verification.py +108 -37
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/sql_data_guard.py +142 -23
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/verification_utils.py +5 -1
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1/src/sql_data_guard.egg-info}/PKG-INFO +1 -1
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/SOURCES.txt +2 -0
- sql_data_guard-0.2.1/test/test_bypass_policies_unit.py +779 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_bypass_unit.py +68 -19
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_cte_scope_unit.py +22 -0
- sql_data_guard-0.2.1/test/test_database_policy_unit.py +346 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_function_policy_unit.py +31 -0
- sql_data_guard-0.2.1/test/test_sql_guard_validation_unit.py +347 -0
- sql_data_guard-0.2.0/src/sql_data_guard/__init__.py +0 -4
- sql_data_guard-0.2.0/src/sql_data_guard/restriction_validation.py +0 -121
- sql_data_guard-0.2.0/test/test_sql_guard_validation_unit.py +0 -177
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/LICENSE.md +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/README.md +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/setup.cfg +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/column_masking.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/mcpwrapper/mcp_wrapper.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/rest/__init__.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/rest/gunicorn.conf.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/rest/sql_data_guard_rest.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/verification_context.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/dependency_links.txt +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/requires.txt +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard.egg-info/top_level.txt +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_column_masking_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_config_suggestion_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_correlated_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_dify_plugin_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_duckdb_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_identifier_case_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_limit_enforcement_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_mcp_wrapper_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_rest_api_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_security_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_curr_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_joins_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_llm.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_sql_guard_updates_unit.py +0 -0
- {sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/test/test_utils.py +0 -0
|
@@ -9,7 +9,7 @@ package-dir = {"" = "src"}
|
|
|
9
9
|
extra-index-url = ["https://pypi.org/simple"]
|
|
10
10
|
[project]
|
|
11
11
|
name = "sql-data-guard"
|
|
12
|
-
version = "0.2.
|
|
12
|
+
version = "0.2.1"
|
|
13
13
|
dependencies = [
|
|
14
14
|
# lower bound: oldest version the test-suite passes on; upper bound: next untested major
|
|
15
15
|
"sqlglot>=25.20.1,<31"
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
Suggesting and applying allow-list changes to a configuration.
|
|
3
3
|
|
|
4
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
|
|
6
|
-
|
|
5
|
+
applies such an answer to a configuration. Both only deal with the allow-lists (``tables`` and
|
|
6
|
+
their columns, and ``databases``); restrictions, masks, function lists, limits and catalogs are
|
|
7
|
+
never touched.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import copy
|
|
@@ -43,8 +44,19 @@ def _has_column(entry: dict, column: str) -> bool:
|
|
|
43
44
|
|
|
44
45
|
def validate_delta(delta: dict) -> None:
|
|
45
46
|
"""Raises ValueError unless ``delta`` has the shape produced by ``suggest_config``."""
|
|
46
|
-
if
|
|
47
|
-
|
|
47
|
+
if (
|
|
48
|
+
not isinstance(delta, dict)
|
|
49
|
+
or "tables" not in delta
|
|
50
|
+
or not set(delta) <= {"tables", "databases"}
|
|
51
|
+
or not isinstance(delta["tables"], list)
|
|
52
|
+
):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"A config delta must be an object with a 'tables' list and optionally a 'databases' list."
|
|
55
|
+
)
|
|
56
|
+
if "databases" in delta:
|
|
57
|
+
databases = delta["databases"]
|
|
58
|
+
if not isinstance(databases, list) or not all(isinstance(d, str) and d for d in databases):
|
|
59
|
+
raise ValueError("'databases' must be a list of database names.")
|
|
48
60
|
for table in delta["tables"]:
|
|
49
61
|
if not isinstance(table, dict) or not set(table) <= _DELTA_TABLE_KEYS:
|
|
50
62
|
raise ValueError(f"A delta table may only have the keys {sorted(_DELTA_TABLE_KEYS)}.")
|
|
@@ -60,14 +72,21 @@ def validate_delta(delta: dict) -> None:
|
|
|
60
72
|
|
|
61
73
|
def merge_config(config: dict, delta: dict) -> dict:
|
|
62
74
|
"""
|
|
63
|
-
Returns a copy of ``config`` with the tables and
|
|
75
|
+
Returns a copy of ``config`` with the tables, columns and databases of ``delta`` added.
|
|
64
76
|
|
|
65
77
|
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.
|
|
67
|
-
|
|
78
|
+
to it; any other delta table is appended. Delta databases missing from ``databases`` are
|
|
79
|
+
appended (the list is created if the configuration has none). Nothing else in the
|
|
80
|
+
configuration changes, and the arguments are not modified. Raises ValueError if ``delta`` is
|
|
81
|
+
malformed.
|
|
68
82
|
"""
|
|
69
83
|
validate_delta(delta)
|
|
70
84
|
merged = copy.deepcopy(config)
|
|
85
|
+
if delta.get("databases"):
|
|
86
|
+
databases = merged.setdefault("databases", [])
|
|
87
|
+
for database in delta["databases"]:
|
|
88
|
+
if database.lower() not in {d.lower() for d in databases}:
|
|
89
|
+
databases.append(database)
|
|
71
90
|
tables = merged.setdefault("tables", [])
|
|
72
91
|
for table in delta["tables"]:
|
|
73
92
|
existing = next(
|
|
@@ -92,12 +111,15 @@ def suggest_config(sql: str, config: dict, dialect: Optional[str] = None) -> Opt
|
|
|
92
111
|
"""
|
|
93
112
|
Suggests the smallest allow-list addition that lets ``sql`` run as written.
|
|
94
113
|
|
|
95
|
-
Returns ``{"tables": [{"table_name", "database_name"?, "columns": [...]}, ...]}``,
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
Returns ``{"tables": [{"table_name", "database_name"?, "columns": [...]}, ...]}``, plus
|
|
115
|
+
``"databases": [...]`` when the configuration has a ``databases`` allow-list that lacks a
|
|
116
|
+
database the query uses, to be applied with ``merge_config``; ``{"tables": []}`` if the
|
|
117
|
+
query needs nothing added. Returns None when widening the allow-list cannot make the query
|
|
118
|
+
acceptable:
|
|
98
119
|
|
|
99
120
|
- the query is rejected for another reason (DML/DDL, several statements, a blocked function,
|
|
100
|
-
a column that cannot be attributed to one table,
|
|
121
|
+
a column that cannot be attributed to one table, a table of another catalog, an
|
|
122
|
+
unqualified table in strict mode, ...);
|
|
101
123
|
- it needs a table without naming any column (``SELECT *`` or ``COUNT(*)`` on a table the
|
|
102
124
|
configuration does not know), because a configured table must list its columns.
|
|
103
125
|
|
|
@@ -117,6 +139,7 @@ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
|
|
|
117
139
|
working = copy.deepcopy(config)
|
|
118
140
|
tables = working["tables"]
|
|
119
141
|
delta_tables: dict = {} # identity -> delta table
|
|
142
|
+
delta_databases: List[str] = []
|
|
120
143
|
|
|
121
144
|
def delta_for(entry: dict) -> dict:
|
|
122
145
|
key = _identity(entry["table_name"], entry.get("database_name"))
|
|
@@ -132,6 +155,15 @@ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
|
|
|
132
155
|
_verify_sql(sql, working, dialect, violations)
|
|
133
156
|
progress = False
|
|
134
157
|
for v in violations:
|
|
158
|
+
if v["type"] == "database":
|
|
159
|
+
known = {d.lower() for d in working["databases"]}
|
|
160
|
+
if v["database_name"].lower() not in known:
|
|
161
|
+
working["databases"].append(v["database_name"])
|
|
162
|
+
delta_databases.append(v["database_name"])
|
|
163
|
+
progress = True
|
|
164
|
+
continue
|
|
165
|
+
if v["type"] not in ("table", "column"):
|
|
166
|
+
return None # e.g. a catalog mismatch: not an allow-list problem
|
|
135
167
|
if v["table_name"] is None:
|
|
136
168
|
return None # a column that belongs to no single table: nothing to suggest
|
|
137
169
|
entry = _find_entry(tables, v["table_name"], v["database_name"])
|
|
@@ -161,7 +193,9 @@ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
|
|
|
161
193
|
result = [t for t in delta_tables.values() if t["columns"]]
|
|
162
194
|
if len(result) != len(delta_tables):
|
|
163
195
|
return None
|
|
164
|
-
delta = {"tables": result}
|
|
196
|
+
delta: dict = {"tables": result}
|
|
197
|
+
if delta_databases:
|
|
198
|
+
delta["databases"] = delta_databases
|
|
165
199
|
merged = merge_config(config, delta)
|
|
166
200
|
verdict = _verify_sql(sql, merged, dialect)
|
|
167
201
|
if not verdict["allowed"] and verdict["fixed"] is None:
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import sqlglot
|
|
4
|
+
import sqlglot.expressions as expr
|
|
5
|
+
|
|
6
|
+
from .column_masking import validate_column_masks
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnsupportedRestrictionError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _validate_max_length(config: dict):
|
|
14
|
+
if "max_length" not in config:
|
|
15
|
+
return
|
|
16
|
+
value = config["max_length"]
|
|
17
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"Invalid 'max_length'. Expected a positive integer. Received: {value!r}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_scalar(value) -> bool:
|
|
24
|
+
"""A restriction value: a string, a bool or a finite number."""
|
|
25
|
+
if isinstance(value, float):
|
|
26
|
+
return math.isfinite(value)
|
|
27
|
+
return isinstance(value, (bool, int, str))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_number(value) -> bool:
|
|
31
|
+
return _is_scalar(value) and not isinstance(value, (bool, str))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_column_name(name) -> bool:
|
|
35
|
+
"""A plain (optionally quoted) column name, as it is pasted into the condition that is added."""
|
|
36
|
+
if not isinstance(name, str) or not name.strip():
|
|
37
|
+
return False
|
|
38
|
+
try:
|
|
39
|
+
statements = sqlglot.parse(name)
|
|
40
|
+
except sqlglot.errors.SqlglotError:
|
|
41
|
+
return False
|
|
42
|
+
if len(statements) != 1: # `id; DROP TABLE x`
|
|
43
|
+
return False
|
|
44
|
+
parsed = statements[0]
|
|
45
|
+
return isinstance(parsed, expr.Column) and not parsed.table
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_columns(table: dict):
|
|
49
|
+
columns = table.get("columns")
|
|
50
|
+
if not isinstance(columns, list) or not columns:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"Each table must have a 'columns' key with valid column definitions."
|
|
53
|
+
)
|
|
54
|
+
if not all(isinstance(c, str) and c.strip() for c in columns):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"The 'columns' of table '{table['table_name']}' must be non-empty strings."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_restriction_shape(restriction, table_name: str):
|
|
61
|
+
"""Checks the keys every restriction needs, whatever its operation."""
|
|
62
|
+
if not isinstance(restriction, dict):
|
|
63
|
+
raise ValueError(f"Each restriction of table '{table_name}' must be an object.")
|
|
64
|
+
if not _is_column_name(restriction.get("column")):
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Each restriction of table '{table_name}' must have a 'column' with a column name. "
|
|
67
|
+
f"Received: {restriction.get('column')!r}"
|
|
68
|
+
)
|
|
69
|
+
operation = restriction.get("operation")
|
|
70
|
+
if operation is not None and not isinstance(operation, str):
|
|
71
|
+
raise UnsupportedRestrictionError(
|
|
72
|
+
f"Invalid restriction: 'operation={operation!r}' is not supported."
|
|
73
|
+
)
|
|
74
|
+
if operation in (None, "="):
|
|
75
|
+
# `value`, or `values` (the column may then equal any of them)
|
|
76
|
+
if "value" in restriction:
|
|
77
|
+
ok = _is_scalar(restriction["value"])
|
|
78
|
+
else:
|
|
79
|
+
values = restriction.get("values")
|
|
80
|
+
ok = (
|
|
81
|
+
isinstance(values, list)
|
|
82
|
+
and len(values) > 0
|
|
83
|
+
and all(_is_scalar(v) for v in values)
|
|
84
|
+
)
|
|
85
|
+
if not ok:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Invalid restriction for column '{restriction['column']}' in table '{table_name}'. "
|
|
88
|
+
"Expected a 'value' (a number, string or boolean) or a non-empty list of 'values'."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _non_empty_string(value) -> bool:
|
|
93
|
+
return isinstance(value, str) and bool(value.strip())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_database_options(config: dict):
|
|
97
|
+
"""`databases`, `strict_database` and `catalog_name` (top level and per table)."""
|
|
98
|
+
if not isinstance(config.get("strict_database", False), bool):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"Invalid 'strict_database'. Expected true or false. "
|
|
101
|
+
f"Received: {config['strict_database']!r}"
|
|
102
|
+
)
|
|
103
|
+
if "catalog_name" in config and not _non_empty_string(config["catalog_name"]):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Invalid 'catalog_name'. Expected a non-empty string. Received: {config['catalog_name']!r}"
|
|
106
|
+
)
|
|
107
|
+
if "databases" in config:
|
|
108
|
+
databases = config["databases"]
|
|
109
|
+
if not isinstance(databases, list) or not all(
|
|
110
|
+
_non_empty_string(d) for d in databases
|
|
111
|
+
):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Invalid 'databases'. Expected a list of database names. Received: {databases!r}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_table_database(config: dict, table: dict):
|
|
118
|
+
name = table["table_name"]
|
|
119
|
+
if "catalog_name" in table and not _non_empty_string(table["catalog_name"]):
|
|
120
|
+
raise ValueError(f"The 'catalog_name' of table '{name}' must be a non-empty string.")
|
|
121
|
+
database_name = table.get("database_name")
|
|
122
|
+
if config.get("strict_database") and not _non_empty_string(database_name):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Table '{name}' must have a 'database_name' when 'strict_database' is set."
|
|
125
|
+
)
|
|
126
|
+
if database_name and "databases" in config:
|
|
127
|
+
if database_name.lower() not in {d.lower() for d in config["databases"]}:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"The database '{database_name}' of table '{name}' is not in 'databases'."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validate_force_limit(config: dict):
|
|
134
|
+
if "force_limit" not in config:
|
|
135
|
+
return
|
|
136
|
+
limit = config["force_limit"]
|
|
137
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Invalid 'force_limit'. Expected a positive integer. Received: {limit!r}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_function_list(config: dict, key: str):
|
|
144
|
+
if key not in config:
|
|
145
|
+
return
|
|
146
|
+
names = config[key]
|
|
147
|
+
if not isinstance(names, list) or not all(
|
|
148
|
+
isinstance(n, str) and n.strip() for n in names
|
|
149
|
+
):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Invalid '{key}'. Expected a list of function names. Received: {names!r}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_restrictions(config: dict):
|
|
156
|
+
"""
|
|
157
|
+
Validates the restrictions in the configuration to ensure only supported operations are used.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
config (dict): The configuration containing the restrictions to validate.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
UnsupportedRestrictionError: If an unsupported restriction operation is found.
|
|
164
|
+
ValueError: If there are no tables in the configuration.
|
|
165
|
+
"""
|
|
166
|
+
supported_operations = [
|
|
167
|
+
"=",
|
|
168
|
+
">",
|
|
169
|
+
"<",
|
|
170
|
+
">=",
|
|
171
|
+
"<=",
|
|
172
|
+
"BETWEEN",
|
|
173
|
+
"IN",
|
|
174
|
+
] # Allowed operations
|
|
175
|
+
_validate_max_length(config)
|
|
176
|
+
_validate_database_options(config)
|
|
177
|
+
_validate_force_limit(config)
|
|
178
|
+
_validate_function_list(config, "blocked_functions")
|
|
179
|
+
_validate_function_list(config, "allowed_functions")
|
|
180
|
+
# Ensure 'tables' exists in config and is not empty
|
|
181
|
+
tables = config.get("tables", [])
|
|
182
|
+
# Check if tables are empty
|
|
183
|
+
if not tables:
|
|
184
|
+
raise ValueError("Configuration must contain at least one table.")
|
|
185
|
+
if not isinstance(tables, list):
|
|
186
|
+
raise ValueError("'tables' must be a list.")
|
|
187
|
+
|
|
188
|
+
seen = set()
|
|
189
|
+
for table in tables:
|
|
190
|
+
if not isinstance(table, dict):
|
|
191
|
+
raise ValueError("Each table must be an object.")
|
|
192
|
+
# Ensure that 'table_name' exists in each table
|
|
193
|
+
if "table_name" not in table:
|
|
194
|
+
raise ValueError("Each table must have a 'table_name' key.")
|
|
195
|
+
if not isinstance(table["table_name"], str) or not table["table_name"].strip():
|
|
196
|
+
raise ValueError("Each 'table_name' must be a non-empty string.")
|
|
197
|
+
database_name = table.get("database_name")
|
|
198
|
+
if database_name is not None and not isinstance(database_name, str):
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"The 'database_name' of table '{table['table_name']}' must be a string."
|
|
201
|
+
)
|
|
202
|
+
_validate_table_database(config, table)
|
|
203
|
+
key = (
|
|
204
|
+
table["table_name"].lower(),
|
|
205
|
+
(database_name or "").lower(),
|
|
206
|
+
str(table.get("catalog_name") or "").lower(),
|
|
207
|
+
)
|
|
208
|
+
if key in seen:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"Table '{table['table_name']}' is defined more than once."
|
|
211
|
+
)
|
|
212
|
+
seen.add(key)
|
|
213
|
+
_validate_columns(table)
|
|
214
|
+
|
|
215
|
+
restrictions = table.get("restrictions", [])
|
|
216
|
+
if not isinstance(restrictions, list):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"The 'restrictions' of table '{table['table_name']}' must be a list."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for restriction in restrictions:
|
|
222
|
+
_validate_restriction_shape(restriction, table["table_name"])
|
|
223
|
+
operation = restriction.get("operation")
|
|
224
|
+
if operation == "BETWEEN":
|
|
225
|
+
values = restriction.get("values")
|
|
226
|
+
if not (
|
|
227
|
+
isinstance(values, list)
|
|
228
|
+
and len(values) == 2
|
|
229
|
+
and all(_is_number(v) for v in values)
|
|
230
|
+
and values[0] < values[1]
|
|
231
|
+
):
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Invalid 'BETWEEN' format. Expected list of two numeric values where min < max. Received: {values}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
elif operation == "IN":
|
|
237
|
+
values = restriction.get("values")
|
|
238
|
+
if not (
|
|
239
|
+
isinstance(values, list)
|
|
240
|
+
and len(values) > 0
|
|
241
|
+
and all(_is_scalar(v) and not isinstance(v, bool) for v in values)
|
|
242
|
+
):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"Invalid 'IN' format. Expected a non-empty list of numbers or strings. Received: {values}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
elif operation in (">", "<", ">=", "<="):
|
|
248
|
+
# comparison operators are evaluated numerically
|
|
249
|
+
value = restriction.get("value")
|
|
250
|
+
if not _is_number(value):
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"Invalid restriction value type for column '{restriction['column']}' in table '{table['table_name']}'. Expected a numeric value."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
elif operation and operation.lower() not in supported_operations:
|
|
256
|
+
raise UnsupportedRestrictionError(
|
|
257
|
+
f"Invalid restriction: 'operation={operation}' is not supported."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
validate_column_masks(config)
|
{sql_data_guard-0.2.0 → sql_data_guard-0.2.1}/src/sql_data_guard/restriction_verification.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import List
|
|
1
|
+
from typing import List, Optional
|
|
2
2
|
|
|
3
3
|
import sqlglot
|
|
4
4
|
import sqlglot.expressions as expr
|
|
@@ -24,7 +24,13 @@ def verify_restrictions(
|
|
|
24
24
|
else:
|
|
25
25
|
and_exps = list(split_to_expressions(where_clause.this, expr.And))
|
|
26
26
|
for c_t in context.config["tables"]:
|
|
27
|
-
for from_t in [
|
|
27
|
+
for from_t in [
|
|
28
|
+
t
|
|
29
|
+
for t in from_tables
|
|
30
|
+
# a CTE with the name of a configured table is not that table (its body is verified,
|
|
31
|
+
# with its restrictions, on its own)
|
|
32
|
+
if table_matches(t, c_t) and (t.db or t.name not in context.cte_names)
|
|
33
|
+
]:
|
|
28
34
|
for r in c_t.get("restrictions", []):
|
|
29
35
|
found = False
|
|
30
36
|
for sub_exp in and_exps:
|
|
@@ -34,7 +40,10 @@ def verify_restrictions(
|
|
|
34
40
|
if not found:
|
|
35
41
|
if from_t.alias:
|
|
36
42
|
t_prefix = f"{from_t.alias}."
|
|
37
|
-
elif
|
|
43
|
+
elif (
|
|
44
|
+
len([t for t in from_tables if same_name(t.name, from_t.name)])
|
|
45
|
+
> 1
|
|
46
|
+
):
|
|
38
47
|
t_prefix = f"{from_t.name}."
|
|
39
48
|
else:
|
|
40
49
|
t_prefix = ""
|
|
@@ -87,11 +96,15 @@ def _create_new_condition(
|
|
|
87
96
|
operand = f"({', '.join(_format_value(v, dialect) for v in values)})"
|
|
88
97
|
else:
|
|
89
98
|
operator = operation if operation in _COMPARISON_OPERATORS else "="
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if "value" in restriction
|
|
93
|
-
else str(restriction["values"])[1:-1]
|
|
99
|
+
values = (
|
|
100
|
+
[restriction["value"]] if "value" in restriction else restriction["values"]
|
|
94
101
|
)
|
|
102
|
+
if operator == "=" and len(values) > 1:
|
|
103
|
+
# `=` with several allowed values: the column may equal any of them
|
|
104
|
+
operator = "IN"
|
|
105
|
+
operand = f"({', '.join(_format_value(v, dialect) for v in values)})"
|
|
106
|
+
else:
|
|
107
|
+
operand = _format_value(values[0], dialect)
|
|
95
108
|
new_condition = sqlglot.parse_one(
|
|
96
109
|
f"{table_prefix}{restriction['column']} {operator} {operand}",
|
|
97
110
|
dialect=dialect,
|
|
@@ -143,54 +156,112 @@ def _verify_restriction(
|
|
|
143
156
|
):
|
|
144
157
|
return False
|
|
145
158
|
|
|
146
|
-
if
|
|
159
|
+
if (
|
|
160
|
+
exp.this.table
|
|
161
|
+
and from_table.alias
|
|
162
|
+
and not same_name(exp.this.table, from_table.alias)
|
|
163
|
+
):
|
|
147
164
|
return False
|
|
148
|
-
if
|
|
165
|
+
if (
|
|
166
|
+
exp.this.table
|
|
167
|
+
and not from_table.alias
|
|
168
|
+
and not same_name(exp.this.table, from_table.name)
|
|
169
|
+
):
|
|
149
170
|
return False
|
|
150
171
|
|
|
151
172
|
values = _get_restriction_values(restriction) # Get correct restriction values
|
|
173
|
+
operation = restriction.get("operation")
|
|
152
174
|
|
|
153
|
-
#
|
|
175
|
+
# Only literal values count: a column (`region = eu`, where `eu` is a column of a derived
|
|
176
|
+
# table), a sub-query (`IN (SELECT 999)`) or an expression could evaluate to anything.
|
|
154
177
|
if isinstance(exp, expr.In):
|
|
155
|
-
|
|
156
|
-
|
|
178
|
+
if exp.args.get("query") or exp.args.get("unnest") or not exp.expressions:
|
|
179
|
+
return False
|
|
180
|
+
expr_values = [_literal_value(val) for val in exp.expressions]
|
|
181
|
+
return all(
|
|
182
|
+
v is not None and _value_allowed(operation, values, v) for v in expr_values
|
|
183
|
+
)
|
|
157
184
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return
|
|
185
|
+
if isinstance(exp, expr.EQ):
|
|
186
|
+
value = _literal_value(exp.right)
|
|
187
|
+
return value is not None and _value_allowed(operation, values, value)
|
|
161
188
|
|
|
162
189
|
if isinstance(exp, expr.Between):
|
|
163
|
-
low = _to_number(exp.args["low"]
|
|
164
|
-
high = _to_number(exp.args["high"]
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
190
|
+
low = _to_number(_literal_value(exp.args["low"]))
|
|
191
|
+
high = _to_number(_literal_value(exp.args["high"]))
|
|
192
|
+
if low is None or high is None:
|
|
193
|
+
return False
|
|
194
|
+
if operation == "BETWEEN":
|
|
195
|
+
low_limit, high_limit = (_to_number(v) for v in values)
|
|
196
|
+
return (
|
|
197
|
+
low_limit is not None
|
|
198
|
+
and high_limit is not None
|
|
199
|
+
and low_limit <= low
|
|
200
|
+
and high <= high_limit
|
|
201
|
+
)
|
|
202
|
+
if operation in (">", ">="):
|
|
203
|
+
return _value_allowed(operation, values, str(low))
|
|
204
|
+
if operation in ("<", "<="):
|
|
205
|
+
return _value_allowed(operation, values, str(high))
|
|
168
206
|
return False
|
|
169
207
|
|
|
170
|
-
if isinstance(exp, (expr.LT, expr.LTE, expr.GT, expr.GTE))
|
|
171
|
-
|
|
172
|
-
):
|
|
173
|
-
if restriction.get("operation") not in _COMPARISON_OPERATORS:
|
|
208
|
+
if isinstance(exp, (expr.LT, expr.LTE, expr.GT, expr.GTE)):
|
|
209
|
+
if operation not in _COMPARISON_OPERATORS:
|
|
174
210
|
return False
|
|
175
|
-
|
|
211
|
+
upper = isinstance(exp, (expr.LT, expr.LTE)) # `col < x` / `col <= x`
|
|
212
|
+
if upper != (operation in ("<", "<=")):
|
|
176
213
|
return False
|
|
177
|
-
|
|
214
|
+
bound = _to_number(_literal_value(exp.right))
|
|
178
215
|
limit = _to_number(values[0])
|
|
179
|
-
if
|
|
180
|
-
return False
|
|
181
|
-
if isinstance(exp, expr.LT) and restriction["operation"] == "<":
|
|
182
|
-
return actual < limit
|
|
183
|
-
elif isinstance(exp, expr.LTE) and restriction["operation"] == "<=":
|
|
184
|
-
return actual <= limit
|
|
185
|
-
elif isinstance(exp, expr.GT) and restriction["operation"] == ">":
|
|
186
|
-
return actual > limit
|
|
187
|
-
elif isinstance(exp, expr.GTE) and restriction["operation"] == ">=":
|
|
188
|
-
return actual >= limit
|
|
189
|
-
else:
|
|
216
|
+
if bound is None or limit is None:
|
|
190
217
|
return False
|
|
218
|
+
# the query's bound must be at least as strict as the restriction's
|
|
219
|
+
if operation == "<" and isinstance(exp, expr.LTE):
|
|
220
|
+
return bound < limit
|
|
221
|
+
if operation == ">" and isinstance(exp, expr.GTE):
|
|
222
|
+
return bound > limit
|
|
223
|
+
return bound <= limit if upper else bound >= limit
|
|
191
224
|
return False
|
|
192
225
|
|
|
193
226
|
|
|
227
|
+
def _literal_value(node: Optional[expr.Expression]) -> Optional[str]:
|
|
228
|
+
"""
|
|
229
|
+
The value of a literal operand, as a string (`'eu'`, `123`, `-5`, `TRUE`), or None if the
|
|
230
|
+
operand is anything else (a column, a sub-query, a function or other expression).
|
|
231
|
+
"""
|
|
232
|
+
while isinstance(node, expr.Paren):
|
|
233
|
+
node = node.this
|
|
234
|
+
if isinstance(node, expr.Literal):
|
|
235
|
+
return node.this
|
|
236
|
+
if isinstance(node, expr.Boolean):
|
|
237
|
+
return str(node.this)
|
|
238
|
+
if (
|
|
239
|
+
isinstance(node, expr.Neg)
|
|
240
|
+
and isinstance(node.this, expr.Literal)
|
|
241
|
+
and not node.this.is_string
|
|
242
|
+
):
|
|
243
|
+
return f"-{node.this.this}"
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _value_allowed(operation: Optional[str], values: List[str], value: str) -> bool:
|
|
248
|
+
"""True if a column equal to `value` satisfies the restriction."""
|
|
249
|
+
if operation == "BETWEEN":
|
|
250
|
+
number, low, high = (_to_number(v) for v in (value, *values))
|
|
251
|
+
return None not in (number, low, high) and low <= number <= high
|
|
252
|
+
if operation in _COMPARISON_OPERATORS:
|
|
253
|
+
number, limit = _to_number(value), _to_number(values[0])
|
|
254
|
+
if number is None or limit is None:
|
|
255
|
+
return False
|
|
256
|
+
return {
|
|
257
|
+
"<": number < limit,
|
|
258
|
+
"<=": number <= limit,
|
|
259
|
+
">": number > limit,
|
|
260
|
+
">=": number >= limit,
|
|
261
|
+
}[operation]
|
|
262
|
+
return value in values
|
|
263
|
+
|
|
264
|
+
|
|
194
265
|
def _get_restriction_values(restriction: dict) -> List[str]:
|
|
195
266
|
if "values" in restriction:
|
|
196
267
|
values = [str(v) for v in restriction["values"]]
|