opa-golib-python-bindings 0.1.2__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 (20) hide show
  1. opa_golib_python_bindings-0.3.0/PKG-INFO +133 -0
  2. opa_golib_python_bindings-0.3.0/README.md +107 -0
  3. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/go/bridge.go +32 -6
  4. opa_golib_python_bindings-0.3.0/go/compile.go +106 -0
  5. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/go/go.mod +3 -0
  6. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/go/go.sum +11 -0
  7. opa_golib_python_bindings-0.3.0/go/trace.go +78 -0
  8. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/pyproject.toml +2 -2
  9. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/src/opa_bindings/_native.py +26 -2
  10. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/src/opa_bindings/engine.py +108 -6
  11. opa_golib_python_bindings-0.1.2/PKG-INFO +0 -73
  12. opa_golib_python_bindings-0.1.2/README.md +0 -48
  13. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/.gitignore +0 -0
  14. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/LICENSE +0 -0
  15. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/Makefile +0 -0
  16. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/go/bridge_call.c +0 -0
  17. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/go/builtins.go +0 -0
  18. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/go/merge.go +0 -0
  19. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/hatch_build.py +0 -0
  20. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.3.0}/src/opa_bindings/__init__.py +0 -0
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.5
2
+ Name: opa-golib-python-bindings
3
+ Version: 0.3.0
4
+ Summary: Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library
5
+ Project-URL: Homepage, https://github.com/phi1010/opa-golib-python-bindings
6
+ Project-URL: Repository, https://github.com/phi1010/opa-golib-python-bindings
7
+ Project-URL: Issues, https://github.com/phi1010/opa-golib-python-bindings/issues
8
+ Author: Phillip Kuhrt
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: authorization,opa,open-policy-agent,policy,rego
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Go
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.14
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: pyyaml; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # opa-golib-python-bindings
28
+
29
+ Python bindings for the [OPA](https://www.openpolicyagent.org/) Rego engine, embedding
30
+ `github.com/open-policy-agent/opa/v1/rego` via a Go c-shared library and a stdlib-only
31
+ ctypes wrapper.
32
+
33
+ ## Build
34
+
35
+ Requires Go >= 1.26 and Python >= 3.14.
36
+
37
+ ```sh
38
+ make build # builds src/opa_bindings/libopabridge.so
39
+ make test # builds + runs pytest
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from opa_bindings import OpaEngine
46
+
47
+ users = {"alice": {"role": "admin"}}
48
+
49
+ with OpaEngine() as engine:
50
+ engine.add_policy("authz.rego", """
51
+ package authz
52
+
53
+ allow if lookup_user(input.user).role == "admin"
54
+ """)
55
+ engine.add_data({"admin": ["alice"]}, path="roles") # deep-merged; conflicts raise
56
+ engine.register_function("lookup_user", lambda name: users.get(name))
57
+
58
+ engine.eval_document("authz.allow", {"user": "alice"}) # -> True
59
+ engine.eval_query("x = data.roles.admin[_]") # -> [{"x": "alice"}]
60
+ ```
61
+
62
+ Notes:
63
+
64
+ - `add_data` deep-merges objects; identical values coexist, conflicting values raise
65
+ `OpaError(code="merge_conflict")` naming the conflicting path.
66
+ - `register_function` infers arity from the callable's signature. A `*args` function is
67
+ variadic and is called from Rego with a single array argument: `many(["a", "b"])`
68
+ (OPA does not support variadic builtins with return values).
69
+ - Builtin arguments and return values are JSON-compatible objects. A callback exception
70
+ becomes an evaluation error; returning is fine.
71
+ - An undefined document raises `OpaUndefinedError`.
72
+ - Pass `coverage=True` to `eval_document` / `eval_query` to capture a coverage
73
+ report (OPA's `cover` tracer) in `engine.last_coverage`: per-file `covered` /
74
+ `not_covered` line ranges plus line counts and a coverage percentage over all
75
+ added policies. Evaluating without `coverage=True` resets it to `None`.
76
+ - Pass `trace=True` to capture the full evaluation trace in `engine.last_trace`:
77
+ a list of event dicts (`op`, `location`, `node`, `locals`, ...) in evaluation
78
+ order. `locals` holds the plugged variable bindings live at each step, so the
79
+ value a statement produced is visible (e.g. `{"x": 6}` after `x := input.n * 2`);
80
+ a false condition appears as a `Fail` event at its location. An undefined
81
+ document still carries its trace — the main way to see which condition failed. Note that `node`
82
+ shows the compiler-rewritten expression (temporaries like `__local0__`), a
83
+ statement may appear multiple times (`Redo` on backtracking), and tracing
84
+ slows evaluation, so keep it opt-in per call. Coverage only records *which*
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.
131
+ - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
132
+ to a `callable(message, location)` to receive it (default: written to stderr);
133
+ `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -0,0 +1,107 @@
1
+ # opa-golib-python-bindings
2
+
3
+ Python bindings for the [OPA](https://www.openpolicyagent.org/) Rego engine, embedding
4
+ `github.com/open-policy-agent/opa/v1/rego` via a Go c-shared library and a stdlib-only
5
+ ctypes wrapper.
6
+
7
+ ## Build
8
+
9
+ Requires Go >= 1.26 and Python >= 3.14.
10
+
11
+ ```sh
12
+ make build # builds src/opa_bindings/libopabridge.so
13
+ make test # builds + runs pytest
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from opa_bindings import OpaEngine
20
+
21
+ users = {"alice": {"role": "admin"}}
22
+
23
+ with OpaEngine() as engine:
24
+ engine.add_policy("authz.rego", """
25
+ package authz
26
+
27
+ allow if lookup_user(input.user).role == "admin"
28
+ """)
29
+ engine.add_data({"admin": ["alice"]}, path="roles") # deep-merged; conflicts raise
30
+ engine.register_function("lookup_user", lambda name: users.get(name))
31
+
32
+ engine.eval_document("authz.allow", {"user": "alice"}) # -> True
33
+ engine.eval_query("x = data.roles.admin[_]") # -> [{"x": "alice"}]
34
+ ```
35
+
36
+ Notes:
37
+
38
+ - `add_data` deep-merges objects; identical values coexist, conflicting values raise
39
+ `OpaError(code="merge_conflict")` naming the conflicting path.
40
+ - `register_function` infers arity from the callable's signature. A `*args` function is
41
+ variadic and is called from Rego with a single array argument: `many(["a", "b"])`
42
+ (OPA does not support variadic builtins with return values).
43
+ - Builtin arguments and return values are JSON-compatible objects. A callback exception
44
+ becomes an evaluation error; returning is fine.
45
+ - An undefined document raises `OpaUndefinedError`.
46
+ - Pass `coverage=True` to `eval_document` / `eval_query` to capture a coverage
47
+ report (OPA's `cover` tracer) in `engine.last_coverage`: per-file `covered` /
48
+ `not_covered` line ranges plus line counts and a coverage percentage over all
49
+ added policies. Evaluating without `coverage=True` resets it to `None`.
50
+ - Pass `trace=True` to capture the full evaluation trace in `engine.last_trace`:
51
+ a list of event dicts (`op`, `location`, `node`, `locals`, ...) in evaluation
52
+ order. `locals` holds the plugged variable bindings live at each step, so the
53
+ value a statement produced is visible (e.g. `{"x": 6}` after `x := input.n * 2`);
54
+ a false condition appears as a `Fail` event at its location. An undefined
55
+ document still carries its trace — the main way to see which condition failed. Note that `node`
56
+ shows the compiler-rewritten expression (temporaries like `__local0__`), a
57
+ statement may appear multiple times (`Redo` on backtracking), and tracing
58
+ slows evaluation, so keep it opt-in per call. Coverage only records *which*
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.
105
+ - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
106
+ to a `callable(message, location)` to receive it (default: written to stderr);
107
+ `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -21,6 +21,7 @@ import (
21
21
  "unsafe"
22
22
 
23
23
  "github.com/open-policy-agent/opa/v1/ast"
24
+ "github.com/open-policy-agent/opa/v1/cover"
24
25
  "github.com/open-policy-agent/opa/v1/rego"
25
26
  "github.com/open-policy-agent/opa/v1/storage/inmem"
26
27
  "github.com/open-policy-agent/opa/v1/topdown/print"
@@ -187,21 +188,21 @@ func OpaRegisterBuiltin(h C.ulonglong, name *C.char, arity C.int, cb C.opa_callb
187
188
  }
188
189
 
189
190
  //export OpaEvalQuery
190
- func OpaEvalQuery(h C.ulonglong, query, inputJson *C.char) *C.char {
191
- return evalCommon(h, C.GoString(query), inputJson)
191
+ func OpaEvalQuery(h C.ulonglong, query, inputJson *C.char, coverage, trace C.int) *C.char {
192
+ return evalCommon(h, C.GoString(query), inputJson, coverage != 0, trace != 0)
192
193
  }
193
194
 
194
195
  //export OpaEvalDocument
195
- func OpaEvalDocument(h C.ulonglong, docPath, inputJson *C.char) *C.char {
196
+ func OpaEvalDocument(h C.ulonglong, docPath, inputJson *C.char, coverage, trace C.int) *C.char {
196
197
  p := C.GoString(docPath)
197
198
  q := "data"
198
199
  if p != "" {
199
200
  q = "data." + p
200
201
  }
201
- return evalCommon(h, q, inputJson)
202
+ return evalCommon(h, q, inputJson, coverage != 0, trace != 0)
202
203
  }
203
204
 
204
- func evalCommon(h C.ulonglong, query string, inputJson *C.char) *C.char {
205
+ func evalCommon(h C.ulonglong, query string, inputJson *C.char, coverage, trace bool) *C.char {
205
206
  e, err := getEngine(h)
206
207
  if err != nil {
207
208
  return errorJSON("invalid_handle", err.Error())
@@ -243,11 +244,36 @@ func evalCommon(h C.ulonglong, query string, inputJson *C.char) *C.char {
243
244
  }
244
245
  collector := &printCollector{}
245
246
  evalOpts = append(evalOpts, rego.EvalPrintHook(collector))
247
+ var cov *cover.Cover
248
+ if coverage {
249
+ cov = cover.New()
250
+ evalOpts = append(evalOpts, rego.EvalQueryTracer(cov))
251
+ }
252
+ var tracer *traceCollector
253
+ if trace {
254
+ tracer = &traceCollector{}
255
+ evalOpts = append(evalOpts, rego.EvalQueryTracer(tracer))
256
+ }
246
257
  rs, err := pq.Eval(context.Background(), evalOpts...)
247
258
  if err != nil {
248
259
  return errorJSON("eval_error", err.Error())
249
260
  }
250
- b, err := json.Marshal(map[string]any{"result": rs, "prints": collector.msgs})
261
+ envelope := map[string]any{"result": rs, "prints": collector.msgs}
262
+ if cov != nil {
263
+ e.mu.Lock()
264
+ parsed := map[string]*ast.Module{}
265
+ for path, src := range e.modules {
266
+ if m, perr := ast.ParseModule(path, src); perr == nil {
267
+ parsed[path] = m
268
+ }
269
+ }
270
+ e.mu.Unlock()
271
+ envelope["coverage"] = cov.Report(parsed)
272
+ }
273
+ if tracer != nil {
274
+ envelope["trace"] = tracer.events
275
+ }
276
+ b, err := json.Marshal(envelope)
251
277
  if err != nil {
252
278
  return errorJSON("internal", err.Error())
253
279
  }
@@ -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=
@@ -0,0 +1,78 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "sync"
7
+
8
+ "github.com/open-policy-agent/opa/v1/ast"
9
+ "github.com/open-policy-agent/opa/v1/topdown"
10
+ )
11
+
12
+ type traceEvent struct {
13
+ Op string `json:"op"`
14
+ QueryID uint64 `json:"query_id"`
15
+ ParentID uint64 `json:"parent_id"`
16
+ Location string `json:"location,omitempty"`
17
+ Node string `json:"node,omitempty"`
18
+ Locals map[string]any `json:"locals,omitempty"`
19
+ Message string `json:"message,omitempty"`
20
+ }
21
+
22
+ // traceCollector implements topdown.QueryTracer, capturing every evaluation
23
+ // event together with the local variable bindings live at that point.
24
+ type traceCollector struct {
25
+ mu sync.Mutex
26
+ events []traceEvent
27
+ }
28
+
29
+ func (t *traceCollector) Enabled() bool { return true }
30
+
31
+ func (t *traceCollector) Config() topdown.TraceConfig {
32
+ return topdown.TraceConfig{PlugLocalVars: true}
33
+ }
34
+
35
+ func (t *traceCollector) TraceEvent(evt topdown.Event) {
36
+ te := traceEvent{
37
+ Op: string(evt.Op),
38
+ QueryID: evt.QueryID,
39
+ ParentID: evt.ParentID,
40
+ Message: evt.Message,
41
+ }
42
+ if evt.Location != nil {
43
+ te.Location = evt.Location.String()
44
+ }
45
+ if evt.Node != nil {
46
+ te.Node = fmt.Sprintf("%v", evt.Node)
47
+ }
48
+ if evt.Locals != nil {
49
+ locals := map[string]any{}
50
+ evt.Locals.Iter(func(k, v ast.Value) bool {
51
+ kv, ok := k.(ast.Var)
52
+ if !ok {
53
+ return false
54
+ }
55
+ name := string(kv)
56
+ // Compiler-generated vars carry the user-facing name in the
57
+ // event metadata; drop them if no such name exists.
58
+ if md, found := evt.LocalMetadata[kv]; found {
59
+ name = string(md.Name)
60
+ }
61
+ if strings.HasPrefix(name, "__local") || strings.HasPrefix(name, "$") {
62
+ return false // compiler temporaries and wildcards
63
+ }
64
+ if j, err := ast.JSON(v); err == nil {
65
+ locals[name] = j
66
+ } else {
67
+ locals[name] = v.String()
68
+ }
69
+ return false
70
+ })
71
+ if len(locals) > 0 {
72
+ te.Locals = locals
73
+ }
74
+ }
75
+ t.mu.Lock()
76
+ t.events = append(t.events, te)
77
+ t.mu.Unlock()
78
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opa-golib-python-bindings"
3
- version = "0.1.2"
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"]
@@ -43,9 +43,33 @@ def load():
43
43
  ]
44
44
 
45
45
  lib.OpaEvalQuery.restype = ctypes.c_void_p
46
- lib.OpaEvalQuery.argtypes = [ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p]
46
+ lib.OpaEvalQuery.argtypes = [
47
+ ctypes.c_uint64,
48
+ ctypes.c_char_p,
49
+ ctypes.c_char_p,
50
+ ctypes.c_int, # coverage
51
+ ctypes.c_int, # trace
52
+ ]
47
53
 
48
54
  lib.OpaEvalDocument.restype = ctypes.c_void_p
49
- lib.OpaEvalDocument.argtypes = [ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p]
55
+ lib.OpaEvalDocument.argtypes = [
56
+ ctypes.c_uint64,
57
+ ctypes.c_char_p,
58
+ ctypes.c_char_p,
59
+ ctypes.c_int, # coverage
60
+ ctypes.c_int, # trace
61
+ ]
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
+ ]
50
74
 
51
75
  return lib
@@ -50,6 +50,17 @@ class OpaEngine:
50
50
  self.print_handler = None
51
51
  #: Prints captured by the most recent eval, as (message, location).
52
52
  self.last_prints = []
53
+ #: Coverage report of the most recent eval with ``coverage=True``:
54
+ #: {"files": {path: {"covered": [...], "not_covered": [...], ...}},
55
+ #: "covered_lines": int, "not_covered_lines": int, "coverage": float}.
56
+ #: None if the last eval did not capture coverage.
57
+ self.last_coverage = None
58
+ #: Evaluation trace of the most recent eval with ``trace=True``: a
59
+ #: list of event dicts {"op", "query_id", "parent_id", "location",
60
+ #: "node", "locals", "message"} in evaluation order, where "locals"
61
+ #: holds the variable bindings live at that point. None if the last
62
+ #: eval did not capture a trace.
63
+ self.last_trace = None
53
64
 
54
65
  # -- lifecycle -----------------------------------------------------
55
66
 
@@ -90,7 +101,16 @@ class OpaEngine:
90
101
  raise OpaError(err.get("code", "unknown"), err.get("message", ""))
91
102
  return envelope
92
103
 
104
+ def _eval_reset(self):
105
+ # Clear per-eval state up front so a failed eval never leaves stale
106
+ # results from a previous evaluation behind.
107
+ self.last_coverage = None
108
+ self.last_trace = None
109
+ self.last_prints = []
110
+
93
111
  def _eval_result(self, envelope):
112
+ self.last_coverage = envelope.get("coverage")
113
+ self.last_trace = envelope.get("trace")
94
114
  self.last_prints = [
95
115
  (p["message"], p["location"]) for p in envelope.get("prints") or []
96
116
  ]
@@ -166,29 +186,111 @@ class OpaEngine:
166
186
  del self._functions[name]
167
187
  raise
168
188
 
169
- def eval_query(self, query: str, input=None):
170
- """Evaluate a Rego query; returns a list of binding dicts."""
189
+ def eval_query(
190
+ self, query: str, input=None, *, coverage: bool = False, trace: bool = False
191
+ ):
192
+ """Evaluate a Rego query; returns a list of binding dicts.
193
+
194
+ With ``coverage=True`` the evaluation is traced and a coverage report
195
+ over all added policies is stored in ``self.last_coverage``. With
196
+ ``trace=True`` the full event trace, including the variable bindings
197
+ at each step, is stored in ``self.last_trace``.
198
+ """
171
199
  self._check_open()
200
+ self._eval_reset()
172
201
  rs = self._eval_result(
173
- self._call(self._lib.OpaEvalQuery, query.encode(), _encode_input(input))
202
+ self._call(
203
+ self._lib.OpaEvalQuery,
204
+ query.encode(),
205
+ _encode_input(input),
206
+ int(coverage),
207
+ int(trace),
208
+ )
174
209
  )
175
210
  if not rs:
176
211
  return []
177
212
  return [r.get("bindings", {}) for r in rs]
178
213
 
179
- def eval_document(self, path: str, input=None):
214
+ def eval_document(
215
+ self, path: str, input=None, *, coverage: bool = False, trace: bool = False
216
+ ):
180
217
  """Evaluate the document at ``data.<path>`` and return its value.
181
218
 
182
- Raises OpaUndefinedError if the document is undefined.
219
+ Raises OpaUndefinedError if the document is undefined. With
220
+ ``coverage=True`` a coverage report is stored in ``self.last_coverage``;
221
+ with ``trace=True`` the event trace is stored in ``self.last_trace``.
183
222
  """
184
223
  self._check_open()
224
+ self._eval_reset()
185
225
  rs = self._eval_result(
186
- self._call(self._lib.OpaEvalDocument, path.encode(), _encode_input(input))
226
+ self._call(
227
+ self._lib.OpaEvalDocument,
228
+ path.encode(),
229
+ _encode_input(input),
230
+ int(coverage),
231
+ int(trace),
232
+ )
187
233
  )
188
234
  if not rs or not rs[0].get("expressions"):
189
235
  raise OpaUndefinedError(f"data.{path}" if path else "data")
190
236
  return rs[0]["expressions"][0]["value"]
191
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
+
192
294
 
193
295
  def _encode_input(input):
194
296
  if input is None:
@@ -1,73 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: opa-golib-python-bindings
3
- Version: 0.1.2
4
- Summary: Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library
5
- Project-URL: Homepage, https://github.com/phi1010/opa-golib-python-bindings
6
- Project-URL: Repository, https://github.com/phi1010/opa-golib-python-bindings
7
- Project-URL: Issues, https://github.com/phi1010/opa-golib-python-bindings/issues
8
- Author: Phillip Kuhrt
9
- License-Expression: Apache-2.0
10
- License-File: LICENSE
11
- Keywords: authorization,opa,open-policy-agent,policy,rego
12
- Classifier: Development Status :: 3 - Alpha
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Operating System :: MacOS
15
- Classifier: Operating System :: POSIX :: Linux
16
- Classifier: Programming Language :: Go
17
- Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.14
19
- Classifier: Topic :: Security
20
- Classifier: Topic :: Software Development :: Libraries
21
- Requires-Python: >=3.14
22
- Provides-Extra: dev
23
- Requires-Dist: pytest; extra == 'dev'
24
- Description-Content-Type: text/markdown
25
-
26
- # opa-golib-python-bindings
27
-
28
- Python bindings for the [OPA](https://www.openpolicyagent.org/) Rego engine, embedding
29
- `github.com/open-policy-agent/opa/v1/rego` via a Go c-shared library and a stdlib-only
30
- ctypes wrapper.
31
-
32
- ## Build
33
-
34
- Requires Go >= 1.26 and Python >= 3.14.
35
-
36
- ```sh
37
- make build # builds src/opa_bindings/libopabridge.so
38
- make test # builds + runs pytest
39
- ```
40
-
41
- ## Usage
42
-
43
- ```python
44
- from opa_bindings import OpaEngine
45
-
46
- users = {"alice": {"role": "admin"}}
47
-
48
- with OpaEngine() as engine:
49
- engine.add_policy("authz.rego", """
50
- package authz
51
-
52
- allow if lookup_user(input.user).role == "admin"
53
- """)
54
- engine.add_data({"admin": ["alice"]}, path="roles") # deep-merged; conflicts raise
55
- engine.register_function("lookup_user", lambda name: users.get(name))
56
-
57
- engine.eval_document("authz.allow", {"user": "alice"}) # -> True
58
- engine.eval_query("x = data.roles.admin[_]") # -> [{"x": "alice"}]
59
- ```
60
-
61
- Notes:
62
-
63
- - `add_data` deep-merges objects; identical values coexist, conflicting values raise
64
- `OpaError(code="merge_conflict")` naming the conflicting path.
65
- - `register_function` infers arity from the callable's signature. A `*args` function is
66
- variadic and is called from Rego with a single array argument: `many(["a", "b"])`
67
- (OPA does not support variadic builtins with return values).
68
- - Builtin arguments and return values are JSON-compatible objects. A callback exception
69
- becomes an evaluation error; returning is fine.
70
- - An undefined document raises `OpaUndefinedError`.
71
- - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
72
- to a `callable(message, location)` to receive it (default: written to stderr);
73
- `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -1,48 +0,0 @@
1
- # opa-golib-python-bindings
2
-
3
- Python bindings for the [OPA](https://www.openpolicyagent.org/) Rego engine, embedding
4
- `github.com/open-policy-agent/opa/v1/rego` via a Go c-shared library and a stdlib-only
5
- ctypes wrapper.
6
-
7
- ## Build
8
-
9
- Requires Go >= 1.26 and Python >= 3.14.
10
-
11
- ```sh
12
- make build # builds src/opa_bindings/libopabridge.so
13
- make test # builds + runs pytest
14
- ```
15
-
16
- ## Usage
17
-
18
- ```python
19
- from opa_bindings import OpaEngine
20
-
21
- users = {"alice": {"role": "admin"}}
22
-
23
- with OpaEngine() as engine:
24
- engine.add_policy("authz.rego", """
25
- package authz
26
-
27
- allow if lookup_user(input.user).role == "admin"
28
- """)
29
- engine.add_data({"admin": ["alice"]}, path="roles") # deep-merged; conflicts raise
30
- engine.register_function("lookup_user", lambda name: users.get(name))
31
-
32
- engine.eval_document("authz.allow", {"user": "alice"}) # -> True
33
- engine.eval_query("x = data.roles.admin[_]") # -> [{"x": "alice"}]
34
- ```
35
-
36
- Notes:
37
-
38
- - `add_data` deep-merges objects; identical values coexist, conflicting values raise
39
- `OpaError(code="merge_conflict")` naming the conflicting path.
40
- - `register_function` infers arity from the callable's signature. A `*args` function is
41
- variadic and is called from Rego with a single array argument: `many(["a", "b"])`
42
- (OPA does not support variadic builtins with return values).
43
- - Builtin arguments and return values are JSON-compatible objects. A callback exception
44
- becomes an evaluation error; returning is fine.
45
- - An undefined document raises `OpaUndefinedError`.
46
- - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
47
- to a `callable(message, location)` to receive it (default: written to stderr);
48
- `engine.last_prints` holds the `(message, location)` pairs of the last eval.