隐私(Privacy)
模式中的 Policy 选项允许为数据库中的实体的查询和突变配置隐私政策。

隐私层的主要优势在于:您只需在模式中编写一次隐私策略,该策略便会持续生效。 无论查询和修改操作在代码库中的何处执行,都将始终经过隐私层的评估。
在本教程中,我们将首先介绍框架中使用的基本术语,接着讲解如何为项目配置策略功能,最后通过几个示例进行总结。
基本术语
策略
ent.Policy 接口包含两个方法:EvalQuery 和 EvalMutation。
第一个方法定义了读的策略,第一个方法定义写的策略。一个策略包括零到多个策略规则(参见下方)。
这些规则以模式中声明的顺序挨个进行评估。
如果所有规则经评估后没有报错,评估就成功完成了,执行的操作就会访问目标节点。

当然如果评估的规则中返回错误或 privacy.Deny 的决策(参见下方),执行的操作返回错误并被取消。

策略规则
每个策略(突变或查询)包含一个或多个隐私规则。这些规则的函数声明如下:
// EvalQuery defines the a read-policy rule.
func(Policy) EvalQuery(context.Context, Query) error
// EvalMutation defines the a write-policy rule.
func(Policy) EvalMutation(context.Context, Mutation) error
隐私决策
有三种决策类型可以帮助你控制隐私规则的评估。
-
privacy.Allow- 如果在隐私规则中返回此类型,评估停止(后续规则将会略过), 执行的操作(查询或突变)访问目标节点。 -
privacy.Deny- 如果在隐私规则中返回此类型,评估停止(后续规则将会略过), 执行的操作取消。评估返回错误。 -
privacy.Skip- 忽略当前的规则, 跳到下一个隐私规则。评估返回nil错误。

我们已经介绍了基本术语,现在就开始编写代码吧。
配置
为在你代码生成中开启隐私选项,以下面两种方式之一启用 privacy 功能:
- CLI
- Go
如果你正在使用默认的 go generate 配置,在 ent/generate.go 文件中添加 --feature privacy 选项:
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy ./schema
推荐在时 privacy 标志时添加 schema/snapshot 功能标志来增强开发体验,例如:
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy,schema/snapshot ./schema
如果你正在使用 GraphQL 文档中的配置,添加功能标志如下:
// +build ignore
package main
import (
"log"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
)
func main() {
opts := []entc.Option{
entc.FeatureNames("privacy"),
}
if err := entc.Generate("./schema", &gen.Config{}, opts...); err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
推荐在时 privacy 标志时添加 schema/snapshot 功能标志来增强开发体验,例如:
opts := []entc.Option{
- entc.FeatureNames("privacy"),
+ entc.FeatureNames("privacy", "schema/snapshot"),
}
隐私策略注册
你应当注意与模式钩子 相同,
如果在模式中使用 Policy 选项,必须 在 main 包中添加下面的导入,
因为在模式包和生成的 ent 包之间可能会有循环导入:
import _ "<project>/ent/runtime"
示例
仅限管理员
我们从一个简单的应用示例开始,该应用允许任何用户读取任何数据,但仅接受具有管理员角色的用户进行数据修改。 为实现示例目的,我们将创建两个额外的包:
rule- 用于在模式中存储不同的隐私规则。viewer- 用于获取和设置正在执行操作的 用户/查看者(user/viewer)。在这个简单示例中,可以是一个普通用户或一个管理员。
在允许完成代码生成后(带有隐私的功能标志),添加带有两个生成策略规则的 Policy 方法。
package schema
import (
"entgo.io/ent"
"entgo.io/ent/examples/privacyadmin/ent/privacy"
)
// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}
// Policy defines the privacy policy of the User.
func (User) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// Deny if not set otherwise.
privacy.AlwaysDenyRule(),
},
Query: privacy.QueryPolicy{
// Allow any viewer to read anything.
privacy.AlwaysAllowRule(),
},
}
}
我们定义了一项策略,该策略拒绝任何修改请求,但接受任何查询请求。 然而,如上所述,在此示例中,我们仅接受具有管理员角色的查看者提交的修改请求。 让我们创建两条隐私规则来强制执行此策略:
package rule
import (
"context"
"entgo.io/ent/examples/privacyadmin/ent/privacy"
"entgo.io/ent/examples/privacyadmin/viewer"
)
// DenyIfNoViewer is a rule that returns Deny decision if the viewer is
// missing in the context.
func DenyIfNoViewer() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
view := viewer.FromContext(ctx)
if view == nil {
return privacy.Denyf("viewer-context is missing")
}
// Skip to the next privacy rule (equivalent to returning nil).
return privacy.Skip
})
}
// AllowIfAdmin is a rule that returns Allow decision if the viewer is admin.
func AllowIfAdmin() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
view := viewer.FromContext(ctx)
if view.Admin() {
return privacy.Allow
}
// Skip to the next privacy rule (equivalent to returning nil).
return privacy.Skip
})
}
如你所见,第一条规则 DenyIfNoViewer 确保每个操作在其上下文中都存在查看者,否则操作将被拒绝。
第二条规则 AllowIfAdmin 允许具有管理员角色的查看者执行任何操作。
现在我们将它们添加到模式中,并运行代码生成:
// Policy defines the privacy policy of the User.
func (User) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
rule.DenyIfNoViewer(),
rule.AllowIfAdmin(),
privacy.AlwaysDenyRule(),
},
Query: privacy.QueryPolicy{
privacy.AlwaysAllowRule(),
},
}
}
因为我们首先定义了 DenyIfNoViewer,它会在其他规则之前执行,在 AllowIfAdmin 规则中访问 viewer.Viewer 对象是安全的。
在添加上述规则并允许代码生成之后,我们期望隐私层逻辑应用于 ent.Client 操作。
func Do(ctx context.Context, client *ent.Client) error {
// Expect operation to fail, because viewer-context
// is missing (first mutation rule check).
if err := client.User.Create().Exec(ctx); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, but got %w", err)
}
// Apply the same operation with "Admin" role.
admin := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.Admin})
if err := client.User.Create().Exec(admin); err != nil {
return fmt.Errorf("expect operation to pass, but got %w", err)
}
// Apply the same operation with "ViewOnly" role.
viewOnly := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.View})
if err := client.User.Create().Exec(viewOnly); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, but got %w", err)
}
// Allow all viewers to query users.
for _, ctx := range []context.Context{ctx, viewOnly, admin} {
// Operation should pass for all viewers.
count := client.User.Query().CountX(ctx)
fmt.Println(count)
}
return nil
}
决策上下文
有时我们希望绑定特定的隐私决策到 context.Context。这种情况下,我们可以使用 privacy.DecisionContext 函数来创建新的带有隐私决策的上下文。
func Do(ctx context.Context, client *ent.Client) error {
// Bind a privacy decision to the context (bypass all other rules).
allow := privacy.DecisionContext(ctx, privacy.Allow)
if err := client.User.Create().Exec(allow); err != nil {
return fmt.Errorf("expect operation to pass, but got %w", err)
}
return nil
}
完整示例请查看 GitHub。
多租户
在这个示例中,我们将会创建具有三个实体类型的模式 —— Tenant, User 和 Group。
辅助包 viewer 和 rule(上面提到过)也存在于在本示例中来帮助我们结构化应用程序。

让我们开始逐步构建这个应用程序。首先创建三个不同的模式(完整代码请参见此处), 由于需要在它们之间共享部分逻辑,我们创建另一个混合模式并将其添加到所有其他模式中,具体如下:
// BaseMixin for all schemas in the graph.
type BaseMixin struct {
mixin.Schema
}
// Policy defines the privacy policy of the BaseMixin.
func (BaseMixin) Policy() ent.Policy {
return privacy.Policy{
Query: privacy.QueryPolicy{
// Deny any query operation in case
// there is no "viewer context".
rule.DenyIfNoViewer(),
// Allow admins to query any information.
rule.AllowIfAdmin(),
},
Mutation: privacy.MutationPolicy{
// Deny any mutation operation in case
// there is no "viewer context".
rule.DenyIfNoViewer(),
},
}
}
// Mixin of the Tenant schema.
func (Tenant) Mixin() []ent.Mixin {
return []ent.Mixin{
BaseMixin{},
}
}
如第一个示例所示,DenyIfNoViewer 隐私规则会拒绝在 context.Context 中不包含 viewer.Viewer 信息的操作。
与前例类似,我们需要添加一个约束条件:仅允许管理员用户创建租户(否则拒绝)。
具体操作是复制上文的 AllowIfAdmin 规则,并将其添加到 Tenant 模式的 Policy中:
// Policy defines the privacy policy of the User.
func (Tenant) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// For Tenant type, we only allow admin users to mutate
// the tenant information and deny otherwise.
rule.AllowIfAdmin(),
privacy.AlwaysDenyRule(),
},
}
}
然后我们期望以下代码能够成功运行:
func Example_CreateTenants(ctx context.Context, client *ent.Client) {
// Expect operation to fail in case viewer-context is missing.
// First mutation privacy policy rule defined in BaseMixin.
if err := client.Tenant.Create().Exec(ctx); !errors.Is(err, privacy.Deny) {
log.Fatal("expect tenant creation to fail, but got:", err)
}
// Expect operation to fail in case the ent.User in the viewer-context
// is not an admin user. Privacy policy defined in the Tenant schema.
viewCtx := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.View})
if err := client.Tenant.Create().Exec(viewCtx); !errors.Is(err, privacy.Deny) {
log.Fatal("expect tenant creation to fail, but got:", err)
}
// Operations should pass successfully as the user in the viewer-context
// is an admin user. First mutation privacy policy in Tenant schema.
adminCtx := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.Admin})
hub, err := client.Tenant.Create().SetName("GitHub").Save(adminCtx)
if err != nil {
log.Fatal("expect tenant creation to pass, but got:", err)
}
fmt.Println(hub)
lab, err := client.Tenant.Create().SetName("GitLab").Save(adminCtx)
if err != nil {
log.Fatal("expect tenant creation to pass, but got:", err)
}
fmt.Println(lab)
// Output:
// Tenant(id=1, name=GitHub)
// Tenant(id=2, name=GitLab)
}
我们继续在数据模型中(参见上图)添加其余的边,由于 User 和 Group 都包含到 Tenant 模式的边,
我们为此创建一个名为 TenantMixin 的共享的 混合模式:
// TenantMixin for embedding the tenant info in different schemas.
type TenantMixin struct {
mixin.Schema
}
// Fields for all schemas that embed TenantMixin.
func (TenantMixin) Fields() []ent.Field {
return []ent.Field{
field.Int("tenant_id").
Immutable(),
}
}
// Edges for all schemas that embed TenantMixin.
func (TenantMixin) Edges() []ent.Edge {
return []ent.Edge{
edge.To("tenant", Tenant.Type).
Field("tenant_id").
Unique().
Required().
Immutable(),
}
}
过滤器规则 Filter Rules
接下来,我们想要强制实行一条规则,限制查看者仅能查询与其所属租户相关的组和用户。
对于此类用例,Ent 提供了一种名为 Filter 的额外隐私规则类型。
我们可以利用 Filter 规则根据查看者的身份筛选实体。
与之前讨论的规则不同,Filter 规则能限制查看者可执行的查询范围,也能返回隐私决策。
// FilterTenantRule is a query/mutation rule that filters out entities that are not in the tenant.
func FilterTenantRule() privacy.QueryMutationRule {
// TenantsFilter is an interface to wrap WhereHasTenantWith()
// predicate that is used by both `Group` and `User` schemas.
type TenantsFilter interface {
WhereTenantID(entql.IntP)
}
return privacy.FilterFunc(func(ctx context.Context, f privacy.Filter) error {
view := viewer.FromContext(ctx)
tid, ok := view.Tenant()
if !ok {
return privacy.Denyf("missing tenant information in viewer")
}
tf, ok := f.(TenantsFilter)
if !ok {
return privacy.Denyf("unexpected filter type %T", f)
}
// Make sure that a tenant reads only entities that have an edge to it.
tf.WhereTenantID(entql.IntEQ(tid))
// Skip to the next privacy rule (equivalent to return nil).
return privacy.Skip
})
}
创建完 FilterTenantRule 隐私规则后,我们将它添加到 TenantMixin 以确保使用这个混合类的 所有模式 都具有这个隐私规则。
// Policy for all schemas that embed TenantMixin.
func (TenantMixin) Policy() ent.Policy {
return rule.FilterTenantRule()
}
然后,在允许代码生成后,我们期望隐私规则在客户端操作中发挥作用。
func Example_TenantView(ctx context.Context, client *ent.Client) {
// Operations should pass successfully as the user in the viewer-context
// is an admin user. First mutation privacy policy in Tenant schema.
adminCtx := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.Admin})
hub := client.Tenant.Create().SetName("GitHub").SaveX(adminCtx)
lab := client.Tenant.Create().SetName("GitLab").SaveX(adminCtx)
// Create 2 tenant-specific viewer contexts.
hubView := viewer.NewContext(ctx, viewer.UserViewer{T: hub})
labView := viewer.NewContext(ctx, viewer.UserViewer{T: lab})
// Create 2 users in each tenant.
hubUsers := client.User.CreateBulk(
client.User.Create().SetName("a8m").SetTenant(hub),
client.User.Create().SetName("nati").SetTenant(hub),
).SaveX(hubView)
fmt.Println(hubUsers)
labUsers := client.User.CreateBulk(
client.User.Create().SetName("foo").SetTenant(lab),
client.User.Create().SetName("bar").SetTenant(lab),
).SaveX(labView)
fmt.Println(labUsers)
// Query users should fail in case viewer-context is missing.
if _, err := client.User.Query().Count(ctx); !errors.Is(err, privacy.Deny) {
log.Fatal("expect user query to fail, but got:", err)
}
// Ensure each tenant can see only its users.
// First and only rule in TenantMixin.
fmt.Println(client.User.Query().Select(user.FieldName).StringsX(hubView))
fmt.Println(client.User.Query().CountX(hubView))
fmt.Println(client.User.Query().Select(user.FieldName).StringsX(labView))
fmt.Println(client.User.Query().CountX(labView))
// Expect admin users to see everything. First
// query privacy policy defined in BaseMixin.
fmt.Println(client.User.Query().CountX(adminCtx)) // 4
// Update operation with specific tenant-view should update
// only the tenant in the viewer-context.
client.User.Update().SetFoods([]string{"pizza"}).SaveX(hubView)
fmt.Println(client.User.Query().AllX(hubView))
fmt.Println(client.User.Query().AllX(labView))
// Delete operation with specific tenant-view should delete
// only the tenant in the viewer-context.
client.User.Delete().ExecX(labView)
fmt.Println(
client.User.Query().CountX(hubView), // 2
client.User.Query().CountX(labView), // 0
)
// DeleteOne with wrong viewer-context is nop.
client.User.DeleteOne(hubUsers[0]).ExecX(labView)
fmt.Println(client.User.Query().CountX(hubView)) // 2
// Unlike queries, admin users are not allowed to mutate tenant specific data.
if err := client.User.DeleteOne(hubUsers[0]).Exec(adminCtx); !errors.Is(err, privacy.Deny) {
log.Fatal("expect user deletion to fail, but got:", err)
}
// Output:
// [User(id=1, tenant_id=1, name=a8m, foods=[]) User(id=2, tenant_id=1, name=nati, foods=[])]
// [User(id=3, tenant_id=2, name=foo, foods=[]) User(id=4, tenant_id=2, name=bar, foods=[])]
// [a8m nati]
// 2
// [foo bar]
// 2
// 4
// [User(id=1, tenant_id=1, name=a8m, foods=[pizza]) User(id=2, tenant_id=1, name=nati, foods=[pizza])]
// [User(id=3, tenant_id=2, name=foo, foods=[]) User(id=4, tenant_id=2, name=bar, foods=[])]
// 2 0
// 2
}
我们通过另外一个在 Group 模式上名为 DenyMismatchedTenants 的隐私规则完成我们的示例。
DenyMismatchedTenants 规则会在关联用户不属于与该组相同的租户时拒绝创建组。
// DenyMismatchedTenants is a rule that runs only on create operations and returns a deny
// decision if the operation tries to add users to groups that are not in the same tenant.
func DenyMismatchedTenants() privacy.MutationRule {
return privacy.GroupMutationRuleFunc(func(ctx context.Context, m *ent.GroupMutation) error {
tid, exists := m.TenantID()
if !exists {
return privacy.Denyf("missing tenant information in mutation")
}
users := m.UsersIDs()
// If there are no users in the mutation, skip this rule-check.
if len(users) == 0 {
return privacy.Skip
}
// Query the tenant-ids of all attached users. Expect all users to be connected to the same tenant
// as the group. Note, we use privacy.DecisionContext to skip the FilterTenantRule defined above.
ids, err := m.Client().User.Query().Where(user.IDIn(users...)).Select(user.FieldTenantID).Ints(privacy.DecisionContext(ctx, privacy.Allow))
if err != nil {
return privacy.Denyf("querying the tenant-ids %v", err)
}
if len(ids) != len(users) {
return privacy.Denyf("one the attached users is not connected to a tenant %v", err)
}
for _, id := range ids {
if id != tid {
return privacy.Denyf("mismatch tenant-ids for group/users %d != %d", tid, id)
}
}
// Skip to the next privacy rule (equivalent to return nil).
return privacy.Skip
})
}
我们向 Group 添加这个规则并运行代码生成。
// Policy defines the privacy policy of the Group.
func (Group) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// Limit DenyMismatchedTenants only for
// Create operation
privacy.OnMutationOperation(
rule.DenyMismatchedTenants(),
ent.OpCreate,
),
},
}
}
再一次,我们期望隐私规则在客户端操作中起作用。
func Example_DenyMismatchedTenants(ctx context.Context, client *ent.Client) {
// Operation should pass successfully as the user in the viewer-context
// is an admin user. First mutation privacy policy in Tenant schema.
adminCtx := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.Admin})
hub := client.Tenant.Create().SetName("GitHub").SaveX(adminCtx)
lab := client.Tenant.Create().SetName("GitLab").SaveX(adminCtx)
// Create 2 tenant-specific viewer contexts.
hubView := viewer.NewContext(ctx, viewer.UserViewer{T: hub})
labView := viewer.NewContext(ctx, viewer.UserViewer{T: lab})
// Create 2 users in each tenant.
hubUsers := client.User.CreateBulk(
client.User.Create().SetName("a8m").SetTenant(hub),
client.User.Create().SetName("nati").SetTenant(hub),
).SaveX(hubView)
fmt.Println(hubUsers)
labUsers := client.User.CreateBulk(
client.User.Create().SetName("foo").SetTenant(lab),
client.User.Create().SetName("bar").SetTenant(lab),
).SaveX(labView)
fmt.Println(labUsers)
// Expect operation to fail as the DenyMismatchedTenants rule makes
// sure the group and the users are connected to the same tenant.
if err := client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(labUsers...).Exec(hubView); !errors.Is(err, privacy.Deny) {
log.Fatal("expect operation to fail, since labUsers are not connected to the same tenant")
}
if err := client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(hubUsers[0], labUsers[0]).Exec(hubView); !errors.Is(err, privacy.Deny) {
log.Fatal("expect operation to fail, since labUsers[0] is not connected to the same tenant")
}
// Expect mutation to pass as all users belong to the same tenant as the group.
entgo := client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(hubUsers...).SaveX(hubView)
fmt.Println(entgo)
// Output:
// [User(id=1, tenant_id=1, name=a8m, foods=[]) User(id=2, tenant_id=1, name=nati, foods=[])]
// [User(id=3, tenant_id=2, name=foo, foods=[]) User(id=4, tenant_id=2, name=bar, foods=[])]
// Group(id=1, tenant_id=1, name=entgo.io)
}
在某些情况下,我们希望拒绝用户对其租户不属于的实体进行操作,同时 避免从数据库加载这些实体(与上述 DenyMismatchedTenants 示例不同)。
为此,我们借助 FilterTenantRule 规则在突变操作中添加过滤条件,当 tenant_id 列与查看器上下文中存储的值不匹配时,操作将抛出 NotFoundError 异常失败。
func Example_DenyMismatchedView(ctx context.Context, client *ent.Client) {
// Continuation of the code above.
// Expect operation to fail, because the FilterTenantRule rule makes sure
// that tenants can update and delete only their groups.
if err := entgo.Update().SetName("fail.go").Exec(labView); !ent.IsNotFound(err) {
log.Fatal("expect operation to fail, since the group (entgo) is managed by a different tenant (hub), but got:", err)
}
// Operation should pass in case it was applied with the right viewer-context.
entgo = entgo.Update().SetName("entgo").SaveX(hubView)
fmt.Println(entgo)
// Output:
// Group(id=1, tenant_id=1, name=entgo)
}
完整示例请查看 GitHub。
请注意,本文档正在积极开发中。