Rethinking Figma’s permissions engine

Figma’s collaboration features are built on a complex set of permissions rules. By early 2021, that complexity had become a source of recurring bugs, support tickets, and stalled projects. The engineering team’s response was to rebuild the technical foundation: a custom cross-platform logic engine paired with a permissions domain specific language (DSL), used to migrate all critical access rules.

The decision to build in-house runs against Figma’s usual preference for open source or off-the-shelf tools. But as the team mapped out the problems with the existing system, no existing solution fit the requirements.

Where permissions live at Figma

The “share” modal is the most visible and complex surface for permissions. It controls file access through two primary mechanisms: roles and links. Roles are hierarchical, meaning a user might inherit access through a folder, team, or organization, with rules that determine when that inherited access is blocked or allowed. Link access adds another layer of constraints: who can access, what level of access, for how long, whether a password is required, and whether the parent organization restricts sharing.

In the early days, all backend logic—including permissions—resided in a Ruby monolith using ActiveRecord. A single has_access? method on the model defined whether a user could access a resource. It was a function with if/else statements, database calls, and a boolean return value, handling deleted files, hierarchy, link types, org restrictions, and billing rules. Product engineers were expected to invoke it at the right points in their controllers. That approach worked for years but eventually stopped scaling.

An in-depth investigation surfaced four core problems.

Complexity and debugging burden

The has_access? methods grew long and riddled with optional parameters. Engineers hesitated to touch them. In debugging, they were simultaneously parsing all permissions logic for a resource, with no way to isolate a single rule. Adding new rules meant understanding every existing one. Debugging commonly meant dozens of print statements and loading context for the entire logic body.

Hierarchical dependencies

Permissions were hierarchical, based on integer levels. But engineers frequently introduced boolean flags that broke the hierarchy’s clean relationship. The result was confusing scenarios where a user with level 300 access (edit) might not have level 100 (view) access when certain flags like ignore_link_access were set. These flags varied between resources, forcing engineers to memorize an inconsistent mental model.

The team concluded that hierarchical permissions needed to give way to a granular, non-hierarchical system where permissions could exist independently.

Database load

Figma was growing fast, and permissions checks accounted for roughly 20% of database load. Because has_access? was a plain Ruby function with ActiveRecord calls, database querying and policy logic were coupled. Optimizing queries risked altering permissions behavior, and vice versa. Engineers without deep permissions knowledge avoided touching it altogether. The team wanted a layer that cleanly separated policy logic from data loading.

Multiple sources of truth

Permissions had to be implemented in two systems: the Sinatra HTTP backend and LiveGraph, Figma’s realtime API layer. Engineers had to duplicate every new rule across both codebases. Rules were frequently missed during migration, causing bugs when the two systems disagreed.

Whatever the solution, it had to meet four requirements:

  1. Allow independent rule authoring without requiring knowledge of all existing rules.
  2. Separate policy logic entirely from data loading.
  3. Work across Ruby, TypeScript, and ideally other languages with no extra work from policy authors.
  4. Support granular permissions for precise modeling of user actions.

From policies to a proof of concept

The initial inspiration came from AWS IAM policies. The declarative model of actions, effects (allow/deny), resources, and conditions provided the granularity and isolation the team wanted. IAM had its own reputation for being difficult to work with, but its structural concepts—particularly a policy author writing rules in isolation—matched Figma’s needs. An evaluation of Open Policy Agent, Zanzibar, and Oso came up short on the core problems, so Figma started building its own.

The first iteration was a policy class in Ruby. It had an effect, a set of permissions, a resource type, and an apply? method that executed Ruby code against loaded resources. Policies could attach to related resources like the user, roles, and parent containers via an attached_through property. That concept proved unintuitive: it allowed only one resource attachment and had to be iterated on.

The team tested the model by converting every existing permissions rule into this policy structure. After weeks of running the full test suite against the converted branch, they had a green CI/CD build with hundreds of tests passing. This served as the critical de-risking step, surfacing all the subtle rules and their product rationale accumulated over years. But the work wasn’t done. The team still faced three open issues:

  • Policies remained complicated to write. The attachment mechanism was harder than simply declaring which resources a policy needed.
  • The imperative Ruby apply? function gave little control over what executed inside it. The team wanted policies restricted to pure boolean logic, free of network calls or side effects.
  • Cross-platform support was unsolved. They experimented with Abstract Syntax Tree (AST) parsing but found it unreliable and painful in even one language.

A JSON-first policy language

The proof of concept worked, but it was tied to a single runtime. To support both Sinatra and LiveGraph, and any future platform, we made every policy JSON-serializable with no embedded code and no AST to parse. That meant extending ExpressionDef, a boolean-logic DSL already used by the LiveGraph team. Its core unit is a triple: two operands with an operator between them, evaluating to a boolean, such as [1, "=", 1] or [2, ">", 3]. You can combine triples with three higher-level operations — and, or, and not — to build more complex statements.

To make the DSL work with real data, we made two small changes. First, the left side of any triple always references a data field. Second, you can reference a data field on the right side by wrapping it in a special ref object. Field names are strings with table and column separated by a dot, like these examples:

  • ["team.permission", "=", "open"]
  • ["file.deleted_at", "<>", null]
  • ["file.team_id", "=", { "ref": "user.blocked_team_id" }]

Using generic names like "file", "user", and "org" — matching our policy vocabulary — keeps the model simple, even though the engine ultimately decides which concrete rows those strings refer to. The JSON approach paid off in a few important ways: policies can be consumed in any language or environment without parsing; data dependencies can be discovered statically by simple traversal; and policy authors get a straightforward API for referencing the data they need.

We chose TypeScript for authoring policies. It was already widespread at Figma, works well for serializing objects to JSON, and the original ExpressionDefs were written in it, making integration with LiveGraph easier. Strong typing and helper functions turned raw triples into readable code:

class DenyEditsForRestrictedTeamUser extends DenyFilePermissionsPolicy {
  description = 'This user has a viewer-restricted seat in a Pro plan, so will not be able to edit the file.'

  applyFilter: ExpressionDef = {
    and: [
      not(isOrgFile(File)),
      teamUserHasPaidStatusOnFile(File, TeamUser, '=', AccountType.RESTRICTED),
    ],
  }
  // This compiles down to
 applyFilter: ExpressionDef = {
    and: [
      not([file.orgId, '<>', null]),
      or([
        and(["file.editor_type", "=", "design"], ["team_user.design_paid_status", "=", "restricted"]),
        and(["file.editor_type", "=", "figjam"], ["team_user.figjam_paid_status", "=", "restricted"]),
      ])
    ],
  }

  permissions = [FilePermission.CAN_EDIT_CANVAS]
}

Values are strongly typed with enums or const objects, which eliminates typos. Reusable snippets live in functions that return ExpressionDefs, so policies compose like ordinary code and stay consistent across the codebase. Convenience functions such as and, or, not, and exists round out the authoring experience.

Evaluating and loading data

From DSL to production required two backend pieces. The first, ApplyEvaluator, is a small library that takes a JSON policy and a set of data, then returns true or false. The second, DatabaseLoader, resolves field names like "file.id" into actual database queries.

ApplyEvaluator: the logic engine

The engine's type model starts with a FieldName — a string with a table and column separated by a dot — and a Value for basic data types:

export type FieldName = string;
export type Value = string | boolean | number | Date | null;

A BinaryExpressionDef is a triple with a FieldName on the left, an operator string in the middle, and either a plain Value on the right or a ref wrapper to distinguish string literals from field references:

export type BinaryExpressionDef = [
  FieldName,
  '='| '<>' | '>' | '<' | '>=' | '<=',
  Value | ExpressionArgumentRef,
]
const type ExpressionArgumentRef = { type: 'field'; ref: FieldName }

Binary expressions can be nested under and and or, and ExpressionDef is the union of all three forms:

export type ExpressionDef =
  | BinaryExpressionDef
  | OrExpressionDef
  | AndExpressionDef

export type OrExpressionDef = {
  or: ExpressionDef[]
}

export type AndExpressionDef = {
  and: ExpressionDef[]
}

Evaluating an ExpressionDef then becomes a short recursive function:

interface Dictionary<T> { [Key: string]: T; }

function evalExpressionDef(expr: ExpressionDef, data: Dictionary<Dictionary<Value>>) {
  // Recursively walk through ExpressionDefs
  if (expr.and) { 
    return expr.and.every(subExpr => evalExpressionDef(subExpr, data)
  }
  if (expr.or) { 
   return expr.or.some(subExpr => evalExpressionDef(subExpr, data)  
  }

  // Evaluate BinaryExpressionDef
  const [leftKey, operation, rightKeyOrValue] = expr;
  // Find values in data using provided keys
  const leftValue : Value = getValueFromKey(leftKey, data);
  const rightValue : Value = getValueFromKey(rightKeyOrValue, data);
  // Evaluate expression
  switch operation {
   case '='
     return leftValue === rightValue
   // ... 
  }
}

Because ExpressionDefs are JSON-serializable, the same evaluator can be implemented in any language. We wrote versions for TypeScript, Ruby, and eventually Go. Each was small enough to build in two or three days, and all share the exact same test suite for consistency.

Data loading: inferring dependencies

Since policies declare all their dependencies explicitly, we can take any permission, walk every policy that grants it, and collect every referenced column. The output looks like a dictionary keyed by table name:

{
  "file": ["id", "name", "created_at", "deleted_at"],
  "team": ["id", "permission", "created_at"],    
  "org": ["id", "public_link_permission"],
  "user": ["id", "email"],
  "team_role": ["id", "level"],
  "org_user": ["id", "role"]  
}

That gives us the columns, but not the rows. To figure out which rows to load, we look at how the permission function is called. Our public API always receives a resource, a user, and a permission name:

file.has_permission?(user, CAN_EDIT)

From the file and user objects we can build a context path that covers four kinds of resources: the ones passed in at call time; those loaded through foreign keys on the resource; those loaded through columns on the user; and those reachable through both:

{
  // Resources known when calling `has_permission?`
  "file": ["id", "name", "created_at", "deleted_at"],
  "user": ["id", "email"],
  
  // Resources loaded through `file` object
  "team": ["id", "permission", "created_at"], // file.team_id
  "org": ["id", "public_link_permission"],    // file.org_id

  // Resources loaded through combination of `file` and `user`
  "team_role": ["id", "level"],    // file.team_id + user.id
  "org_user": ["id", "role"]       // file.org     + user.id
}
A diagram showing a file flowing through to a user.

On ActiveRecord models, simple functions define these IDs, giving us a way to query any model generically:

class File
  def context_path 
    {
      :project => self.project_id,
      :team    => self.team_id,
      :org     => self.org_id,
      :file    => self.file_id,
    }
  end
end

class User
  def context_path 
    { :user => self.user_id }
  end
end

def get_context_path(resource, user)
  context_path = {}.merge(resource.context_path).merge(user.context_path)
  if context_path[:org] && context_path[:user]
    context_path[:org_user] = [context_path[:org], context_path[:user]]
  end
  if context_path[:team] && context_path[:user]
    context_path[:team_role] = [context_path[:team], context_path[:user]]
  end
  return context_path
end

Once the context_path has the right IDs, the DatabaseLoader can fetch everything. When a new resource becomes loadable, we immediately specify how its context path gets populated. When a policy references a new type, we make sure its path exists. But for the engineer, none of this matters: they just write policies referencing "file", "user", "org", or "team_role" and let the backend handle row resolution, ordering, read replicas, caching, and everything else.

The full system — DSL, ApplyEvaluator, and DatabaseLoader — follows a simple loop:

function hasPermission(resource, user, permissionName) {
  // Find all relevant policies
  const policies = ALL_POLICIES
         .filter(p => p.permissions.include(permissionName))
  // Parse all resources required from policies
  const resourcesToLoad = policies.reduce((memo, p) => {
    const dataDependencies = parseDependences(p.applyFilter)
    return memo.merge(dataDependencies)
  }, {})
  
  // Load all necessary data
  const loadedResources = DatabaseLoader.load(resourceToLoad)
 
  // Bisect policies into DENY and ALLOW policies
  const [denyPolicies, allowPolicies] = policies
                      .bisect(p => p.effect === DENY)

  // Return false if any of the DENY policies evaluate to true
  const shouldDeny = denyPolicies.any(p => {
    return ApplyEvaluator.evaluate(loadedResources, p.applyFilter)
  })
  if (shouldDeny) { return false }

  // Return true if any of the ALLOW policies evaluate to true
  return allowPolicies.any(p => {
     return ApplyEvaluator.evaluate(loadedResources, p.applyFilter)
  })
}

From there, performance work was mostly about avoiding redundant work: skipping policies already evaluated to false and reusing data already in memory when it was passed into the function.

Debugging and optimization payoffs of the DSL

With the initial system running, the Figma team continued iterating to improve performance, respond to feedback, and harden the platform. Three features proved especially valuable — enabled precisely because the authorization logic was a lightweight, JSON-serializable DSL evaluated by a TypeScript engine.

A front-end debugger for permissions

Because Figma had both a Sinatra database loader and a TypeScript ApplyEvaluator, the team was able to build a front-end debugger on top of them. Figma employees in engineering and support could input a user ID and resource ID, and the backend would load all relevant data. An HTTP route then passed that data to the React-based front end, where a recursive component used the ApplyEvaluator to walk policy evaluations.

The architecture made this straightforward. Since the data loading and logical evaluation were separated, the team had full control over the debugging presentation. Users could expand or collapse and and or permission rules, see the data that was evaluated, and check whether a rule resolved to true or false — letting them pinpoint the exact policy line that was incorrect or behaved unexpectedly.

This capability extended to the command line. Engineers could enable debugging on a particular policy and pass an environment variable when running tests to get a detailed evaluation breakdown for that policy.

TypeScript

[DenyEditsForNonPaidOrgUser] Filter passed to should_apply:
    [AND] true:
      - ["file.parent_org_id"]: 5281 <> null : true
        [NOT] true:
          [AND] false:
            - ["file.parent_org_id"]: 5281 <> null : true
            - ["file.team_id"]: 6697 = null : false
            - ["file.folder_id"]: 21654 <> null : true
            - ["org_user.drafts_folder_id"]: 21652 = { "ref": "file.folder_id"} : false
        [OR] true:
            [OR] true:
                [AND] true:
                  - ["file.editor_type"]: "design" = "design" : true
                  - ["org_user.account_type"]: "restricted" = "restricted" : true

The DSL itself gave the team the flexibility needed to build this tooling without much friction.

Faster evaluation with lazy loading and short-circuiting

The team knew that certain permissions could require loading more than a dozen tables. In many cases, that data wasn't strictly necessary — users often gain access through multiple allow policies, and the system only needs one allow policy to evaluate to true. All deny policies must evaluate to false, but a single definitive allow is sufficient to grant access.

The goal was to load only the data needed for a first evaluation pass and stop early if a definitive result appeared. To do this, the ApplyEvaluator needed a way to signal when a policy couldn't yet be conclusively evaluated. Take the following ExpressionDef: given certain data, a logical expression might always resolve to true without further database queries.

TypeScript

{ // Data
  "team": {
    "permission": "secret"
  },
  "file": PENDING_LOAD,  // We have not attempted to load this row!
  "project": PENDING_LOAD,
}

{ // ExpressionDef
  "and": [ // false
    ["file.id", "<>", null], // ?
    ["team.permission", "=", "open"], // false
    ["project.deleted_at", "<>", null], // ?
  ]
}

If the parent statement changes to an "or" with the same data, the evaluator now needs to know the values of both "file.id" and "project.deleted_at" before it can state whether the policy is true or false.

TypeScript

{
  "or": [
    ["file.id", "<>", null],
    ["team.permission", "=", "open"], // false
    ["project.deleted_at", "<>", null],
  ]
}

This is a third state distinct from true and false. The team represented it with null, meaning the policy couldn't yet be evaluated conclusively. This new state then drove database load optimization. The system partitioned all table dependencies for a set of policies into a sequence of discrete load steps, ordered by heuristics — file, folder, and team roles were prioritized because they are the second-most common way (after link access) that users get resource access.

The evaluator would iterate over these batches, feeding each newly loaded set of resources into the ApplyEvaluator. If it returned true or false, the execution short-circuited. If it returned null, the next batch of resources loaded. This simple change more than halved total permissions evaluation time and reduced database load.

Static analysis with a linter

A third big advantage came from the ease of running static analysis over policies. The team saw recurring bugs in the logic of policies, most notably BinaryExpressionDefs with a = operation in the center and a field reference on the right side, where both values evaluated to null.

TypeScript

{
  "file": { "team_id": null }, 
  "team": { "id": null }
}

TypeScript

["file.id", "=", { "ref": "team.id" }]

That condition actually evaluates to true, but likely doesn't express what the policy author intended. The correct approach is to add a sibling check under an and ensuring one of those fields is not null:

TypeScript

{
  "and": {
    ["team.id", "<>", null],
    ["file.id", "=", { "ref": "team.id" }]
  }
}

This insight led the team to introduce a linter into unit tests that iterates over all policy ExpressionDefs and throws an error when a right-side field reference with a = operation lacks an accompanying sibling <> null check under an "and" ExpressionDef. In short, the linter disallows comparing two field names unless one of the references is explicitly checked as non-null. Similar rules were implemented for other operations like <> checks.

Because the DSL is JSON-serializable, writing the linter required no specialized AST parsing — just simple TypeScript that recursively iterated through the ExpressionDefs. Once added to CI/CD, the linter caught several other bugs before they could impact production.

The team considered moving these checks into the engine itself, but chose static analysis for two reasons: the linting logic only needed to be written once since it ran at build time rather than being cross-platform, and because it worked at build time, engineers caught bugs faster without waiting until tests or, worse, production. There was also the philosophical point: the multi-engine approach works only because the engine is simple, and changing it isn't worth the risk unless absolutely necessary.

What started as a project that many expected to end with an existing solution evolved into a bespoke authorization DSL, developed because the team stayed focused on the core problem and stayed open to alternatives.

At Figma, this approach eliminated most incidents and bugs caused by logic drift between the Ruby and LiveGraph codebases. The debugger gave engineering and support teams a way to unblock themselves when permission checks behaved unexpectedly, along with the tools to investigate deeply. Designing the DSL at such a fundamental level provided a flexibility that the team says continues to pay off.