Skip to content

SYSTEM Cited by 2 sources

Figma Permissions DSL

What it is

Figma's in-house authorization engine (rolled out from early 2021 on), replacing a Ruby-monolith has_access? method that mixed policy logic with ActiveRecord database calls. It is built from three decoupled components:

  1. Policy DSL — declarative classes (authored in TypeScript) that extend AllowFilePermissionsPolicy / DenyFilePermissionsPolicy and contain an applyFilter: ExpressionDef + permissions: […] + optional description. Every policy compiles to a JSON-serializable ExpressionDef boolean-logic blob.
  2. ApplyEvaluator — a small (2–3 days per language for a senior engineer) library that walks an ExpressionDef over a dictionary of loaded data and returns true / false / null (indeterminate). Implementations exist in Ruby, TypeScript, and Go, tested against a shared test suite.
  3. DatabaseLoader — a per-language loader that resolves "table.column" strings to concrete rows via a context_path (an {entity_kind → id} map derived from the (resource, user) input at the permission-check call site).

Sinatra (HTTP backend) and LiveGraph (realtime API layer) both consume the same policies, which was the single largest forcing function for JSON-serializable policy storage — pre-DSL, the Ruby and LiveGraph code paths had to be manually kept in sync, producing correctness bugs.

The DSL core

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

export type BinaryExpressionDef = [
  FieldName,
  '=' | '<>' | '>' | '<' | '>=' | '<=',
  Value | ExpressionArgumentRef,
];

export type ExpressionArgumentRef = { type: 'field'; ref: FieldName };

export type ExpressionDef =
  | BinaryExpressionDef
  | { or: ExpressionDef[] }
  | { and: ExpressionDef[] };

A FieldName is a "table.column" string — "file.id", "team.permission", "org_user.role". The right side of a BinaryExpressionDef can be either a scalar Value or a reference to another field via { type: "field", ref: "user.blocked_team_id" }. See patterns/expression-def-triples, concepts/json-serializable-dsl.

The policy shape (TypeScript authoring)

class DenyEditsForRestrictedTeamUser extends DenyFilePermissionsPolicy {
  description = '...';
  applyFilter: ExpressionDef = {
    and: [
      not(isOrgFile(File)),
      teamUserHasPaidStatusOnFile(File, TeamUser, '=', AccountType.RESTRICTED),
    ],
  };
  permissions = [FilePermission.CAN_EDIT_CANVAS];
}

Helpers (and, or, not, exists, domain-specific functions like isOrgFile(File)) return ExpressionDef and compose freely. Types, enums, and const objects on field names/values prevent typos. The TypeScript compiler is the first line of static safety; see patterns/policy-static-analysis-in-ci for the additional linter layer.

The evaluation algorithm

hasPermission(resource, user, permissionName):
  policies         = ALL_POLICIES.filter(p => permissionName in p.permissions)
  resourcesToLoad  = ∪ parseDependencies(p.applyFilter) for each p
  [denies, allows] = policies.bisect(p => p.effect == DENY)

  for batch in partitionedLoadPlan(resourcesToLoad):
    loaded ∪= DatabaseLoader.load(batch)
    shouldDeny = denies.any(p => ApplyEvaluator.evaluate(loaded, p.applyFilter) == true)
    if shouldDeny: return false
    mayAllow = allows.any(p => ApplyEvaluator.evaluate(loaded, p.applyFilter) == true)
    if mayAllow and no deny is still null: return true
    if all verdicts are true/false (not null): break
  return false

patterns/deny-overrides-allow is the outer truth-resolution rule; patterns/progressive-data-loading + concepts/three-valued-logic is the inner optimization that "more than halved" evaluation time.

context_path — row addressing

Given file.has_permission?(user, CAN_EDIT), the loader needs to know which rows to fetch. Each resource model exposes context_path:

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

class User
  def context_path
    { user: user_id }
  end
end

Plus a merge step at the call site that synthesizes composite keys like org_user: [org_id, user_id] and team_role: [team_id, user_id]. Policy authors reference "team_role.level" and never reason about which row is being queried — the engine owns query planning, replica selection, caching.

Ecosystem on top

  • Ruby / TypeScript / Go ApplyEvaluators with a shared test suite enforce consistency; each language takes 2–3 days to add because the engine is deliberately tiny.
  • React front-end debugger (Figma-employee-only): input a user ID + resource ID → backend loads everything via DatabaseLoader → data ships to the browser → a recursive React component integrated with ApplyEvaluator renders the boolean tree with per-node truth + data values + expand/collapse and/or branches.
  • CLI debugger with structured tree output enabled via an environment-variable flag on unit tests; surfaces the data each leaf saw and its truth value.
  • Static-analysis linter in CI walks every policy ExpressionDef and flags e.g. [field, '=', { ref: … }] without a sibling [field, '<>', null] guard. Bugs that used to ship to prod now fail the build.

Why the DSL over existing engines

Figma investigated Open Policy Agent, Zanzibar, Oso — none matched the four named problems enumerated in the article:

  1. Engineers could not write new policies without reasoning about existing ones.
  2. Flat hierarchy + boolean escape-flags produced a non-hierarchical matrix pretending to be hierarchical.
  3. Database-load and policy-logic were coupled in one function (~20% of Figma's database load was permissions checks).
  4. Cross-platform drift between Sinatra Ruby and LiveGraph TypeScript.

The DSL was shaped by IAM's effect + action + resource + condition primitives (systems/aws-iam) but the initial Ruby PoC's apply? method + attached_through resource-attachment metaphor proved unintuitive and limiting, then AST parsing for cross-platform proved unreliable — which drove the pivot to a JSON-serializable DSL based on ExpressionDef triples. See concepts/permissions-dsl, concepts/data-policy-separation.

Relationship to other wiki authorization systems

  • IAM (systems/aws-iam) — direct design inspiration for effect / actions / resource / conditions; rejected as a substrate for Figma (no runtime, not about application-level resources).
  • Cedar (systems/cedar) + Amazon Verified Permissions (systems/amazon-verified-permissions) — Cedar is the managed-cloud equivalent of what Figma built in-house; also analyzable-by-construction, also supports static reasoning over policies, also concepts/policy-as-data. Cedar was released 2023, after Figma started this work in early 2021. Figma's DSL is simpler (fewer entity/schema abstractions, simpler evaluation model) and tailored to Figma's object model.
  • Himeji (systems/himeji) — Airbnb's authorization system; different trade-off (write-time relation denormalization for fast read-time checks, more ReBAC-flavored). Figma's data-policy-separation reads policies at hot-path time but short-circuits aggressively.

Caveats

  • No numbers disclosed — no policies-in-production count, no evaluations/sec, no p50/p99 latency, no exact post-DSL database-load %. The article quantifies exactly one speedup: "more than halved the total execution time of our permissions evaluation".
  • Go evaluator is named but not explained — which service uses it is not stated.
  • Not open-source (as of 2026-04-21).
  • Policy orchestration beyond simple deny-overrides-allow not specified (priorities, tie-breaking, partial-result semantics).
  • Database-loader internals not detailed beyond "full control … which queries, replicas or primary, caching."

Detection layer on top — Response Sampling

PermissionsV2 is the preventive authorization surface. Figma added a complementary detection layer — Response Sampling — that asynchronously re-verifies sampled response bodies against PermissionsV2 to catch authorization gaps that slipped past the preventive checks. The async verification job re-runs hasPermission(file, user, CAN_VIEW) per identifier extracted from the sampled response; unexpected results land in triage dashboards.

This is concepts/detection-in-depth applied to authorization: the DSL is responsible for being right at every call site; Response Sampling is responsible for noticing when it wasn't. See patterns/response-sampling-for-authz.

Findings from within days of Response Sampling rollout included paths where files bypassed permission checks entirely — a class of bug PermissionsV2 can't catch alone because it relies on being invoked at the right call sites (Source: sources/2026-04-21-figma-visibility-at-scale-sensitive-data-exposure).

Seen in

  • sources/2026-04-21-figma-how-we-built-a-custom-permissions-dsl — canonical source for the whole system. Four problems driving the build, IAM as inspiration, the first Ruby PoC and why it didn't scale, the JSON-serializable ExpressionDef pivot, the ApplyEvaluator / DatabaseLoader split, the context_path row-addressing primitive, three-valued short-circuit evaluation, React debugger + CLI debugger, static-analysis linter.
Last updated · 200 distilled / 1,178 read