Ent Privacy Rules for Row Level Authorization in Go Apps
Ent privacy rules in Go: query filters, mutation checks, a deny by default policy and a system bypass, run on PostgreSQL with each decision printed

Authorization bugs are rarely about cryptography. They are a query without the right WHERE, an endpoint that forgot to check, a background job that reads what a user may not. Ent's privacy layer is built for that class of bug: you attach rules to an entity once, and every read and write that goes through the Ent client is checked, whether it came from a handler written yesterday or a script written next year. This article builds a policy with filter rules and mutation rules on PostgreSQL 18.6, runs ten operations as different viewers, and prints what each is allowed to do.
Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It builds on the Ent tutorial and hooks. The database side of the same idea is row level security, and the GORM version of tenant isolation is compared in GORM multi tenant patterns. Multi tenancy in Ent proper is in the next article.
How a policy works
A policy has two lists of rules, one for queries and one for mutations. Each rule is a function that looks at the context (and the query or mutation) and returns one of three decisions:
| Decision | Meaning |
|---|---|
privacy.Allow | Stop. The operation is permitted. |
privacy.Deny (or Denyf(...)) | Stop. The operation is refused with an error. |
privacy.Skip | No opinion. Evaluate the next rule. |
Rules run in order and the first Allow or Deny decides. If every rule skips, the default is allow, so a policy normally ends with an explicit rule: AlwaysAllowRule() to permit what survived the earlier rules, or AlwaysDenyRule() to refuse everything else. Choose deliberately; the lab's lists end with AlwaysAllowRule() because the earlier rules narrow what the viewer can reach.
Rules can also modify the operation before returning Skip. A query rule can add a predicate (q.Where(...)), and a mutation rule can add a predicate to the mutation. That is how row level filtering works: the rule narrows the query, then lets evaluation continue. The code that issued the query does not change.
The setup
The model is a Doc with a title and an owner_id. Three viewers appear: Ada (id 1), Bob (id 2) and an administrator. The viewer travels in the context.Context, so every call that takes a context carries its identity:
package viewer
import "context"
type Viewer struct {
ID int
Admin bool
}
type key struct{}
func NewContext(ctx context.Context, v *Viewer) context.Context {
return context.WithValue(ctx, key{}, v)
}
func FromContext(ctx context.Context) *Viewer {
v, _ := ctx.Value(key{}).(*Viewer)
return v
}The rules live in their own package. They are small functions, each testable alone:
package rule
import (
"context"
"example.test/lab/ent"
"example.test/lab/ent/doc"
"example.test/lab/ent/privacy"
"example.test/lab/viewer"
)
// DenyIfNoViewer fails closed: a context without a viewer reads and writes nothing.
func DenyIfNoViewer() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
if viewer.FromContext(ctx) == nil {
return privacy.Denyf("missing viewer in the context")
}
return privacy.Skip
})
}
func AllowAdmin() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
if v := viewer.FromContext(ctx); v != nil && v.Admin {
return privacy.Allow
}
return privacy.Skip
})
}
// OwnerOnlyQuery narrows every query to the viewer's own rows and lets evaluation continue.
func OwnerOnlyQuery() privacy.QueryRule {
return privacy.DocQueryRuleFunc(func(ctx context.Context, q *ent.DocQuery) error {
q.Where(doc.OwnerID(viewer.FromContext(ctx).ID))
return privacy.Skip
})
}
// OwnerOnlyMutation forbids creating for someone else and narrows updates and deletes to own rows.
func OwnerOnlyMutation() privacy.MutationRule {
return privacy.DocMutationRuleFunc(func(ctx context.Context, m *ent.DocMutation) error {
v := viewer.FromContext(ctx)
if m.Op().Is(ent.OpCreate) {
if owner, _ := m.OwnerID(); owner != v.ID {
return privacy.Denyf("cannot create a document for user %d", owner)
}
return privacy.Skip
}
m.Where(doc.OwnerID(v.ID))
return privacy.Skip
})
}The schema declares only fields; the policy goes in a second file that is added after the first generation, for a reason explained below:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
type Doc struct{ ent.Schema }
func (Doc) Fields() []ent.Field {
return []ent.Field{field.String("title").NotEmpty(), field.Int("owner_id")}
}package schema
import (
"entgo.io/ent"
"example.test/lab/ent/privacy"
"example.test/lab/rule"
)
func (Doc) Policy() ent.Policy {
return privacy.Policy{
Query: privacy.QueryPolicy{
rule.DenyIfNoViewer(),
rule.AllowAdmin(),
rule.OwnerOnlyQuery(),
privacy.AlwaysAllowRule(),
},
Mutation: privacy.MutationPolicy{
rule.DenyIfNoViewer(),
rule.AllowAdmin(),
rule.OwnerOnlyMutation(),
privacy.AlwaysAllowRule(),
},
}
}The privacy layer is an optional feature, enabled in the generate directive:
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy ./schemapackage main
import (
"database/sql"
"log"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"example.test/lab/ent"
_ "github.com/jackc/pgx/v5/stdlib"
)
const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
// open recreates a schema and returns a client whose connections use it.
func open(schema string, opts ...ent.Option) *ent.Client {
admin, err := sql.Open("pgx", base)
if err != nil {
log.Fatal(err)
}
admin.Exec("drop schema if exists " + schema + " cascade")
admin.Exec("create schema " + schema)
admin.Close()
db, err := sql.Open("pgx", base+"&search_path="+schema)
if err != nil {
log.Fatal(err)
}
drv := entsql.OpenDB(dialect.Postgres, db)
return ent.NewClient(append([]ent.Option{ent.Driver(drv)}, opts...)...)
}
func must(err error) {
if err != nil {
log.SetFlags(0)
log.Fatal(err)
}
}package main
import (
"context"
"fmt"
"example.test/lab/ent"
"example.test/lab/ent/doc"
"example.test/lab/ent/privacy"
_ "example.test/lab/ent/runtime"
"example.test/lab/viewer"
)
func main() {
client := open("l0741")
defer client.Close()
admin := viewer.NewContext(context.Background(), &viewer.Viewer{ID: 99, Admin: true})
ada := viewer.NewContext(context.Background(), &viewer.Viewer{ID: 1})
bob := viewer.NewContext(context.Background(), &viewer.Viewer{ID: 2})
must(client.Schema.Create(admin))
// An administrator seeds one document for each user.
d1 := client.Doc.Create().SetTitle("Ada's plan").SetOwnerID(1).SaveX(admin)
d2 := client.Doc.Create().SetTitle("Bob's notes").SetOwnerID(2).SaveX(admin)
client.Doc.Create().SetTitle("Ada's draft").SetOwnerID(1).SaveX(admin)
fmt.Println("no viewer in the context:", errOf(client.Doc.Query().Count(context.Background())))
fmt.Println("admin sees:", client.Doc.Query().CountX(admin))
fmt.Println("Ada sees:", client.Doc.Query().CountX(ada), "| Bob sees:", client.Doc.Query().CountX(bob))
_, err := client.Doc.Get(ada, d2.ID)
fmt.Println("Ada reads Bob's document:", ent.IsNotFound(err))
titles := client.Doc.Query().Where(doc.TitleContains("notes")).Select(doc.FieldTitle).StringsX(ada)
fmt.Println("Ada searches for 'notes':", titles)
_, err = client.Doc.UpdateOneID(d2.ID).SetTitle("hijacked").Save(ada)
fmt.Println("Ada edits Bob's document:", ent.IsNotFound(err))
n, err := client.Doc.Update().Where(doc.IDEQ(d2.ID)).SetTitle("hijacked").Save(ada)
fmt.Println("Ada bulk edits Bob's document, rows changed:", n, err)
err = client.Doc.DeleteOneID(d2.ID).Exec(ada)
fmt.Println("Ada deletes Bob's document:", ent.IsNotFound(err))
_, err = client.Doc.Create().SetTitle("forged").SetOwnerID(2).Save(ada)
fmt.Println("Ada creates a document for Bob:", errOf(0, err))
_, err = client.Doc.Create().SetTitle("mine").SetOwnerID(1).Save(ada)
fmt.Println("Ada creates her own:", err)
err = client.Doc.DeleteOneID(d1.ID).Exec(ada)
fmt.Println("Ada deletes her own:", err)
// A trusted job states that it skips the rules, explicitly.
system := privacy.DecisionContext(context.Background(), privacy.Allow)
fmt.Println("system job sees:", client.Doc.Query().CountX(system))
}
func errOf(n int, err error) string {
if err == nil {
return "no error"
}
return err.Error()
}set -e
GOFLAGS=-mod=mod go generate ./ent
# The policy imports the generated privacy package, so it is added after the first generation.
mv ent/schema/doc_policy.txt ent/schema/doc_policy.go
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .no viewer in the context: missing viewer in the context: ent/privacy: deny rule
admin sees: 3
Ada sees: 2 | Bob sees: 1
Ada reads Bob's document: true
Ada searches for 'notes': []
Ada edits Bob's document: true
Ada bulk edits Bob's document, rows changed: 0 <nil>
Ada deletes Bob's document: true
Ada creates a document for Bob: cannot create a document for user 2: ent/privacy: deny rule
Ada creates her own: <nil>
Ada deletes her own: <nil>
system job sees: 3The two-step generation
The policy file imports ent/privacy, a package that Ent generates when the feature is on. But Ent's generator loads the schema package to read it, and the schema package cannot compile while the package it imports does not exist. So the first run generates without the policy (the file is named doc_policy.txt and Go ignores it), the script then renames it to doc_policy.go, and the second run generates again with the policy in place. After that, the two stay in step. This is a known property of the privacy and hooks features, and the repository should document it (the codegen workflow article covers the rest of the generation habits).
The rules
DenyIfNoViewer is the first rule in both lists. It returns privacy.Denyf("missing viewer in the context") if the context carries no viewer, and Skip otherwise. It is what makes the policy fail closed: an operation whose author forgot to put identity in the context is refused, not silently run as nobody. The lab calls a query with a plain context.Background() and prints no viewer in the context: missing viewer in the context: ent/privacy: deny rule.
AllowAdmin returns privacy.Allow for an administrator and Skip for everyone else. The administrator seeds the documents and sees all 3 of them: admin sees: 3.
OwnerOnlyQuery appends doc.OwnerID(viewer.ID) to the query and returns Skip. This is the filter. It runs for every query on Doc, including counts, Get, Only and eager loads from another entity. The output: Ada sees: 2 | Bob sees: 1, from the same client.Doc.Query().CountX(ctx) call with different contexts. A search for titles containing notes (Bob's document) as Ada found nothing: Ada searches for 'notes': [].
OwnerOnlyMutation handles writes. For a create it checks the new row's owner_id against the viewer and denies a mismatch; for an update or delete it adds owner_id = viewer to the mutation's predicates.
What the ten operations did
| Operation | Result |
|---|---|
| Query with no viewer | denied, missing viewer in the context |
| Admin counts | 3 |
| Ada counts, Bob counts | 2, 1 |
Ada Gets Bob's document | NotFound |
| Ada searches Bob's title | empty |
Ada UpdateOneID on Bob's document | NotFound |
| Ada bulk updates Bob's document by id | 0 rows changed, no error |
| Ada deletes Bob's document | NotFound |
| Ada creates a document owned by Bob | denied, cannot create a document for user 2 |
| Ada creates her own, deletes her own | succeeds |
System job with DecisionContext(Allow) | sees 3 |
Two results deserve comment. Reading, updating and deleting another user's document all produced NotFound, not a permission error. That is a design choice that follows from filter rules: to the viewer, the row does not exist. It avoids confirming that an id is real, which is a small information leak that "forbidden" would create. If you want a distinct 403, write a rule that loads the row and denies explicitly. Second, the bulk update returned 0 changed rows with no error. The filter made the target set empty, so the operation succeeded with nothing to do. Code that must report "you cannot edit this" has to check the count.
The create case is different: it is denied with an explicit message, because there is no existing row to filter. Rules for creates validate the values being written.
Bypassing, on purpose
Background jobs, migrations and admin tools need to see everything. Ent's answer is explicit: privacy.DecisionContext(ctx, privacy.Allow) returns a context that short circuits the policy. The lab's system job uses it and sees all 3 documents. Two properties make this safe enough:
- It is a visible, greppable call. A code review can ask why each use exists.
- It applies to one context, not the client. Ordinary request handling never gets it unless code hands it over.
Some teams wrap it as system.Context(ctx) in one package and lint for other uses. Keep the places that create such contexts few, and never derive the context from request input.
Designing a rule list
Order is the design. Read each list top to bottom as a sentence: "refuse anyone we cannot identify; let administrators through; narrow everyone else to what they own; allow what is left." Reordering changes meaning. Put AllowAdmin before the owner filter and administrators are not narrowed. Put it after, and the filter has already been added to the administrator's query before Allow ends evaluation, so the administrator sees only their own rows. The lab's order is the one that works, and a unit test over the table above protects it from an innocent refactor.
Three kinds of rule cover most applications:
- Identity rules, which look only at the context: is there a viewer, is the viewer an administrator, does the viewer hold a role. They return
Allow,DenyorSkip, and they are shared across entities asQueryMutationRule. - Filter rules, which narrow a query or mutation to what the viewer may touch: owner, tenant, team membership. They return
Skipafter adding a predicate. - Value rules, which check what is being written: the owner of a new row, a status transition, a field the viewer may not set. They return
Denywith a message orSkip.
Keep each rule to one concern and name it for what it enforces. A policy that reads DenyIfNoViewer, AllowAdmin, OwnerOnly is reviewable at a glance. One rule that does all three in nested conditions is not.
Roles, teams and sharing
Ownership is the simplest model. Real products add shared documents, teams and roles. The pattern extends: a filter rule can add an Or predicate, such as "owned by the viewer, or shared with one of the viewer's teams", using an edge predicate (doc.HasSharesWith(share.UserID(v.ID))). That turns into an EXISTS subquery on every query, so the cost grows with the sharing model. Index the sharing table, and keep the predicate to one or two conditions. For anything more elaborate, such as inherited folder permissions, compute the set of visible ids once and filter on it, or use a dedicated authorization service and keep Ent's policy as the enforcement point that asks it.
For mutations, remember that "can read" and "can write" differ. The lab's owner can do both. A shared reader would pass the query filter and fail a mutation rule that requires ownership or an editor role. Write the two lists separately and test both.
Mistakes to avoid
Putting identity in a package level variable. The viewer must travel in the context. A global breaks the moment two requests run at once, and it cannot be tested.
Using a system context by default. Some codebases give every background job DecisionContext(Allow) and never narrow it. A job that processes one user's data should run as that user.
Forgetting that eager loads are queries. Loading Doc through another entity runs the doc policy. If the rule depends on a viewer that the loading code did not pass, the load fails closed with missing viewer. That is correct, and it surprises people the first time.
Treating NotFound as a bug. A refused read looks exactly like a missing row. Do not log it as an error, and do not retry it.
Leaving a default allow. If every rule skips, the operation is allowed. A policy without a final rule is allow by default, which is wrong for a security control. End with AlwaysDenyRule() when you can list what is allowed explicitly.
Where the policy does not reach
Privacy rules apply to operations through the Ent client. They do not apply to:
- Raw SQL run with the
sql/execqueryfeature or on the underlying*sql.DB. - Other programs writing to the same database.
- Code that holds a system context.
If a guarantee must hold against all of them, enforce it in the database too. PostgreSQL row level security (see the GORM multi tenant article for a run) applies to every connection of a limited role. A common layering: Ent privacy for the application's own correctness and good errors, RLS as the backstop.
Testing a policy
A policy is code with security consequences and deserves the table above as a test. Create documents as an administrator, run each operation as each viewer, and assert both what succeeds and what is refused. Test the negative cases hardest: another user's id, an empty context, a create with a foreign owner, a bulk update. Keep the test's database role the same as the application's, and include a test that a context without a viewer is denied, because that is the line that protects you from a forgotten WithContext.
Also test eager loading. A Doc loaded through another entity's WithDocs() passes through the doc policy, so the owner filter applies there too, which is the property you want and the one that is easiest to break by adding a bypass.
Performance
A privacy rule runs for every operation, so keep rules cheap: read the viewer from the context, compare, add a predicate. A rule that queries the database (loading the user's roles, say) adds a statement to every operation. Load the permissions once per request, put them in the viewer value, and have rules read them from there. Index the columns the filters use (owner_id here), since the filter appears in every query on the entity.
Checklist
- Start both lists with a rule that denies a missing viewer.
- Use filter rules for read isolation and mutation predicates for write isolation.
- End each list with an explicit allow or deny decision.
- Validate create inputs in a rule, since there is no existing row to filter.
- Make bypasses explicit with
DecisionContext, and keep them rare. - Back critical guarantees with database level controls.
- Generate in two steps when the schema imports generated packages.
The feature is documented in the Ent privacy guide, and the database level equivalent in the PostgreSQL row security policies chapter.


