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.
Files changed (68) hide show
  1. sqlguard_ninja/__init__.py +35 -0
  2. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
  3. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
  4. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
  5. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
  6. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
  7. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
  8. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
  9. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
  10. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
  11. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
  12. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
  13. sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
  14. sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
  15. sqlguard_ninja/_version.py +1 -0
  16. sqlguard_ninja/benchmarking/__init__.py +1 -0
  17. sqlguard_ninja/benchmarking/adapters.py +303 -0
  18. sqlguard_ninja/benchmarking/builtin.py +39 -0
  19. sqlguard_ninja/benchmarking/compare.py +168 -0
  20. sqlguard_ninja/benchmarking/dataset.py +265 -0
  21. sqlguard_ninja/benchmarking/fixtures.py +274 -0
  22. sqlguard_ninja/benchmarking/metrics.py +114 -0
  23. sqlguard_ninja/benchmarking/runner.py +126 -0
  24. sqlguard_ninja/benchmarking/translate.py +64 -0
  25. sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
  26. sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
  27. sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
  28. sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
  29. sqlguard_ninja/cli/__init__.py +1 -0
  30. sqlguard_ninja/cli/config.py +76 -0
  31. sqlguard_ninja/cli/main.py +433 -0
  32. sqlguard_ninja/cli/templates.py +91 -0
  33. sqlguard_ninja/core/__init__.py +1 -0
  34. sqlguard_ninja/core/analysis.py +581 -0
  35. sqlguard_ninja/core/findings.py +133 -0
  36. sqlguard_ninja/core/guard.py +256 -0
  37. sqlguard_ninja/core/rules/__init__.py +1 -0
  38. sqlguard_ninja/core/rules/access.py +127 -0
  39. sqlguard_ninja/core/rules/base.py +40 -0
  40. sqlguard_ninja/core/rules/catalog.py +61 -0
  41. sqlguard_ninja/core/rules/functions.py +197 -0
  42. sqlguard_ninja/core/rules/statements.py +99 -0
  43. sqlguard_ninja/core/rules/structure.py +72 -0
  44. sqlguard_ninja/core/rules/tenant.py +196 -0
  45. sqlguard_ninja/integrations/__init__.py +43 -0
  46. sqlguard_ninja/integrations/audit.py +109 -0
  47. sqlguard_ninja/integrations/checkpoint.py +342 -0
  48. sqlguard_ninja/integrations/executors.py +64 -0
  49. sqlguard_ninja/integrations/fastapi.py +89 -0
  50. sqlguard_ninja/integrations/langchain.py +67 -0
  51. sqlguard_ninja/integrations/langgraph.py +87 -0
  52. sqlguard_ninja/policy/__init__.py +39 -0
  53. sqlguard_ninja/policy/model.py +370 -0
  54. sqlguard_ninja/privacy/__init__.py +9 -0
  55. sqlguard_ninja/privacy/rules.py +286 -0
  56. sqlguard_ninja/privacy/suggest.py +46 -0
  57. sqlguard_ninja/py.typed +0 -0
  58. sqlguard_ninja/query_cost/__init__.py +1 -0
  59. sqlguard_ninja/query_cost/postgres.py +226 -0
  60. sqlguard_ninja/query_cost/static.py +241 -0
  61. sqlguard_ninja/reporting/__init__.py +1 -0
  62. sqlguard_ninja/reporting/markdown.py +137 -0
  63. sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
  64. sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
  65. sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
  66. sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
  67. sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
  68. sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
@@ -0,0 +1,99 @@
1
+ """Statement-shape rules: read-only enforcement and session-changing statements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ from sqlglot import exp
8
+
9
+ from sqlguard_ninja.core.analysis import SESSION_NODE_TYPES, WRITE_NODE_TYPES
10
+ from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
11
+ from sqlguard_ninja.core.rules.base import RuleContext
12
+
13
+ _MYSQL_FAMILY = {"mysql", "doris", "starrocks", "singlestore"}
14
+
15
+
16
+ class ReadOnlyStatementRule:
17
+ """Only a single SELECT (or set operation of SELECTs) is permitted, with no writes inside.
18
+
19
+ The check is an allowlist on the root statement plus a deny walk over the full tree, so
20
+ data-modifying CTEs, ``SELECT ... INTO`` and row locks are caught wherever they appear.
21
+ """
22
+
23
+ rule_ids = (
24
+ "statement.not_read_only",
25
+ "statement.unsupported",
26
+ "statement.write_in_query",
27
+ "statement.select_into",
28
+ "statement.locking",
29
+ "statement.executable_comment",
30
+ )
31
+
32
+ def check(self, ctx: RuleContext) -> Iterator[Finding]:
33
+ root = ctx.analysis.root
34
+ if root is None:
35
+ return
36
+ if ctx.analysis.dialect in _MYSQL_FAMILY and "/*!" in ctx.analysis.sql:
37
+ yield Finding(
38
+ rule_id="statement.executable_comment",
39
+ category=Category.STATEMENT,
40
+ severity=Severity.CRITICAL,
41
+ action=Action.DENY,
42
+ message="MySQL executable comments (/*! ... */) are not permitted.",
43
+ detail="The server executes the comment body; the parser treats it as a comment.",
44
+ )
45
+ if isinstance(root, exp.Command):
46
+ keyword = str(root.this).split()[0].upper() if root.this else "UNKNOWN"
47
+ yield Finding(
48
+ rule_id="statement.unsupported",
49
+ category=Category.STATEMENT,
50
+ severity=Severity.HIGH,
51
+ action=Action.DENY,
52
+ message="This statement type is not supported; only SELECT queries are allowed.",
53
+ detail=f"unparsed command starting with {keyword}",
54
+ )
55
+ return
56
+ if not isinstance(root, (exp.Select, exp.SetOperation)):
57
+ yield Finding(
58
+ rule_id="statement.not_read_only",
59
+ category=Category.STATEMENT,
60
+ severity=Severity.CRITICAL,
61
+ action=Action.DENY,
62
+ message="Only read-only SELECT queries are allowed.",
63
+ detail=f"statement type {type(root).__name__}",
64
+ )
65
+ return
66
+
67
+ reported: set[str] = set()
68
+ for node in root.walk():
69
+ if isinstance(node, (*WRITE_NODE_TYPES, *SESSION_NODE_TYPES, exp.Command)):
70
+ key = type(node).__name__
71
+ if key in reported:
72
+ continue
73
+ reported.add(key)
74
+ yield Finding(
75
+ rule_id="statement.write_in_query",
76
+ category=Category.STATEMENT,
77
+ severity=Severity.CRITICAL,
78
+ action=Action.DENY,
79
+ message="The query contains a write or session-changing statement.",
80
+ detail=f"{key} nested inside a SELECT (e.g. a data-modifying CTE)",
81
+ )
82
+ elif isinstance(node, exp.Into) and "into" not in reported:
83
+ reported.add("into")
84
+ yield Finding(
85
+ rule_id="statement.select_into",
86
+ category=Category.STATEMENT,
87
+ severity=Severity.CRITICAL,
88
+ action=Action.DENY,
89
+ message="SELECT ... INTO creates a table and is not permitted.",
90
+ )
91
+ elif isinstance(node, exp.Lock) and "lock" not in reported:
92
+ reported.add("lock")
93
+ yield Finding(
94
+ rule_id="statement.locking",
95
+ category=Category.STATEMENT,
96
+ severity=Severity.HIGH,
97
+ action=Action.DENY,
98
+ message="Row-locking clauses (FOR UPDATE/SHARE) are not permitted.",
99
+ )
@@ -0,0 +1,72 @@
1
+ """Structural limits: joins, nesting depth and LIMIT policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
8
+ from sqlguard_ninja.core.rules.base import RuleContext
9
+
10
+
11
+ class StructureLimitsRule:
12
+ rule_ids = (
13
+ "structure.max_joins",
14
+ "structure.max_depth",
15
+ "structure.limit_exceeded",
16
+ "structure.limit_missing",
17
+ "structure.limit_unparseable",
18
+ )
19
+
20
+ def check(self, ctx: RuleContext) -> Iterator[Finding]:
21
+ a = ctx.analysis
22
+ limits = ctx.limits
23
+ if limits.max_joins is not None and a.join_count > limits.max_joins:
24
+ yield Finding(
25
+ rule_id="structure.max_joins",
26
+ category=Category.STRUCTURE,
27
+ severity=Severity.MEDIUM,
28
+ action=Action.DENY,
29
+ message=f"The query uses {a.join_count} joins; the limit is {limits.max_joins}.",
30
+ )
31
+ if limits.max_subquery_depth is not None and a.max_depth > limits.max_subquery_depth:
32
+ yield Finding(
33
+ rule_id="structure.max_depth",
34
+ category=Category.STRUCTURE,
35
+ severity=Severity.MEDIUM,
36
+ action=Action.DENY,
37
+ message=f"Subqueries are nested {a.max_depth} levels deep; the limit is "
38
+ f"{limits.max_subquery_depth}.",
39
+ )
40
+ if not a.scopes:
41
+ return
42
+ root = a.scopes[-1] # traverse_scope yields the root scope last
43
+ if limits.max_limit is not None:
44
+ for facts in a.scopes:
45
+ if facts.limit_unparseable:
46
+ yield Finding(
47
+ rule_id="structure.limit_unparseable",
48
+ category=Category.STRUCTURE,
49
+ severity=Severity.MEDIUM,
50
+ action=Action.DENY,
51
+ message="LIMIT must be a numeric literal.",
52
+ )
53
+ break
54
+ if facts.limit is not None and facts.limit > limits.max_limit:
55
+ yield Finding(
56
+ rule_id="structure.limit_exceeded",
57
+ category=Category.STRUCTURE,
58
+ severity=Severity.MEDIUM,
59
+ action=Action.DENY,
60
+ message=f"LIMIT {facts.limit} exceeds the maximum of {limits.max_limit}.",
61
+ remediation=f"Use LIMIT {limits.max_limit} or less.",
62
+ )
63
+ break
64
+ if limits.require_limit and not root.has_limit:
65
+ yield Finding(
66
+ rule_id="structure.limit_missing",
67
+ category=Category.STRUCTURE,
68
+ severity=Severity.LOW,
69
+ action=Action.DENY,
70
+ message="The query must include a LIMIT clause.",
71
+ remediation=f"Add LIMIT {limits.max_limit or 100}.",
72
+ )
@@ -0,0 +1,196 @@
1
+ """Tenant isolation: every read of a tenant-scoped table must be pinned to the caller's tenant.
2
+
3
+ Semantics (fail closed):
4
+
5
+ * The tenant value comes only from the trusted request context, never from the query.
6
+ * A table reference is *pinned* when, within the scope that reads it, its tenant column is
7
+ connected through ``=`` equalities to the context tenant value (as a literal or an
8
+ approved bind parameter). Only top-level ``AND`` conjuncts of ``WHERE`` and of
9
+ ``INNER JOIN ... ON`` count, plus the ``ON`` clause of the ``LEFT JOIN`` that introduces
10
+ the reference. Anything under ``OR``/``NOT`` is ignored, so ``tenant_id = 1 OR 1 = 1``
11
+ does not pin anything.
12
+ * A filter applied in an *outer* scope does not pin a table read in an inner scope
13
+ (CTE or derived table). This is stricter than the database's semantics and is a known
14
+ source of false positives; it keeps the rule local and auditable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterator
20
+ from typing import Any
21
+
22
+ from sqlglot import exp
23
+
24
+ from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
25
+ from sqlguard_ninja.core.rules.base import RuleContext
26
+
27
+ Term = tuple[str, ...]
28
+
29
+
30
+ class _UnionFind:
31
+ def __init__(self) -> None:
32
+ self.parent: dict[Term, Term] = {}
33
+
34
+ def find(self, x: Term) -> Term:
35
+ self.parent.setdefault(x, x)
36
+ while self.parent[x] != x:
37
+ self.parent[x] = self.parent[self.parent[x]]
38
+ x = self.parent[x]
39
+ return x
40
+
41
+ def union(self, a: Term, b: Term) -> None:
42
+ ra, rb = self.find(a), self.find(b)
43
+ if ra != rb:
44
+ self.parent[ra] = rb
45
+
46
+ def members(self, x: Term) -> list[Term]:
47
+ root = self.find(x)
48
+ return [t for t in list(self.parent) if self.find(t) == root]
49
+
50
+
51
+ def _conjuncts(node: exp.Expr | None) -> list[exp.Expr]:
52
+ if node is None:
53
+ return []
54
+ if isinstance(node, (exp.Where, exp.Having)):
55
+ node = node.this
56
+ while isinstance(node, exp.Paren):
57
+ node = node.this
58
+ if isinstance(node, exp.And):
59
+ return _conjuncts(node.left) + _conjuncts(node.right)
60
+ return [node]
61
+
62
+
63
+ def _term(node: exp.Expr) -> Term | None:
64
+ while isinstance(node, exp.Paren):
65
+ node = node.this
66
+ if isinstance(node, exp.Column) and node.table and not isinstance(node.this, exp.Star):
67
+ return ("col", node.table, node.name)
68
+ # Casts are unwrapped only around constants. A cast of the *column* can be lossy
69
+ # (e.g. text ids '1' and '01' both cast to 1), so it never counts as a pin.
70
+ while isinstance(node, (exp.Paren, exp.Cast, exp.TryCast)):
71
+ node = node.this
72
+ if isinstance(node, exp.Literal):
73
+ return ("const", str(node.this))
74
+ if isinstance(node, exp.Neg) and isinstance(node.this, exp.Literal):
75
+ return ("const", "-" + str(node.this.this))
76
+ if isinstance(node, exp.Placeholder):
77
+ return ("param", str(node.name or node.this or "?").lower())
78
+ if isinstance(node, exp.Parameter):
79
+ return ("param", str(node.name).lower())
80
+ return None
81
+
82
+
83
+ def _add_equalities(uf: _UnionFind, preds: list[exp.Expr]) -> None:
84
+ for pred in preds:
85
+ if isinstance(pred, exp.EQ):
86
+ left, right = _term(pred.left), _term(pred.right)
87
+ if left and right:
88
+ uf.union(left, right)
89
+ elif isinstance(pred, exp.In) and not pred.args.get("query"):
90
+ values = pred.expressions
91
+ left = _term(pred.this)
92
+ if left and len(values) == 1:
93
+ right = _term(values[0])
94
+ if right:
95
+ uf.union(left, right)
96
+ elif left and len(values) > 1:
97
+ # Several tenants requested: record each so the rule can explain it.
98
+ for value in values:
99
+ t = _term(value)
100
+ if t:
101
+ uf.union(left, ("multi", *t))
102
+
103
+
104
+ class TenantIsolationRule:
105
+ rule_ids = ("tenant.context_missing", "tenant.missing_predicate", "tenant.wrong_value")
106
+
107
+ def check(self, ctx: RuleContext) -> Iterator[Finding]:
108
+ if ctx.role is not None and not ctx.role.tenant_scoped:
109
+ return
110
+ catalog = ctx.policy.catalog
111
+ cfg = ctx.policy.tenant
112
+ tenant_value: Any = ctx.context.get(cfg.context_key)
113
+ params = {p.lower() for p in cfg.parameter_names}
114
+ reported_missing_ctx = False
115
+
116
+ for facts in ctx.analysis.scopes:
117
+ scoped = [
118
+ ref
119
+ for ref in facts.tables
120
+ if not ref.is_function
121
+ and ref.name in catalog.tables
122
+ and catalog.tables[ref.name].tenant_column
123
+ ]
124
+ if not scoped or not isinstance(facts.expression, exp.Select):
125
+ continue
126
+ if tenant_value is None:
127
+ if not reported_missing_ctx:
128
+ reported_missing_ctx = True
129
+ yield Finding(
130
+ rule_id="tenant.context_missing",
131
+ category=Category.TENANT,
132
+ severity=Severity.CRITICAL,
133
+ action=Action.DENY,
134
+ message="The request has no tenant context; tenant-scoped data is "
135
+ "unavailable.",
136
+ detail=f"context key {cfg.context_key!r} not supplied by the application",
137
+ )
138
+ continue
139
+ select = facts.expression
140
+ joins: list[exp.Join] = list(select.args.get("joins") or [])
141
+ global_preds = _conjuncts(select.args.get("where"))
142
+ left_join_on: dict[str, list[exp.Expr]] = {}
143
+ for join in joins:
144
+ side = (join.side or "").upper()
145
+ kind = (join.kind or "").upper()
146
+ on_preds = _conjuncts(join.args.get("on"))
147
+ if not side and kind in ("", "INNER"):
148
+ global_preds += on_preds
149
+ elif side == "LEFT":
150
+ left_join_on[join.alias_or_name] = on_preds
151
+
152
+ expected = str(tenant_value)
153
+ for ref in scoped:
154
+ tenant_col = catalog.tables[ref.name].tenant_column
155
+ assert tenant_col is not None
156
+ uf = _UnionFind()
157
+ _add_equalities(uf, global_preds + left_join_on.get(ref.alias, []))
158
+ members = uf.members(("col", ref.alias, tenant_col))
159
+ pinned = any(
160
+ m == ("const", expected) or (m[0] == "param" and m[1] in params)
161
+ for m in members
162
+ )
163
+ others = sorted(
164
+ {m[-1] for m in members if m[0] in ("const", "multi") and m[-1] != expected}
165
+ )
166
+ multi = any(m[0] == "multi" for m in members)
167
+ table_txt = ctx.name("table", ref.name)
168
+ if pinned and not multi:
169
+ continue
170
+ if others or multi:
171
+ yield Finding(
172
+ rule_id="tenant.wrong_value",
173
+ category=Category.TENANT,
174
+ severity=Severity.CRITICAL,
175
+ action=Action.DENY,
176
+ message=f"The query filters {table_txt} by a tenant other than the "
177
+ "caller's own.",
178
+ detail=f"{ref.alias}.{tenant_col} compared with {others}",
179
+ table=ref.name,
180
+ column=tenant_col,
181
+ )
182
+ else:
183
+ yield Finding(
184
+ rule_id="tenant.missing_predicate",
185
+ category=Category.TENANT,
186
+ severity=Severity.CRITICAL,
187
+ action=Action.DENY,
188
+ message=f"Every read of {table_txt} must filter on the caller's tenant.",
189
+ detail=f"no top-level equality on {ref.alias}.{tenant_col} in its scope",
190
+ table=ref.name,
191
+ column=tenant_col,
192
+ remediation=f"Add `{ref.alias}.{tenant_col} = :{cfg.parameter_names[0]}`"
193
+ " to the WHERE clause of the scope that reads this table."
194
+ if cfg.parameter_names
195
+ else None,
196
+ )
@@ -0,0 +1,43 @@
1
+ """Agent and application integrations.
2
+
3
+ The framework-specific modules (``langchain``, ``langgraph``, ``fastapi``) import their
4
+ frameworks and are only available with the matching extra installed.
5
+ """
6
+
7
+ from sqlguard_ninja.integrations.audit import (
8
+ AuditEvent,
9
+ InMemoryAuditSink,
10
+ JsonlAuditSink,
11
+ LoggingAuditSink,
12
+ redact_sql,
13
+ )
14
+ from sqlguard_ninja.integrations.checkpoint import (
15
+ ApprovalDecision,
16
+ ApprovalQueue,
17
+ ApprovalRequest,
18
+ CallbackApprover,
19
+ CheckpointOutcome,
20
+ DenyAllApprover,
21
+ EnforcementMode,
22
+ RequestContext,
23
+ SQLBlockedError,
24
+ SQLCheckpoint,
25
+ )
26
+
27
+ __all__ = [
28
+ "ApprovalDecision",
29
+ "ApprovalQueue",
30
+ "ApprovalRequest",
31
+ "AuditEvent",
32
+ "CallbackApprover",
33
+ "CheckpointOutcome",
34
+ "DenyAllApprover",
35
+ "EnforcementMode",
36
+ "InMemoryAuditSink",
37
+ "JsonlAuditSink",
38
+ "LoggingAuditSink",
39
+ "RequestContext",
40
+ "SQLBlockedError",
41
+ "SQLCheckpoint",
42
+ "redact_sql",
43
+ ]
@@ -0,0 +1,109 @@
1
+ """Audit events and sinks.
2
+
3
+ Events never contain literal values from the SQL by default: the statement is stored as its
4
+ SHA-256 plus a *redacted* form in which every literal is replaced by ``?``. That keeps
5
+ customer data and secrets that an LLM may have inlined out of logs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import threading
13
+ import uuid
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any, Protocol
17
+
18
+ import sqlglot
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+ from sqlglot import exp
21
+ from sqlglot.errors import SqlglotError
22
+
23
+
24
+ def redact_sql(sql: str, dialect: str) -> str | None:
25
+ """Replace every literal with ``?``. Returns None if the SQL cannot be parsed."""
26
+ try:
27
+ statements = [s for s in sqlglot.parse(sql, dialect=dialect) if s is not None]
28
+ except SqlglotError:
29
+ return None
30
+ if not statements:
31
+ return None
32
+
33
+ def _strip(node: exp.Expr) -> exp.Expr:
34
+ if isinstance(node, exp.Literal):
35
+ return exp.Var(this="?") # dialect-neutral marker, never a bind parameter
36
+ return node
37
+
38
+ try:
39
+ return "; ".join(s.transform(_strip).sql(dialect=dialect) for s in statements)
40
+ except SqlglotError:
41
+ return None
42
+
43
+
44
+ class AuditEvent(BaseModel):
45
+ model_config = ConfigDict(frozen=True)
46
+
47
+ event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
48
+ timestamp: str = Field(
49
+ default_factory=lambda: datetime.now(UTC).isoformat(timespec="milliseconds")
50
+ )
51
+ event_type: str = "sql.checkpoint"
52
+ trace_id: str
53
+ principal: str | None
54
+ role: str | None
55
+ tenant: str | None
56
+ mode: str
57
+ decision: str
58
+ authorized: bool
59
+ enforced: bool
60
+ would_block: bool
61
+ sql_sha256: str
62
+ sql_redacted: str | None
63
+ dialect: str
64
+ policy: str
65
+ findings: list[dict[str, Any]]
66
+ approval: dict[str, Any] | None = None
67
+ attempt: int = 1
68
+ elapsed_ms: float = 0.0
69
+
70
+
71
+ class AuditSink(Protocol):
72
+ def emit(self, event: AuditEvent) -> None: ...
73
+
74
+
75
+ class InMemoryAuditSink:
76
+ """Keeps events in a list. For tests and demos."""
77
+
78
+ def __init__(self) -> None:
79
+ self.events: list[AuditEvent] = []
80
+ self._lock = threading.Lock()
81
+
82
+ def emit(self, event: AuditEvent) -> None:
83
+ with self._lock:
84
+ self.events.append(event)
85
+
86
+
87
+ class JsonlAuditSink:
88
+ """Append one JSON object per line. Thread-safe within a process."""
89
+
90
+ def __init__(self, path: str | Path) -> None:
91
+ self.path = Path(path)
92
+ self.path.parent.mkdir(parents=True, exist_ok=True)
93
+ self._lock = threading.Lock()
94
+
95
+ def emit(self, event: AuditEvent) -> None:
96
+ line = event.model_dump_json() + "\n"
97
+ with self._lock, self.path.open("a", encoding="utf-8") as fh:
98
+ fh.write(line)
99
+
100
+
101
+ class LoggingAuditSink:
102
+ """Emit events through :mod:`logging` as JSON strings."""
103
+
104
+ def __init__(self, logger: logging.Logger | None = None, level: int = logging.INFO) -> None:
105
+ self.logger = logger or logging.getLogger("sqlguard_ninja.audit")
106
+ self.level = level
107
+
108
+ def emit(self, event: AuditEvent) -> None:
109
+ self.logger.log(self.level, json.dumps(event.model_dump(mode="json")))