Documentation
¶
Overview ¶
Package opa provides a Transformer that enforces Open Policy Agent policies on queries by injecting policy-derived WHERE conditions.
You supply a PolicyFunc that is called once per table referenced in the query (FROM and JOINs). The function inspects the table name and returns zero or more AST condition nodes to append to the WHERE clause. If the function returns an error the query is rejected entirely — useful for hard "access denied" rules.
Basic usage ¶
policy := func(table string) ([]nodes.Node, error) {
if table == "secrets" {
return nil, errors.New("access denied")
}
// Restrict "users" to tenant_id = 42
if table == "users" {
t := nodes.NewTable(table)
cond := t.Col("tenant_id").Eq(42)
return []nodes.Node{cond}, nil
}
return nil, nil // no extra conditions
}
o := opa.New(policy)
query := managers.NewSelectManager(table)
query.Use(o)
// SELECT * FROM "users" WHERE "users"."tenant_id" = 42
Combining with other plugins ¶
OPA composes with any other Transformer. Register multiple plugins with successive query.Use() calls and they are applied in order:
query.Use(softdelete.New()) query.Use(opa.New(policy))
Design notes ¶
The OPA plugin is code-only — it requires a PolicyFunc at construction time, so it is not exposed in the interactive REPL. For REPL-friendly plugins see the softdelete package.
Nested queries and raw SQL ¶
Via a manager, the policy is applied inside subqueries, CTE bodies and set-operation branches, and is evaluated once per table per query. A name that refers to a CTE is not a table and is skipped. Queries containing raw SQL (SqlLiteral, StringJoin) are refused with plugins.ErrUnpoliceable.
Index ¶
- Variables
- type Client
- func (c *Client) Compile(tableName string) ([]nodes.Node, error)
- func (c *Client) CompileWithMasks(tableName string) (*CompileResult, error)
- func (c *Client) DiscoverInputs(dataUnknowns ...string) ([]string, error)
- func (c *Client) DiscoverPolicies() ([]PolicyInfo, error)
- func (c *Client) DiscoverTables() ([]string, error)
- func (c *Client) Explain(tableName string) (*ExplainResult, error)
- func (c *Client) FetchMasks() (map[string]map[string]MaskAction, error)
- type ColumnResolver
- type CompileResult
- type ExplainResult
- type ExplainTranslation
- type MaskAction
- type OPA
- func (o *OPA) RefusesRawSQL() bool
- func (o *OPA) TransformDelete(*nodes.DeleteStatement) (*nodes.DeleteStatement, error)
- func (o *OPA) TransformInsert(*nodes.InsertStatement) (*nodes.InsertStatement, error)
- func (o *OPA) TransformSelect(core *nodes.SelectCore) (*nodes.SelectCore, error)
- func (o *OPA) TransformSelectScoped(core *nodes.SelectCore, scope plugins.Scope) (*nodes.SelectCore, error)
- func (o *OPA) TransformUpdate(*nodes.UpdateStatement) (*nodes.UpdateStatement, error)
- type Option
- type PolicyFunc
- type PolicyInfo
- type ReplaceAction
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrMaskedColumnUse = errors.New("opa: masked column used outside a plain projection")
ErrMaskedColumnUse is returned when a masked column is used anywhere other than as a bare projection, where it cannot be replaced by its mask.
var ErrUnsupportedStatement = errors.New("opa: only SELECT statements can be policed")
ErrUnsupportedStatement is returned when the plugin is applied to an INSERT, UPDATE or DELETE. The plugin only polices SELECT, so it refuses other statements rather than letting them through unpoliced.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client communicates with an OPA server's Compile API.
func NewClient ¶
NewClient creates an OPA Client with the given base URL, policy path, and input. The policy path is normalized to include the "data." prefix if not already present.
SECURITY: The baseURL is used as-is for HTTP requests. In production, use HTTPS to prevent policy decisions and input data from being transmitted in plain text.
func (*Client) Compile ¶
Compile calls the OPA Compile API for the given table and returns AST condition nodes that can be injected into a WHERE clause.
func (*Client) CompileWithMasks ¶
func (c *Client) CompileWithMasks(tableName string) (*CompileResult, error)
CompileWithMasks calls the OPA Compile API for the given table and fetches masks from the OPA Data API. Returns a CompileResult containing both row-filtering conditions and column masks.
func (*Client) DiscoverInputs ¶
DiscoverInputs calls the OPA Compile API to discover which input fields a policy references, including both filter rules and mask rules. Additional data paths (e.g. "data.consignments") can be passed so that rules referencing those paths also produce residuals, exposing all required input fields.
func (*Client) DiscoverPolicies ¶
func (c *Client) DiscoverPolicies() ([]PolicyInfo, error)
DiscoverPolicies queries the OPA server's policies list and returns all policies whose package path contains "include", "filter", or "mask" as a substring. One PolicyInfo is returned per top-level rule found within each qualifying package. Results are sorted by FullPath.
func (*Client) DiscoverTables ¶
DiscoverTables inspects the policy source for the current client's package and returns the table names referenced as collections (via iteration or indexing). Identifiers that match a known OPA package path segment are excluded to avoid confusing sub-packages with database tables. Returns nil (not an error) when the package is not found on the server.
func (*Client) Explain ¶
func (c *Client) Explain(tableName string) (*ExplainResult, error)
Explain calls the OPA Compile API and returns diagnostic information about how the response translates to SQL conditions.
func (*Client) FetchMasks ¶
func (c *Client) FetchMasks() (map[string]map[string]MaskAction, error)
FetchMasks queries the OPA Data API to evaluate the masks rule for the current policy. Returns nil if no masks are defined or all mask values are non-string (meaning "no mask").
type ColumnResolver ¶
ColumnResolver returns the column names for a given table. It is required when masks are returned by the OPA server and the query uses star projections, because the star must be expanded into explicit column references to allow individual columns to be replaced with their mask values.
type CompileResult ¶
type CompileResult struct {
Conditions []nodes.Node
Masks map[string]map[string]MaskAction
}
CompileResult holds both the row-filtering conditions and column masks.
type ExplainResult ¶
type ExplainResult struct {
RequestJSON string
RawJSON string
QueryCount int
ExpressionCount int
Translations []ExplainTranslation
Conditions []nodes.Node
Masks map[string]map[string]MaskAction
UnconditionalAllow bool
AccessDenied bool
}
ExplainResult holds the diagnostic output from an Explain call.
type ExplainTranslation ¶
type ExplainTranslation struct {
Operator string // OPA operator (eq, neq, lt, etc.)
Column string // column name from data ref
Value any // literal value
SQL string // resulting SQL fragment
}
ExplainTranslation records how a single OPA expression was translated.
type MaskAction ¶
type MaskAction struct {
Replace *ReplaceAction `json:"replace"`
}
MaskAction describes how to mask a single column.
type OPA ¶
type OPA struct {
plugins.BaseTransformer
// contains filtered or unexported fields
}
OPA is a Transformer that evaluates a policy function against every table in the query and injects the resulting conditions. It supports two modes:
- PolicyFunc mode (via New): calls a Go function to evaluate policy
- Server mode (via NewFromServer): calls an OPA server's Compile API
func New ¶
func New(policy PolicyFunc) *OPA
New creates an OPA transformer with the given policy function.
Example ¶
In PolicyFunc mode a Go function plays the policy engine: it returns the conditions each table must satisfy, and the plugin appends them to WHERE.
package main
import (
"fmt"
"github.com/bawdo/gosbee/managers"
"github.com/bawdo/gosbee/nodes"
"github.com/bawdo/gosbee/plugins/opa"
"github.com/bawdo/gosbee/visitors"
)
func main() {
policy := func(table string) ([]nodes.Node, error) {
if table == "orders" {
return []nodes.Node{nodes.NewTable("orders").Col("tenant_id").Eq(42)}, nil
}
return nil, nil
}
orders := nodes.NewTable("orders")
query := managers.NewSelectManager(orders).
Select(orders.Col("id"), orders.Col("total")).
Use(opa.New(policy))
sql, params, err := query.ToSQL(visitors.NewPostgresVisitor())
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(sql)
fmt.Println(params)
}
Output: SELECT "orders"."id", "orders"."total" FROM "orders" WHERE "orders"."tenant_id" = $1 [42]
func NewFromServer ¶
NewFromServer creates an OPA transformer that calls an OPA server's Compile API to evaluate policies. The url is the base URL of the OPA server (e.g., "http://localhost:8181"), policyPath is the Rego policy path (e.g., "data.authz.allow"), and input is the input document to send with each request. Optional Option values can configure additional behavior such as column resolvers for masking.
Example ¶
In server mode the plugin asks OPA for both the row filter and the column masks. Masking a SELECT * needs the table's columns, which a ColumnResolver supplies: the star is expanded and the masked column is replaced by its mask value, bound as a parameter under the column's name.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/bawdo/gosbee/managers"
"github.com/bawdo/gosbee/nodes"
"github.com/bawdo/gosbee/plugins/opa"
"github.com/bawdo/gosbee/visitors"
)
// fakeOPA is an in-process stand-in for an OPA server, so the examples need
// no policy server and no network beyond the loopback listener httptest
// opens. It answers the two calls the plugin makes:
//
// - POST /v1/compile, the partial evaluation of the row filter, with one
// residual: tenant_id = 42.
// - POST /v1/data/<package>/masks, the column masks, replacing
// billed_total with "<MASKED>".
//
// A real deployment points NewFromServer at its OPA server instead.
func fakeOPA() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/v1/compile":
_, _ = w.Write([]byte(`{"result":{"queries":[[{"index":0,"terms":[` +
`{"type":"ref","value":[{"type":"var","value":"eq"}]},` +
`{"type":"ref","value":[{"type":"var","value":"data"},{"type":"string","value":"consignments"},` +
`{"type":"var","value":"$0"},{"type":"string","value":"tenant_id"}]},` +
`{"type":"number","value":42}]}]]}}`))
case strings.HasSuffix(r.URL.Path, "/masks"):
_, _ = w.Write([]byte(`{"result":{"consignments":{"billed_total":{"replace":{"value":"<MASKED>"}}}}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func main() {
srv := fakeOPA()
defer srv.Close()
columns := func(table string) ([]string, error) {
if table == "consignments" {
return []string{"id", "account_name", "billed_total"}, nil
}
return nil, fmt.Errorf("unknown table %q", table)
}
plugin := opa.NewFromServer(srv.URL, "data.policies.consignments.include",
map[string]any{"subject": map[string]any{"tenant_id": 42}},
opa.WithColumnResolver(columns))
query := managers.NewSelectManager(nodes.NewTable("consignments")).
Use(plugin)
sql, params, err := query.ToSQL(visitors.NewPostgresVisitor())
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(sql)
fmt.Println(params)
}
Output: SELECT "consignments"."id", "consignments"."account_name", $1 AS "billed_total" FROM "consignments" WHERE "consignments"."tenant_id" = $2 [<MASKED> 42]
Example (MaskedColumnRefused) ¶
A masked column can only be replaced where it is projected bare. Anywhere else, here a WHERE condition, replacing it would change what the query means, so the query is refused with ErrMaskedColumnUse rather than rendered unmasked.
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/bawdo/gosbee/managers"
"github.com/bawdo/gosbee/nodes"
"github.com/bawdo/gosbee/plugins/opa"
"github.com/bawdo/gosbee/visitors"
)
// fakeOPA is an in-process stand-in for an OPA server, so the examples need
// no policy server and no network beyond the loopback listener httptest
// opens. It answers the two calls the plugin makes:
//
// - POST /v1/compile, the partial evaluation of the row filter, with one
// residual: tenant_id = 42.
// - POST /v1/data/<package>/masks, the column masks, replacing
// billed_total with "<MASKED>".
//
// A real deployment points NewFromServer at its OPA server instead.
func fakeOPA() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/v1/compile":
_, _ = w.Write([]byte(`{"result":{"queries":[[{"index":0,"terms":[` +
`{"type":"ref","value":[{"type":"var","value":"eq"}]},` +
`{"type":"ref","value":[{"type":"var","value":"data"},{"type":"string","value":"consignments"},` +
`{"type":"var","value":"$0"},{"type":"string","value":"tenant_id"}]},` +
`{"type":"number","value":42}]}]]}}`))
case strings.HasSuffix(r.URL.Path, "/masks"):
_, _ = w.Write([]byte(`{"result":{"consignments":{"billed_total":{"replace":{"value":"<MASKED>"}}}}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func main() {
srv := fakeOPA()
defer srv.Close()
plugin := opa.NewFromServer(srv.URL, "data.policies.consignments.include", nil)
consignments := nodes.NewTable("consignments")
query := managers.NewSelectManager(consignments).
Select(consignments.Col("id")).
Where(consignments.Col("billed_total").Gt(1000)).
Use(plugin)
_, _, err := query.ToSQL(visitors.NewPostgresVisitor())
fmt.Println(errors.Is(err, opa.ErrMaskedColumnUse))
}
Output: true
func (*OPA) RefusesRawSQL ¶ added in v0.2.0
RefusesRawSQL always reports true: a raw SQL fragment (SqlLiteral, StringJoin) may read from a table the policy has not been asked about, so the walker refuses it with an error wrapping plugins.ErrUnpoliceable.
func (*OPA) TransformDelete ¶ added in v0.2.0
func (o *OPA) TransformDelete(*nodes.DeleteStatement) (*nodes.DeleteStatement, error)
TransformDelete refuses the statement: the plugin only polices SELECT.
func (*OPA) TransformInsert ¶ added in v0.2.0
func (o *OPA) TransformInsert(*nodes.InsertStatement) (*nodes.InsertStatement, error)
TransformInsert refuses the statement: the plugin only polices SELECT.
func (*OPA) TransformSelect ¶
func (o *OPA) TransformSelect(core *nodes.SelectCore) (*nodes.SelectCore, error)
TransformSelect applies the transformer to core and every query nested in it via plugins.ApplySelect, so a direct call outside a manager gets the same scope (qualifiers, CTE names, raw SQL refusal) as the plugin walker. The input is not modified.
func (*OPA) TransformSelectScoped ¶ added in v0.2.0
func (o *OPA) TransformSelectScoped(core *nodes.SelectCore, scope plugins.Scope) (*nodes.SelectCore, error)
TransformSelectScoped evaluates the policy for each table referenced in the query (FROM and JOINs) and appends any returned conditions to the WHERE clause. Names that refer to a CTE, and derived-table aliases, are not tables and are skipped. If the policy returns an error for any table, the query is rejected. Conditions for an aliased table (FROM users AS u) are rebound onto the alias, so they filter u rather than another users row.
Policy results are memoised per table for the duration of one walk, so a table referenced in several nested queries is compiled once. In server mode masks are fetched once per walk from the OPA Data API and applied to the projections. In PolicyFunc mode the user-supplied function is called directly (no masking support).
func (*OPA) TransformUpdate ¶ added in v0.2.0
func (o *OPA) TransformUpdate(*nodes.UpdateStatement) (*nodes.UpdateStatement, error)
TransformUpdate refuses the statement: the plugin only polices SELECT.
type Option ¶
type Option func(*OPA)
Option configures an OPA transformer.
func WithColumnResolver ¶
func WithColumnResolver(resolver ColumnResolver) Option
WithColumnResolver sets the column resolver used to expand star projections when column masks are present. Without a resolver, star projections with masks will produce an error.
type PolicyFunc ¶
PolicyFunc evaluates a policy for the given table name and returns conditions to inject into the query's WHERE clause. Returning a non-nil error rejects the query entirely (e.g., "access denied").
type PolicyInfo ¶
type PolicyInfo struct {
PackagePath string // e.g. "policies.filtering.platform.consignment"
RuleName string // e.g. "include"
FullPath string // e.g. "data.policies.filtering.platform.consignment.include"
}
PolicyInfo describes a single queryable policy rule discovered on the OPA server.
type ReplaceAction ¶
type ReplaceAction struct {
Value string `json:"value"`
}
ReplaceAction replaces the column value with a literal string.