Skip to content

Writing a policy

The policy receives what a statement does and returns allow. It is written in Rego and evaluated by an OPA engine embedded in curral, so there is no sidecar.

package curral
import rego.v1
default allow := false
allow if "admin" in input.roles
allow if {
"analyst" in input.roles
input.resolved
input.statement_type == "SELECT"
every t in input.tables { startswith(t, "sales.") }
}
Terminal window
curral serve ... --policy policy.rego
  • Always start from default allow := false.
  • Require input.resolved for anyone who is not an admin. It is false whenever the inspection could not fully determine what a statement reads or writes.
  • Table names are fully qualified: catalog.schema.table.

Every field the policy receives is listed in Policy input.

--policy is repeatable and accepts JSON or YAML as well as .rego. Data files are available as data.*, so grants can live in a file that is easier to edit than the rules:

roles.json
{
"allowed_functions": ["range", "generate_series", "unnest"],
"roles": {
"analyst": { "read": ["sales", "logs"], "write": [], "deny_tables": ["sales.main.salaries"] },
"etl": { "read": ["logs"], "write": ["sales"], "deny_tables": [] }
}
}
Terminal window
curral serve ... --policy policy.rego --policy roles.json

examples/policy.rego is a complete role-based model over that data: admin runs anything, readers run SELECT/EXPLAIN on readable databases, writers run INSERT/UPDATE/DELETE on writable ones, and DDL stays with admins.

Functions such as read_parquet or read_csv read data without going through a table, so they bypass table grants. They arrive in input.functions; allow them explicitly:

every f in input.functions { f in data.allowed_functions }

dry_run runs inspection and policy without executing, and returns what the policy saw and decided:

Terminal window
curl -u analyst:analyst-pw localhost:8080/v1/query \
-d '{"sql":"DELETE FROM orders WHERE id = 1","dry_run":true}'
{"dry_run":true,"decision":"deny","decided_by":"policy","statement_type":"DELETE",
"database":"sales","tables":["sales.main.orders"],"targets":["sales.main.orders"],
"functions":[],"databases":["sales"],"resolved":true,"policy_sha256":"..."}

decided_by: engine means the statement is one curral always denies, such as ATTACH or INSTALL.

The same policy can return more than allow. Each rule is turned on by a flag:

Rule Flag
data.curral.limits --policy-limits-query timeout, row limit and concurrency per request; see Limits
data.curral.masks --policy-masks-query column masks; see Column masking

Row filters live in their own file; see Row-level security.

Edit the files and send SIGHUP. An invalid policy is rejected and the previous one stays in effect. The hash of the policy in effect is in every audit event (policy_sha256). See Reloading configuration.