跳到主要内容

隐私(Privacy)

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

gopher-privacy

隐私层的主要优势在于:您只需在模式中编写一次隐私策略,该策略便会持续生效。 无论查询和修改操作在代码库中的何处执行,都将始终经过隐私层的评估。

在本教程中,我们将首先介绍框架中使用的基本术语,接着讲解如何为项目配置策略功能,最后通过几个示例进行总结。

基本术语

策略

ent.Policy 接口包含两个方法:EvalQueryEvalMutation。 第一个方法定义了读的策略,第一个方法定义写的策略。一个策略包括零到多个策略规则(参见下方)。 这些规则以模式中声明的顺序挨个进行评估。

如果所有规则经评估后没有报错,评估就成功完成了,执行的操作就会访问目标节点。

privacy-rules

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

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

推荐在时 privacy 标志时添加 schema/snapshot 功能标志来增强开发体验,例如:

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy,schema/snapshot ./schema

隐私策略注册

重要

你应当注意与模式钩子 相同, 如果在模式中使用 Policy 选项,必须 在 main 包中添加下面的导入, 因为在模式包和生成的 ent 包之间可能会有循环导入:

import _ "<project>/ent/runtime"

示例

仅限管理员

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

  • rule - 用于在模式中存储不同的隐私规则。
  • viewer - 用于获取和设置正在执行操作的 用户/查看者(user/viewer)。在这个简单示例中,可以是一个普通用户或一个管理员。

在允许完成代码生成后(带有隐私的功能标志),添加带有两个生成策略规则的 Policy 方法。

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

我们定义了一项策略,该策略拒绝任何修改请求,但接受任何查询请求。 然而,如上所述,在此示例中,我们仅接受具有管理员角色的查看者提交的修改请求。 让我们创建两条隐私规则来强制执行此策略:

examples/privacyadmin/rule/rule.go
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 允许具有管理员角色的查看者执行任何操作。 现在我们将它们添加到模式中,并运行代码生成:

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

examples/privacyadmin/example_test.go
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 函数来创建新的带有隐私决策的上下文。

examples/privacyadmin/example_test.go
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, UserGroup。 辅助包 viewerrule(上面提到过)也存在于在本示例中来帮助我们结构化应用程序。

tenant-example

让我们开始逐步构建这个应用程序。首先创建三个不同的模式(完整代码请参见此处), 由于需要在它们之间共享部分逻辑,我们创建另一个混合模式并将其添加到所有其他模式中,具体如下:

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

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

与前例类似,我们需要添加一个约束条件:仅允许管理员用户创建租户(否则拒绝)。 具体操作是复制上文的 AllowIfAdmin 规则,并将其添加到 Tenant 模式的 Policy中:

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

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

examples/privacytenant/example_test.go

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

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

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

注意

隐私过滤器选项需要使用 entql 功能标志(查看 上述 介绍)开启。

examples/privacytenant/rule/rule.go
// 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 以确保使用这个混合类的 所有模式 都具有这个隐私规则。

examples/privacytenant/ent/schema/mixin.go
// Policy for all schemas that embed TenantMixin.
func (TenantMixin) Policy() ent.Policy {
return rule.FilterTenantRule()
}

然后,在允许代码生成后,我们期望隐私规则在客户端操作中发挥作用。

examples/privacytenant/example_test.go

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

examples/privacytenant/rule/rule.go
// 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 添加这个规则并运行代码生成。

examples/privacytenant/ent/schema/group.go
// 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,
),
},
}
}

再一次,我们期望隐私规则在客户端操作中起作用。

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

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

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