跳到主要内容

隐私保护

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

gopher-privacy

隐私层的主要优势在于,您只需一次性(在 Schema 中)编写隐私策略,该策略便会始终生效。无论代码库中的哪个位置执行查询和变更操作,都会经过隐私层的处理。

本教程将首先介绍框架中使用的基本术语,接着说明如何为项目配置隐私策略功能,最后通过几个示例进行演示。

基本术语

策略

ent.Policy 接口包含两个方法:EvalQueryEvalMutation。前者定义了读取策略,后者定义了写入策略。策略包含零个或多个隐私规则(详见下文)。这些规则按照在 Schema 中声明的顺序进行评估。

如果所有规则均评估完成且未返回错误,则评估成功完成,执行的操作可获得对目标节点的访问权限。

privacy-rules

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

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-allow

了解基本术语后,让我们开始编写一些代码。

配置

要在代码生成中启用隐私选项,可通过以下两种方式之一启用 privacy 功能:

如果使用默认的 go generate 配置,请在 ent/generate.go 文件中添加 --feature privacy 选项:

ent/generate.go
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

隐私策略注册

信息

请注意,与 Schema Hooks 类似,如果在 Schema 中使用 Policy 选项,则必须在主包中添加以下导入,因为 Schema 包与生成的 ent 包之间可能存在循环导入:

import _ "<project>/ent/runtime"

示例

仅管理员访问

我们从一个简单的示例开始:该应用程序允许任何用户读取任何数据,但仅接受具有管理员角色的用户进行变更操作。为此示例,我们将创建两个额外的包:

  • rule - 用于存放 Schema 中的不同隐私规则。
  • viewer - 用于获取和设置正在执行操作的用户/查看者。在此简单示例中,用户可以是普通用户或管理员。

启用隐私功能标志并运行代码生成后,我们添加包含两个生成的策略规则的 Policy 方法。

examples/privacyadmin/ent/schema/user.go
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(),
},
}
}

我们定义了一个拒绝任何变更但接受任何查询的策略。但如上所述,在此示例中,我们仅接受具有管理员角色的查看者的变更操作。让我们创建两个隐私规则来强制执行此策略:

examples/privacyadmin/rule/rule.go
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 中,并运行代码生成:

examples/privacyadmin/ent/schema/user.go
// 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 操作。

examples/privacyadmin/example_test.go
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 函数创建一个附加了隐私决策的新上下文。

examples/privacyadmin/example_test.go
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:TenantUserGroup。帮助包 viewerrule(如上所述)也在此示例中用于帮助我们构建应用程序。

tenant-example

让我们逐步构建此应用程序。首先创建三个不同的 Schema(完整代码请参见此处),由于我们希望在其中共享一些逻辑,因此创建另一个 混合 Schema 并将其添加到所有其他 Schema 中:

examples/privacytenant/ent/schema/mixin.go
// 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(),
},
}
}
examples/privacytenant/ent/schema/tenant.go
// Tenant Schema 的 Mixin。
func (Tenant) Mixin() []ent.Mixin {
return []ent.Mixin{
BaseMixin{},
}
}

如第一个示例所述,如果 context.Context 不包含 viewer.Viewer 信息,DenyIfNoViewer 隐私规则将拒绝操作。

与上一个示例类似,我们希望添加一个约束:仅管理员用户可以创建租户(否则拒绝)。我们通过复制上面的 AllowIfAdmin 规则并将其添加到 Tenant Schema 的 Policy 中来实现:

examples/privacytenant/ent/schema/tenant.go
// Policy 定义了 User 的隐私策略。
func (Tenant) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// 对于 Tenant 类型,我们仅允许管理员用户变更租户信息,否则拒绝。
rule.AllowIfAdmin(),
privacy.AlwaysDenyRule(),
},
}
}

然后,我们期望以下代码成功运行:

examples/privacytenant/example_test.go

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)
}

我们继续添加数据模型中的其余边(见上图),由于 UserGroup 都有到 Tenant Schema 的边,我们为此创建一个名为 TenantMixin 的共享 混合 Schema

examples/privacytenant/ent/schema/mixin.go
// 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 规则除了返回隐私决策外,还可以限制查看者可以进行的查询范围。

注意

隐私过滤选项需要使用 entql 功能标志启用(请参见上文的说明)。

examples/privacytenant/rule/rule.go
// 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 也都具有此隐私规则。

examples/privacytenant/ent/schema/mixin.go
// 所有嵌入 TenantMixin 的 Schema 的策略。
func (TenantMixin) Policy() ent.Policy {
return rule.FilterTenantRule()
}

然后,运行代码生成后,我们期望隐私规则对客户端操作生效。

examples/privacytenant/example_test.go

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 规则在创建操作时,如果关联的用户不属于与组相同的租户,则拒绝创建组。

examples/privacytenant/rule/rule.go
// 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 并运行代码生成。

examples/privacytenant/ent/schema/group.go
// Policy 定义了 Group 的隐私策略。
func (Group) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// 将 DenyMismatchedTenants 仅限制于创建操作。
privacy.OnMutationOperation(
rule.DenyMismatchedTenants(),
ent.OpCreate,
),
},
}
}

再次,我们期望隐私规则对客户端操作生效。

examples/privacytenant/example_test.go
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 而失败。

examples/privacytenant/example_test.go
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 找到。

请注意,本文档正在积极开发中。