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:
- Policy DSL — declarative classes (authored in TypeScript) that
extend
AllowFilePermissionsPolicy/DenyFilePermissionsPolicyand contain anapplyFilter: ExpressionDef+permissions: […]+ optionaldescription. Every policy compiles to a JSON-serializableExpressionDefboolean-logic blob. ApplyEvaluator— a small (2–3 days per language for a senior engineer) library that walks anExpressionDefover a dictionary of loaded data and returnstrue/false/null(indeterminate). Implementations exist in Ruby, TypeScript, and Go, tested against a shared test suite.DatabaseLoader— a per-language loader that resolves"table.column"strings to concrete rows via acontext_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 withApplyEvaluatorrenders the boolean tree with per-node truth + data values + expand/collapseand/orbranches. - 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
ExpressionDefand 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:
- Engineers could not write new policies without reasoning about existing ones.
- Flat hierarchy + boolean escape-flags produced a non-hierarchical matrix pretending to be hierarchical.
- Database-load and policy-logic were coupled in one function (~20% of Figma's database load was permissions checks).
- 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
ExpressionDefpivot, theApplyEvaluator/DatabaseLoadersplit, thecontext_pathrow-addressing primitive, three-valued short-circuit evaluation, React debugger + CLI debugger, static-analysis linter.