Writing a Mapping File
A mapping file is a YAML document that tells a Sync pipeline how to transform Auth0 events into FGA tuples. For the full mapping syntax (rules, tuple templates, tuple filters, iterators, and the expression language), see Mapping Language. This page covers Auth0's suggested authorization model, ready-to-use reference templates, how validation works for a Sync pipeline, and how to work with mapping files from the command line.
Suggested authorization model
The model below is a good starting point for most Auth0 B2B customers using organizations, groups, and roles. It separates the Auth0-managed tuples (written by the Sync pipeline) from the app-managed tuples (written directly by your application). The reference templates below are written against this exact model: same relation names, verified against it directly.
model
schema 1.1
# ── Auth0-managed types ──────────────────────────────────────────────
# Tuples for these are written/deleted by the Sync pipeline.
# The relation names here must match what your mapping file produces.
type connection # Auth0 connection (e.g. SAML, SCIM enterprise connection)
# organization_membership exists purely so a role assignment can be revoked without knowing which
# roles a user held: organization.member.deleted only carries organization_id and user_id, never
# role IDs, so role assignments are wired through this object instead of directly to the user.
# Deleting the single `member` tuple below on membership removal revokes every role wired to it in
# one write, with nothing left to enumerate.
type organization_membership # one object per (organization, user) pair, id "{{organization_id}}_{{user_id}}"
relations
define member: [user]
type role # tenant-defined role, assigned to a user within an organization
relations
define assignee: [user, organization_membership#member]
type organization # Auth0 Organization
relations
define member: [user] # organization member
type group # SCIM group synced from an enterprise connection
relations
define connection: [connection] # which connection owns this group
define member: [user, group#member] # direct members + nested groups
# ── App-managed types ─────────────────────────────────────────────────
# Tuples for these are written by your application, not the Sync pipeline.
# Customize these for your domain: this is just an example.
type user
Key constraints:
- The Auth0-managed relation names (
member,assignee, etc.) must exactly match what your mapping file emits. If you rename them in the model, update the mapping too. - App-managed types are entirely up to you; remove what you don't need, add what your product requires (e.g.,
document,project,team).
Modeling app-defined vs. customer-managed roles
Roles fall into two categories, and each needs different modeling:
- App-defined roles: your application hardcodes permission logic against a fixed, known set of roles it created itself in Auth0 (for example, an "Approver" role your app checks for explicitly). Your mapping can branch on these by identity.
- Customer-managed roles: tenant admins create their own roles with arbitrary names and IDs your application can't know ahead of time. Your mapping has to handle any role generically.
Some applications need only one category; others need both.
Branch on the role's ID (input.data.object.role.id), not its name. A role's name can be changed by whoever manages roles in your Auth0 tenant (see Create Roles), but its ID is stable for the role's lifetime. Matching by name is fragile: a rename silently breaks the branch, with no error to catch it.
App-defined roles
Since your application already knows about these roles, model each as its own named relation directly on organization, for example:
type organization
relations
define member: [user]
define approver: [user] # illustrative: an app-defined role, not an Auth0-provided one
Map it by matching the role's ID, hardcoded to the specific role you created in your own tenant:
- name: "Assign approver role to user in organization"
when: >-
input.type == "organization.member.role.assigned" &&
input.data.object.role.id == "rol_YOUR_APPROVER_ROLE_ID"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
tuples:
- user: "user:{{ variables.user_id }}"
relation: "approver"
object: "organization:{{ variables.organization_id }}"
- name: "Remove approver role from user in organization"
when: >-
input.type == "organization.member.role.deleted" &&
input.data.object.role.id == "rol_YOUR_APPROVER_ROLE_ID"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
tuples:
- action: delete
user: "user:{{ variables.user_id }}"
relation: "approver"
object: "organization:{{ variables.organization_id }}"
Replace rol_YOUR_APPROVER_ROLE_ID with the real role ID from your tenant. Because this writes a direct relation on organization, no separate cleanup rule is needed: Cleaning up roles on membership removal's filter-by-user+organization technique already reaches it when the membership is removed.
Customer-managed roles
Since these roles' names, IDs, and count aren't known ahead of time, model them generically as role:{id}#assignee, wired through an intermediate organization_membership object so a role assignment can still be revoked when its organization membership disappears. See the comprehensive template below for the full mapping and model.
Combining both
If your application has both kinds, add two separate rules per event, each guarded by whether the role ID matches your known, hardcoded set: one rule pair for the app-defined branch (shown above), and one for everything else, which is exactly the comprehensive template's "Assign role to user in organization" / "Remove role from user in organization" rules below, with input.data.object.role.id != "rol_YOUR_APPROVER_ROLE_ID" (and any other app-defined role IDs) added to their when guards so the two branches don't overlap. Rules are independent and all matching ones fire for a given event, so ordering between the two pairs doesn't matter, as long as their when conditions are mutually exclusive.
Reference templates
Two starting points for your own mapping file, both built on the model above. Paste either into the dashboard's mapping field and adjust relation names to match your model.
Minimal template
The smallest useful mapping: adding and removing a user from an organization. Same example walked through in Getting Started:
version: "1"
rules:
- name: "Add user to organization"
when: input.type == "organization.member.added"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
tuples:
- user: "user:{{ variables.user_id }}"
relation: "member"
object: "organization:{{ variables.organization_id }}"
- name: "Remove user from organization"
when: input.type == "organization.member.deleted"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
tuples:
- action: delete
user: "user:{{ variables.user_id }}"
relation: "member"
object: "organization:{{ variables.organization_id }}"
Needs a model with at least:
model
schema 1.1
type user
type organization
relations
define member: [user]
This is a minimal slice of the model above: it also covers tenant roles and SCIM groups, both shown in the comprehensive template below. Grow into it as you map more events.
Comprehensive template
Covers organization membership, tenant-role assignment within an organization membership (see What data can you sync), and SCIM group membership including nested groups.
version: "1"
rules:
- name: "Add user to organization"
when: input.type == "organization.member.added"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
tuples:
- user: "user:{{ variables.user_id }}"
relation: "member"
object: "organization:{{ variables.organization_id }}"
- name: "Remove user from organization"
when: input.type == "organization.member.deleted"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
tuples:
- action: delete
user: "user:{{ variables.user_id }}"
relation: "member"
object: "organization:{{ variables.organization_id }}"
- action: delete
user: "user:{{ variables.user_id }}"
relation: "member"
object: "organization_membership:{{ variables.organization_id }}_{{ variables.user_id }}"
- name: "Assign role to user in organization"
when: input.type == "organization.member.role.assigned"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
role_id: input.data.object.role.id
tuples:
- user: "user:{{ variables.user_id }}"
relation: "member"
object: "organization_membership:{{ variables.organization_id }}_{{ variables.user_id }}"
- user: "organization_membership:{{ variables.organization_id }}_{{ variables.user_id }}#member"
relation: "assignee"
object: "role:{{ variables.role_id }}"
- name: "Remove role from user in organization"
when: input.type == "organization.member.role.deleted"
variables:
organization_id: input.data.object.organization.id
user_id: input.data.object.user.user_id
role_id: input.data.object.role.id
tuples:
- action: delete
user: "organization_membership:{{ variables.organization_id }}_{{ variables.user_id }}#member"
relation: "assignee"
object: "role:{{ variables.role_id }}"
- name: "Add user member to group"
when: >-
input.type == "group.member.added" &&
input.data.object.member.member_type == "user"
variables:
group_id: input.data.object?.group?.id
member_id: input.data.object?.member?.id
tuples:
- user: "user:{{ variables.member_id }}"
relation: "member"
object: "group:{{ variables.group_id }}"
- name: "Add group member to group (nested groups)"
when: >-
input.type == "group.member.added" &&
input.data.object.member.member_type == "group"
variables:
group_id: input.data.object?.group?.id
member_id: input.data.object?.member?.id
tuples:
- user: "group:{{ variables.member_id }}#member"
relation: "member"
object: "group:{{ variables.group_id }}"
- name: "Remove user member from group"
when: >-
input.type == "group.member.deleted" &&
input.data.object.member.member_type == "user"
variables:
group_id: input.data.object?.group?.id
member_id: input.data.object?.member?.id
tuples:
- action: delete
user: "user:{{ variables.member_id }}"
relation: "member"
object: "group:{{ variables.group_id }}"
This intentionally leaves out a few things already covered in full elsewhere, to avoid duplication:
- Removing a nested group member (mirror of "Add group member to group" above, using
action: delete) and cleaning up all tuples ongroup.deleted; see Tuple filters.
Role assignments here are written to role:{{ variables.role_id }}#assignee, not to a relation on the organization itself. This lets the mapping handle any role name without predefining one relation per role in the model. A role ID is tenant-wide, not organization-scoped, so the organization-and-user tuple-filter technique in Cleaning up roles on membership removal can't reach these tuples directly on organization.member.deleted — that technique assumes roles are written as relations on the organization, which is a different, valid modeling choice but not what this template does.
Instead, role assignments are wired through the model's organization_membership type rather than directly from user to role: assigning a role writes both user --member--> organization_membership:{organization_id}_{user_id} and organization_membership:{organization_id}_{user_id}#member --assignee--> role:{role_id}. organization.member.deleted's payload only ever has organization.id and user.user_id, never role IDs, so there's no way to filter or enumerate role tuples directly from that event; but the "Remove user from organization" rule above can still delete the single organization_membership membership tuple from those two fields alone, which revokes every role wired through it in one write, correctly, even without knowing which roles the user held. If the same user holds the same role via a second organization they're still a member of, each organization gets its own organization_membership:{organization_id}_{user_id} object, so removing one organization's membership doesn't affect the other.
Validating a mapping file
Validate a mapping directly in the FGA Dashboard, from the Sync pipeline's Mappings tab: paste the YAML into the Mappings field and click Add Mappings (or Update Mappings, if you're changing an existing one). Validation checks, in order:
- YAML syntax — malformed YAML is rejected with a line/column pointer before anything else runs.
- Structure — required fields, valid
actionvalues, and the other structural rules described in Mapping Language. - Consistency with your selected FGA model — every relation a tuple template writes to must actually exist on that model.
All errors across all rules are reported at once, not just the first one. See Troubleshooting: Mapping validation errors for the full breakdown by category.
You can also validate a mapping locally before uploading it, using the OpenFGA CLI's fga mapping command group:
fga mapping validate mapping.yaml --model-file model.fga
This catches the same three error categories as the dashboard. The CLI can also do more than the dashboard can, including running a mapping's embedded tests; see below.
Working with mappings using the CLI
The OpenFGA CLI's fga mapping command group lets you develop, validate, and test mapping files locally, entirely offline, with no store reads or writes, before they run against live input. fga mapping test is the only way to actually execute a mapping's embedded tests: cases; the dashboard checks syntax, structure, and model consistency when you add or update a mapping, but never runs them.
This tooling is for transforming data that's still in an external system's own schema (events, webhooks, API payloads) into FGA tuples. If your data is already shaped as FGA tuples (YAML, JSON, or CSV) and you just need to load it, you don't need the mapper at all. See Import tuples instead.
| Command | Flags | Purpose |
|---|---|---|
fga mapping init [file] | --force, --minimal | Scaffold a starter mapping YAML (rules + embedded tests). Output path is positional, defaults to mapping.yaml. |
fga mapping validate <file> | --model-file, --format text|json, --verbose/-v | Compile the mapping; with --model-file, also verify every tuple template is consistent with that FGA model. |
fga mapping test <file> | --format text|json|junit, --run <substr>, --fail-fast, --output-file/-o, --verbose/-v, --no-color | Run the mapping's embedded tests: cases and report pass/fail. |
fga mapping run <file> | --input, --writes-only, --format jsonl|json, --aggregate, --continue-on-error, --interactive/-i | Feed JSON input (stdin or --input) through the mapping and emit the resulting tuple operations, for piping into fga tuple write. |
The mapping file path is a positional argument, not a flag, for example fga mapping validate mapping.yaml --model-file model.fga.
run's default output is JSONL, one operation per line. --writes-only emits bare tuples (no op field, deletes dropped) in exactly the shape fga tuple write --file accepts:
cat input.jsonl | fga mapping run mapping.yaml --writes-only --format json > tuples.json
fga tuple write --store-id 01H... --file tuples.json
fga tuple write doesn't read from stdin yet, so this is a two-step handoff through a file rather than a single pipe. If stdin support ships later, this collapses into one command.
Offline limitation. run can dedupe writes/deletes and detect conflicts without a store, but delete-by-filter rules (tuple_filters without a fully-specified tuple) need a live FGA Read to know what they match. Offline, these are reported as a stderr warning, and under tuple_filter_operations in the --format json output, instead of being expanded into concrete deletes. Fully-specified delete tuples still work normally.
Exit codes (all four commands):
| Code | Meaning |
|---|---|
0 | Success |
1 | Runtime failure: I/O, JSON parse/eval error, write/delete conflict, or a failing test |
2 | Usage error, compile error, or model inconsistency |