opa-golib-python-bindings 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/PKG-INFO +47 -1
  2. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/README.md +45 -0
  3. opa_golib_python_bindings-0.3.0/go/compile.go +106 -0
  4. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/go.mod +3 -0
  5. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/go.sum +11 -0
  6. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/pyproject.toml +2 -2
  7. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/src/opa_bindings/_native.py +12 -0
  8. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/src/opa_bindings/engine.py +56 -0
  9. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/.gitignore +0 -0
  10. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/LICENSE +0 -0
  11. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/Makefile +0 -0
  12. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/bridge.go +0 -0
  13. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/bridge_call.c +0 -0
  14. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/builtins.go +0 -0
  15. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/merge.go +0 -0
  16. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/go/trace.go +0 -0
  17. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/hatch_build.py +0 -0
  18. {opa_golib_python_bindings-0.2.0 → opa_golib_python_bindings-0.3.0}/src/opa_bindings/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opa-golib-python-bindings
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library
5
5
  Project-URL: Homepage, https://github.com/phi1010/opa-golib-python-bindings
6
6
  Project-URL: Repository, https://github.com/phi1010/opa-golib-python-bindings
@@ -21,6 +21,7 @@ Classifier: Topic :: Software Development :: Libraries
21
21
  Requires-Python: >=3.14
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: pyyaml; extra == 'dev'
24
25
  Description-Content-Type: text/markdown
25
26
 
26
27
  # opa-golib-python-bindings
@@ -82,6 +83,51 @@ Notes:
82
83
  statement may appear multiple times (`Redo` on backtracking), and tracing
83
84
  slows evaluation, so keep it opt-in per call. Coverage only records *which*
84
85
  statements were evaluated; traces are how to see their results.
86
+ - `compile_filters` partially evaluates a query and translates the residual
87
+ policy into a data filter (OPA's Compile-API / data-filter machinery):
88
+
89
+ ```python
90
+ engine.add_policy("filters.rego", """
91
+ package filters
92
+
93
+ include if input.fruits.colour == "green"
94
+ include if {
95
+ input.fruits.name == "banana"
96
+ input.user == "admin"
97
+ }
98
+ """)
99
+ engine.compile_filters(
100
+ "data.filters.include",
101
+ {"user": "admin"}, # known input
102
+ unknowns=["input.fruits"], # left symbolic
103
+ target="sql", dialect="postgresql",
104
+ )
105
+ # -> {"query": "WHERE (fruits.colour = E'green' OR fruits.name = E'banana')",
106
+ # "masks": None}
107
+ ```
108
+
109
+ `target="sql"` (dialects `postgresql`, `mysql`, `sqlserver`, `sqlite`)
110
+ yields a WHERE clause string; `target="ucast"` (dialects `all`, `prisma`,
111
+ `linq`, or `""`) yields a UCAST condition dict (the `ucast.json` wire
112
+ format). `query` is `None` when the policy can never match and `""`/`{}`
113
+ when it always matches. `mappings` renames tables/columns (e.g.
114
+ `{"fruits": {"$self": "fruit_table", "colour": "col"}}`), and `mask_rule`
115
+ names a rule evaluated to produce column masks (returned under `"masks"`).
116
+ Residual conditions that cannot be expressed for the chosen target raise
117
+ `OpaError(code="compile_error")`.
118
+
119
+ Unknown refs must have the shape `input.<table>.<column>` — exactly two
120
+ segments after `input`, whatever the declared unknown boundary is, and for
121
+ every target/dialect (`ucast`/`all` included; `mappings` cannot deepen it).
122
+ So `unknowns=["input.item"]` permits only `input.item.<column>`, while the
123
+ bare `unknowns=["input"]` permits `input.<table>.<column>`. Deeper refs
124
+ like `input.item.attrs.price.value` fail with `pe_fragment_error: invalid
125
+ ref operand`, so nested documents (e.g. an EAV entity with per-attribute
126
+ type/value objects, or per-locale value objects) must be flattened into
127
+ columns (`input.attr.value_number`, `input.attr.value_de`, ...). Dynamic
128
+ column choice is fine as long as the key is known at compile time:
129
+ `input.attr[sprintf("value_%s", [input.locale])]` resolves to a single
130
+ column during partial evaluation.
85
131
  - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
86
132
  to a `callable(message, location)` to receive it (default: written to stderr);
87
133
  `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -57,6 +57,51 @@ Notes:
57
57
  statement may appear multiple times (`Redo` on backtracking), and tracing
58
58
  slows evaluation, so keep it opt-in per call. Coverage only records *which*
59
59
  statements were evaluated; traces are how to see their results.
60
+ - `compile_filters` partially evaluates a query and translates the residual
61
+ policy into a data filter (OPA's Compile-API / data-filter machinery):
62
+
63
+ ```python
64
+ engine.add_policy("filters.rego", """
65
+ package filters
66
+
67
+ include if input.fruits.colour == "green"
68
+ include if {
69
+ input.fruits.name == "banana"
70
+ input.user == "admin"
71
+ }
72
+ """)
73
+ engine.compile_filters(
74
+ "data.filters.include",
75
+ {"user": "admin"}, # known input
76
+ unknowns=["input.fruits"], # left symbolic
77
+ target="sql", dialect="postgresql",
78
+ )
79
+ # -> {"query": "WHERE (fruits.colour = E'green' OR fruits.name = E'banana')",
80
+ # "masks": None}
81
+ ```
82
+
83
+ `target="sql"` (dialects `postgresql`, `mysql`, `sqlserver`, `sqlite`)
84
+ yields a WHERE clause string; `target="ucast"` (dialects `all`, `prisma`,
85
+ `linq`, or `""`) yields a UCAST condition dict (the `ucast.json` wire
86
+ format). `query` is `None` when the policy can never match and `""`/`{}`
87
+ when it always matches. `mappings` renames tables/columns (e.g.
88
+ `{"fruits": {"$self": "fruit_table", "colour": "col"}}`), and `mask_rule`
89
+ names a rule evaluated to produce column masks (returned under `"masks"`).
90
+ Residual conditions that cannot be expressed for the chosen target raise
91
+ `OpaError(code="compile_error")`.
92
+
93
+ Unknown refs must have the shape `input.<table>.<column>` — exactly two
94
+ segments after `input`, whatever the declared unknown boundary is, and for
95
+ every target/dialect (`ucast`/`all` included; `mappings` cannot deepen it).
96
+ So `unknowns=["input.item"]` permits only `input.item.<column>`, while the
97
+ bare `unknowns=["input"]` permits `input.<table>.<column>`. Deeper refs
98
+ like `input.item.attrs.price.value` fail with `pe_fragment_error: invalid
99
+ ref operand`, so nested documents (e.g. an EAV entity with per-attribute
100
+ type/value objects, or per-locale value objects) must be flattened into
101
+ columns (`input.attr.value_number`, `input.attr.value_de`, ...). Dynamic
102
+ column choice is fine as long as the key is known at compile time:
103
+ `input.attr[sprintf("value_%s", [input.locale])]` resolves to a single
104
+ column during partial evaluation.
60
105
  - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
61
106
  to a `callable(message, location)` to receive it (default: written to stderr);
62
107
  `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -0,0 +1,106 @@
1
+ package main
2
+
3
+ /*
4
+ #include <stdlib.h>
5
+ */
6
+ import "C"
7
+
8
+ import (
9
+ "context"
10
+ "encoding/json"
11
+
12
+ "github.com/open-policy-agent/opa/v1/ast"
13
+ "github.com/open-policy-agent/opa/v1/rego"
14
+ regocompile "github.com/open-policy-agent/opa/v1/rego/compile"
15
+ "github.com/open-policy-agent/opa/v1/storage/inmem"
16
+ )
17
+
18
+ // OpaCompileFilters partially evaluates a query with respect to the given
19
+ // unknowns and translates the residual into a data filter for the given
20
+ // target/dialect ("sql" with postgresql/mysql/sqlserver/sqlite, or "ucast"
21
+ // with all/prisma/linq/""). The result envelope holds
22
+ // {"query": <string or object>, "masks": <object or null>}.
23
+ //
24
+ //export OpaCompileFilters
25
+ func OpaCompileFilters(h C.ulonglong, query, inputJson, unknownsJson, target, dialect, mappingsJson, maskRule *C.char) *C.char {
26
+ e, err := getEngine(h)
27
+ if err != nil {
28
+ return errorJSON("invalid_handle", err.Error())
29
+ }
30
+
31
+ parsedQuery, err := ast.ParseBody(C.GoString(query))
32
+ if err != nil {
33
+ return errorJSON("parse_error", err.Error())
34
+ }
35
+
36
+ var unknownStrs []string
37
+ if err := json.Unmarshal([]byte(C.GoString(unknownsJson)), &unknownStrs); err != nil {
38
+ return errorJSON("invalid_json", err.Error())
39
+ }
40
+ unknowns := make([]*ast.Term, len(unknownStrs))
41
+ for i, u := range unknownStrs {
42
+ if unknowns[i], err = ast.ParseTerm(u); err != nil {
43
+ return errorJSON("parse_error", "unknown "+u+": "+err.Error())
44
+ }
45
+ }
46
+
47
+ tgt, dia := C.GoString(target), C.GoString(dialect)
48
+ copts := []regocompile.CompileOption{
49
+ regocompile.ParsedQuery(parsedQuery),
50
+ regocompile.ParsedUnknowns(unknowns...),
51
+ regocompile.Target(tgt, dia),
52
+ }
53
+
54
+ if s := C.GoString(mappingsJson); s != "" {
55
+ var mappings map[string]any
56
+ if err := json.Unmarshal([]byte(s), &mappings); err != nil {
57
+ return errorJSON("invalid_json", err.Error())
58
+ }
59
+ copts = append(copts, regocompile.Mappings(mappings))
60
+ }
61
+
62
+ if s := C.GoString(maskRule); s != "" {
63
+ ref, err := ast.ParseRef(s)
64
+ if err != nil {
65
+ return errorJSON("parse_error", "mask rule: "+err.Error())
66
+ }
67
+ copts = append(copts, regocompile.MaskRule(ref))
68
+ }
69
+
70
+ e.mu.Lock()
71
+ ropts := []func(*rego.Rego){
72
+ rego.Store(inmem.NewFromObject(e.data)),
73
+ rego.StrictBuiltinErrors(true),
74
+ }
75
+ for path, src := range e.modules {
76
+ ropts = append(ropts, rego.Module(path, src))
77
+ }
78
+ for _, b := range e.builtins {
79
+ ropts = append(ropts, makeBuiltin(uint64(h), b))
80
+ }
81
+ e.mu.Unlock()
82
+ copts = append(copts, regocompile.Rego(ropts...))
83
+
84
+ prepared, err := regocompile.New(copts...).Prepare(context.Background())
85
+ if err != nil {
86
+ return errorJSON("prepare_error", err.Error())
87
+ }
88
+
89
+ evalOpts := []rego.EvalOption{}
90
+ if inputJson != nil {
91
+ if s := C.GoString(inputJson); s != "" {
92
+ var input any
93
+ if err := json.Unmarshal([]byte(s), &input); err != nil {
94
+ return errorJSON("invalid_json", err.Error())
95
+ }
96
+ evalOpts = append(evalOpts, rego.EvalInput(input))
97
+ }
98
+ }
99
+
100
+ filters, err := prepared.Compile(context.Background(), evalOpts...)
101
+ if err != nil {
102
+ return errorJSON("compile_error", err.Error())
103
+ }
104
+ f := filters.For(tgt, dia)
105
+ return resultJSON(map[string]any{"query": f.Query, "masks": f.Masks})
106
+ }
@@ -11,6 +11,9 @@ require (
11
11
  github.com/gobwas/glob v0.2.3 // indirect
12
12
  github.com/goccy/go-json v0.10.6 // indirect
13
13
  github.com/google/uuid v1.6.0 // indirect
14
+ github.com/huandu/go-clone v1.7.3 // indirect
15
+ github.com/huandu/go-sqlbuilder v1.42.1 // indirect
16
+ github.com/huandu/xstrings v1.4.0 // indirect
14
17
  github.com/lestrrat-go/blackmagic v1.0.4 // indirect
15
18
  github.com/lestrrat-go/dsig v1.2.1 // indirect
16
19
  github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
@@ -37,6 +37,15 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
37
37
  github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
38
38
  github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
39
39
  github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
40
+ github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
41
+ github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
42
+ github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
43
+ github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ=
44
+ github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE=
45
+ github.com/huandu/go-sqlbuilder v1.42.1 h1:9DVQbKg1uFNGaVqgWMpaBNkM3Fv58p2m/+AxWkTrCYc=
46
+ github.com/huandu/go-sqlbuilder v1.42.1/go.mod h1:BEm32AHl29lzKDeV3HAIkzrz9cgRyumkDohHeGYYBoM=
47
+ github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
48
+ github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
40
49
  github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
41
50
  github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
42
51
  github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -82,6 +91,7 @@ github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr
82
91
  github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
83
92
  github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
84
93
  github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
94
+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
85
95
  github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
86
96
  github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
87
97
  github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -128,6 +138,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
128
138
  gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
129
139
  gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
130
140
  gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
141
+ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
131
142
  gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
132
143
  gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
133
144
  gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opa-golib-python-bindings"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -26,7 +26,7 @@ Repository = "https://github.com/phi1010/opa-golib-python-bindings"
26
26
  Issues = "https://github.com/phi1010/opa-golib-python-bindings/issues"
27
27
 
28
28
  [project.optional-dependencies]
29
- dev = ["pytest"]
29
+ dev = ["pytest", "pyyaml"]
30
30
 
31
31
  [build-system]
32
32
  requires = ["hatchling"]
@@ -60,4 +60,16 @@ def load():
60
60
  ctypes.c_int, # trace
61
61
  ]
62
62
 
63
+ lib.OpaCompileFilters.restype = ctypes.c_void_p
64
+ lib.OpaCompileFilters.argtypes = [
65
+ ctypes.c_uint64,
66
+ ctypes.c_char_p, # query
67
+ ctypes.c_char_p, # input JSON
68
+ ctypes.c_char_p, # unknowns JSON array
69
+ ctypes.c_char_p, # target
70
+ ctypes.c_char_p, # dialect
71
+ ctypes.c_char_p, # mappings JSON
72
+ ctypes.c_char_p, # mask rule ref
73
+ ]
74
+
63
75
  return lib
@@ -235,6 +235,62 @@ class OpaEngine:
235
235
  raise OpaUndefinedError(f"data.{path}" if path else "data")
236
236
  return rs[0]["expressions"][0]["value"]
237
237
 
238
+ def compile_filters(
239
+ self,
240
+ query: str,
241
+ input=None,
242
+ *,
243
+ unknowns=("input",),
244
+ target: str = "sql",
245
+ dialect: str = "postgresql",
246
+ mappings=None,
247
+ mask_rule: str | None = None,
248
+ ):
249
+ """Partially evaluate ``query`` and translate it into a data filter.
250
+
251
+ Everything under the refs in ``unknowns`` (e.g. ``"input.fruits"``)
252
+ is left unknown; the rest is evaluated using ``input`` and the data
253
+ and policies already added. The residual conditions are translated
254
+ for ``target``/``dialect``:
255
+
256
+ - ``target="sql"`` with dialect ``postgresql``, ``mysql``,
257
+ ``sqlserver`` or ``sqlite``: returns a SQL WHERE clause string.
258
+ - ``target="ucast"`` with dialect ``all``, ``prisma``, ``linq`` or
259
+ ``""``: returns a UCAST condition object (JSON-compatible dict).
260
+
261
+ ``mappings`` optionally renames tables/columns (see the OPA docs on
262
+ Compile API mappings); ``mask_rule`` names a rule (e.g.
263
+ ``"data.filters.masks"``) evaluated to produce column masks.
264
+
265
+ Returns ``{"query": <str or dict or None>, "masks": <dict or None>}``.
266
+ A query of ``None`` means the policy can never be satisfied; an empty
267
+ query means it is always satisfied. Raises OpaError with code
268
+ ``compile_error`` if the residual policy cannot be expressed as a
269
+ filter for the chosen target.
270
+
271
+ Unknown refs are limited to ``input.<table>.<column>`` — exactly two
272
+ segments after ``input``, for every target/dialect, wherever the
273
+ declared unknown boundary sits (``mappings`` cannot deepen this):
274
+ ``unknowns=["input.item"]`` permits only ``input.item.<column>``,
275
+ the bare ``unknowns=["input"]`` permits ``input.<table>.<column>``.
276
+ Nested documents like ``input.item.attrs.price.value`` are rejected
277
+ and must be flattened into columns; a dynamic column picked from
278
+ known values (``input.attr[sprintf("value_%s", [input.locale])]``)
279
+ is fine, as it resolves during partial evaluation.
280
+ """
281
+ self._check_open()
282
+ envelope = self._call(
283
+ self._lib.OpaCompileFilters,
284
+ query.encode(),
285
+ _encode_input(input),
286
+ json.dumps(list(unknowns)).encode(),
287
+ target.encode(),
288
+ dialect.encode(),
289
+ b"" if mappings is None else json.dumps(mappings).encode(),
290
+ (mask_rule or "").encode(),
291
+ )
292
+ return envelope.get("result")
293
+
238
294
 
239
295
  def _encode_input(input):
240
296
  if input is None: