隐私保护
Schema 中的 Policy 选项可用于为数据库中的实体查询和变更操作配置隐私策略。

隐私层的主要优势在于,您只需一次性(在 Schema 中)编写隐私策略,该策略便会始终生效。无论代码库中的哪个位置执行查询和变更操作,都会经过隐私层的处理。
本教程将首先介绍框架中使用的基本术语,接着说明如何为项目配置隐私策略功能,最后通过几个示例进行演示。
基本术语
策略
ent.Policy 接口包含两个方法:EvalQuery 和 EvalMutation。前者定义了读取策略,后者定义了写入策略。策略包含零个或多个隐私规则(详见下文)。这些规则按照在 Schema 中声明的顺序进行评估。
如果所有规则均评估完成且未返回错误,则评估成功完成,执行的操作可获得对目标节点的访问权限。

但如果某个已评估的规则返回错误或 privacy.Deny 决策(详见下文),则执行的操作将返回错误并被取消。

隐私规则
每个策略(变更或查询)包含一个或多个隐私规则。这些规则的函数签名如下:
// EvalQuery 定义了读取策略规则。
func(Policy) EvalQuery(context.Context, Query) error
// EvalMutation 定义了写入策略规则。
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
建议将 schema/snapshot 功能标志与 privacy 标志一同添加,以提升开发体验,例如:
//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)
}
}
建议将 schema/snapshot 功能标志与 privacy 标志一同添加,以提升开发体验,例如:
opts := []entc.Option{
- entc.FeatureNames("privacy"),
+ entc.FeatureNames("privacy", "schema/snapshot"),
}
隐私策略注册
请注意,与 Schema Hooks 类似,如果在 Schema 中使用 Policy 选项,则必须在主包中添加以下导入,因为 Schema 包与生成的 ent 包之间可能存在循环导入:
import _ "<project>/ent/runtime"
示例
仅管理员访问
我们从一个简单的示例开始:该应用程序允许任何用户读取任何数据,但仅接受具有管理员角色的用户进行变更操作。为此示例,我们将创建两个额外的包:
rule- 用于存放 Schema 中的不同隐私规则。viewer- 用于获取和设置正在执行操作的用户/查看者。在此简单示例中,用户可以是普通用户或管理员。
启用隐私功能标志并运行代码生成后,我们添加包含两个生成的策略规则的 Policy 方法。
package schema
import (
"entgo.io/ent"
"entgo.io/ent/examples/privacyadmin/ent/privacy"
)
// User 定义了 User 实体的 Schema。
type User struct {
ent.Schema
}
// Policy 定义了 User 的隐私策略。
func (User) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// 除非另有设置,否则拒绝。
privacy.AlwaysDenyRule(),
},
Query: privacy.QueryPolicy{
// 允许任何查看者读取任何内容。
privacy.AlwaysAllowRule(),
},
}
}
我们定义了一个拒绝任何变更但接受任何查询的策略。但如上所述,在此示例中,我们仅接受具有管理员角色的查看者的变更操作。让我们创建两个隐私规则来强制执行此策略:
package rule
import (
"context"
"entgo.io/ent/examples/privacyadmin/ent/privacy"
"entgo.io/ent/examples/privacyadmin/viewer"
)
// DenyIfNoViewer 是一个规则:如果上下文中缺少查看者,则返回拒绝决策。
func DenyIfNoViewer() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
view := viewer.FromContext(ctx)
if view == nil {
return privacy.Denyf("缺少查看者上下文")
}
// 跳至下一个隐私规则(等同于返回 nil)。
return privacy.Skip
})
}
// AllowIfAdmin 是一个规则:如果查看者是管理员,则允许访问。
func AllowIfAdmin() privacy.QueryMutationRule {
return privacy.ContextQueryMutationRule(func(ctx context.Context) error {
view := viewer.FromContext(ctx)
if view.Admin() {
return privacy.Allow
}
// 跳至下一个隐私规则(等同于返回 nil)。
return privacy.Skip
})
}
如您所见,第一个规则 DenyIfNoViewer 确保每个操作在其上下文中都有一个查看者,否则操作将被拒绝。第二个规则 AllowIfAdmin 接受具有管理员角色的查看者的任何操作。让我们将它们添加到 Schema 中,并运行代码生成:
// Policy 定义了 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 {
// 预期操作失败,因为缺少查看者上下文(首次变更规则检查)。
if err := client.User.Create().Exec(ctx); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("预期操作失败,但得到 %w", err)
}
// 使用“管理员”角色执行相同操作。
admin := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.Admin})
if err := client.User.Create().Exec(admin); err != nil {
return fmt.Errorf("预期操作成功,但得到 %w", err)
}
// 使用“仅查看”角色执行相同操作。
viewOnly := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.View})
if err := client.User.Create().Exec(viewOnly); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("预期操作失败,但得到 %w", err)
}
// 允许所有查看者查询用户。
for _, ctx := range []context.Context{ctx, viewOnly, admin} {
// 操作应对所有查看者成功。
count := client.User.Query().CountX(ctx)
fmt.Println(count)
}
return nil
}
决策上下文
有时,我们希望将特定的隐私决策绑定到 context.Context。在这种情况下,可以使用 privacy.DecisionContext 函数创建一个附加了隐私决策的新上下文。
func Do(ctx context.Context, client *ent.Client) error {
// 将隐私决策绑定到上下文(绕过所有其他规则)。
allow := privacy.DecisionContext(ctx, privacy.Allow)
if err := client.User.Create().Exec(allow); err != nil {
return fmt.Errorf("预期操作成功,但得到 %w", err)
}
return nil
}
完整示例可在 GitHub 找到。
多租户
在此示例中,我们将创建一个包含三种实体类型的 Schema:Tenant、User 和 Group。帮助包 viewer 和 rule(如上所述)也在此示例中用于帮助我们构建应用程序。

让我们逐步构建此应用程序。首先创建三个不同的 Schema(完整代码请参见此处),由于我们希望在其中共享一些逻辑,因此创建另一个 混合 Schema 并将其添加到所有其他 Schema 中:
// BaseMixin 用于图中所有 Schema。
type BaseMixin struct {
mixin.Schema
}
// Policy 定义了 BaseMixin 的隐私策略。
func (BaseMixin) Policy() ent.Policy {
return privacy.Policy{
Query: privacy.QueryPolicy{
// 如果缺少“查看者上下文”,拒绝任何查询操作。
rule.DenyIfNoViewer(),
// 允许管理员查询任何信息。
rule.AllowIfAdmin(),
},
Mutation: privacy.MutationPolicy{
// 如果缺少“查看者上下文”,拒绝任何变更操作。
rule.DenyIfNoViewer(),
},
}
}
// Tenant Schema 的 Mixin。
func (Tenant) Mixin() []ent.Mixin {
return []ent.Mixin{
BaseMixin{},
}
}
如第一个示例所述,如果 context.Context 不包含 viewer.Viewer 信息,DenyIfNoViewer 隐私规则将拒绝操作。
与上一个示例类似,我们希望添加一个约束:仅管理员用户可以创建租户(否则拒绝)。我们通过复制上面的 AllowIfAdmin 规则并将其添加到 Tenant Schema 的 Policy 中来实现:
// Policy 定义了 User 的隐私策略。
func (Tenant) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// 对于 Tenant 类型,我们仅允许管理员用户变更租户信息,否则拒绝。
rule.AllowIfAdmin(),
privacy.AlwaysDenyRule(),
},
}
}
然后,我们期望以下代码成功运行:
func Example_CreateTenants(ctx context.Context, client *ent.Client) {
// 如果缺少查看者上下文,预期操作失败。BaseMixin 中定义的首次变更隐私策略规则。
if err := client.Tenant.Create().Exec(ctx); !errors.Is(err, privacy.Deny) {
log.Fatal("预期租户创建失败,但得到:", err)
}
// 如果查看者上下文中的 ent.User 不是管理员用户,预期操作失败。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("预期租户创建失败,但得到:", err)
}
// 操作应成功执行,因为查看者上下文中的用户是管理员用户。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("预期租户创建成功,但得到:", err)
}
fmt.Println(hub)
lab, err := client.Tenant.Create().SetName("GitLab").Save(adminCtx)
if err != nil {
log.Fatal("预期租户创建成功,但得到:", err)
}
fmt.Println(lab)
// Output:
// Tenant(id=1, name=GitHub)
// Tenant(id=2, name=GitLab)
}
我们继续添加数据模型中的其余边(见上图),由于 User 和 Group 都有到 Tenant Schema 的边,我们为此创建一个名为 TenantMixin 的共享 混合 Schema:
// TenantMixin 用于在不同 Schema 中嵌入租户信息。
type TenantMixin struct {
mixin.Schema
}
// 所有嵌入 TenantMixin 的 Schema 的字段。
func (TenantMixin) Fields() []ent.Field {
return []ent.Field{
field.Int("tenant_id").
Immutable(),
}
}
// 所有嵌入 TenantMixin 的 Schema 的边。
func (TenantMixin) Edges() []ent.Edge {
return []ent.Edge{
edge.To("tenant", Tenant.Type).
Field("tenant_id").
Unique().
Required().
Immutable(),
}
}
过滤规则
接下来,我们可能希望强制执行一个规则,限制查看者只能查询与其所属租户相连的组和用户。对于此类用例,Ent 提供了另一种名为 Filter 的隐私规则。我们可以使用 Filter 规则根据查看者的身份过滤实体。与我们之前讨论的规则不同,Filter 规则除了返回隐私决策外,还可以限制查看者可以进行的查询范围。
// FilterTenantRule 是一个查询/变更规则,用于过滤掉不在租户中的实体。
func FilterTenantRule() privacy.QueryMutationRule {
// TenantsFilter 是一个接口,用于包装 WhereHasTenantWith() 谓词,该谓词被 `Group` 和 `User` Schema 使用。
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("查看者中缺少租户信息")
}
tf, ok := f.(TenantsFilter)
if !ok {
return privacy.Denyf("意外的过滤器类型 %T", f)
}
// 确保租户仅读取与其有边连接的实体。
tf.WhereTenantID(entql.IntEQ(tid))
// 跳至下一个隐私规则(等同于返回 nil)。
return privacy.Skip
})
}
创建 FilterTenantRule 隐私规则后,我们将其添加到 TenantMixin 中,以确保所有使用此混合的 Schema 也都具有此隐私规则。
// 所有嵌入 TenantMixin 的 Schema 的策略。
func (TenantMixin) Policy() ent.Policy {
return rule.FilterTenantRule()
}
然后,运行代码生成后,我们期望隐私规则对客户端操作生效。
func Example_TenantView(ctx context.Context, client *ent.Client) {
// 操作应成功执行,因为查看者上下文中的用户是管理员用户。Tenant Schema 中的首次变更隐私策略。
adminC极 := viewer.NewContext(ctx, viewer.UserViewer{R极: viewer.Admin})
hub := client.Tenant.Create().SetName("GitHub").SaveX(adminCtx)
lab := client.Tenant.Create().SetName("GitLab").Save极(adminCtx)
// 创建 2 个特定于租户的查看者上下文。
hubView := viewer.NewContext(ctx, viewer.UserViewer{T: hub})
labView := viewer.NewContext(ctx, viewer.UserViewer{T: lab})
// 在每个租户中创建 2 个用户。
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)
// 如果缺少查看者上下文,查询用户应失败。
if _, err := client.User.Query().Count(ctx); !errors.Is(err, privacy.Deny) {
log.Fatal("预期用户查询失败,但得到:", err)
}
// 确保每个租户只能看到其用户。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))
// 预期管理员用户可以看到所有内容。BaseMixin 中定义的首次查询隐私策略。
fmt.Println(client.User.Query().Count极(adminCtx)) // 4
// 使用特定租户视图的更新操作应仅更新查看者上下文中的租户。
client.User.Update().SetFoods([]string{"pizza"}).SaveX(h极View)
fmt.Println(client.User.Query().AllX(hubView))
fmt.Println(client.User.Query().AllX(labView))
// 使用特定租户视图的删除操作应仅删除查看者上下文中的租户。
client.User.Delete().ExecX(labView)
fmt.Println(
client.User.Query().CountX(hubView), // 2
client.User.Query().CountX(labView), // 0
)
// 使用错误的查看者上下文进行 DeleteOne 操作是无操作的。
client.User.DeleteOne(hubUsers[0]).ExecX(labView)
fmt.Println(client.User.Query().CountX(hubView)) // 2
// 与查询不同,不允许管理员用户变更特定于租户的数据。
if err := client.User.DeleteOne(hubUsers[0]).Exec(adminCtx); !errors.Is(err, privacy.Deny) {
log.Fatal("预期用户删除失败,但得到:", 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极=2, name=bar, foods=[])]
// 2 0
// 2
}
我们以 Group Schema 上的另一个名为 DenyMismatchedTenants 的隐私规则结束示例。DenyMismatchedTenants 规则在创建操作时,如果关联的用户不属于与组相同的租户,则拒绝创建组。
// DenyMismatchedTenants 是一个仅针对创建操作运行的规则,如果操作尝试将用户添加到不在同一租户的组,则返回拒绝决策。
func DenyMismatchedTenants() privacy.MutationRule {
return privacy.GroupMutationRuleFunc(func(ctx context.Context, m *ent.GroupMutation) error {
tid, exists := m.TenantID()
if !exists {
return privacy.Denyf("变更中缺少租户信息")
}
users := m.UsersIDs()
// 如果变更中没有用户,跳过此规则检查。
if len(users) == 0 {
return privacy.Skip
}
// 查询所有附加用户的租户 ID。预期所有用户都与组连接到同一租户。注意,我们使用 privacy.DecisionContext 来跳过上面定义的 FilterTenantRule。
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("查询租户 ID %v", err)
}
if len(ids) != len极sers) {
return privacy.Denyf("附加的用户之一未连接到租户 %v", err)
}
for _, id := range ids {
if id != tid {
return privacy.Denyf("组/用户的租户 ID 不匹配 %d != %d", tid, id)
}
}
// 跳至下一个隐私规则(等同于返回 nil)。
return privacy.Skip
})
}
我们将此规则添加到 Group Schema 并运行代码生成。
// Policy 定义了 Group 的隐私策略。
func (Group) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// 将 DenyMismatchedTenants 仅限制于创建操作。
privacy.OnMutationOperation(
rule.DenyMismatchedTenants(),
ent.OpCreate,
),
},
}
}
再次,我们期望隐私规则对客户端操作生效。
func Example_DenyMismatchedTenants(ctx context.Context, client *ent.Client) {
// 操作应成功执行,因为查看者上下文中的用户是管理员用户。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)
// 创建 2 个特定于租户的查看者上下文。
hubView := viewer.NewContext(ctx, viewer.UserViewer{T: hub})
labView := viewer.NewContext(ctx, viewer.UserViewer{T: lab})
// 在每个租户中创建 2 个用户。
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)
// 预期操作失败,因为 DenyMismatchedTenants 规则确保组和用户连接到同一租户。
if err := client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(labUsers...).Exec(hubView); !errors.Is(err, privacy.Deny) {
log.Fatal("预期操作失败,因为 labUsers 未连接到同一租户")
}
if err := client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(hubUsers[0], labUsers[0]).Exec(hubView); !errors.Is(err, privacy.Deny) {
log.Fatal("预期操作失败,因为 labUsers[0] 未连接到同一租户")
}
// 预期变更成功,因为所有用户都属于与组相同的租户。
entgo := client.Group.Create().SetName("entgo.io").极etTenant(hub).AddUsers(hubUsers...).SaveX(hubView)
fmt.Println(entgo)
// Output:
// [User(id=1, tenant_id=极, 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) {
// 续接上文代码。
// 预期操作失败,因为 FilterTenantRule 规则确保租户只能更新和删除其自己的组。
if err := entgo.Update().SetName("fail.go").Exec(labView); !ent.IsNotFound(err) {
log.Fatal("预期操作失败,因为组 (entgo) 由其他租户 (hub) 管理,但得到:", err)
}
// 如果使用正确的查看者上下文应用操作,操作应成功。
entgo = entgo.Update().SetName("entgo").SaveX(hubView)
fmt.Println(entgo)
// Output:
// Group(id=1, tenant_id=1, name=entgo)
}
完整示例可在 GitHub 找到。
请注意,本文档正在积极开发中。