sqlguard-ninja 0.1.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.
- sqlguard_ninja/__init__.py +35 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
- sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
- sqlguard_ninja/_version.py +1 -0
- sqlguard_ninja/benchmarking/__init__.py +1 -0
- sqlguard_ninja/benchmarking/adapters.py +303 -0
- sqlguard_ninja/benchmarking/builtin.py +39 -0
- sqlguard_ninja/benchmarking/compare.py +168 -0
- sqlguard_ninja/benchmarking/dataset.py +265 -0
- sqlguard_ninja/benchmarking/fixtures.py +274 -0
- sqlguard_ninja/benchmarking/metrics.py +114 -0
- sqlguard_ninja/benchmarking/runner.py +126 -0
- sqlguard_ninja/benchmarking/translate.py +64 -0
- sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
- sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
- sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
- sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
- sqlguard_ninja/cli/__init__.py +1 -0
- sqlguard_ninja/cli/config.py +76 -0
- sqlguard_ninja/cli/main.py +433 -0
- sqlguard_ninja/cli/templates.py +91 -0
- sqlguard_ninja/core/__init__.py +1 -0
- sqlguard_ninja/core/analysis.py +581 -0
- sqlguard_ninja/core/findings.py +133 -0
- sqlguard_ninja/core/guard.py +256 -0
- sqlguard_ninja/core/rules/__init__.py +1 -0
- sqlguard_ninja/core/rules/access.py +127 -0
- sqlguard_ninja/core/rules/base.py +40 -0
- sqlguard_ninja/core/rules/catalog.py +61 -0
- sqlguard_ninja/core/rules/functions.py +197 -0
- sqlguard_ninja/core/rules/statements.py +99 -0
- sqlguard_ninja/core/rules/structure.py +72 -0
- sqlguard_ninja/core/rules/tenant.py +196 -0
- sqlguard_ninja/integrations/__init__.py +43 -0
- sqlguard_ninja/integrations/audit.py +109 -0
- sqlguard_ninja/integrations/checkpoint.py +342 -0
- sqlguard_ninja/integrations/executors.py +64 -0
- sqlguard_ninja/integrations/fastapi.py +89 -0
- sqlguard_ninja/integrations/langchain.py +67 -0
- sqlguard_ninja/integrations/langgraph.py +87 -0
- sqlguard_ninja/policy/__init__.py +39 -0
- sqlguard_ninja/policy/model.py +370 -0
- sqlguard_ninja/privacy/__init__.py +9 -0
- sqlguard_ninja/privacy/rules.py +286 -0
- sqlguard_ninja/privacy/suggest.py +46 -0
- sqlguard_ninja/py.typed +0 -0
- sqlguard_ninja/query_cost/__init__.py +1 -0
- sqlguard_ninja/query_cost/postgres.py +226 -0
- sqlguard_ninja/query_cost/static.py +241 -0
- sqlguard_ninja/reporting/__init__.py +1 -0
- sqlguard_ninja/reporting/markdown.py +137 -0
- sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
- sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
- sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
- sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
- sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
- sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Pre-execution SQL checkpoint for agents.
|
|
2
|
+
|
|
3
|
+
The checkpoint separates two questions:
|
|
4
|
+
|
|
5
|
+
1. *Policy evaluation* — what does the policy say about this SQL for this caller?
|
|
6
|
+
(:meth:`Guard.validate`, a pure function of SQL + policy + trusted context.)
|
|
7
|
+
2. *Execution authorization* — given that verdict, the enforcement mode and any human
|
|
8
|
+
approval, may this exact SQL run now?
|
|
9
|
+
|
|
10
|
+
Nothing an LLM produces can change either answer. The role and tenant come from a
|
|
11
|
+
:class:`RequestContext` the application builds from its own authentication. Tool arguments,
|
|
12
|
+
SQL comments and model "explanations" are not inputs to either decision. Approvals are bound
|
|
13
|
+
to the SHA-256 of the exact SQL, the trace and the requesting principal, and can be used once.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
from typing import Any, Protocol, TypeVar
|
|
25
|
+
|
|
26
|
+
from sqlguard_ninja.core.findings import Decision, ValidationResult
|
|
27
|
+
from sqlguard_ninja.core.guard import Guard
|
|
28
|
+
from sqlguard_ninja.integrations.audit import AuditEvent, AuditSink, redact_sql
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EnforcementMode(StrEnum):
|
|
34
|
+
BLOCK = "block"
|
|
35
|
+
"""DENY and REVIEW are not executed."""
|
|
36
|
+
REVIEW = "review"
|
|
37
|
+
"""DENY is not executed; REVIEW runs only with a valid human approval."""
|
|
38
|
+
AUDIT = "audit"
|
|
39
|
+
"""Everything runs; findings are recorded with ``enforced=False``. Monitoring only."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RequestContext:
|
|
44
|
+
"""Trusted, server-side facts about the request. Build it from your auth layer only."""
|
|
45
|
+
|
|
46
|
+
principal: str
|
|
47
|
+
role: str | None
|
|
48
|
+
tenant_id: Any = None
|
|
49
|
+
trace_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
50
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def policy_context(self, tenant_key: str) -> dict[str, Any]:
|
|
53
|
+
ctx = dict(self.attributes)
|
|
54
|
+
if self.tenant_id is not None:
|
|
55
|
+
ctx[tenant_key] = self.tenant_id
|
|
56
|
+
return ctx
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ApprovalRequest:
|
|
61
|
+
request_id: str
|
|
62
|
+
trace_id: str
|
|
63
|
+
principal: str
|
|
64
|
+
role: str | None
|
|
65
|
+
sql_sha256: str
|
|
66
|
+
summary: str
|
|
67
|
+
created_at: float
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ApprovalDecision:
|
|
72
|
+
approved: bool
|
|
73
|
+
approver: str
|
|
74
|
+
reason: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Approver(Protocol):
|
|
78
|
+
"""Decide (or defer) an approval request. Return None while pending."""
|
|
79
|
+
|
|
80
|
+
def decide(self, request: ApprovalRequest) -> ApprovalDecision | None: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DenyAllApprover:
|
|
84
|
+
"""Default: nothing is ever approved."""
|
|
85
|
+
|
|
86
|
+
def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
|
|
87
|
+
return ApprovalDecision(False, "system", "no approver configured")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CallbackApprover:
|
|
91
|
+
"""Synchronous approval through a callback (e.g. a CLI prompt or chat-ops bot)."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, fn: Callable[[ApprovalRequest], ApprovalDecision | None]) -> None:
|
|
94
|
+
self.fn = fn
|
|
95
|
+
|
|
96
|
+
def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
|
|
97
|
+
return self.fn(request)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ApprovalQueue:
|
|
101
|
+
"""Asynchronous human approval.
|
|
102
|
+
|
|
103
|
+
A REVIEW query is parked as a pending request. A human (a different principal) approves it
|
|
104
|
+
out of band; the agent may then re-submit *the identical SQL* within ``ttl_s`` seconds.
|
|
105
|
+
Each approval is single-use.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, ttl_s: float = 900.0, clock: Callable[[], float] = time.time) -> None:
|
|
109
|
+
self.ttl_s = ttl_s
|
|
110
|
+
self.clock = clock
|
|
111
|
+
self.pending: dict[str, ApprovalRequest] = {}
|
|
112
|
+
self._decided: dict[str, tuple[ApprovalRequest, ApprovalDecision, float]] = {}
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _key(req: ApprovalRequest) -> str:
|
|
117
|
+
return f"{req.trace_id}|{req.principal}|{req.sql_sha256}"
|
|
118
|
+
|
|
119
|
+
def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
|
|
120
|
+
key = self._key(request)
|
|
121
|
+
with self._lock:
|
|
122
|
+
decided = self._decided.get(key)
|
|
123
|
+
if decided is not None:
|
|
124
|
+
_req, decision, at = decided
|
|
125
|
+
if self.clock() - at > self.ttl_s:
|
|
126
|
+
del self._decided[key]
|
|
127
|
+
return ApprovalDecision(False, decision.approver, "approval expired")
|
|
128
|
+
del self._decided[key] # single use
|
|
129
|
+
return decision
|
|
130
|
+
if not any(self._key(p) == key for p in self.pending.values()):
|
|
131
|
+
self.pending[request.request_id] = request
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _resolve(self, request_id: str, approver: str, approved: bool, reason: str) -> None:
|
|
135
|
+
with self._lock:
|
|
136
|
+
req = self.pending.get(request_id)
|
|
137
|
+
if req is None:
|
|
138
|
+
raise KeyError(f"no pending approval {request_id!r}")
|
|
139
|
+
if approver == req.principal:
|
|
140
|
+
raise PermissionError("the requesting principal cannot approve its own query")
|
|
141
|
+
del self.pending[request_id]
|
|
142
|
+
self._decided[self._key(req)] = (
|
|
143
|
+
req,
|
|
144
|
+
ApprovalDecision(approved, approver, reason),
|
|
145
|
+
self.clock(),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def approve(self, request_id: str, approver: str, reason: str = "") -> None:
|
|
149
|
+
self._resolve(request_id, approver, True, reason)
|
|
150
|
+
|
|
151
|
+
def reject(self, request_id: str, approver: str, reason: str = "") -> None:
|
|
152
|
+
self._resolve(request_id, approver, False, reason)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class CheckpointOutcome:
|
|
157
|
+
authorized: bool
|
|
158
|
+
"""May the exact SQL be executed now?"""
|
|
159
|
+
enforced: bool
|
|
160
|
+
"""False in audit mode: a negative verdict was recorded but did not stop execution."""
|
|
161
|
+
would_block: bool
|
|
162
|
+
"""True whenever the policy verdict was not ALLOW, regardless of mode."""
|
|
163
|
+
mode: EnforcementMode
|
|
164
|
+
result: ValidationResult
|
|
165
|
+
agent_message: str
|
|
166
|
+
"""Text safe to return to the LLM. Never contains approval tokens or internal detail."""
|
|
167
|
+
approval_request: ApprovalRequest | None = None
|
|
168
|
+
approval: ApprovalDecision | None = None
|
|
169
|
+
attempt: int = 1
|
|
170
|
+
locked_out: bool = False
|
|
171
|
+
audit_event: AuditEvent | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SQLBlockedError(PermissionError):
|
|
175
|
+
def __init__(self, outcome: CheckpointOutcome) -> None:
|
|
176
|
+
super().__init__(outcome.agent_message)
|
|
177
|
+
self.outcome = outcome
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SQLCheckpoint:
|
|
181
|
+
"""Evaluate and authorize SQL before execution. Thread-safe."""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
guard: Guard,
|
|
186
|
+
mode: EnforcementMode | str = EnforcementMode.BLOCK,
|
|
187
|
+
*,
|
|
188
|
+
approver: Approver | None = None,
|
|
189
|
+
audit_sink: AuditSink | None = None,
|
|
190
|
+
max_denied_attempts: int | None = 5,
|
|
191
|
+
dialect: str | None = None,
|
|
192
|
+
log_redacted_sql: bool = True,
|
|
193
|
+
) -> None:
|
|
194
|
+
self.guard = guard
|
|
195
|
+
self.mode = EnforcementMode(mode)
|
|
196
|
+
self.approver: Approver = approver or DenyAllApprover()
|
|
197
|
+
self.audit_sink = audit_sink
|
|
198
|
+
self.max_denied_attempts = max_denied_attempts
|
|
199
|
+
self.dialect = dialect or guard.policy.dialect
|
|
200
|
+
self.log_redacted_sql = log_redacted_sql
|
|
201
|
+
self._attempts: dict[str, int] = {}
|
|
202
|
+
self._denials: dict[str, int] = {}
|
|
203
|
+
self._lock = threading.Lock()
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------ evaluate
|
|
206
|
+
def evaluate(self, sql: str, context: RequestContext) -> CheckpointOutcome:
|
|
207
|
+
started = time.perf_counter()
|
|
208
|
+
policy_ctx = context.policy_context(self.guard.policy.tenant.context_key)
|
|
209
|
+
result = self.guard.validate(
|
|
210
|
+
sql, role=context.role, dialect=self.dialect, context=policy_ctx
|
|
211
|
+
)
|
|
212
|
+
with self._lock:
|
|
213
|
+
attempt = self._attempts.get(context.trace_id, 0) + 1
|
|
214
|
+
self._attempts[context.trace_id] = attempt
|
|
215
|
+
denials = self._denials.get(context.trace_id, 0)
|
|
216
|
+
locked = self.max_denied_attempts is not None and denials >= self.max_denied_attempts
|
|
217
|
+
|
|
218
|
+
would_block = result.decision is not Decision.ALLOW
|
|
219
|
+
approval_request: ApprovalRequest | None = None
|
|
220
|
+
approval: ApprovalDecision | None = None
|
|
221
|
+
if locked and self.mode is not EnforcementMode.AUDIT:
|
|
222
|
+
authorized, enforced = False, True
|
|
223
|
+
elif result.decision is Decision.ALLOW:
|
|
224
|
+
authorized, enforced = True, True
|
|
225
|
+
elif self.mode is EnforcementMode.AUDIT:
|
|
226
|
+
authorized, enforced = True, False
|
|
227
|
+
elif result.decision is Decision.REVIEW and self.mode is EnforcementMode.REVIEW:
|
|
228
|
+
approval_request = ApprovalRequest(
|
|
229
|
+
request_id=uuid.uuid4().hex,
|
|
230
|
+
trace_id=context.trace_id,
|
|
231
|
+
principal=context.principal,
|
|
232
|
+
role=context.role,
|
|
233
|
+
sql_sha256=result.sql_sha256,
|
|
234
|
+
summary="; ".join(f.message for f in result.blocking_findings)[:500],
|
|
235
|
+
created_at=time.time(),
|
|
236
|
+
)
|
|
237
|
+
approval = self.approver.decide(approval_request)
|
|
238
|
+
if approval is not None and approval.approver == context.principal:
|
|
239
|
+
approval = ApprovalDecision(False, approval.approver, "self-approval rejected")
|
|
240
|
+
authorized = approval is not None and approval.approved
|
|
241
|
+
enforced = True
|
|
242
|
+
else:
|
|
243
|
+
authorized, enforced = False, True
|
|
244
|
+
|
|
245
|
+
if not authorized and enforced:
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._denials[context.trace_id] = self._denials.get(context.trace_id, 0) + 1
|
|
248
|
+
|
|
249
|
+
message = self._agent_message(
|
|
250
|
+
result, authorized, enforced, locked, approval_request, approval
|
|
251
|
+
)
|
|
252
|
+
event = AuditEvent(
|
|
253
|
+
trace_id=context.trace_id,
|
|
254
|
+
principal=context.principal,
|
|
255
|
+
role=result.role,
|
|
256
|
+
tenant=None if context.tenant_id is None else str(context.tenant_id),
|
|
257
|
+
mode=self.mode.value,
|
|
258
|
+
decision=result.decision.value,
|
|
259
|
+
authorized=authorized,
|
|
260
|
+
enforced=enforced,
|
|
261
|
+
would_block=would_block,
|
|
262
|
+
sql_sha256=result.sql_sha256,
|
|
263
|
+
sql_redacted=redact_sql(sql, self.dialect) if self.log_redacted_sql else None,
|
|
264
|
+
dialect=self.dialect,
|
|
265
|
+
policy=result.policy_name,
|
|
266
|
+
findings=[
|
|
267
|
+
{"rule_id": f.rule_id, "action": f.action.value, "category": f.category.value}
|
|
268
|
+
for f in result.findings
|
|
269
|
+
],
|
|
270
|
+
approval=None
|
|
271
|
+
if approval is None
|
|
272
|
+
else {"approved": approval.approved, "approver": approval.approver},
|
|
273
|
+
attempt=attempt,
|
|
274
|
+
elapsed_ms=(time.perf_counter() - started) * 1000,
|
|
275
|
+
)
|
|
276
|
+
if self.audit_sink is not None:
|
|
277
|
+
self.audit_sink.emit(event)
|
|
278
|
+
return CheckpointOutcome(
|
|
279
|
+
authorized=authorized,
|
|
280
|
+
enforced=enforced,
|
|
281
|
+
would_block=would_block,
|
|
282
|
+
mode=self.mode,
|
|
283
|
+
result=result,
|
|
284
|
+
agent_message=message,
|
|
285
|
+
approval_request=approval_request,
|
|
286
|
+
approval=approval,
|
|
287
|
+
attempt=attempt,
|
|
288
|
+
locked_out=locked,
|
|
289
|
+
audit_event=event,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _agent_message(
|
|
294
|
+
result: ValidationResult,
|
|
295
|
+
authorized: bool,
|
|
296
|
+
enforced: bool,
|
|
297
|
+
locked: bool,
|
|
298
|
+
approval_request: ApprovalRequest | None,
|
|
299
|
+
approval: ApprovalDecision | None,
|
|
300
|
+
) -> str:
|
|
301
|
+
if locked and not authorized:
|
|
302
|
+
return (
|
|
303
|
+
"Query blocked: too many policy violations in this session. "
|
|
304
|
+
"No further queries will run in this conversation."
|
|
305
|
+
)
|
|
306
|
+
reasons = "; ".join(
|
|
307
|
+
f.message + (f" Suggestion: {f.remediation}" if f.remediation else "")
|
|
308
|
+
for f in result.blocking_findings
|
|
309
|
+
)
|
|
310
|
+
if authorized and not enforced:
|
|
311
|
+
return "Query executed (audit mode). Policy findings were recorded."
|
|
312
|
+
if authorized:
|
|
313
|
+
return "Query permitted."
|
|
314
|
+
if approval_request is not None:
|
|
315
|
+
if approval is None:
|
|
316
|
+
return (
|
|
317
|
+
"Query requires human approval and was not executed. "
|
|
318
|
+
f"Reason: {reasons} Approval is handled outside this conversation."
|
|
319
|
+
)
|
|
320
|
+
return f"Query was not approved and was not executed. Reason: {reasons}"
|
|
321
|
+
return (
|
|
322
|
+
f"Query blocked by policy and not executed. Reason: {reasons} "
|
|
323
|
+
"This decision is enforced by the server and cannot be overridden by the assistant."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------- execute
|
|
327
|
+
def execute(
|
|
328
|
+
self,
|
|
329
|
+
sql: str,
|
|
330
|
+
context: RequestContext,
|
|
331
|
+
executor: Callable[[str, Mapping[str, Any]], T],
|
|
332
|
+
) -> T:
|
|
333
|
+
"""Evaluate, then run ``executor(sql, params)`` only if authorized.
|
|
334
|
+
|
|
335
|
+
``params`` contains the trusted context values (e.g. ``tenant_id``) for bind
|
|
336
|
+
parameters. The SQL is executed exactly as validated.
|
|
337
|
+
"""
|
|
338
|
+
outcome = self.evaluate(sql, context)
|
|
339
|
+
if not outcome.authorized:
|
|
340
|
+
raise SQLBlockedError(outcome)
|
|
341
|
+
params = context.policy_context(self.guard.policy.tenant.context_key)
|
|
342
|
+
return executor(sql, params)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Small, safe executors used by the integrations and examples.
|
|
2
|
+
|
|
3
|
+
These are the *last* line of defence after the checkpoint: they open read-only sessions so
|
|
4
|
+
that the database itself refuses writes even if a query were authorized by mistake (or
|
|
5
|
+
deliberately, in audit mode). Use a least-privilege database account in production.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
Rows = list[dict[str, Any]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bound_params(sql: str, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
19
|
+
# Only pass parameters the statement mentions; sqlite3 ignores extras for named style,
|
|
20
|
+
# but filtering keeps driver errors clear.
|
|
21
|
+
return {k: v for k, v in params.items() if f":{k}" in sql or f"%({k})s" in sql}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sqlite_executor(conn: sqlite3.Connection, max_rows: int = 200) -> Callable[..., Rows]:
|
|
25
|
+
"""Execute on SQLite with ``PRAGMA query_only = ON`` (writes fail at the engine).
|
|
26
|
+
|
|
27
|
+
Calls are serialised with a lock so one connection can be shared by threaded servers;
|
|
28
|
+
open it with ``check_same_thread=False`` for that.
|
|
29
|
+
"""
|
|
30
|
+
conn.execute("PRAGMA query_only = ON")
|
|
31
|
+
lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def run(sql: str, params: Mapping[str, Any]) -> Rows:
|
|
34
|
+
with lock:
|
|
35
|
+
cur = conn.execute(sql, _bound_params(sql, params))
|
|
36
|
+
cols = [d[0] for d in cur.description or ()]
|
|
37
|
+
return [dict(zip(cols, row, strict=False)) for row in cur.fetchmany(max_rows)]
|
|
38
|
+
|
|
39
|
+
return run
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def psycopg_executor(
|
|
43
|
+
conninfo: str, max_rows: int = 200, statement_timeout_ms: int = 5000
|
|
44
|
+
) -> Callable[..., Rows]: # pragma: no cover - exercised by PostgreSQL integration tests
|
|
45
|
+
"""Execute on PostgreSQL in a READ ONLY transaction with a statement timeout.
|
|
46
|
+
|
|
47
|
+
Use ``%(tenant_id)s`` style bind parameters in SQL sent to PostgreSQL.
|
|
48
|
+
"""
|
|
49
|
+
import psycopg # noqa: PLC0415 - optional dependency
|
|
50
|
+
from psycopg.rows import dict_row # noqa: PLC0415
|
|
51
|
+
|
|
52
|
+
def run(sql: str, params: Mapping[str, Any]) -> Rows:
|
|
53
|
+
with (
|
|
54
|
+
psycopg.connect(conninfo, row_factory=dict_row) as conn,
|
|
55
|
+
conn.transaction(force_rollback=True),
|
|
56
|
+
):
|
|
57
|
+
conn.execute("SET TRANSACTION READ ONLY")
|
|
58
|
+
conn.execute(
|
|
59
|
+
"SELECT set_config('statement_timeout', %s, true)", (str(statement_timeout_ms),)
|
|
60
|
+
)
|
|
61
|
+
cur = conn.execute(sql, _bound_params(sql, params))
|
|
62
|
+
return list(cur.fetchmany(max_rows))
|
|
63
|
+
|
|
64
|
+
return run
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""FastAPI integration: a router exposing validation and guarded execution.
|
|
2
|
+
|
|
3
|
+
Authentication is the application's job. Pass a dependency that returns a
|
|
4
|
+
:class:`RequestContext` built from your own auth (session, JWT, mTLS...). The request body
|
|
5
|
+
can never set the role or tenant. Install with ``pip install "sqlguard-ninja[fastapi]"``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from sqlguard_ninja.integrations.checkpoint import RequestContext, SQLBlockedError, SQLCheckpoint
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SQLRequest(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
sql: str = Field(max_length=100_000)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FindingOut(BaseModel):
|
|
25
|
+
rule_id: str
|
|
26
|
+
action: str
|
|
27
|
+
message: str
|
|
28
|
+
remediation: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ValidateResponse(BaseModel):
|
|
32
|
+
decision: str
|
|
33
|
+
authorized: bool
|
|
34
|
+
message: str
|
|
35
|
+
findings: list[FindingOut]
|
|
36
|
+
trace_id: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_router(
|
|
40
|
+
checkpoint: SQLCheckpoint,
|
|
41
|
+
context_dependency: Callable[..., RequestContext],
|
|
42
|
+
executor: Callable[[str, Mapping[str, Any]], Any] | None = None,
|
|
43
|
+
prefix: str = "/v1/sql",
|
|
44
|
+
) -> APIRouter:
|
|
45
|
+
router = APIRouter(prefix=prefix, tags=["sql"])
|
|
46
|
+
|
|
47
|
+
@router.post("/validate", response_model=ValidateResponse)
|
|
48
|
+
def validate(
|
|
49
|
+
body: SQLRequest,
|
|
50
|
+
ctx: RequestContext = Depends(context_dependency), # noqa: B008
|
|
51
|
+
) -> ValidateResponse:
|
|
52
|
+
outcome = checkpoint.evaluate(body.sql, ctx)
|
|
53
|
+
return ValidateResponse(
|
|
54
|
+
decision=outcome.result.decision.value,
|
|
55
|
+
authorized=outcome.authorized,
|
|
56
|
+
message=outcome.agent_message,
|
|
57
|
+
findings=[
|
|
58
|
+
FindingOut(
|
|
59
|
+
rule_id=f.rule_id,
|
|
60
|
+
action=f.action.value,
|
|
61
|
+
message=f.message,
|
|
62
|
+
remediation=f.remediation,
|
|
63
|
+
)
|
|
64
|
+
for f in outcome.result.findings
|
|
65
|
+
],
|
|
66
|
+
trace_id=ctx.trace_id,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if executor is not None:
|
|
70
|
+
run = executor
|
|
71
|
+
|
|
72
|
+
@router.post("/query")
|
|
73
|
+
def query(
|
|
74
|
+
body: SQLRequest,
|
|
75
|
+
ctx: RequestContext = Depends(context_dependency), # noqa: B008
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
try:
|
|
78
|
+
rows = checkpoint.execute(body.sql, ctx, run)
|
|
79
|
+
except SQLBlockedError as exc:
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=403,
|
|
82
|
+
detail={
|
|
83
|
+
"decision": exc.outcome.result.decision.value,
|
|
84
|
+
"message": exc.outcome.agent_message,
|
|
85
|
+
},
|
|
86
|
+
) from exc
|
|
87
|
+
return {"rows": rows, "trace_id": ctx.trace_id}
|
|
88
|
+
|
|
89
|
+
return router
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""LangChain integration: a SQL tool whose execution is gated by :class:`SQLCheckpoint`.
|
|
2
|
+
|
|
3
|
+
The tool's argument schema contains **only** the query. Role, tenant and approvals cannot be
|
|
4
|
+
supplied by the model; they come from ``context_provider``, which the application binds to its
|
|
5
|
+
authenticated request. Install with ``pip install "sqlguard-ninja[langchain]"``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from langchain_core.tools import BaseTool
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
16
|
+
|
|
17
|
+
from sqlguard_ninja.integrations.checkpoint import RequestContext, SQLBlockedError, SQLCheckpoint
|
|
18
|
+
|
|
19
|
+
Executor = Callable[[str, Mapping[str, Any]], Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SQLQueryInput(BaseModel):
|
|
23
|
+
"""Arguments the model may send. Extra fields (e.g. ``role``) are rejected."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
query: str = Field(description="A single read-only SQL SELECT statement.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GuardedSQLTool(BaseTool):
|
|
31
|
+
"""Run SQL only after the checkpoint authorizes it; return rows or the policy reason."""
|
|
32
|
+
|
|
33
|
+
name: str = "sql_db_query"
|
|
34
|
+
description: str = (
|
|
35
|
+
"Run a read-only SQL query against the database and return rows as JSON. "
|
|
36
|
+
"Queries are checked against a server-side data access policy before execution; "
|
|
37
|
+
"if a query is blocked, the response explains why. Use bind parameter :tenant_id "
|
|
38
|
+
"for the current tenant."
|
|
39
|
+
)
|
|
40
|
+
args_schema: type[BaseModel] = SQLQueryInput
|
|
41
|
+
raise_on_block: bool = False
|
|
42
|
+
|
|
43
|
+
_checkpoint: SQLCheckpoint = PrivateAttr()
|
|
44
|
+
_executor: Executor = PrivateAttr()
|
|
45
|
+
_context_provider: Callable[[], RequestContext] = PrivateAttr()
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
checkpoint: SQLCheckpoint,
|
|
50
|
+
executor: Executor,
|
|
51
|
+
context_provider: Callable[[], RequestContext],
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(**kwargs)
|
|
55
|
+
self._checkpoint = checkpoint
|
|
56
|
+
self._executor = executor
|
|
57
|
+
self._context_provider = context_provider
|
|
58
|
+
|
|
59
|
+
def _run(self, query: str, **_: Any) -> str:
|
|
60
|
+
context = self._context_provider()
|
|
61
|
+
try:
|
|
62
|
+
rows = self._checkpoint.execute(query, context, self._executor)
|
|
63
|
+
except SQLBlockedError as exc:
|
|
64
|
+
if self.raise_on_block:
|
|
65
|
+
raise
|
|
66
|
+
return exc.outcome.agent_message
|
|
67
|
+
return json.dumps(rows, default=str)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""LangGraph integration: guard and execute nodes plus a router.
|
|
2
|
+
|
|
3
|
+
Trust model:
|
|
4
|
+
|
|
5
|
+
* The request context is read from ``config["configurable"][CONTEXT_KEY]``, which the
|
|
6
|
+
*application* sets when it invokes the graph. Graph nodes (including LLM nodes) can write
|
|
7
|
+
state, but they cannot change the run configuration.
|
|
8
|
+
* :func:`make_execute_node` re-evaluates the SQL through the checkpoint. It never trusts an
|
|
9
|
+
``authorized`` flag found in state, so a node that tampers with state gains nothing.
|
|
10
|
+
|
|
11
|
+
Install with ``pip install "sqlguard-ninja[langgraph]"``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from langchain_core.runnables import RunnableConfig
|
|
20
|
+
|
|
21
|
+
from sqlguard_ninja.integrations.checkpoint import RequestContext, SQLBlockedError, SQLCheckpoint
|
|
22
|
+
|
|
23
|
+
CONTEXT_KEY = "sqlninja_context"
|
|
24
|
+
|
|
25
|
+
State = Mapping[str, Any]
|
|
26
|
+
Node = Callable[[State, RunnableConfig], dict[str, Any]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _context(config: Any) -> RequestContext:
|
|
30
|
+
configurable = (config or {}).get("configurable", {}) if isinstance(config, Mapping) else {}
|
|
31
|
+
ctx = configurable.get(CONTEXT_KEY)
|
|
32
|
+
if not isinstance(ctx, RequestContext):
|
|
33
|
+
raise PermissionError(
|
|
34
|
+
f"missing trusted RequestContext in config['configurable'][{CONTEXT_KEY!r}]"
|
|
35
|
+
)
|
|
36
|
+
return ctx
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_guard_node(checkpoint: SQLCheckpoint, sql_key: str = "sql") -> Node:
|
|
40
|
+
"""Node that evaluates ``state[sql_key]`` and records the verdict under ``guard``."""
|
|
41
|
+
|
|
42
|
+
# LangGraph injects the run config only when the parameter is annotated RunnableConfig.
|
|
43
|
+
def guard_node(state: State, config: RunnableConfig) -> dict[str, Any]:
|
|
44
|
+
outcome = checkpoint.evaluate(str(state.get(sql_key, "")), _context(config))
|
|
45
|
+
return {
|
|
46
|
+
"guard": {
|
|
47
|
+
"authorized": outcome.authorized,
|
|
48
|
+
"decision": outcome.result.decision.value,
|
|
49
|
+
"message": outcome.agent_message,
|
|
50
|
+
"rules": [f.rule_id for f in outcome.result.blocking_findings],
|
|
51
|
+
"locked_out": outcome.locked_out,
|
|
52
|
+
},
|
|
53
|
+
"attempts": int(state.get("attempts", 0)) + 1,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return guard_node
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_execute_node(
|
|
60
|
+
checkpoint: SQLCheckpoint,
|
|
61
|
+
executor: Callable[[str, Mapping[str, Any]], Any],
|
|
62
|
+
sql_key: str = "sql",
|
|
63
|
+
) -> Node:
|
|
64
|
+
"""Node that executes the SQL *after re-checking it*; writes ``rows`` or ``error``."""
|
|
65
|
+
|
|
66
|
+
def execute_node(state: State, config: RunnableConfig) -> dict[str, Any]:
|
|
67
|
+
try:
|
|
68
|
+
rows = checkpoint.execute(str(state.get(sql_key, "")), _context(config), executor)
|
|
69
|
+
except SQLBlockedError as exc:
|
|
70
|
+
return {"rows": None, "error": exc.outcome.agent_message}
|
|
71
|
+
return {"rows": rows, "error": None}
|
|
72
|
+
|
|
73
|
+
return execute_node
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def route_after_guard(max_attempts: int = 3) -> Callable[[State], str]:
|
|
77
|
+
"""Router: ``"execute"`` if authorized, ``"revise"`` to retry, ``"end"`` when exhausted."""
|
|
78
|
+
|
|
79
|
+
def route(state: State) -> str:
|
|
80
|
+
guard = state.get("guard") or {}
|
|
81
|
+
if guard.get("authorized"):
|
|
82
|
+
return "execute"
|
|
83
|
+
if guard.get("locked_out") or int(state.get("attempts", 0)) >= max_attempts:
|
|
84
|
+
return "end"
|
|
85
|
+
return "revise"
|
|
86
|
+
|
|
87
|
+
return route
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Policy schema and loading."""
|
|
2
|
+
|
|
3
|
+
from sqlguard_ninja.policy.model import (
|
|
4
|
+
Access,
|
|
5
|
+
Catalog,
|
|
6
|
+
ColumnDef,
|
|
7
|
+
CostThresholds,
|
|
8
|
+
Defaults,
|
|
9
|
+
Disclosure,
|
|
10
|
+
FunctionPolicy,
|
|
11
|
+
LabelDef,
|
|
12
|
+
Limits,
|
|
13
|
+
Policy,
|
|
14
|
+
PolicyError,
|
|
15
|
+
Role,
|
|
16
|
+
Sensitivity,
|
|
17
|
+
TableDef,
|
|
18
|
+
TableGrant,
|
|
19
|
+
TenantConfig,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Access",
|
|
24
|
+
"Catalog",
|
|
25
|
+
"ColumnDef",
|
|
26
|
+
"CostThresholds",
|
|
27
|
+
"Defaults",
|
|
28
|
+
"Disclosure",
|
|
29
|
+
"FunctionPolicy",
|
|
30
|
+
"LabelDef",
|
|
31
|
+
"Limits",
|
|
32
|
+
"Policy",
|
|
33
|
+
"PolicyError",
|
|
34
|
+
"Role",
|
|
35
|
+
"Sensitivity",
|
|
36
|
+
"TableDef",
|
|
37
|
+
"TableGrant",
|
|
38
|
+
"TenantConfig",
|
|
39
|
+
]
|