snapshot of https://github.com/ent/ent.git (master, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
682
doc/md/privacy.mdx
Normal file
682
doc/md/privacy.mdx
Normal file
@@ -0,0 +1,682 @@
|
||||
---
|
||||
id: privacy
|
||||
title: Privacy
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
The `Policy` option in the schema allows configuring privacy policy for queries and mutations of entities in the database.
|
||||
|
||||

|
||||
|
||||
The main advantage of the privacy layer is that, you write the privacy policy **once** (in the schema), and it is **always**
|
||||
evaluated. No matter where queries and mutations are performed in your codebase, it will always go through the privacy layer.
|
||||
|
||||
In this tutorial, we will start by going over the basic terms we use in the framework, continue with a section for configuring
|
||||
the policy feature to your project, and finish with a few examples.
|
||||
|
||||
## Basic Terms
|
||||
|
||||
### Policy
|
||||
|
||||
The `ent.Policy` interface contains two methods: `EvalQuery` and `EvalMutation`. The first defines the read-policy, and
|
||||
the second defines the write-policy. A policy contains zero or more privacy rules (see below). These rules are evaluated
|
||||
in the same order they are declared in the schema.
|
||||
|
||||
If all rules are evaluated without returning an error, the evaluation finishes successfully, and the executed operation
|
||||
gets access to the target nodes.
|
||||
|
||||

|
||||
|
||||
However, if one of the evaluated rules returns an error or a `privacy.Deny` decision (see below), the executed operation
|
||||
returns an error, and it is cancelled.
|
||||
|
||||

|
||||
|
||||
### Privacy Rules
|
||||
|
||||
Each policy (mutation or query) includes one or more privacy rules. The function signature for these rules is as follows:
|
||||
|
||||
```go
|
||||
// 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 Decisions
|
||||
|
||||
There are three types of decision that can help you control the privacy rules evaluation.
|
||||
|
||||
- `privacy.Allow` - If returned from a privacy rule, the evaluation stops (next rules will be skipped), and the executed
|
||||
operation (query or mutation) gets access to the target nodes.
|
||||
|
||||
- `privacy.Deny` - If returned from a privacy rule, the evaluation stops (next rules will be skipped), and the executed
|
||||
operation is cancelled. This equivalent to returning any error.
|
||||
|
||||
- `privacy.Skip` - Skip the current rule, and jump to the next privacy rule. This equivalent to returning a `nil` error.
|
||||
|
||||

|
||||
|
||||
Now that we’ve covered the basic terms, let’s start writing some code.
|
||||
|
||||
## Configuration
|
||||
|
||||
In order to enable the privacy option in your code generation, enable the `privacy` feature with one of two options:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="cli" label="CLI" default>
|
||||
|
||||
If you are using the default go generate config, add `--feature privacy` option to the `ent/generate.go` file as follows:
|
||||
|
||||
```go title="ent/generate.go"
|
||||
package ent
|
||||
|
||||
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy ./schema
|
||||
```
|
||||
|
||||
It is recommended to add the [`schema/snapshot`](features.md#auto-solve-merge-conflicts) feature-flag along with the
|
||||
`privacy` flag to enhance the development experience, for example:
|
||||
|
||||
```go
|
||||
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature privacy,schema/snapshot ./schema
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="entc" label="Go">
|
||||
|
||||
If you are using the configuration from the GraphQL documentation, add the feature flag as follows:
|
||||
|
||||
```go
|
||||
// +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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
It is recommended to add the [`schema/snapshot`](features.md#auto-solve-merge-conflicts) feature-flag along with the
|
||||
`privacy` flag to enhance the development experience, for example:
|
||||
|
||||
```diff
|
||||
opts := []entc.Option{
|
||||
- entc.FeatureNames("privacy"),
|
||||
+ entc.FeatureNames("privacy", "schema/snapshot"),
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### Privacy Policy Registration
|
||||
|
||||
:::important
|
||||
You should notice that similar to [schema hooks](hooks.md#hooks-registration), if you use the **`Policy`** option in your schema,
|
||||
you **MUST** add the following import in the main package, because a circular import is possible between the schema package,
|
||||
and the generated ent package:
|
||||
|
||||
```go
|
||||
import _ "<project>/ent/runtime"
|
||||
```
|
||||
:::
|
||||
|
||||
## Examples
|
||||
|
||||
### Admin Only
|
||||
|
||||
We start with a simple example of an application that lets any user read any data, and accepts mutations only from users
|
||||
with admin role. We will create 2 additional packages for the purpose of the examples:
|
||||
|
||||
- `rule` - for holding the different privacy rules in our schema.
|
||||
- `viewer` - for getting and setting the user/viewer who's executing the operation. In this simple example, it can be
|
||||
either a normal user or an admin.
|
||||
|
||||
After running the code-generation (with the feature-flag for privacy), we add the `Policy` method with 2 generated policy rules.
|
||||
|
||||
```go title="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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We defined a policy that rejects any mutation and accepts any query. However, as mentioned above, in this example,
|
||||
we accept mutations only from viewers with admin role. Let's create 2 privacy rules to enforce this:
|
||||
|
||||
```go title="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
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
As you can see, the first rule `DenyIfNoViewer`, makes sure every operation has a viewer in its context,
|
||||
otherwise, the operation rejected. The second rule `AllowIfAdmin`, accepts any operation from viewer with
|
||||
admin role. Let's add them to the schema, and run the code-generation:
|
||||
|
||||
```go title="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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since we define the `DenyIfNoViewer` first, it will be executed before all other rules, and accessing the
|
||||
`viewer.Viewer` object is safe in the `AllowIfAdmin` rule.
|
||||
|
||||
After adding the rules above and running the code-generation, we expect the privacy-layer logic to be applied on
|
||||
`ent.Client` operations.
|
||||
|
||||
```go title="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
|
||||
}
|
||||
```
|
||||
|
||||
### Decision Context
|
||||
|
||||
Sometimes, we want to bind a specific privacy decision to the `context.Context`. In cases like this, we
|
||||
can use the `privacy.DecisionContext` function to create a new context with a privacy decision attached to it.
|
||||
|
||||
```go title="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
|
||||
}
|
||||
```
|
||||
|
||||
The full example exists in [GitHub](https://github.com/ent/ent/tree/master/examples/privacyadmin).
|
||||
|
||||
### Multi Tenancy
|
||||
|
||||
In this example, we're going to create a schema with 3 entity types - `Tenant`, `User` and `Group`.
|
||||
The helper packages `viewer` and `rule` (as mentioned above) also exist in this example to help us structure the application.
|
||||
|
||||

|
||||
|
||||
Let's start building this application piece by piece. We begin by creating 3 different schemas (see the full code [here](https://github.com/ent/ent/tree/master/examples/privacytenant/ent/schema)),
|
||||
and since we want to share some logic between them, we create another [mixed-in schema](schema-mixin.md) and add it to all other schemas as follows:
|
||||
|
||||
```go title="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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```go title="examples/privacytenant/ent/schema/tenant.go"
|
||||
// Mixin of the Tenant schema.
|
||||
func (Tenant) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
BaseMixin{},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As explained in the first example, the `DenyIfNoViewer` privacy rule, denies the operation if the `context.Context` does not
|
||||
contain the `viewer.Viewer` information.
|
||||
|
||||
Similar to the previous example, we want to add a constraint that only admin users can create tenants (and deny otherwise).
|
||||
We do it by copying the `AllowIfAdmin` rule from above, and adding it to the `Policy` of the `Tenant` schema:
|
||||
|
||||
```go title="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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, we expect the following code to run successfully:
|
||||
|
||||
```go title="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)
|
||||
}
|
||||
```
|
||||
|
||||
We continue by adding the rest of the edges in our data-model (see image above), and since both `User` and `Group` have
|
||||
an edge to the `Tenant` schema, we create a shared [mixed-in schema](schema-mixin.md) named `TenantMixin` for this:
|
||||
|
||||
```go title="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
|
||||
|
||||
Next, we may want to enforce a rule that will limit viewers to only query groups and users that are connected to the tenant they belong to.
|
||||
For use cases like this, Ent has an additional type of privacy rule named `Filter`.
|
||||
We can use `Filter` rules to filter out entities based on the identity of the viewer.
|
||||
Unlike the rules we previously discussed, `Filter` rules can limit the scope of the queries a viewer can make, in addition to returning privacy decisions.
|
||||
|
||||
:::info Note
|
||||
The privacy filtering option needs to be enabled using the [`entql`](features.md#entql-filtering) feature-flag (see instructions [above](#configuration)).
|
||||
:::
|
||||
|
||||
```go title="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
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
After creating the `FilterTenantRule` privacy rule, we add it to the `TenantMixin` to make sure **all schemas**
|
||||
that use this mixin, will also have this privacy rule.
|
||||
|
||||
```go title="examples/privacytenant/ent/schema/mixin.go"
|
||||
// Policy for all schemas that embed TenantMixin.
|
||||
func (TenantMixin) Policy() ent.Policy {
|
||||
return rule.FilterTenantRule()
|
||||
}
|
||||
```
|
||||
|
||||
Then, after running the code-generation, we expect the privacy-rules to take effect on the client operations.
|
||||
|
||||
```go title="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
|
||||
}
|
||||
```
|
||||
|
||||
We finish our example with another privacy-rule named `DenyMismatchedTenants` on the `Group` schema.
|
||||
The `DenyMismatchedTenants` rule rejects group creation if the associated users do not belong to
|
||||
the same tenant as the group.
|
||||
|
||||
```go title="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
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
We add this rule to the `Group` schema and run code-generation.
|
||||
|
||||
```go title="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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Again, we expect the privacy-rules to take effect on the client operations.
|
||||
|
||||
```go title="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)
|
||||
}
|
||||
```
|
||||
|
||||
In some cases, we want to reject user operations on entities that do not belong to their tenant **without loading
|
||||
these entities from the database** (unlike the `DenyMismatchedTenants` example above).
|
||||
To achieve this, we rely on the `FilterTenantRule` rule to add its filtering on mutations as well, and expect
|
||||
operations to fail with `NotFoundError` in case the `tenant_id` column does not match the one stored in the
|
||||
viewer-context.
|
||||
|
||||
```go title="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)
|
||||
}
|
||||
```
|
||||
|
||||
The full example exists in [GitHub](https://github.com/ent/ent/tree/master/examples/privacytenant).
|
||||
|
||||
Please note that this documentation is under active development.
|
||||
Reference in New Issue
Block a user