ent-framework
  • Ent Framework
  • Getting Started
    • Code Structure
    • Connect to a Database
    • Create Ent Classes
    • VC: Viewer Context and Principal
    • Ent API: insert*()
    • Built-in Field Types
    • Ent API: load*() by ID
    • N+1 Selects Solution
    • Automatic Batching Examples
    • Ent API: select() by Expression
    • Ent API: loadBy*() Unique Key
    • Ent API: update*()
    • Ent API: deleteOriginal()
    • Ent API: count() by Expression
    • Ent API: exists() by Expression
    • Ent API: selectBy() Unique Key Prefix
    • Ent API: upsert*()
    • Privacy Rules
    • Validators
    • Triggers
    • Custom Field Types
  • Ent API: Configuration and Types
  • Scalability
    • Replication and Automatic Lag Tracking
    • Sharding and Microsharding
    • Sharding Terminology
    • Locating a Shard and ID Format
    • Sharding Low-Level API
    • Shard Affinity and Ent Colocation
    • Inverses and Cross Shard Foreign Keys
    • Shards Rebalancing and pg-microsharding Tool
    • Connection Pooling
  • Advanced
    • Database Migrations and pg-mig Tool
    • Ephemeral (Symbol) Fields
    • Atomic Updates and CAS
    • Custom Field Refactoring
    • VC Flavors
    • Query Cache and VC Caches
    • Loaders and Custom Batching
    • PostgreSQL Specific Features
    • Query Planner Hints
    • Cluster Maintenance Queries
    • Logging and Diagnostic Tools
    • Composite Primary Keys
    • Passwords Rotation
  • Architecture
    • Abstraction Layers
    • Ent Framework, Meta’s TAO, entgo
    • JIT in SQL Queries Batching
    • To JOIN or not to JOIN
Powered by GitBook
On this page
  • PostgreSQL Built-in Row Level Security?
  • How Ent Framework Privacy Rules Work
  • privacyLoad Rules and Graph Reachability
  • privacyLoad is a Safety Net, not a Filter
  • privacyLoad and load*() Calls
  • privacyInsert and Referential Permissions
  • privacyUpdate and privacyDelete
  • Rule Classes
  • Predicates
  • Custom Functional Predicates
  • Custom Class Predicates
  • Built-in Predicates
  • Running Privacy Rules Manually

Was this helpful?

Edit on GitHub
  1. Getting Started

Privacy Rules

PreviousEnt API: upsert*()NextValidators

Last updated 15 days ago

Was this helpful?

A crucial reason on why Ent Framework exists at all is its privacy layer. No data exits the API unless it's rechecked against a set of explicitly defined security predicates. In other words, when you have multiple users in your service, you can enforce the strict guarantees that one user can't see other user's data even in theory.

In relational databases world, the concept of per-row security rechecking is called "row-level security".

PostgreSQL Built-in Row Level Security?

Before we continue, we must mention that some support for row-level security is , but it has several drawbacks that makes it almost useless in web development:

  1. It is expensive and, at the same time, too "sloppy" and low-level (the amount of DDL code you need to write is large, and there is no framework in place to help you with it).

  2. There is no support for "per-transaction variables" in PostgreSQL (no per-session variables as well), so if you want to pass an "acting user ID" (similar to Ent Framework VC's Principal), other than the database DDL user/role, into the query, then you can't.

  3. PostgreSQL doesnt't support microsharding, so you basically can't recheck security against the data living in a different microshard.

How Ent Framework Privacy Rules Work

In each Ent class, you need to define an explicit set of rules and determine, can a VC read that Ent (privacyLoad), create a new Ent (privacyInsert), update the Ent (privacyUpdate) and delete the Ent (privacyDelete):

const schema = new PgSchema(
  "comments",
  {
    id: { type: ID, autoInsert: "nextval('comments_id_seq')" },
    created_at: { type: Date, autoInsert: "now()" },
    topic_id: { type: ID },
    creator_id: { type: ID },
    message: { type: String },
  },
  []
);

export class EntComment extends BaseEnt(cluster, schema) {
  static override configure() {
    return new this.Configuration({
      shardAffinity: ["topic_id"],
      privacyInferPrincipal: async (_vc, row) => row.creator_id,
      privacyLoad: [
        new AllowIf(new OutgoingEdgePointsToVC("creator_id")),
        new AllowIf(new CanReadOutgoingEdge("topic_id", EntTopic)),
      ],
      privacyInsert: [
        new Require(new OutgoingEdgePointsToVC("creator_id")),
        new Require(new CanReadOutgoingEdge("topic_id", EntTopic))
      ],
      // privacyUpdate and privacyDelete derive from privacyInsert
      // if they are not explicitly specified.
    });
  }
}

privacyLoad Rules and Graph Reachability

When you run e.g. EntComment.loadX(vc, "123") or any other API call, like loadBy*() or select(), Ent Framework runs privacyLoad rules for each Ent.

Typically, a Rule class used in privacyLoad is AllowIf: it allows reading the Ent immediately as soon as the passed Predicate succeeds. There are several pre-defined Predicate classes, and you can also create your own predicates, or just pass an async boolean function; we'll discuss it a bit later.

So, the logic in the example is following:

  1. new OutgoingEdgePointsToVC("creator_id"): if comment.creator_id equals to vc.principal, then the read is immediately allowed. It means that you (vc) are trying to read a commend which you created (its creator_id is your user ID).

  2. new CanReadOutgoingEdge("topic_id", EntTopic): if vc.principal is able to run EntTopic.loadX(vc, comment.topic_id) successfully, then reading of the comment is immediately allowed. This is an extremely powerful construction, the essence of Ent Framework's privacy layer: you can delegate privacy checks to other Ents in the graph. And since the engine does batching and caching aggressively, this all will be performance efficient.

Idiomatically, privacyLoad defines access permissions in terms of graph edges reachability: typically, if there is at least one path in the graph originating from the VC and ending at the target Ent, then this VC is allowed to read the Ent.

privacyLoad is a Safety Net, not a Filter

I.e. if you run a select() call, it will either return you all of the loaded Ents (if they all pass privacy checks) or throw a detailed error (if some of them don't). This applies to all other API calls as well.

There are several reasons for such behavior:

  1. Performance. Ent Framework sits close to the underlying relational database. If you run a call that returns multiple Ents (e.g. select()), you most likely want to make sure that the query matches an existing database index. So you have to encode the filtering logic in your where condition directly, not just "bulk-load everything and then post-filter". It also applies to other aspects of fetching like pagination, limit clause etc.

  2. Debugging simplicity. In Meta (where privacy rules actually did filter), it was a severe pain to figure out, why some query returns you an empty (or incomplete) response. This is because privacy rules were implicitly filtering "invisible" Ents, and once the Ents are hidden, you don't even know whether they are filtered out or do not exist.

privacyLoad and load*() Calls

  • loadX() and loadByX(): they obviously throw EntNotReadable error (derived from EntAccessError base class) if the privacy checks fails.

  • loadNullable() and loadByNullable(): they will also throw EntNotReadableError, not just return null! This greatly helps with debugging of privacy rules violations.

  • loadIfReadableNullable(): this is what you want to use in an unlikely case when you really need to treat an unavailable Ent as absent. The method name is long intentionally: the best practice is to not use it too often.

privacyInsert and Referential Permissions

As opposed to privacyLoad, where a single succeeded rule allows the read, for privacyInsert (as well as privacyUpdate and privacyDelete), all of them must pass typically.

This is because the ability to insert an Ent means that the VC has permissions to reference other Ents in all field edges. In reality, for every field edge (foreign key) defined in the Ent, there should be at least one associated Require privacy rule.

Having permissions to insert an Ent is almost always the same as having permissions to reference other Ents in its foreign key fields. If we forget to check some of the field edges, then it is possible that the user will be able to create an Ent "belonging" to someone else (by e.g. referencing someone else's ID).

The logic in the example above:

  1. new Require(new OutgoingEdgePointsToVC("creator_id")): it is required that the value of comment.creator_id is equal to vc.principal. I.e. you can only reference yourself as a creator of the just inserted comment.

  2. new Require(new CanReadOutgoingEdge("topic_id", EntTopic)): it is required that, to create a comment on some topic, you must have at least read access to that topic. I.e. you can create comments on someone else's topics too, as soon as you can read those topics.

Notice that here we again use delegation: instead of introducing complicated boilerplate in comments privacy rules, we say: "I fully trust the way how privacy is implemented at EntTopic, and I don't want to know details about it at EntComment level". Basically, you build a chain of trust.

privacyUpdate and privacyDelete

privacyUpdate/Delete rules are similar to privacyInsert, but they are checked by update*() and delete*() calls correspondingly.

If there is no privacyUpdate block defined, then the rules are inherited from privacyInsert array.

If there is no privacyDelete block mentioned in the configuration, then Ent Framework uses privacyUpdate rules for it. (And if there are no privacyUpdate rules, then privacyInsert).

Rule Classes

Item in privacyLoad/Insert/Update/Delete arrays are called a Rules. There are several built-in rules:

  • new AllowIf(predicate): if predicate resolves to true and doesn't throw, allows the access immediately, without checking the next rules. Commonly, AllowIf is used in privacyLoad rules. It checks that there is at least one path in the graph originating at the user denoted by the VC and ending at the target Ent. Also, you may use AllowIf in the prefix of privacyInsert/Update/Delete rules to e.g. allow an admin VC access the Ent early, without checking all other rules.

  • new Require(predicate): if predicateresolves to true and doesn't throw, tells Ent Framework to go to the next rule in the array to continue. If that was the last Require rule in the array, allows access. This rule is commonly used in privacyInsert/Update/Delete blocks, where the goal is to insure that all rules succeed.

  • new DenyIf(predicate): if predicate returns true or throws an error, then the access is immediately rejected. This rule is rarely useful, but you can try to utilize it for ealy denial of access in any of the privacy arrays.

Predicates

Predicate is like a function which accepts an acting VC and a database row. It returns true/false or throws an error.

Custom Functional Predicates

The simplest way to define a predicate is exactly that, pass it as an async function:

privacyLoad: [
  new AllowIf(new OutgoingEdgePointsToVC("id")),
  new AllowIf(async function CommentIsInPublicTopic(vc, row) {
    const topic = await EntTopic.loadX(vc, row.topic_id);
    return topic.published_at !== null;
  }),
]

Notice that we gave this function an inline name, CommentIsInPublicTopic. If the predicate returns false or throws an error, that name will be used as a part of the error message. Of course we could just use an anonymous lambda (like async (vc) => {}), but if we did so and the predicate returned false, then the error won't be much descriptive.

Here, row is strongly-typed: you can use Ent data fields. It is not an Ent instance though, which is currently a TypeScript limitation: you can't self-reference a class in its mixin.

Custom Class Predicates

You can also define preticates as classes, to make them more friendly for debugging. In fact, Ent Framework's built-in predicates are implemented as classes.

As an example, let's see how a built-in predicate CanReadOutgoingEdge works:

export class CanReadOutgoingEdge<TField extends string>
  implements Predicate<Record<TField, string | null>>
{
  readonly name;

  constructor(
    public readonly field: TField,
    public readonly toEntClass: EntClass,
  ) {
    this.name = `${this.constructor.name}(${this.field})`;
  }

  async check(vc: VC, row: Record<TField, string | null>): Promise<boolean> {
    const toID = row[this.field];
    if (!toID) {
      return false;
    }
    const cache = vc.cache(IDsCacheReadable);
    if (cache.has(toID)) {
      return true;
    }
    await this.toEntClass.loadX(vc, toID);
    // sill here and not thrown? save to the cache
    cache.add(toID);
    return true;
  }
}

Each predicate class must be defined with implements Predicate which requires the method check(vc, row) to be implemented, as well as the name property to exist.

In the class constructor, you accept any predicate configuration parameters and build a more descriptive name for the predicate instance than just the predicate name.

And in check() method, you implement your predicate's logic, the same way as you would do it in a functional predicate.

Built-in Predicates

This is the simplest possible predicate, since it always returns true. It is useful when you want to create an Ent class which can be read by anyone.

Checks that ent[field] is equal to vc.principal. This is useful for fields like created_by or user_id or some similar cases, when you want to make sure that the VC's acting user is mentioned in the Ent field to make this field readable (or writable).

Delegates the privacy check to another Ent Class (ToEntClass) considering that toEnt.id is equal to ent[field] . Sounds complicated, but in proactice it means the the VC has permissions to read another Ent that is parent to the current Ent, and is pointed by field . A good example is a predicate on EntComment: privacyLoad: [new CanReadOutgoindEdge("topic_id", EntTopic)] means that, to read this comment, the VC must be able to read its parent topic.

Similar to CanReadOutgoingEdge above, but delegates the check to the parent Ent's privacyUpdate rules.

Same as CanUpdateOutgoingEdge, but for privacyDelete delegation to the parent Ent.

Checks that there is a child Ent in the graph (EntEdge) that points to both vc.principal and to our current Ent. In other words, checks that there is a direct junction Ent sitting in between the VC and our current Ent. Optionally, you can provide an entEdgeFilter callback which is fed with that junction Ent (of EntEdge class) and should return true or false for filtering purposes.

Imagine you have EntUser and EntOrganization Ents, and also EntEmployment junction Ent with (organization_id, user_id) field edges (foreign keys). You want to check that some EntOrganization is readable by a VC:

const employmentsSchema = new PgSchema(
  "employments",
  {
    id: { type: ID, autoInsert: "nextval('employments_id_seq')" },
    organization_id: { type: ID },
    user_id: { type: ID },
  },
  ["organization_id", "user_id"],
);

export class EntEmployment extends BaseEnt(cluster, employmentsSchema) {
  ...
}

...

export class EntOrgainzation extends BaseEnt(cluster, organizationsSchema) {
  static override configure() {
    return new this.Configuration({
      privacyLoad: [
        new AllowIf(
          new IncomingEdgeFromVCExists(
            EntEmployment,     // junction Ent
            "user_id",         // points to vc.principal
            "organization_id", // ponts to this.id
          ),
        ),
      ],
      ...
    });
  }
}

You use IncomingEdgeFromVCExists just once in EntOrganization, and then for all other children Ents, you delegate permission checks to their parent organization, using OutgoingEdgePointsToVC typically.

This is a composite predicate, allowing to call other predicates in pallel. It returns true if any of the predicates returned true and no predicates threw an error.

Notice that you likely don't need this predicate when working with privacyLoad, since it's typically a chain of AllowIf rules. The AllowIf rule already works in an "or-fashion". But for privacyUpdate/Delete rules, the Or predicate may be useful (Require rule is "and-ish" on its nature).

This predicate returns true if there is flavor of a particular class added to the acting VC.

A very common case is to define your own VCAdmin flavor which is added to a VC very early in the request cycle with vc = vc.withFlavor(new VCAdmin()), when the corresponding user is an admin and can see any data in the database. Then, in privacyLoad/Insert/Update/Delete of the Ent classes, you can add new AllowIf(new VCHasFlavor(VCAdmin)) to allow an admin to read that Ent unconditionally.

Running Privacy Rules Manually

Every Ent class exposes a special "constant" VALIDATION static property that allows you to run privacy rules and fields validators manually if needed. Read more about this in Ent API: Configuration and Types.

As opposed to , privacy rules do not post-filter the loaded Ents. They only recheck and throw.

Let's consider a common example: an Ent class with is_archived boolean field. You obviously want the archived Ents to fail the privacy checks of a regular VC. In Ent Framework, it is not enough: you also have to modify your select() calls to explicitly mention is_archived: false, otherwise your queries will start throwing EntNotReadableError when trying to load an archived Ent. Add a static helper method to your Ent class if you don't want to repear is_archived over and over again. (BTW, to still enable archived Ents reading, you may create a VCReadArchive .)

The way privacy rules interact with and API calls is following:

For convenience, Ent Framework already includes some of the most useful predicates. This set is constantly growing, so check for the most up-to-date list.

new ()

new (field)

new (field, ToEntClass)

new (field, ToEntClass)

new (field, ToEntClass)

new (EntEdge, entEdgeVCField, entEdgeFKField, entEdgeFilter?)

new (predicate1, predicate2, ...)

new (FlaviorClass)

will be discussed later in details. For now, we can just mentioned that it's some kind of a "flag" which can be added to a VC instance for later rechecking or to carry some auxiliary information (more precisely, you can derive a new VC with a flavor added to it, since VC itself is an immutable object).

built in to PostgreSQL
Meta's Ent Framework
flavor
load*()
loadBy*()
src/ent/predicates
True
OutgoingEdgePointsToVC
CanReadOutgoingEdge
CanUpdateOutgoingEdge
CanDeleteOutgoingEdge
IncomingEdgeFromVCExists
Or
VCHasFlavor
Flavors