Attributes and metadata
Use this when a field on a user, organization, or connection should drive an FGA relation. Metadata is the most common case: a user's department_id in app_metadata controlling what they can access, or an organization's plan in metadata gating a feature. The same pattern works for any other attribute on these objects; see Other attributes.
Auth0 doesn't emit a dedicated event per field. Instead, user.updated, organization.updated, and connection.updated fire on any change to the object. The event carries the object's entire new state in data.object, together with the state before the change in data.previous_object. The tuples that result from a change have to be derived from these two sources, and there are two ways to do it: Option 1 and Option 2, compared under Choosing an approach.
This only works when the value maps onto a bounded, enumerable relation: a department, a cost center, a plan tier, something you'd be comfortable writing as sometype:{value} in your model. Free-form or high-cardinality values (an arbitrary text note, a user's date of birth, anything with effectively unlimited distinct values) don't translate into FGA tuples usefully.
Use app_metadata, not user_metadata, for anything that grants access. user_metadata is intended for data the user can edit, so a permission derived from it can be changed by the user it applies to.
Model
type department
type user
relations
define department: [department]
The same shape applies to organization metadata: swap user/department for organization/plan, as in the subscription plan example.
Option 1: Diff against previous_object
Compare the field in data.object with the same field in data.previous_object, and act only when they differ. Use ?. so a user with no app_metadata yet doesn't make the rule fail.
version: "1"
rules:
- name: "Set department when it changes"
when: >-
input.type == "user.updated" &&
input.data.object.app_metadata?.department_id != nil &&
input.data.object.app_metadata?.department_id !=
input.data.previous_object?.app_metadata?.department_id
variables:
user_id: input.data.object.user_id
department_id: input.data.object.app_metadata.department_id
tuples:
- user: "department:{{ variables.department_id }}"
relation: "department"
object: "user:{{ variables.user_id }}"
- name: "Remove the previous department when it changes or is cleared"
when: >-
input.type == "user.updated" &&
input.data.previous_object?.app_metadata?.department_id != nil &&
input.data.object.app_metadata?.department_id !=
input.data.previous_object?.app_metadata?.department_id
variables:
user_id: input.data.object.user_id
previous_department_id: input.data.previous_object.app_metadata.department_id
tuples:
- action: delete
user: "department:{{ variables.previous_department_id }}"
relation: "department"
object: "user:{{ variables.user_id }}"
tests:
- name: "user.updated sets department when it first appears"
input:
type: "user.updated"
data:
object:
user_id: "u_alice"
app_metadata:
department_id: "dept_eng"
previous_object:
user_id: "u_alice"
app_metadata: {}
expect_tuples:
- user: "department:dept_eng"
relation: "department"
object: "user:u_alice"
- name: "user.updated does nothing when the department is unchanged"
input:
type: "user.updated"
data:
object:
user_id: "u_alice"
name: "Alice Smith"
app_metadata:
department_id: "dept_eng"
previous_object:
user_id: "u_alice"
name: "Alice"
app_metadata:
department_id: "dept_eng"
expect_tuples: []
The two rules together cover every case:
| Change | Set rule | Remove rule |
|---|---|---|
Field appears (nil to a) | writes a | skipped |
Field changes (a to b) | writes b | deletes a |
Field is cleared (a to nil) | skipped | deletes a |
| Field is unchanged, or the update is for another field | skipped | skipped |
When the field changes from a to b, both rules fire on the same event. Every write and delete ignores conflicts (see Idempotent writes and deletes), so reprocessing an event is safe.
Option 2: Reconcile with tuple filters
Ignore previous_object and reconcile against what FGA holds now. On every update, the mapping engine reads the tuples matching the filter, compares them to the desired state in tuples, and writes the minimum changes. This is the reconcile pattern (read-diff-write), the same one used for the organization plan in the mapping language reference.
version: "1"
rules:
- name: "Set department from user attribute"
when: >-
input.type == "user.updated" &&
input.data.object.app_metadata?.department_id != nil
variables:
user_id: input.data.object.user_id
department_id: input.data.object.app_metadata.department_id
tuple_filters:
- relation: department
object: "user:{{ variables.user_id }}"
tuples:
- user: "department:{{ variables.department_id }}"
relation: "department"
object: "user:{{ variables.user_id }}"
- name: "Remove department when cleared from user attribute"
when: >-
input.type == "user.updated" &&
input.data.object.app_metadata?.department_id == nil
variables:
user_id: input.data.object.user_id
tuple_filters:
- relation: department
object: "user:{{ variables.user_id }}"
action: delete
tests:
- name: "user.updated sets department from app_metadata"
input:
type: "user.updated"
data:
object:
user_id: "u_alice"
app_metadata:
department_id: "dept_eng"
expect_tuples:
- user: "department:dept_eng"
relation: "department"
object: "user:u_alice"
expect_tuple_filters:
- relation: department
object: "user:u_alice"
- name: "user.updated removes department when app_metadata is absent"
input:
type: "user.updated"
data:
object:
user_id: "u_alice"
expect_tuples: []
expect_tuple_filters:
- relation: department
object: "user:u_alice"
action: delete
- name: "user.updated removes department when app_metadata clears it"
input:
type: "user.updated"
data:
object:
user_id: "u_alice"
app_metadata: {}
expect_tuples: []
expect_tuple_filters:
- relation: department
object: "user:u_alice"
action: delete
Both rules key off the same field, split by whether it's present: the first reconciles a new value in; the second, guarded by the opposite condition, deletes it outright when the field is cleared. A reconciling filter can't also be told "and if there's nothing to write, delete instead"; that needs its own rule with action: delete.
Because the second rule matches every user.updated event where the field is absent, it deletes department tuples for all those users, including ones your application wrote. This is the incompatibility noted in the table above.
Choosing an approach
Option 1: Diff against previous_object | Option 2: Reconcile with tuple filters | |
|---|---|---|
| Speed and writes | Faster. Does nothing when the field didn't change, and never reads from FGA. Fewer calls, which helps if you're close to rate limits. | Slower. Every update event reads the current tuples from FGA and writes the difference, even when nothing changed. |
| Self-healing | No. It only removes the exact value Auth0 says was there before. If an earlier event was missed, a stale tuple stays until you reconcile. | Yes. Every update event brings FGA back to what Auth0 says now, including after a missed event. |
| Tuples your app writes | Compatible. It only touches the tuple for the old and new value, so your application can write other department tuples on the same user. | Not compatible. The filter claims every department tuple on that user and deletes any that aren't the current value, including ones your application wrote. |
If Auth0 is the only writer of the relation and you'd rather it correct itself, choose Option 2. If your application writes tuples of the same shape, or event volume is a concern, choose Option 1 and plan to reconcile periodically.
Other entities
The examples above use a user's app_metadata. Swap the event and the path for other entities:
| Entity | Event | Metadata path | Notes |
|---|---|---|---|
| User | user.updated | input.data.object.app_metadata.* | Also user_metadata, but see the note above about access. |
| Organization | organization.updated | input.data.object.metadata.* (use metadata?. in guards) | See the subscription plan example for the write side of Option 2. Add a matching "remove when cleared" rule if you need one. |
| Connection | connection.updated | input.data.object.metadata.* | The metadata field is defined separately for each connection type, so use json_path (see Expression language) if one mapping handles several types. |
For Option 1, read the same path from input.data.previous_object instead of input.data.object.
Other attributes
Nothing here is specific to metadata. Any field on data.object works the same way, as long as its value maps onto a bounded relation. On a user, for example, a boolean such as blocked or email_verified can drive a relation, and so can an identity-provider field such as the connection a user signed up through.
Point the rules at the field's path instead of app_metadata.*. For a top-level user field, that is input.data.object.<field> and input.data.previous_object.<field>. Check the field's exact path in the event schema, since nested fields such as identities are arrays and need an iterator rather than a direct path.
Don't use an email address, phone number, or other personally identifiable attribute as the value that ends up in a tuple; see Data privacy. Map it to an opaque value, such as a domain ID, first.
Known limitations
- Both options handle one field at a time. If several attribute-driven relations change in the same update, each needs its own rule set. For Option 2, that can be its own
tuple_filtersentry within one rule, scoped to that relation; a single filter can't reconcile two different relations against two different desired values at once.