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,370 @@
1
+ """Versioned, validated policy schema.
2
+
3
+ A policy describes *what exists* (the catalog and its classification labels) and *who may
4
+ see what* (roles). It is loaded from YAML or JSON and validated eagerly so that a typo in a
5
+ role grant fails at start-up instead of silently allowing or denying queries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ import yaml
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
17
+
18
+ from sqlguard_ninja.core.findings import Action
19
+
20
+
21
+ class PolicyError(ValueError):
22
+ """Raised when a policy file is malformed or internally inconsistent."""
23
+
24
+
25
+ class Access(StrEnum):
26
+ """How a role may use a column or a label."""
27
+
28
+ ALLOW = "allow"
29
+ AGGREGATE_ONLY = "aggregate_only"
30
+ REVIEW = "review"
31
+ DENY = "deny"
32
+
33
+
34
+ class Sensitivity(StrEnum):
35
+ LOW = "low"
36
+ MEDIUM = "medium"
37
+ HIGH = "high"
38
+ CRITICAL = "critical"
39
+
40
+
41
+ class _Model(BaseModel):
42
+ model_config = ConfigDict(extra="forbid", frozen=True)
43
+
44
+
45
+ def _lower_keys(value: Any) -> Any:
46
+ if isinstance(value, dict):
47
+ return {str(k).lower(): v for k, v in value.items()}
48
+ return value
49
+
50
+
51
+ def _lower_list(value: Any) -> Any:
52
+ if isinstance(value, list):
53
+ return [str(v).lower() for v in value]
54
+ return value
55
+
56
+
57
+ class LabelDef(_Model):
58
+ """A classification label such as ``contact`` or ``financial``."""
59
+
60
+ description: str = ""
61
+ sensitivity: Sensitivity = Sensitivity.MEDIUM
62
+ masking: str | None = Field(
63
+ default=None, description="Recommended masking or minimisation technique."
64
+ )
65
+ special_category: bool = Field(
66
+ default=False, description="GDPR Art. 9 special-category data, when so configured."
67
+ )
68
+
69
+
70
+ class ColumnDef(_Model):
71
+ """Metadata for one column. Labels are configuration, never inferred from the name."""
72
+
73
+ type: str = "text"
74
+ labels: tuple[str, ...] = ()
75
+ identifying: bool = Field(
76
+ default=False,
77
+ description="Uniquely identifies a person/entity. Used for small-cohort checks.",
78
+ )
79
+ alternatives: tuple[str, ...] = Field(
80
+ default=(), description="Less sensitive columns of the same table that may suffice."
81
+ )
82
+ json_fields: dict[str, tuple[str, ...]] = Field(
83
+ default_factory=dict,
84
+ description="Labels for paths inside a JSON column, keyed by dotted path.",
85
+ )
86
+
87
+ _norm_labels = field_validator("labels", "alternatives", mode="before")(_lower_list)
88
+
89
+ @field_validator("json_fields", mode="before")
90
+ @classmethod
91
+ def _norm_json(cls, value: Any) -> Any:
92
+ if isinstance(value, dict):
93
+ return {str(k).lower(): _lower_list(list(v)) for k, v in value.items()}
94
+ return value
95
+
96
+ def effective_labels(self, json_path: tuple[str, ...] | None = None) -> set[str]:
97
+ """Labels revealed by reading this column, or one JSON path inside it.
98
+
99
+ Reading the whole column reveals every nested field, so its labels are the union of
100
+ the column's own labels and all JSON field labels. Reading a path reveals the
101
+ column's labels plus labels of that path, its ancestors and its descendants.
102
+ """
103
+ labels = set(self.labels)
104
+ if json_path is None:
105
+ for field_labels in self.json_fields.values():
106
+ labels.update(field_labels)
107
+ return labels
108
+ requested = ".".join(p.lower() for p in json_path)
109
+ for path, field_labels in self.json_fields.items():
110
+ if (
111
+ path == requested
112
+ or path.startswith(requested + ".")
113
+ or requested.startswith(path + ".")
114
+ ):
115
+ labels.update(field_labels)
116
+ return labels
117
+
118
+
119
+ class TableDef(_Model):
120
+ description: str = ""
121
+ columns: dict[str, ColumnDef]
122
+ tenant_column: str | None = None
123
+ estimated_rows: int | None = Field(default=None, ge=0)
124
+
125
+ @field_validator("columns", mode="before")
126
+ @classmethod
127
+ def _norm_columns(cls, value: Any) -> Any:
128
+ if not isinstance(value, dict):
129
+ return value
130
+ out: dict[str, Any] = {}
131
+ for name, spec in value.items():
132
+ if spec is None:
133
+ spec = {}
134
+ elif isinstance(spec, str):
135
+ spec = {"type": spec}
136
+ elif isinstance(spec, list):
137
+ spec = {"labels": spec}
138
+ out[str(name).lower()] = spec
139
+ return out
140
+
141
+ @field_validator("tenant_column", mode="before")
142
+ @classmethod
143
+ def _norm_tenant(cls, value: Any) -> Any:
144
+ return value.lower() if isinstance(value, str) else value
145
+
146
+ @model_validator(mode="after")
147
+ def _check(self) -> TableDef:
148
+ if self.tenant_column and self.tenant_column not in self.columns:
149
+ raise ValueError(f"tenant_column '{self.tenant_column}' is not a declared column")
150
+ for name, col in self.columns.items():
151
+ for alt in col.alternatives:
152
+ if alt not in self.columns:
153
+ raise ValueError(f"column '{name}' lists unknown alternative '{alt}'")
154
+ return self
155
+
156
+
157
+ class Catalog(_Model):
158
+ default_schema: str | None = Field(
159
+ default=None,
160
+ description="Schema that unqualified names resolve to (e.g. 'public' or 'main').",
161
+ )
162
+ tables: dict[str, TableDef]
163
+
164
+ _norm = field_validator("tables", mode="before")(_lower_keys)
165
+
166
+ @field_validator("default_schema", mode="before")
167
+ @classmethod
168
+ def _norm_schema(cls, value: Any) -> Any:
169
+ return value.lower() if isinstance(value, str) else value
170
+
171
+ def sqlglot_schema(self) -> dict[str, dict[str, str]]:
172
+ return {
173
+ name: {col: spec.type for col, spec in table.columns.items()}
174
+ for name, table in self.tables.items()
175
+ }
176
+
177
+
178
+ class TableGrant(_Model):
179
+ """Which columns of a table a role may reference."""
180
+
181
+ columns: Literal["*"] | tuple[str, ...] = "*"
182
+ deny_columns: tuple[str, ...] = ()
183
+ column_access: dict[str, Access] = Field(
184
+ default_factory=dict, description="Per-column overrides; most specific rule wins."
185
+ )
186
+
187
+ @field_validator("columns", mode="before")
188
+ @classmethod
189
+ def _norm_cols(cls, value: Any) -> Any:
190
+ return _lower_list(value) if isinstance(value, list) else value
191
+
192
+ _norm_deny = field_validator("deny_columns", mode="before")(_lower_list)
193
+ _norm_access = field_validator("column_access", mode="before")(_lower_keys)
194
+
195
+
196
+ class Limits(_Model):
197
+ max_sql_length: int = Field(default=20_000, gt=0)
198
+ max_ast_nodes: int = Field(default=5_000, gt=0)
199
+ max_joins: int | None = Field(default=8, ge=0)
200
+ max_subquery_depth: int | None = Field(default=5, ge=0)
201
+ max_limit: int | None = Field(default=None, gt=0)
202
+ require_limit: bool = False
203
+
204
+
205
+ class Role(_Model):
206
+ description: str = ""
207
+ tables: dict[str, TableGrant] = Field(default_factory=dict)
208
+ labels: dict[str, Access] = Field(
209
+ default_factory=dict, description="Access per classification label."
210
+ )
211
+ unlisted_label_access: Access = Field(
212
+ default=Access.DENY,
213
+ description="Access for labelled columns whose label the role does not mention.",
214
+ )
215
+ tenant_scoped: bool = True
216
+ limits: Limits | None = None
217
+
218
+ _norm_labels = field_validator("labels", mode="before")(_lower_keys)
219
+
220
+ @field_validator("tables", mode="before")
221
+ @classmethod
222
+ def _grant_shorthand(cls, value: Any) -> Any:
223
+ # Allow `tables: {orders: "*"}` and `tables: {orders: [id, status]}`.
224
+ if isinstance(value, dict):
225
+ out: dict[str, Any] = {}
226
+ for name, grant in value.items():
227
+ if grant is None or grant == "*":
228
+ grant = {"columns": "*"}
229
+ elif isinstance(grant, list):
230
+ grant = {"columns": grant}
231
+ out[str(name).lower()] = grant
232
+ return out
233
+ return value
234
+
235
+
236
+ class FunctionPolicy(_Model):
237
+ mode: Literal["denylist", "allowlist"] = "denylist"
238
+ deny: tuple[str, ...] = ()
239
+ allow: tuple[str, ...] = Field(
240
+ default=(),
241
+ description="In allowlist mode: the permitted functions. In denylist mode: "
242
+ "exceptions removed from the built-in deny list.",
243
+ )
244
+ review: tuple[str, ...] = ()
245
+ unknown_function_action: Action = Field(
246
+ default=Action.WARN,
247
+ description="Action for functions sqlglot does not recognise (possible UDFs).",
248
+ )
249
+
250
+ _norm = field_validator("deny", "allow", "review", mode="before")(_lower_list)
251
+
252
+
253
+ class TenantConfig(_Model):
254
+ context_key: str = "tenant_id"
255
+ parameter_names: tuple[str, ...] = Field(
256
+ default=("tenant_id",),
257
+ description="Bind-parameter names that the application fills with the trusted tenant.",
258
+ )
259
+
260
+
261
+ class Defaults(_Model):
262
+ unknown_table_action: Action = Action.DENY
263
+ unresolved_column_action: Action = Action.DENY
264
+ unexpandable_star_action: Action = Action.DENY
265
+ catalog_access_action: Action = Action.DENY
266
+ minimization_action: Action = Action.WARN
267
+ small_cohort_action: Action = Action.REVIEW
268
+
269
+
270
+ class CostThresholds(_Model):
271
+ """Static and planner thresholds; see :mod:`sqlguard_ninja.query_cost`."""
272
+
273
+ enabled: bool = True
274
+ review_score: int = Field(default=50, ge=0, le=100)
275
+ deny_score: int = Field(default=80, ge=0, le=100)
276
+ large_table_rows: int = Field(default=1_000_000, ge=0)
277
+ max_plan_total_cost: float | None = None
278
+ max_plan_rows: float | None = None
279
+
280
+
281
+ class Disclosure(StrEnum):
282
+ """How much schema detail agent-facing messages may contain."""
283
+
284
+ NAMES = "names"
285
+ GENERIC = "generic"
286
+
287
+
288
+ class Policy(_Model):
289
+ """Top-level policy document (``version: 1``)."""
290
+
291
+ version: Literal[1] = 1
292
+ name: str
293
+ description: str = ""
294
+ dialect: str = "postgres"
295
+ catalog: Catalog
296
+ labels: dict[str, LabelDef] = Field(default_factory=dict)
297
+ roles: dict[str, Role] = Field(default_factory=dict)
298
+ default_role: str | None = None
299
+ tenant: TenantConfig = Field(default_factory=TenantConfig)
300
+ functions: FunctionPolicy = Field(default_factory=FunctionPolicy)
301
+ limits: Limits = Field(default_factory=Limits)
302
+ defaults: Defaults = Field(default_factory=Defaults)
303
+ cost: CostThresholds = Field(default_factory=CostThresholds)
304
+ disclosure: Disclosure = Disclosure.NAMES
305
+
306
+ _norm_labels = field_validator("labels", mode="before")(_lower_keys)
307
+
308
+ @model_validator(mode="after")
309
+ def _check_references(self) -> Policy:
310
+ errors: list[str] = []
311
+ for tname, table in self.catalog.tables.items():
312
+ for cname, col in table.columns.items():
313
+ for label in col.effective_labels():
314
+ if label not in self.labels:
315
+ errors.append(f"{tname}.{cname}: unknown label '{label}'")
316
+ for rname, role in self.roles.items():
317
+ for label in role.labels:
318
+ if label not in self.labels:
319
+ errors.append(f"role '{rname}': unknown label '{label}'")
320
+ for tname, grant in role.tables.items():
321
+ tdef = self.catalog.tables.get(tname)
322
+ if tdef is None:
323
+ errors.append(f"role '{rname}': grant on unknown table '{tname}'")
324
+ continue
325
+ named = [
326
+ *(grant.columns if grant.columns != "*" else ()),
327
+ *grant.deny_columns,
328
+ *grant.column_access,
329
+ ]
330
+ for cname in named:
331
+ if cname not in tdef.columns:
332
+ errors.append(f"role '{rname}': unknown column '{tname}.{cname}'")
333
+ if self.default_role and self.default_role not in self.roles:
334
+ errors.append(f"default_role '{self.default_role}' is not defined")
335
+ if self.cost.review_score > self.cost.deny_score:
336
+ errors.append("cost.review_score must not exceed cost.deny_score")
337
+ if errors:
338
+ raise ValueError("; ".join(errors))
339
+ return self
340
+
341
+ # ------------------------------------------------------------------ loading
342
+ @classmethod
343
+ def from_dict(cls, data: dict[str, Any]) -> Policy:
344
+ try:
345
+ return cls.model_validate(data)
346
+ except ValueError as exc: # pydantic.ValidationError subclasses ValueError
347
+ raise PolicyError(f"invalid policy: {exc}") from exc
348
+
349
+ @classmethod
350
+ def from_file(cls, path: str | Path) -> Policy:
351
+ """Load a policy from ``.yaml``/``.yml`` or ``.json``."""
352
+ p = Path(path)
353
+ try:
354
+ text = p.read_text(encoding="utf-8")
355
+ except OSError as exc:
356
+ raise PolicyError(f"cannot read policy file {p}: {exc}") from exc
357
+ data = json.loads(text) if p.suffix.lower() == ".json" else yaml.safe_load(text)
358
+ if not isinstance(data, dict):
359
+ raise PolicyError(f"policy file {p} must contain a mapping at the top level")
360
+ return cls.from_dict(data)
361
+
362
+ def role(self, name: str | None) -> tuple[str | None, Role | None]:
363
+ """Resolve a role name (falling back to ``default_role``)."""
364
+ key = name or self.default_role
365
+ if key is None:
366
+ return None, None
367
+ return key, self.roles.get(key)
368
+
369
+ def effective_limits(self, role: Role | None) -> Limits:
370
+ return role.limits if role is not None and role.limits is not None else self.limits
@@ -0,0 +1,9 @@
1
+ """Privacy policy pack (classification labels, aggregate-only access, minimisation)."""
2
+
3
+ from sqlguard_ninja.privacy.rules import (
4
+ ColumnAccessDecision,
5
+ PrivacyPolicyRule,
6
+ resolve_column_access,
7
+ )
8
+
9
+ __all__ = ["ColumnAccessDecision", "PrivacyPolicyRule", "resolve_column_access"]
@@ -0,0 +1,286 @@
1
+ """Privacy policy pack: label-based column restrictions, aggregate-only access, data
2
+ minimisation and small-cohort re-identification warnings.
3
+
4
+ This is a technical control library. It helps implement data-minimisation and
5
+ need-to-know principles; it does not certify GDPR compliance.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass
12
+
13
+ from sqlguard_ninja.core.analysis import AggregateKind, Clause, ColumnRef
14
+ from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
15
+ from sqlguard_ninja.core.rules.base import RuleContext
16
+ from sqlguard_ninja.policy.model import Access, ColumnDef, Disclosure, Policy, Role, Sensitivity
17
+
18
+ _SEVERITY = {
19
+ Sensitivity.LOW: Severity.LOW,
20
+ Sensitivity.MEDIUM: Severity.MEDIUM,
21
+ Sensitivity.HIGH: Severity.HIGH,
22
+ Sensitivity.CRITICAL: Severity.CRITICAL,
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ColumnAccessDecision:
28
+ """How a role may use one column, and why."""
29
+
30
+ restrictions: tuple[tuple[Access, str], ...]
31
+ """(access, source) pairs; source is ``column:<name>`` or ``label:<label>``."""
32
+ labels: frozenset[str]
33
+
34
+ @property
35
+ def strongest(self) -> Access:
36
+ order = [Access.DENY, Access.AGGREGATE_ONLY, Access.REVIEW, Access.ALLOW]
37
+ present = {a for a, _ in self.restrictions}
38
+ return next((a for a in order if a in present), Access.ALLOW)
39
+
40
+
41
+ def resolve_column_access(
42
+ policy: Policy,
43
+ role: Role | None,
44
+ table: str,
45
+ column: str,
46
+ json_path: tuple[str, ...] | None = None,
47
+ ) -> ColumnAccessDecision:
48
+ """Resolve effective access: explicit column rule → label rules → role default."""
49
+ tdef = policy.catalog.tables.get(table)
50
+ cdef: ColumnDef | None = tdef.columns.get(column) if tdef else None
51
+ labels = frozenset(cdef.effective_labels(json_path)) if cdef else frozenset()
52
+ if role is not None:
53
+ grant = role.tables.get(table)
54
+ if grant is not None and column in grant.column_access:
55
+ return ColumnAccessDecision(
56
+ ((grant.column_access[column], f"column:{table}.{column}"),), labels
57
+ )
58
+ restrictions: list[tuple[Access, str]] = []
59
+ for label in sorted(labels):
60
+ access = Access.DENY if role is None else role.labels.get(label, role.unlisted_label_access)
61
+ if access is not Access.ALLOW:
62
+ restrictions.append((access, f"label:{label}"))
63
+ return ColumnAccessDecision(tuple(restrictions), labels)
64
+
65
+
66
+ class PrivacyPolicyRule:
67
+ rule_ids = (
68
+ "privacy.label_denied",
69
+ "privacy.aggregate_only",
70
+ "privacy.label_review",
71
+ "privacy.small_cohort",
72
+ "privacy.minimization",
73
+ )
74
+
75
+ def check(self, ctx: RuleContext) -> Iterator[Finding]:
76
+ policy = ctx.policy
77
+ role = ctx.role
78
+ emitted: set[tuple[str, str, str]] = set()
79
+ aggregate_only_used = False
80
+ star_labelled: dict[str, set[str]] = {}
81
+
82
+ for ref in ctx.analysis.columns:
83
+ tdef = policy.catalog.tables.get(ref.table)
84
+ if tdef is None or ref.column not in tdef.columns:
85
+ continue
86
+ if role is not None and ref.table not in role.tables:
87
+ continue
88
+ decision = resolve_column_access(policy, role, ref.table, ref.column, ref.json_path)
89
+ if ref.whole_row and decision.labels:
90
+ star_labelled.setdefault(ref.table, set()).update(decision.labels)
91
+ for access, source in decision.restrictions:
92
+ finding = self._finding_for(ctx, ref, access, source, decision.labels)
93
+ if finding is None:
94
+ if access is Access.AGGREGATE_ONLY:
95
+ aggregate_only_used = True
96
+ continue
97
+ key = (finding.rule_id, f"{ref.table}.{ref.column}", "")
98
+ if key in emitted:
99
+ continue
100
+ emitted.add(key)
101
+ yield finding
102
+
103
+ if aggregate_only_used:
104
+ yield from self._small_cohort(ctx)
105
+
106
+ for table, labels in sorted(star_labelled.items()):
107
+ yield Finding(
108
+ rule_id="privacy.minimization",
109
+ category=Category.PRIVACY,
110
+ severity=Severity.LOW,
111
+ action=policy.defaults.minimization_action,
112
+ message=f"Selecting every column of {ctx.name('table', table)} returns "
113
+ "classified data; select only the columns the task needs.",
114
+ labels=tuple(sorted(labels)),
115
+ table=table,
116
+ )
117
+ yield from self._alternatives(ctx)
118
+
119
+ # -------------------------------------------------------------- helpers
120
+ def _finding_for(
121
+ self,
122
+ ctx: RuleContext,
123
+ ref: ColumnRef,
124
+ access: Access,
125
+ source: str,
126
+ labels: frozenset[str],
127
+ ) -> Finding | None:
128
+ policy = ctx.policy
129
+ col_txt = ctx.name("column", f"{ref.table}.{ref.column}")
130
+ label_names = tuple(sorted(labels))
131
+ label_defs = [policy.labels[x] for x in label_names if x in policy.labels]
132
+ severity = max(
133
+ (_SEVERITY[d.sensitivity] for d in label_defs),
134
+ default=Severity.MEDIUM,
135
+ key=list(Severity).index,
136
+ )
137
+ special = any(d.special_category for d in label_defs)
138
+ masking = next((d.masking for d in label_defs if d.masking), None)
139
+ alt = self._allowed_alternative(ctx, ref)
140
+ remediation_parts = []
141
+ if alt:
142
+ remediation_parts.append(f"Use {ctx.name('column', alt)} instead.")
143
+ if masking:
144
+ remediation_parts.append(f"Recommended treatment: {masking}.")
145
+ remediation = " ".join(remediation_parts) or None
146
+ path_txt = f" (JSON path {'.'.join(ref.json_path)})" if ref.json_path else ""
147
+ cls_txt = (
148
+ "special-category personal data"
149
+ if special
150
+ else ("classified data (" + ", ".join(label_names) + ")" if label_names else "data")
151
+ )
152
+ if policy.disclosure == Disclosure.GENERIC:
153
+ cls_txt = "classified data"
154
+ meta = {"source": source, "clause": ref.clause.value, "aggregate": ref.aggregate.value}
155
+
156
+ if access is Access.DENY:
157
+ return Finding(
158
+ rule_id="privacy.label_denied",
159
+ category=Category.PRIVACY,
160
+ severity=Severity.CRITICAL if special else severity,
161
+ action=Action.DENY,
162
+ message=f"The {col_txt}{path_txt} contains {cls_txt} that this role may not "
163
+ "access.",
164
+ detail=f"restricted by {source}",
165
+ table=ref.table,
166
+ column=ref.column,
167
+ labels=label_names,
168
+ remediation=remediation,
169
+ metadata=meta,
170
+ )
171
+ if access is Access.AGGREGATE_ONLY:
172
+ if ref.aggregate is AggregateKind.STATISTICAL:
173
+ return None
174
+ where = (
175
+ "in a filter, grouping or ordering"
176
+ if ref.clause in (Clause.WHERE, Clause.JOIN, Clause.GROUP, Clause.ORDER)
177
+ else "as individual values"
178
+ )
179
+ return Finding(
180
+ rule_id="privacy.aggregate_only",
181
+ category=Category.PRIVACY,
182
+ severity=severity,
183
+ action=Action.DENY,
184
+ message=f"The {col_txt} may only be used inside statistical aggregates "
185
+ f"(COUNT, SUM, AVG, ...), not {where}.",
186
+ detail=f"restricted by {source}; aggregate={ref.aggregate.value}",
187
+ table=ref.table,
188
+ column=ref.column,
189
+ labels=label_names,
190
+ remediation=remediation
191
+ or "Aggregate the column (e.g. AVG) and group by non-identifying attributes.",
192
+ metadata=meta,
193
+ )
194
+ if access is Access.REVIEW:
195
+ return Finding(
196
+ rule_id="privacy.label_review",
197
+ category=Category.PRIVACY,
198
+ severity=severity,
199
+ action=Action.REVIEW,
200
+ message=f"Access to the {col_txt} ({cls_txt}) requires human approval.",
201
+ detail=f"restricted by {source}",
202
+ table=ref.table,
203
+ column=ref.column,
204
+ labels=label_names,
205
+ remediation=remediation,
206
+ metadata=meta,
207
+ )
208
+ return None
209
+
210
+ def _allowed_alternative(self, ctx: RuleContext, ref: ColumnRef) -> str | None:
211
+ tdef = ctx.policy.catalog.tables[ref.table]
212
+ cdef = tdef.columns[ref.column]
213
+ for alt in cdef.alternatives:
214
+ decision = resolve_column_access(ctx.policy, ctx.role, ref.table, alt)
215
+ if decision.strongest is Access.ALLOW and self._granted(ctx, ref.table, alt):
216
+ return f"{ref.table}.{alt}"
217
+ return None
218
+
219
+ @staticmethod
220
+ def _granted(ctx: RuleContext, table: str, column: str) -> bool:
221
+ if ctx.role is None:
222
+ return True
223
+ grant = ctx.role.tables.get(table)
224
+ if grant is None or column in grant.deny_columns:
225
+ return False
226
+ return grant.columns == "*" or column in grant.columns
227
+
228
+ def _small_cohort(self, ctx: RuleContext) -> Iterator[Finding]:
229
+ """Aggregates over a group filtered or grouped by an identifier reveal single values."""
230
+ catalog = ctx.policy.catalog
231
+ for ref in ctx.analysis.columns:
232
+ if ref.clause not in (Clause.WHERE, Clause.GROUP, Clause.JOIN, Clause.HAVING):
233
+ continue
234
+ if ref.clause is Clause.JOIN:
235
+ continue # join keys are expected to be identifiers
236
+ tdef = catalog.tables.get(ref.table)
237
+ if tdef is None or ref.column not in tdef.columns:
238
+ continue
239
+ if tdef.columns[ref.column].identifying and ref.aggregate is AggregateKind.NONE:
240
+ yield Finding(
241
+ rule_id="privacy.small_cohort",
242
+ category=Category.PRIVACY,
243
+ severity=Severity.MEDIUM,
244
+ action=ctx.policy.defaults.small_cohort_action,
245
+ message="An aggregate of restricted data is filtered or grouped by an "
246
+ "identifying column, which can reveal individual values.",
247
+ detail=f"identifying column {ref.table}.{ref.column} in {ref.clause.value}",
248
+ table=ref.table,
249
+ column=ref.column,
250
+ remediation="Group by non-identifying attributes and ensure groups are "
251
+ "large enough (k-anonymity is not checked statically).",
252
+ )
253
+ return
254
+
255
+ def _alternatives(self, ctx: RuleContext) -> Iterator[Finding]:
256
+ """Suggest less sensitive alternatives for permitted-but-labelled columns."""
257
+ seen: set[tuple[str, str]] = set()
258
+ for ref in ctx.analysis.columns:
259
+ if ref.whole_row or ref.clause is not Clause.SELECT:
260
+ continue
261
+ key = (ref.table, ref.column)
262
+ if key in seen:
263
+ continue
264
+ seen.add(key)
265
+ tdef = ctx.policy.catalog.tables.get(ref.table)
266
+ if tdef is None or ref.column not in tdef.columns:
267
+ continue
268
+ if not tdef.columns[ref.column].alternatives:
269
+ continue
270
+ decision = resolve_column_access(ctx.policy, ctx.role, ref.table, ref.column)
271
+ if decision.strongest is not Access.ALLOW or not decision.labels:
272
+ continue
273
+ alt = self._allowed_alternative(ctx, ref)
274
+ if alt:
275
+ yield Finding(
276
+ rule_id="privacy.minimization",
277
+ category=Category.PRIVACY,
278
+ severity=Severity.INFO,
279
+ action=Action.WARN,
280
+ message=f"The {ctx.name('column', f'{ref.table}.{ref.column}')} is "
281
+ f"classified; {ctx.name('column', alt)} may be sufficient.",
282
+ table=ref.table,
283
+ column=ref.column,
284
+ labels=tuple(sorted(decision.labels)),
285
+ remediation=f"Prefer {alt}.",
286
+ )