vericto 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vericto/__init__.py +55 -0
- vericto/__main__.py +18 -0
- vericto/_agent.py +153 -0
- vericto/_version.py +1 -0
- vericto/airflow/__init__.py +221 -0
- vericto/airflow/policy.py +85 -0
- vericto/ci_api.py +343 -0
- vericto/dbapi.py +211 -0
- vericto/dbt.py +710 -0
- vericto/dialects.py +37 -0
- vericto/errors.py +123 -0
- vericto/evaluator.py +30 -0
- vericto/guard.py +276 -0
- vericto/langchain.py +233 -0
- vericto/llamaindex.py +118 -0
- vericto/placeholders.py +169 -0
- vericto/py.typed +0 -0
- vericto/runtime_api.py +283 -0
- vericto/sanitize.py +118 -0
- vericto/snowflake.py +112 -0
- vericto/sqlalchemy.py +81 -0
- vericto/sqlite.py +135 -0
- vericto/verdict.py +172 -0
- vericto-0.2.0.dist-info/METADATA +541 -0
- vericto-0.2.0.dist-info/RECORD +28 -0
- vericto-0.2.0.dist-info/WHEEL +4 -0
- vericto-0.2.0.dist-info/entry_points.txt +2 -0
- vericto-0.2.0.dist-info/licenses/LICENSE +93 -0
vericto/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Vericto: check every SQL statement with Vericto before it runs.
|
|
2
|
+
|
|
3
|
+
The core is driver-agnostic:
|
|
4
|
+
|
|
5
|
+
* :class:`Vericto` (the guard) applies a verdict: raise on block, run the masked
|
|
6
|
+
rewrite, warn on flag, and the fail mode when Vericto cannot answer.
|
|
7
|
+
* :class:`Evaluator` is how a verdict is obtained; :class:`RuntimeApiEvaluator`
|
|
8
|
+
asks the Vericto Runtime API.
|
|
9
|
+
|
|
10
|
+
Driver wrappers live in submodules so that importing ``vericto`` never imports a
|
|
11
|
+
database driver: :mod:`vericto.snowflake`, :mod:`vericto.sqlite`,
|
|
12
|
+
:mod:`vericto.sqlalchemy` and the generic :mod:`vericto.dbapi`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .dbapi import wrap
|
|
17
|
+
from .dialects import KNOWN_DIALECTS, normalize_dialect
|
|
18
|
+
from .errors import (
|
|
19
|
+
VerictoBlocked,
|
|
20
|
+
VerictoConfigError,
|
|
21
|
+
VerictoError,
|
|
22
|
+
VerictoFailOpenWarning,
|
|
23
|
+
VerictoFlaggedWarning,
|
|
24
|
+
VerictoQuotaExceeded,
|
|
25
|
+
VerictoUnavailable,
|
|
26
|
+
VerictoUnsupportedDialect,
|
|
27
|
+
)
|
|
28
|
+
from .evaluator import Evaluator
|
|
29
|
+
from .guard import Vericto
|
|
30
|
+
from .placeholders import PlaceholderMappingError
|
|
31
|
+
from .runtime_api import DEFAULT_API_URL, RuntimeApiEvaluator
|
|
32
|
+
from .verdict import Decision, Finding, Verdict
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DEFAULT_API_URL",
|
|
36
|
+
"KNOWN_DIALECTS",
|
|
37
|
+
"Decision",
|
|
38
|
+
"Evaluator",
|
|
39
|
+
"Finding",
|
|
40
|
+
"PlaceholderMappingError",
|
|
41
|
+
"RuntimeApiEvaluator",
|
|
42
|
+
"Verdict",
|
|
43
|
+
"Vericto",
|
|
44
|
+
"VerictoBlocked",
|
|
45
|
+
"VerictoConfigError",
|
|
46
|
+
"VerictoError",
|
|
47
|
+
"VerictoFailOpenWarning",
|
|
48
|
+
"VerictoFlaggedWarning",
|
|
49
|
+
"VerictoQuotaExceeded",
|
|
50
|
+
"VerictoUnavailable",
|
|
51
|
+
"VerictoUnsupportedDialect",
|
|
52
|
+
"__version__",
|
|
53
|
+
"normalize_dialect",
|
|
54
|
+
"wrap",
|
|
55
|
+
]
|
vericto/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""``python -m vericto dbt check ...``: the same command as ``vericto-dbt``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: list[str]) -> int:
|
|
9
|
+
if argv and argv[0] == "dbt":
|
|
10
|
+
from .dbt import main as dbt_main
|
|
11
|
+
|
|
12
|
+
return dbt_main(argv[1:])
|
|
13
|
+
sys.stderr.write("usage: python -m vericto dbt check [options] (see: python -m vericto dbt --help)\n")
|
|
14
|
+
return 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
sys.exit(main(sys.argv[1:]))
|
vericto/_agent.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Shared pieces of the LLM-framework integrations (LangChain, LlamaIndex).
|
|
2
|
+
|
|
3
|
+
* :func:`block_message`: the text an agent receives instead of rows when Vericto
|
|
4
|
+
blocks its query, written so the model can correct itself.
|
|
5
|
+
* :func:`describe_verdict`: a one-paragraph verdict for check tools.
|
|
6
|
+
* :class:`EngineGate`: checks the SQL an integration sends through a SQLAlchemy
|
|
7
|
+
engine, and only that SQL (not schema reflection, not the framework's own
|
|
8
|
+
``SET search_path``), so a query costs one evaluation.
|
|
9
|
+
|
|
10
|
+
Nothing here imports LangChain or LlamaIndex.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextvars
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import Any, Collection, Iterator, Optional
|
|
18
|
+
|
|
19
|
+
from .errors import VerictoBlocked, VerictoConfigError
|
|
20
|
+
from .guard import Vericto
|
|
21
|
+
from .verdict import Decision, Verdict
|
|
22
|
+
|
|
23
|
+
ON_BLOCK = ("return", "raise")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_on_block(value: str) -> str:
|
|
27
|
+
mode = str(value).strip().lower()
|
|
28
|
+
if mode not in ON_BLOCK:
|
|
29
|
+
raise VerictoConfigError(f"on_block must be 'return' or 'raise', got {value!r}.")
|
|
30
|
+
return mode
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _rule_label(verdict: Verdict) -> str:
|
|
34
|
+
rule = verdict.rule_code or "a Vericto rule"
|
|
35
|
+
return f"{rule}, severity {verdict.severity}" if verdict.severity else rule
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _denied(verdict: Verdict) -> Optional[str]:
|
|
39
|
+
if not verdict.access_denied:
|
|
40
|
+
return None
|
|
41
|
+
parts = []
|
|
42
|
+
for item in verdict.access_denied:
|
|
43
|
+
if isinstance(item, dict):
|
|
44
|
+
name = ".".join(str(item[k]) for k in ("schema", "table", "column") if item.get(k))
|
|
45
|
+
parts.append(name or str(item))
|
|
46
|
+
else:
|
|
47
|
+
parts.append(str(item))
|
|
48
|
+
return ", ".join(parts)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def block_message(blocked: VerictoBlocked) -> str:
|
|
52
|
+
"""What the agent reads when its query is blocked. Never contains row data."""
|
|
53
|
+
verdict = blocked.verdict
|
|
54
|
+
lines = [f"Error: Vericto blocked this query ({_rule_label(verdict)}). It was NOT executed."]
|
|
55
|
+
if verdict.message:
|
|
56
|
+
lines.append(f"Reason: {verdict.message}")
|
|
57
|
+
others = [f for f in verdict.findings if f.rule_code != verdict.rule_code]
|
|
58
|
+
if others:
|
|
59
|
+
lines.append("Other findings: " + ", ".join(f.rule_code for f in others) + ".")
|
|
60
|
+
denied = _denied(verdict)
|
|
61
|
+
if denied:
|
|
62
|
+
lines.append(f"Not allowed for this agent: {denied}.")
|
|
63
|
+
if verdict.suggested_fix:
|
|
64
|
+
lines.append(f"Suggested fix: {verdict.suggested_fix}")
|
|
65
|
+
lines.append("Rewrite the query following the suggested fix and try again.")
|
|
66
|
+
else:
|
|
67
|
+
lines.append("Rewrite the query so that it complies with this rule, or tell the user it cannot be run.")
|
|
68
|
+
if verdict.event_id:
|
|
69
|
+
lines.append(f"(Vericto event {verdict.event_id})")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe_verdict(verdict: Verdict) -> str:
|
|
74
|
+
"""A readable verdict for a check-only tool."""
|
|
75
|
+
if verdict.blocked:
|
|
76
|
+
return block_message(VerictoBlocked(verdict, verdict.sql))
|
|
77
|
+
if verdict.decision is Decision.UNEVALUATED:
|
|
78
|
+
return f"UNEVALUATED: Vericto could not evaluate the query ({verdict.message or 'no verdict'})."
|
|
79
|
+
if verdict.decision is Decision.ALLOWED and not verdict.masked:
|
|
80
|
+
text = "ALLOWED: Vericto found no problem with this query."
|
|
81
|
+
elif verdict.decision is Decision.PARSE_ERROR:
|
|
82
|
+
text = "PARSE_ERROR: Vericto could not parse the query; the workspace policy lets it run."
|
|
83
|
+
if verdict.parse_error:
|
|
84
|
+
text += f" Parser: {verdict.parse_error}"
|
|
85
|
+
else:
|
|
86
|
+
text = f"{verdict.decision.value}: allowed to run ({_rule_label(verdict)})."
|
|
87
|
+
if verdict.message:
|
|
88
|
+
text += f" {verdict.message}"
|
|
89
|
+
if verdict.masked:
|
|
90
|
+
text += f" Sensitive columns are masked: the query that will run is: {verdict.rewritten_query}"
|
|
91
|
+
return text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EngineGate:
|
|
95
|
+
"""A ``before_cursor_execute`` listener that is active only inside :meth:`active`.
|
|
96
|
+
|
|
97
|
+
The integrations wrap their query entry points in ``with gate.active():``; the
|
|
98
|
+
statements SQLAlchemy sends to the driver meanwhile are enforced (blocked ones
|
|
99
|
+
raise :class:`~vericto.VerictoBlocked`, masked ones run as the rewrite).
|
|
100
|
+
Statements in ``internal`` (fixed SQL the framework issues itself, such as
|
|
101
|
+
``SET search_path``) are let through unevaluated.
|
|
102
|
+
|
|
103
|
+
When ``passthrough`` is true the engine is already checked by
|
|
104
|
+
:func:`vericto.sqlalchemy.instrument`: no listener is added, so a statement is
|
|
105
|
+
evaluated once, and blocks still raise from that listener.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
engine: Any,
|
|
111
|
+
guard: Vericto,
|
|
112
|
+
dialect: str,
|
|
113
|
+
*,
|
|
114
|
+
internal: Collection[str] = (),
|
|
115
|
+
passthrough: bool = False,
|
|
116
|
+
) -> None:
|
|
117
|
+
from sqlalchemy import event
|
|
118
|
+
|
|
119
|
+
self.guard = guard
|
|
120
|
+
self.dialect = dialect
|
|
121
|
+
self.internal = frozenset(internal)
|
|
122
|
+
self._active: contextvars.ContextVar[bool] = contextvars.ContextVar(f"vericto_gate_{id(self)}", default=False)
|
|
123
|
+
self._target = getattr(engine, "sync_engine", engine)
|
|
124
|
+
self._paramstyle: Optional[str] = getattr(self._target.dialect, "paramstyle", None)
|
|
125
|
+
self._listening = False
|
|
126
|
+
if not passthrough:
|
|
127
|
+
event.listen(self._target, "before_cursor_execute", self._before_cursor_execute, retval=True)
|
|
128
|
+
self._listening = True
|
|
129
|
+
|
|
130
|
+
def _before_cursor_execute(
|
|
131
|
+
self, conn: Any, cursor: Any, statement: str, parameters: Any, context: Any, executemany: bool
|
|
132
|
+
) -> tuple[str, Any]:
|
|
133
|
+
if not self._active.get() or statement in self.internal:
|
|
134
|
+
return statement, parameters
|
|
135
|
+
has_params = executemany or bool(parameters)
|
|
136
|
+
final = self.guard.prepare(statement, self.dialect, paramstyle=self._paramstyle if has_params else None)
|
|
137
|
+
return final, parameters
|
|
138
|
+
|
|
139
|
+
@contextmanager
|
|
140
|
+
def active(self) -> Iterator[None]:
|
|
141
|
+
token = self._active.set(True)
|
|
142
|
+
try:
|
|
143
|
+
yield
|
|
144
|
+
finally:
|
|
145
|
+
self._active.reset(token)
|
|
146
|
+
|
|
147
|
+
def remove(self) -> None:
|
|
148
|
+
"""Detach the listener (idempotent)."""
|
|
149
|
+
from sqlalchemy import event
|
|
150
|
+
|
|
151
|
+
if self._listening and event.contains(self._target, "before_cursor_execute", self._before_cursor_execute):
|
|
152
|
+
event.remove(self._target, "before_cursor_execute", self._before_cursor_execute)
|
|
153
|
+
self._listening = False
|
vericto/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Vericto for Apache Airflow (2.9+ and 3.x), on the common-sql provider.
|
|
2
|
+
|
|
3
|
+
Two ways in, sharing the same check:
|
|
4
|
+
|
|
5
|
+
* :class:`VerictoSQLExecuteQueryOperator`: a drop-in ``SQLExecuteQueryOperator``
|
|
6
|
+
that checks every statement before any of them runs::
|
|
7
|
+
|
|
8
|
+
from vericto.airflow import VerictoSQLExecuteQueryOperator
|
|
9
|
+
|
|
10
|
+
VerictoSQLExecuteQueryOperator(task_id="cleanup", conn_id="warehouse", sql="sql/cleanup.sql")
|
|
11
|
+
|
|
12
|
+
* :func:`vericto.airflow.policy.guard_sql_tasks`: a cluster policy for
|
|
13
|
+
``airflow_local_settings.task_policy`` that guards existing SQL operators
|
|
14
|
+
without DAG changes.
|
|
15
|
+
|
|
16
|
+
The SQL checked is the **rendered** SQL (templates are rendered before the check
|
|
17
|
+
runs). A script is split with the hook's own ``split_sql_string`` and every
|
|
18
|
+
statement is evaluated before the first one runs:
|
|
19
|
+
|
|
20
|
+
* ``BLOCKED``: the task fails with :class:`VerictoTaskBlocked` (an
|
|
21
|
+
``AirflowFailException``, so it is not retried: the same SQL would be blocked
|
|
22
|
+
again) and nothing runs.
|
|
23
|
+
* masked columns: the masked rewrite runs instead.
|
|
24
|
+
* ``FLAGGED`` / ``MONITORED``: runs; logged on the ``vericto`` logger and passed to
|
|
25
|
+
the guard's ``on_verdict``.
|
|
26
|
+
* Vericto unavailable: ``fail_mode="closed"`` (default) fails the task with
|
|
27
|
+
:class:`~vericto.VerictoUnavailable`, which is retried like any other error.
|
|
28
|
+
|
|
29
|
+
The dialect comes from the connection type (``postgres``, ``redshift`` (as
|
|
30
|
+
postgres), ``mysql``, ``mssql``, ``oracle``, ``snowflake``, ``sqlite``), or from
|
|
31
|
+
``vericto_dialect`` in the connection's extra, or from the operator argument.
|
|
32
|
+
|
|
33
|
+
Requires the ``airflow`` extra: ``pip install "vericto[airflow]"``. The guard is
|
|
34
|
+
built from the ``VERICTO_*`` environment variables of the worker the first time a
|
|
35
|
+
task needs it (DAG parsing never calls Vericto).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
import threading
|
|
42
|
+
from typing import Any, List, Mapping, Optional
|
|
43
|
+
|
|
44
|
+
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
|
45
|
+
|
|
46
|
+
from ..dialects import normalize_dialect
|
|
47
|
+
from ..errors import VerictoBlocked, VerictoConfigError
|
|
48
|
+
from ..guard import Vericto
|
|
49
|
+
from ..verdict import Verdict
|
|
50
|
+
|
|
51
|
+
try: # Airflow 3
|
|
52
|
+
from airflow.sdk.exceptions import AirflowFailException
|
|
53
|
+
except ImportError: # Airflow 2
|
|
54
|
+
from airflow.exceptions import AirflowFailException # type: ignore[no-redef,attr-defined,unused-ignore]
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger("vericto")
|
|
57
|
+
|
|
58
|
+
#: Airflow connection type -> Vericto dialect.
|
|
59
|
+
CONN_TYPE_DIALECTS: Mapping[str, str] = {
|
|
60
|
+
"postgres": "postgres",
|
|
61
|
+
"postgresql": "postgres",
|
|
62
|
+
"redshift": "postgres",
|
|
63
|
+
"mysql": "mysql",
|
|
64
|
+
"mariadb": "mysql",
|
|
65
|
+
"mssql": "mssql",
|
|
66
|
+
"oracle": "oracle",
|
|
67
|
+
"snowflake": "snowflake",
|
|
68
|
+
"sqlite": "sqlite",
|
|
69
|
+
}
|
|
70
|
+
ON_UNSUPPORTED = ("fail", "skip")
|
|
71
|
+
EXTRA_KEY = "vericto_dialect"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class VerictoTaskBlocked(VerictoBlocked, AirflowFailException):
|
|
75
|
+
"""Vericto blocked a statement of the task; nothing ran. Not retried."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_shared: Optional[Vericto] = None
|
|
79
|
+
_shared_lock = threading.Lock()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def shared_guard() -> Vericto:
|
|
83
|
+
"""The process-wide guard, built from ``VERICTO_*`` on first use."""
|
|
84
|
+
global _shared
|
|
85
|
+
with _shared_lock:
|
|
86
|
+
if _shared is None:
|
|
87
|
+
_shared = Vericto()
|
|
88
|
+
return _shared
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_on_unsupported(value: str) -> str:
|
|
92
|
+
if value not in ON_UNSUPPORTED:
|
|
93
|
+
raise VerictoConfigError(f"on_unsupported must be 'fail' or 'skip', got {value!r}.")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dialect_for_hook(hook: Any, override: Optional[str] = None) -> Optional[str]:
|
|
98
|
+
"""The Vericto dialect for a DB hook, or ``None`` when its connection type is not mapped."""
|
|
99
|
+
if override:
|
|
100
|
+
return normalize_dialect(override)
|
|
101
|
+
conn = None
|
|
102
|
+
try:
|
|
103
|
+
conn = hook.connection
|
|
104
|
+
except Exception: # no connection property on this hook, or it cannot be read
|
|
105
|
+
logger.debug("vericto: could not read the hook's connection", exc_info=True)
|
|
106
|
+
if conn is not None:
|
|
107
|
+
extra = getattr(conn, "extra_dejson", None) or {}
|
|
108
|
+
if isinstance(extra, Mapping) and extra.get(EXTRA_KEY):
|
|
109
|
+
return normalize_dialect(str(extra[EXTRA_KEY]))
|
|
110
|
+
conn_type = (getattr(conn, "conn_type", None) or getattr(hook, "conn_type", None) or "").lower()
|
|
111
|
+
return CONN_TYPE_DIALECTS.get(conn_type)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _paramstyle(hook: Any, sql: str, parameters: Any) -> Optional[str]:
|
|
115
|
+
if not parameters:
|
|
116
|
+
return None
|
|
117
|
+
if isinstance(parameters, Mapping) and "%(" in sql:
|
|
118
|
+
return "pyformat"
|
|
119
|
+
placeholder = getattr(hook, "placeholder", "%s")
|
|
120
|
+
return "format" if placeholder == "%s" else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _statements(hook: Any, sql: Any) -> List[str]:
|
|
124
|
+
if isinstance(sql, str):
|
|
125
|
+
split = getattr(hook, "split_sql_string", None)
|
|
126
|
+
parts = split(sql) if callable(split) else [sql]
|
|
127
|
+
return [p for p in parts if p and p.strip()]
|
|
128
|
+
if isinstance(sql, (list, tuple)):
|
|
129
|
+
return [s for s in sql if isinstance(s, str) and s.strip()]
|
|
130
|
+
raise TypeError(f"vericto: expected the task's sql as str or list of str, got {type(sql).__name__}.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_task_sql(
|
|
134
|
+
task: Any,
|
|
135
|
+
*,
|
|
136
|
+
guard: Optional[Vericto] = None,
|
|
137
|
+
dialect: Optional[str] = None,
|
|
138
|
+
on_unsupported: str = "fail",
|
|
139
|
+
) -> List[Verdict]:
|
|
140
|
+
"""Check a SQL task's rendered ``sql`` and apply masks to it. Call before it runs.
|
|
141
|
+
|
|
142
|
+
Every statement is evaluated before any runs. Raises :class:`VerictoTaskBlocked`
|
|
143
|
+
on a block, :class:`~vericto.VerictoUnavailable` (fail-closed) or
|
|
144
|
+
:class:`~vericto.VerictoConfigError`. When a statement is masked, ``task.sql``
|
|
145
|
+
is replaced with the statements to run.
|
|
146
|
+
"""
|
|
147
|
+
hook = task.get_db_hook()
|
|
148
|
+
name = dialect_for_hook(hook, dialect)
|
|
149
|
+
if name is None:
|
|
150
|
+
conn_type = getattr(getattr(hook, "connection", None), "conn_type", None) or getattr(hook, "conn_type", "?")
|
|
151
|
+
message = (
|
|
152
|
+
f"Vericto has no dialect for the connection type '{conn_type}' of task {task.task_id}. "
|
|
153
|
+
f"Set '{EXTRA_KEY}' in the connection extra or vericto_dialect= on the operator "
|
|
154
|
+
f"(one of: postgres, mysql, oracle, mssql, snowflake)."
|
|
155
|
+
)
|
|
156
|
+
if on_unsupported == "skip":
|
|
157
|
+
logger.warning("vericto: %s The task runs unchecked (on_unsupported='skip').", message)
|
|
158
|
+
return []
|
|
159
|
+
raise VerictoConfigError(message + " Nothing was executed.")
|
|
160
|
+
|
|
161
|
+
g = guard if guard is not None else shared_guard()
|
|
162
|
+
g.require_dialect(name)
|
|
163
|
+
sql = task.sql
|
|
164
|
+
statements = _statements(hook, sql)
|
|
165
|
+
parameters = getattr(task, "parameters", None)
|
|
166
|
+
verdicts: List[Verdict] = []
|
|
167
|
+
for statement in statements:
|
|
168
|
+
try:
|
|
169
|
+
verdicts.append(g.enforce(statement, name, paramstyle=_paramstyle(hook, statement, parameters)))
|
|
170
|
+
except VerictoBlocked as exc:
|
|
171
|
+
raise VerictoTaskBlocked(exc.verdict, exc.sql) from None
|
|
172
|
+
|
|
173
|
+
if any(v.masked for v in verdicts):
|
|
174
|
+
rewritten = [v.sql_to_execute for v in verdicts]
|
|
175
|
+
task.sql = rewritten[0] if isinstance(sql, str) and len(rewritten) == 1 else rewritten
|
|
176
|
+
logger.info("vericto: task %s runs the masked rewrite of %d statement(s)", task.task_id, len(rewritten))
|
|
177
|
+
return verdicts
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class VerictoSQLExecuteQueryOperator(SQLExecuteQueryOperator):
|
|
181
|
+
"""``SQLExecuteQueryOperator`` that checks the rendered SQL with Vericto first.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
vericto_guard: the :class:`~vericto.Vericto` to use; the process-wide guard
|
|
185
|
+
from ``VERICTO_*`` when omitted.
|
|
186
|
+
vericto_dialect: override the dialect read from the connection.
|
|
187
|
+
vericto_on_unsupported: ``"fail"`` (default): a connection type Vericto has
|
|
188
|
+
no dialect for fails the task before anything runs. ``"skip"``: run it
|
|
189
|
+
unchecked, with a warning.
|
|
190
|
+
|
|
191
|
+
Every other argument is ``SQLExecuteQueryOperator``'s.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
*,
|
|
197
|
+
vericto_guard: Optional[Vericto] = None,
|
|
198
|
+
vericto_dialect: Optional[str] = None,
|
|
199
|
+
vericto_on_unsupported: str = "fail",
|
|
200
|
+
**kwargs: Any,
|
|
201
|
+
) -> None:
|
|
202
|
+
super().__init__(**kwargs)
|
|
203
|
+
self.vericto_guard = vericto_guard
|
|
204
|
+
self.vericto_dialect = vericto_dialect
|
|
205
|
+
self.vericto_on_unsupported = _check_on_unsupported(vericto_on_unsupported)
|
|
206
|
+
|
|
207
|
+
def execute(self, context: Any) -> Any:
|
|
208
|
+
check_task_sql(
|
|
209
|
+
self, guard=self.vericto_guard, dialect=self.vericto_dialect, on_unsupported=self.vericto_on_unsupported
|
|
210
|
+
)
|
|
211
|
+
return super().execute(context) # type: ignore[no-untyped-call,unused-ignore] # untyped upstream
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"CONN_TYPE_DIALECTS",
|
|
216
|
+
"VerictoSQLExecuteQueryOperator",
|
|
217
|
+
"VerictoTaskBlocked",
|
|
218
|
+
"check_task_sql",
|
|
219
|
+
"dialect_for_hook",
|
|
220
|
+
"shared_guard",
|
|
221
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""A cluster policy that guards existing SQL tasks with Vericto, without DAG changes.
|
|
2
|
+
|
|
3
|
+
In ``airflow_local_settings.py`` (on ``PYTHONPATH`` or in ``$AIRFLOW_HOME/config``)
|
|
4
|
+
of the DAG processor **and the workers**::
|
|
5
|
+
|
|
6
|
+
from vericto.airflow.policy import guard_sql_tasks
|
|
7
|
+
|
|
8
|
+
def task_policy(task):
|
|
9
|
+
guard_sql_tasks(task)
|
|
10
|
+
|
|
11
|
+
Every common-sql operator (``SQLExecuteQueryOperator`` and its subclasses, the
|
|
12
|
+
``SQL*Check`` operators, provider operators built on them) then checks its
|
|
13
|
+
rendered SQL with Vericto right before it runs, exactly as
|
|
14
|
+
:class:`~vericto.airflow.VerictoSQLExecuteQueryOperator` does. The check is
|
|
15
|
+
installed as the task's ``pre_execute`` hook (an existing hook still runs, first),
|
|
16
|
+
so the operator class, its serialization and the UI are unchanged.
|
|
17
|
+
|
|
18
|
+
Airflow applies ``task_policy`` whenever a DAG file is parsed, including on the
|
|
19
|
+
worker that runs the task, so the guard is in place where the SQL executes.
|
|
20
|
+
Parsing never calls Vericto; the guard is built from the worker's ``VERICTO_*``
|
|
21
|
+
environment on the first checked task.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Callable, Optional, Tuple, Type
|
|
27
|
+
|
|
28
|
+
from airflow.providers.common.sql.operators.sql import BaseSQLOperator
|
|
29
|
+
|
|
30
|
+
from ..guard import Vericto
|
|
31
|
+
from . import VerictoSQLExecuteQueryOperator, _check_on_unsupported, check_task_sql
|
|
32
|
+
|
|
33
|
+
MARKER = "_vericto_guarded"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def guard_sql_tasks(
|
|
37
|
+
task: Any,
|
|
38
|
+
*,
|
|
39
|
+
guard: Optional[Vericto] = None,
|
|
40
|
+
dialect: Optional[str] = None,
|
|
41
|
+
on_unsupported: str = "fail",
|
|
42
|
+
operators: Tuple[Type[Any], ...] = (BaseSQLOperator,),
|
|
43
|
+
when: Optional[Callable[[Any], bool]] = None,
|
|
44
|
+
) -> bool:
|
|
45
|
+
"""Guard ``task`` if it is a SQL operator. Returns whether it is now guarded.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
task: the task ``task_policy`` receives.
|
|
49
|
+
guard: the :class:`~vericto.Vericto` to use; the process-wide one from
|
|
50
|
+
``VERICTO_*`` when omitted.
|
|
51
|
+
dialect: force a dialect instead of reading it from the connection.
|
|
52
|
+
on_unsupported: ``"fail"`` (default) fails a task whose connection type has
|
|
53
|
+
no Vericto dialect, before anything runs; ``"skip"`` runs it unchecked
|
|
54
|
+
with a warning. Set ``vericto_dialect`` in a connection's extra to map
|
|
55
|
+
ODBC/JDBC connections.
|
|
56
|
+
operators: the operator classes to guard (default: every common-sql operator).
|
|
57
|
+
when: an optional predicate on the task (e.g. by ``conn_id`` or DAG tags).
|
|
58
|
+
|
|
59
|
+
Idempotent: a task is guarded once, and
|
|
60
|
+
:class:`~vericto.airflow.VerictoSQLExecuteQueryOperator` tasks are left alone
|
|
61
|
+
(they check themselves).
|
|
62
|
+
"""
|
|
63
|
+
mode = _check_on_unsupported(on_unsupported)
|
|
64
|
+
if not isinstance(task, operators) or isinstance(task, VerictoSQLExecuteQueryOperator):
|
|
65
|
+
return False
|
|
66
|
+
if getattr(task, MARKER, False):
|
|
67
|
+
return True
|
|
68
|
+
if when is not None and not when(task):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
previous = getattr(task, "_pre_execute_hook", None)
|
|
72
|
+
|
|
73
|
+
def vericto_pre_execute(context: Any) -> None:
|
|
74
|
+
if previous is not None:
|
|
75
|
+
previous(context)
|
|
76
|
+
# The task being executed: the rendered copy Airflow made for this run.
|
|
77
|
+
running = context.get("task", task) if hasattr(context, "get") else task
|
|
78
|
+
check_task_sql(running, guard=guard, dialect=dialect, on_unsupported=mode)
|
|
79
|
+
|
|
80
|
+
task._pre_execute_hook = vericto_pre_execute
|
|
81
|
+
setattr(task, MARKER, True)
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["guard_sql_tasks"]
|