Saltar al contenido principal

Privacidad

[Traducción Beta No Oficial]

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

La opción Policy en el esquema permite configurar políticas de privacidad para consultas y mutaciones de entidades en la base de datos.

gopher-privacidad

La principal ventaja de la capa de privacidad es que escribes la política una vez (en el esquema) y se evalúa siempre. No importa dónde se ejecuten las consultas y mutaciones en tu base de código, siempre pasarán por la capa de privacidad.

En este tutorial, comenzaremos repasando los términos básicos del framework, continuaremos con la configuración de la función de políticas para tu proyecto y finalizaremos con varios ejemplos prácticos.

Términos básicos

Política

La interfaz ent.Policy contiene dos métodos: EvalQuery y EvalMutation. El primero define la política de lectura y el segundo la política de escritura. Una política contiene cero o más reglas de privacidad (ver abajo), que se evalúan en el mismo orden en que se declaran en el esquema.

Si todas las reglas se evalúan sin errores, el proceso finaliza correctamente y la operación obtiene acceso a los nodos objetivo.

reglas-privacidad

Sin embargo, si alguna regla devuelve un error o una decisión privacy.Deny (ver abajo), la operación se cancela y devuelve un error.

privacidad-denegada

Reglas de privacidad

Cada política (de mutación o consulta) incluye una o más reglas de privacidad con la siguiente firma:

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

Decisiones de privacidad

Existen tres tipos de decisiones que controlan la evaluación de las reglas:

  • privacy.Allow - Si se devuelve desde una regla, la evaluación se detiene (se saltan las siguientes reglas) y la operación obtiene acceso a los nodos objetivo.

  • privacy.Deny - Si se devuelve desde una regla, la evaluación se detiene y la operación se cancela (equivalente a devolver un error).

  • privacy.Skip - Omite la regla actual y pasa a evaluar la siguiente regla (equivalente a devolver un error nil).

privacidad-permitida

Ahora que hemos cubierto los términos básicos, comencemos a escribir código.

Configuración

Para habilitar la función de privacidad en tu generación de código, activa la característica privacy mediante una de estas opciones:

If you are using the default go generate config, add --feature privacy option to the ent/generate.go file as follows:

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 feature-flag along with the privacy flag to enhance the development experience, for example:

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

Registro de políticas

información

Al igual que con los ganchos de esquema, si usas la opción Policy en tu esquema, DEBES agregar esta importación en el paquete principal para evitar importaciones circulares:

import _ "<project>/ent/runtime"

Ejemplos

Solo administradores

Comenzamos con un ejemplo simple donde cualquier usuario puede leer datos, pero solo los administradores pueden realizar mutaciones. Creamos dos paquetes adicionales:

  • rule - para almacenar las reglas de privacidad

  • viewer - para gestionar el usuario/visor que ejecuta la operación (puede ser usuario normal o administrador)

Tras ejecutar la generación de código (con la bandera para privacidad), añadimos el método Policy con dos reglas generadas.

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

Definimos una política que rechaza cualquier mutación pero permite cualquier consulta. Sin embargo, como queremos permitir mutaciones solo a administradores, creamos dos reglas:

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

Como ves, la primera regla DenyIfNoViewer asegura que cada operación tenga un visor en su contexto, rechazando operaciones sin él. La segunda regla AllowIfAdmin permite operaciones de administradores. Añadimos estas reglas al esquema y generamos código:

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

Al definir DenyIfNoViewer primero, se ejecutará antes que otras reglas, permitiendo acceder al objeto viewer.Viewer de forma segura en AllowIfAdmin.

Tras añadir estas reglas y generar código, la lógica de privacidad se aplicará en las operaciones de 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
}

Contexto de decisión

Para vincular decisiones específicas al context.Context, usamos 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
}

El ejemplo completo está en GitHub.

Multi-tenencia

En este ejemplo creamos un esquema con tres tipos de entidades: Tenant, User y Group, usando los paquetes viewer y rule:

ejemplo-tenant

Comencemos a construir esta aplicación paso a paso. Empezamos creando 3 esquemas diferentes (consulte el código completo aquí), y ya que queremos compartir lógica entre ellos, creamos otro esquema mixin y lo añadimos a todos los demás esquemas de la siguiente manera:

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{},
}
}

Como en el primer ejemplo, la regla de privacidad DenyIfNoViewer deniega la operación si el context.Context no contiene la información viewer.Viewer.

Similar al ejemplo anterior, añadimos una restricción para que solo los administradores puedan crear inquilinos (y denegarlo en caso contrario). Lo hacemos copiando la regla AllowIfAdmin de arriba y añadiéndola a la Policy del esquema Tenant:

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

Este código debería ejecutarse correctamente:

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

Continuamos añadiendo el resto de las aristas en nuestro modelo de datos (ver imagen arriba), y dado que tanto User como Group tienen una arista al esquema Tenant, creamos un esquema compartido llamado TenantMixin para esto:

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

Reglas de filtrado

A continuación, podemos querer aplicar una regla que limite a los usuarios a consultar solo grupos y usuarios conectados al tenant al que pertenecen. Para casos de uso como este, Ent tiene un tipo adicional de regla de privacidad llamada Filter. Podemos usar reglas Filter para filtrar entidades según la identidad del usuario que visualiza. A diferencia de las reglas anteriores, las reglas Filter pueden limitar el alcance de las consultas además de devolver decisiones de privacidad.

[Nota]

Habilita el filtrado con la bandera entql siguiendo las instrucciones.

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

Tras crear FilterTenantRule, la añadimos a TenantMixin para aplicarla a todos los esquemas que usen este mixin.

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

Al generar el código, las reglas de privacidad afectarán a las operaciones del cliente.

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
}

Terminamos nuestro ejemplo con otra regla de privacidad llamada DenyMismatchedTenants en el esquema Group. La regla DenyMismatchedTenants rechaza la creación de grupos si los usuarios asociados no pertenecen al mismo inquilino que el grupo.

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

Añadimos esta regla al esquema Group y ejecutamos la generación de código.

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

De nuevo, esperamos que las reglas de privacidad surtan efecto en las operaciones del cliente.

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

En algunos casos, queremos rechazar operaciones de usuario sobre entidades que no pertenecen a su inquilino sin cargar estas entidades desde la base de datos (a diferencia del ejemplo DenyMismatchedTenants anterior). Para lograrlo, nos basamos en la regla FilterTenantRule para aplicar su filtrado también en mutaciones, y esperamos que las operaciones fallen con NotFoundError si la columna tenant_id no coincide con la almacenada en el contexto del visor.

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

El ejemplo completo está en GitHub.

Ten en cuenta que esta documentación está en desarrollo activo.