メインコンテンツへスキップする

プライバシー

スキーマのPolicyオプションでは、データベース内のエンティティのクエリやミューテーションに対するプライバシーポリシーを設定することができます。

gopher-privacy

プライバシー層の主な利点は、プライバシー・ポリシーを一度だけ (スキーマの中で) 書けば、それが常に評価されることです。 コードベースのどこでクエリやミューテーションが実行されても、必ずプライバシー層を通過します。

このチュートリアルでは、フレームワークで使用する基本的な用語の説明から始まり、あなたのプロジェクトにポリシー機能を設定するためのセクションに続いて、最後にいくつかの例を紹介します。

基本用語

ポリシー

ent.Policyインタフェースには、2つのメソッドがあります。EvalQueryEvalMutationです。 1つ目は読み込みポリシーを、2つ目は書き込みポリシーを定義します。 ポリシーには、0個以上のプライバシールールが含まれています (下記参照)。 これらのルールは、スキーマで宣言されているのと同じ順番で評価されます。

すべてのルールがエラーを返さずに評価された場合、評価は正常に終了し、実行された操作はターゲットノードへのアクセスを取得します。

privacy-rules

ただし、評価されたルールの1つがエラーまたはprivacy.Denyの判定 (後述) を返した場合、実行された操作 はエラーを返し、キャンセルされます。

privacy-deny

プライバシールール

各ポリシー (ミューテーションまたはクエリ) には、1つまたは複数のプライバシールールが含まれています。 これらのルールの関数シグネチャは以下のとおりです。

// EvalQueryは、読み込みポリシーのルールを定義する
func(Policy) EvalQuery(context.Context, Query) error

// EvalMutationは、書き込みポリシーのルールを定義する
func(Policy) EvalMutation(context.Context, Mutation) error

プライバシーの決定

プライバシールールの評価制御に役立つ3種類の決定があります。

  • privacy.Allow - プライバシー・ルールから返された場合、評価が停止し (次のルールはスキップされる)、実行された操作 (クエリまたはミューテーション) がターゲット・ノードへのアクセスを取得することになる。

  • privacy.Deny - プライバシールールから返された場合、評価は停止し (次のルールはスキップされます)、実行された操作はキャンセルされる。 これは、任意のエラーを返すことと同等である。

  • privacy.Skip - 現在のルールをスキップして、次のプライバシールールにジャンプする。 これは、 nil エラーを返すことと同等である。

privacy-allow

さて、基本的な用語の説明が終わったところで、早速コードを書いてみましょう。

設定

コード生成時にプライバシーオプションを有効にするには、2つのオプションのうち1つでprivacy機能を有効にします。

1. デフォルトのgo generate configを使用している場合、--feature privacyオプションを以下のようにent/generate.goファイルに追加します。

package ent

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

開発体験を向上させるために、schema/snapshot feature-flagをprivacyと一緒に追加することをお勧めします (例:--feature privacy,schema/snapshot)。

2. GraphQLのドキュメントに記載されている設定を使用している場合は、以下のように機能フラグを追加します。

// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.

// +build ignore

package main


import (
"log"

"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"entgo.io/contrib/entgql"
)

func main() {
opts := []entc.Option{
entc.FeatureNames("privacy"),
}
err := entc.Generate("./schema", &gen.Config{
Templates: entgql.AllTemplates,
}, opts...)
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
スキーマ・フックと同様に、スキーマでPolicyオプションを使用する場合、スキーマ・パッケージと生成されたentパッケージの間で循環的なインポートが可能なため、メイン・パッケージで以下のインポートを追加しなければならないことに気づくはずです。
import _ "<project>/ent/runtime"

使用例

管理者専用

まず、どのユーザーにも任意のデータを読ませることができ、管理者権限を持つユーザーからのみミューテーションを受け付けるアプリケーションの簡単な例を紹介します。 例の目的のために2つの追加パッケージを作成します。

  • rule - スキーマの異なるプライバシールールを保持するためのもの。
  • viewer - 操作を実行しているユーザー/ビューアーを取得/設定するためのもの。 この単純な例では、通常のユーザーでも管理者でも構わない。

コード生成 (プライバシーのための機能フラグ付き) を実行した後、生成された2つのポリシー・ルールを持つPolicyメソッドを追加します。

examples/privacyadmin/ent/schema/user.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/examples/privacyadmin/ent/privacy"
)

// Userは、Userエンティティのスキーマ定義を保持する
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(),
},
}
}

私たちは、あらゆるミューテーションを拒否し、あらゆるクエリを受け入れるポリシーを定義しました。 ただし、前述のように、この例では、管理者ロールを持つビューアーからのみミューテーションを受け付けます。 これを実現するために、2つのプライバシールールを作りましょう。

examples/privacyadmin/rule/rule.go
package rule

import (
"context"

"entgo.io/ent/examples/privacyadmin/ent/privacy"
"entgo.io/ent/examples/privacyadmin/viewer"
)

// DenyIfNoViewer は、コンテキスト内にビューアが存在しない場合に
// Deny の判定を返すルールである
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")
}
// 次のプライバシールールにスキップする (nilを返すのと同じ)
return privacy.Skip
})
}

// AllowIfAdminは、ビューアーが管理者の場合にAllowの判定を返すルールである
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は、すべての操作がそのコンテキスト内にビューアを持っていることを確認し、そうでない場合は操作を拒否します。 2つ目のルールAllowIfAdminは、adminロールを持つビューアからのあらゆる操作を受け入れます。 それらをスキーマに追加して、コード生成を実行してみましょう。

examples/privacyadmin/ent/schema/user.go
// Policyはユーザーのプライバシーポリシーを定義する。
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 {
// viewer-contextがないので、操作が失敗することを
// 期待する (第1のミューテーションルールチェック)
if err := client.User.Create().Exec(ctx); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, but got %w", err)
}
// "Admin"ロールで同じ操作を適用します。
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)
}
// "ViewOnly"ロールで同じ操作を適用します。
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)
}
// すべてのビューアーにユーザーの検索を許可します。
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("expect operation to pass, but got %w", err)
}
return nil
}

完全な例は GitHub にあります。

マルチテナント

この例では、TenantUserGroupの3つのエンティティ・タイプを持つスキーマを作成します。 この例では、 (前述の) ヘルパー・パッケージであるviewerruleも存在し、アプリケーションを構成するのに役立っています。

tenant-example

まずは、このアプリケーションを少しずつ作っていきましょう。 まず3つの異なるスキーマを作成し (完全なコードはこちらをご覧ください)、それらの間でいくつかのロジックを共有したいので、別の混在するスキーマを作成し、それを他のすべてのスキーマに以下のように追加します。

examples/privacytenant/ent/schema/mixin.go
// グラフ内のすべてのスキーマの BaseMixin
type BaseMixin struct {
mixin.Schema
}

// Policyは、BaseMixinのプライバシーポリシーを定義する
func (BaseMixin) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
rule.DenyIfNoViewer(),
},
Query: privacy.QueryPolicy{
rule.DenyIfNoViewer(),
},
}
}
examples/privacytenant/ent/schema/tenant.go
// Mixin of the Tenant schema.
func (Tenant) Mixin() []ent.Mixin {
return []ent.Mixin{
BaseMixin{},
}
}

最初の例で説明したように、DenyIfNoViewerプライバシールールでは、context.Contextviewer.Viewerの情報が含まれていない場合、操作を拒否します。

前述の例と同様に、管理者ユーザーのみがテナントを作成できる (それ以外は拒否する) という制約を追加したいと思います。 これを行うには、上記のAllowIfAdminルールをコピーして、TenantスキーマのPolicyに追加します。

examples/privacytenant/ent/schema/tenant.go
// Policyは、ユーザーのプライバシーポリシーを定義する
func (Tenant) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// テナントタイプでは、管理者ユーザーにのみテナント情報の変更を
// 許可し、それ以外は拒否します。
rule.AllowIfAdmin(),
privacy.AlwaysDenyRule(),
},
}
}

そして、次のコードが正常に実行されることを期待しています。

examples/privacytenant/example_test.go
func Do(ctx context.Context, client *ent.Client) error {
// viewer-contextがないので、操作が失敗することを
// 期待する(第1のミューテーションルールチェック)
if err := client.Tenant.Create().Exec(ctx); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, but got %w", err)
}
// viewer が管理者でなければテナントの作成を拒否します。
viewCtx := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.View})
if err := client.Tenant.Create().Exec(viewCtx); !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, but got %w", err)
}
// "Admin"ロールで同じ操作を適用し、成功することを確かめます。
adminCtx := viewer.NewContext(ctx, viewer.UserViewer{Role: viewer.Admin})
hub, err := client.Tenant.Create().SetName("GitHub").Save(adminCtx)
if err != nil {
return fmt.Errorf("expect operation to pass, but got %w", err)
}
fmt.Println(hub)
lab, err := client.Tenant.Create().SetName("GitLab").Save(adminCtx)
if err != nil {
return fmt.Errorf("expect operation to pass, but got %w", err)
}
fmt.Println(lab)
return nil
}

続けて、データ・モデルに残りのエッジを追加します (上の画像を参照)。UserGroupの両方がTenantスキーマへのエッジを持っているので、このためにTenantMixinという名前の共有の混在するスキーマを作成します。

examples/privacytenant/ent/schema/mixin.go
// TenantMixinは、異なるスキーマにテナント情報を埋め込むためのものです
type TenantMixin struct {
mixin.Schema
}

// TenantMixin を埋め込むすべてのスキーマのエッジ
func (TenantMixin) Edges() []ent.Edge {
return []ent.Edge{
edge.To("tenant", Tenant.Type).
Unique().
Required(),
}
}

次に、ビューアーが所属するテナントに接続されているグループやユーザーのみを照会できるように制限するルールを適用します。 このような場合のために、EntにはFilterというプライバシールールの種類が追加されています。 Filterルールを使用すると、ビューアーのアイデンティティに基づいてエンティティをフィルタリングすることができます。 前に説明したルールとは異なり、Filterルールは、プライバシー判断を返すだけでなく、ビューアーができるクエリの範囲を制限することができます。

注意:プライバシーフィルタリングオプションは、entql機能フラグを使用して有効にする必要があります(上記の説明を参照)

examples/privacytenant/rule/rule.go
// FilterTenantRuleは、テナントに入っていないエンティティをフィルタリングするクエリ/ミューテーションルールです
func FilterTenantRule() privacy.QueryMutationRule {
// TenantsFilterは、`Group`と`User`の両方のスキーマで使用される
// WhereHasTenantWith() 述語をラップするインターフェースです
type TenantsFilter interface {
WhereHasTenantWith(...predicate.Tenant)
}
return privacy.FilterFunc(func(ctx context.Context, f privacy.Filter) error {
view := viewer.FromContext(ctx)
if view.Tenant() == "" {
return privacy.Denyf("missing tenant information in viewer")
}
tf, ok := f.(TenantsFilter)
if !ok {
return privacy.Denyf("unexpected filter type %T", f)
}
// テナントがエッジの効いたエンティティーだけを読むようにする
tf.WhereHasTenantWith(tenant.Name(view.Tenant()))
// 次のプライバシールールにスキップする(nilを返すのと同じ)
return privacy.Skip
})
}

FilterTenantRuleプライバシー・ルールを作成した後、それをTenantMixinに追加して、このmixinを使用するすべてのスキーマがこのプライバシー・ルールも持っていることを確認します。

examples/privacytenant/ent/schema/mixin.go
// TenantMixinを埋め込むすべてのスキーマのポリシー
func (TenantMixin) Policy() ent.Policy {
return privacy.Policy{
Query: privacy.QueryPolicy{
rule.AllowIfAdmin(),
// テナントに接続されていないエンティティをフィルタリングします
// ビューアーが管理者である場合、このポリシールールは上記でスキップされます。
rule.FilterTenantRule(),
},
}
}

次に、コード生成を実行した後、クライアントの操作にプライバシールールが適用されることを期待します。

examples/privacytenant/example_test.go
func Do(ctx context.Context, client *ent.Client) error {
// 上記のコードブロックの続きです

// 上記で作成した2つのテナントに接続する2つのユーザーを作成する
hubUser := client.User.Create().SetName("a8m").SetTenant(hub).SaveX(adminCtx)
labUser := client.User.Create().SetName("nati").SetTenant(lab).SaveX(adminCtx)

hubView := viewer.NewContext(ctx, viewer.UserViewer{T: hub})
out := client.User.Query().OnlyX(hubView)
// "GitHub" のテナントは、そのユーザー(つまりa8m)だけを読み取ることを期待します
if out.ID != hubUser.ID {
return fmt.Errorf("expect result for user query, got %v", out)
}
fmt.Println(out)

labView := viewer.NewContext(ctx, viewer.UserViewer{T: lab})
out = client.User.Query().OnlyX(labView)
// その"GitLab" テナントが、そのユーザー(つまりnati)だけを読むことを期待します。
if out.ID != labUser.ID {
return fmt.Errorf("expect result for user query, got %v", out)
}
fmt.Println(out)
return nil
}

この例では、Groupスキーマに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("missing tenant information in mutation")
}
users := m.UsersIDs()
// ミューテーションにユーザーがいない場合は、このルールチェックをスキップします
if len(users) == 0 {
return privacy.Skip
}
// すべてのユーザーのテナントIDを問い合わせます。 結果は1件で、
// 上記グループのtenant-idと一致しました
id, err := m.Client().User.Query().Where(user.IDIn(users...)).QueryTenant().OnlyID(ctx)
if err != nil {
return privacy.Denyf("querying the tenant-id %v", err)
}
if id != tid {
return privacy.Denyf("mismatch tenant-ids for group/users %d != %d", tid, id)
}
// 次のプライバシールールにスキップする(nilを返すのと同じ)
return privacy.Skip
})
}

このルールをGroup スキーマに追加し、コード生成を実行します。

examples/privacytenant/ent/schema/group.go
// Policyはグループのプライバシーポリシーを定義する
func (Group) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// DenyMismatchedTenantsをCreate操作に
// のみ制限する
privacy.OnMutationOperation(
rule.DenyMismatchedTenants(),
ent.OpCreate,
),
},
}
}

繰り返しになりますが、我々は、プライバシールールがクライアントの操作に適用されることを期待しています。

examples/privacytenant/example_test.go
func Do(ctx context.Context, client *ent.Client) error {
// 上記のコードブロックの続きです

// DenyMismatchedTenantsルールは、グループとユーザーが同じテナントに
// 接続されていることを確認するため、操作が失敗することを期待します。
err = client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(labUser).Exec(adminCtx)
if !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, since user (nati) is not connected to the same tenant")
}
err = client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(labUser, hubUser).Exec(adminCtx)
if !errors.Is(err, privacy.Deny) {
return fmt.Errorf("expect operation to fail, since some users (nati) are not connected to the same tenant")
}
entgo, err := client.Group.Create().SetName("entgo.io").SetTenant(hub).AddUsers(hubUser).Save(adminCtx)
if err != nil {
return fmt.Errorf("expect operation to pass, but got %w", err)
}
fmt.Println(entgo)
return nil
}

場合によっては、テナントに属さないエンティティに対するユーザーの操作を、データベースからこれらのエンティティを読み込むことなく拒否したいことがあります(上記のDenyMismatchedTenantsの例とは異なります) これを実現するには、FilterTenantRuleルールをミューテーションにも使用します。次のように特定の操作に限定することもできます。

examples/privacytenant/ent/schema/group.go
// Policyはグループのプライバシーポリシーを定義する
func (Group) Policy() ent.Policy {
return privacy.Policy{
Mutation: privacy.MutationPolicy{
// DenyMismatchedTenantsをCreate操作に
// のみ制限する
privacy.OnMutationOperation(
rule.DenyMismatchedTenants(),
ent.OpCreate,
),
// FilterTenantRuleをCreate操作に
// のみ制限する
privacy.OnMutationOperation(
rule.FilterTenantRule(),
ent.OpUpdateOne|ent.OpDeleteOne,
),
},
}
}

次に、クライアントの操作にプライバシールールが適用されることを期待します。

examples/privacytenant/example_test.go
func Do(ctx context.Context, client *ent.Client) error {
// 上記のコードブロックの続きです

// FilterTenantRuleルールにより、テナントが自分のグループのみを
// 更新・削除できるようになっているため、操作が失敗することが予想されます。
err = entgo.Update().SetName("fail.go").Exec(labView)
if !ent.IsNotFound(err) {
return fmt.Errorf("expect operation to fail, since the group (entgo) is managed by a different tenant (hub), but got %w", err)
}
entgo, err = entgo.Update().SetName("entgo").Save(hubView)
if err != nil {
return fmt.Errorf("expect operation to pass, but got %w", err)
}
fmt.Println(entgo)
return nil
}

完全な例は GitHub にあります。

このドキュメントは積極的に開発中であることにご注意ください。