> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lumos.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Conditions

> The JSON conditions behind Access Policies and workflow branches: how to build one, every operator and attribute, and how missing values behave.

A condition is a JSON object that Lumos checks against one identity to decide whether the identity
matches. A user in your organization is one kind of identity. Two v1 fields take a condition:

| Field | Where it appears | A match means |
| - | - | - |
| `access_condition` | An [Access Policy](https://docs.lumos.com/reference/v1/objects/access-policy.md), sent with [`POST /v1/access-policies`](https://docs.lumos.com/api-reference/v1/access-policies/create-access-policy), [`PUT /v1/access-policies/{access_policy_id}`](https://docs.lumos.com/api-reference/v1/access-policies/update-access-policy), or [`PATCH /v1/access-policies/{access_policy_id}`](https://docs.lumos.com/api-reference/v1/access-policies/patch-access-policy) | The policy covers the identity. |
| `condition` | Each branch of a `branch` step in a [workflow](https://docs.lumos.com/reference/v1/objects/workflow.md) | The workflow takes that branch for the identity it's running for, unless an earlier branch matched. |

Both fields use the same operators and attributes. This is the smallest useful condition:

```json theme={null}
{ "attribute": "identity.team", "equals": "Engineering" }
```

It matches identities whose team is `Engineering`.

## How a condition is built

A condition is either a rule or a combination of conditions.

A **rule** names one `attribute`, one operator, and the value to compare against, like the
example above. Put exactly one operator in each rule. A rule with two operators returns `400`.

**Combine** conditions with `and` and `or`. Each takes a list of at least one condition, and
combinations can nest.

```json theme={null}
{
  "and": [
    { "attribute": "identity.status", "equals": "ACTIVE" },
    { "attribute": "identity.custom_attributes['Department']", "in": ["Engineering", "Design"] }
  ]
}
```

**Negate** with `not`. These two forms mean the same thing. The second is the one the condition
builder in Lumos writes.

```json theme={null}
{ "not": { "attribute": "identity.groups[*].external_id", "contains_any": ["grp-contractors"] } }
```

```json theme={null}
{ "attribute": "identity.groups[*].external_id", "not": { "contains_any": ["grp-contractors"] } }
```

A **value** is a string, a number, a boolean, or a date-time, or a list of them for the operators
that take a list. Any value, and `attribute` itself, can instead be an object with a `label` and a
`value`. Lumos compares `value` and keeps `label` for display.

## What Lumos rejects

Lumos checks a condition when you save it, and returns `400` for a condition it can't evaluate:

* A key it doesn't recognize, such as a misspelled operator.
* A rule with more than one operator.
* An attribute that isn't in [Attributes](#attributes), including a misspelled path or a custom
  attribute your organization has deleted.
* An operator that isn't offered for the attribute's type, or one that isn't offered negated.
* A date-time without a time zone, as [Values](#values) describes.

An unrecognized key returns `code` `validation_error` on an Access Policy and `bad_request` on a
workflow. Every other problem returns `bad_request`, and `detail` names each problem. On a
workflow, `detail` starts with the branch, such as `branches[0].condition`.

Apart from date-times, Lumos doesn't check values, so a misspelled group ID or status saves, and
`equals` on it never matches.

A condition saved in Lumos before an attribute was deleted fails the same check, so an update that
sends it back returns `400`.
[Patch Access Policy](https://docs.lumos.com/api-reference/v1/access-policies/patch-access-policy.md) without
`access_condition` keeps the stored condition.

## Stored form

Lumos returns a condition in its stored form. In that form, `attribute` and every value are objects
with a `label` and a `value`, and Lumos adds the labels you didn't send.

```json You send theme={null}
{ "attribute": "identity.team", "equals": "Engineering" }
```

```json You read back theme={null}
{
  "attribute": { "label": "Team", "value": "identity.team" },
  "equals": { "label": "Engineering", "value": "Engineering" }
}
```

Both forms evaluate the same way, so you can send the stored form unchanged. To tell whether a
stored condition is the one you wrote, compare the `value` fields and ignore the labels.

## Operators

| Operator | Offered for | Matches when | Example |
| - | - | - | - |
| `equals` | String, Integer, Boolean, Date-time, App | The attribute equals the value. | `{"attribute": "identity.status", "equals": "ACTIVE"}` |
| `!=` | String, Integer, Boolean, Date-time, App | The attribute doesn't equal the value, including when it has no value. | `{"attribute": "identity.team", "!=": "Sales"}` |
| `>` | Integer, Date-time | The attribute is greater than, or later than, the value. | `{"attribute": "identity.start_date", ">": "2025-01-01T00:00:00Z"}` |
| `>=` | Integer, Date-time | The attribute is greater than or equal to the value. | `{"attribute": "identity.custom_attributes['Level']", ">=": 5}` |
| `<` | Integer, Date-time | The attribute is less than, or earlier than, the value. | `{"attribute": "identity.end_date", "<": "2027-01-01T00:00:00Z"}` |
| `<=` | Integer, Date-time | The attribute is less than or equal to the value. | `{"attribute": "identity.custom_attributes['Level']", "<=": 3}` |
| `exists` | String, Integer, Boolean, Date-time, App | With `true`, the attribute has a value. With `false`, it has none. | `{"attribute": "identity.end_date", "exists": true}` |
| `contains` | String | The value appears somewhere in the attribute's text. | `{"attribute": "identity.title", "contains": "Engineer"}` |
| `in` | String, Integer, Boolean, Date-time, App | The attribute equals one of the listed values. | `{"attribute": "identity.status", "in": ["ACTIVE", "STAGED"]}` |
| `contains_any` | Group | The attribute holds at least one of the listed values. | `{"attribute": "identity.groups[*].external_id", "contains_any": ["grp-engineering"]}` |
| `is_subset_of` | None of the attributes on this page | Every value the attribute holds is in the list. | — |
| `starts_with` | String | The attribute's text starts with the value. | `{"attribute": "identity.title", "starts_with": "Senior"}` |
| `ends_with` | String | The attribute's text ends with the value. | `{"attribute": "identity.email", "ends_with": "@acme.com"}` |

**Offered for** lists the [attribute types](#attributes) each operator is offered for. Pair each
attribute with an operator offered for its type. Any other pairing returns `400`.

Every operator except `equals`, `!=`, `>`, `>=`, `<`, and `<=` is offered negated with `not`.
Each of those six has an opposite operator, so it isn't offered negated, and negating one returns
`400`.

Write `!=` rather than negating `equals`, and `<=` rather than negating `>`. `exists` works either
way, because `"exists": false` and `"not": { "exists": true }` match the same identities.

Text comparisons are case-sensitive, so `"ends_with": "@acme.com"` doesn't match `Jane@ACME.com`.

### `contains`, `in`, `contains_any`, and `is_subset_of`

These four names sound alike, but the operators behave differently.

| Operator | Asks | Compares values |
| - | - | - |
| `contains` | Does this text contain this substring? | As text |
| `in` | Is the attribute's value one of these? | Loosely, so the text `"5030"` matches the number `5030` |
| `contains_any` | Does the attribute hold at least one of these values? | Exactly, so `"5030"` doesn't match `5030` |
| `is_subset_of` | Is every value the attribute holds in this list? | Exactly |

`contains` matches part of the attribute's text. `contains_any` compares whole values, so a rule
asking for the group `app-dropbox-read` doesn't match an identity in the group `app-dropbox-readwrite`.

Give `in` a list. Given a single string, `in` matches when the attribute's value appears inside
that string. For example, `{"attribute": "identity.team", "in": "Engineering"}` also matches a team
named `Eng`, and a team whose value is an empty string.

`is_subset_of` is the "every value" counterpart to `contains_any`. An attribute with no value never
matches it, even against an empty list. No attribute on this page offers it, so a rule that uses it
returns `400`.

## Attributes

An `attribute` is a path into the identity being checked. These are the paths Lumos offers:

| Attribute | Type | Holds | Where to find values |
| - | - | - | - |
| `identity.email` | String | The identity's email address. | `email` on [the user](https://docs.lumos.com/reference/v1/objects/user.md), from [`GET /v1/users`](https://docs.lumos.com/api-reference/v1/users/list-users). |
| `identity.team` | String | The identity's `Team` custom attribute. | See [Custom attributes](#custom-attributes). |
| `identity.title` | String | The identity's `Title` custom attribute. | See [Custom attributes](#custom-attributes). |
| `identity.status` | String | The identity's lifecycle status. | `STAGED`, `ACTIVE`, `SUSPENDED`, or `INACTIVE`, in uppercase. `status` on [the user](https://docs.lumos.com/reference/v1/objects/user.md) returns the same values in lowercase, such as `active`. |
| `identity.start_date` | Date-time | The identity's `Start Date` custom attribute. | See [Custom attributes](#custom-attributes), and [Values](#values) for the format. |
| `identity.end_date` | Date-time | The identity's `End Date` custom attribute. | See [Custom attributes](#custom-attributes), and [Values](#values) for the format. |
| `identity.manager.uuid` | User | The user ID of the identity's manager, from the `Manager` custom attribute. | No operator is offered for this type, so any rule on it returns `400`. See [Custom attributes](#custom-attributes). |
| `identity.groups[*].external_id` | Group | The ID of every group the identity belongs to, in the app that syncs the group. | `integration_specific_id` on [a group](https://docs.lumos.com/reference/v1/objects/group.md), from [`GET /v1/groups`](https://docs.lumos.com/api-reference/v1/groups/list-groups). |
| `identity.source_app.id` | App | The app Lumos created this identity from. | `id` on an app, from [List Apps](https://docs.lumos.com/api-reference/v1/apps/list-apps.md). |

In a rule, use the group's `integration_specific_id`, not its `id`. An `integration_specific_id` is
unique only within the app that syncs the group. If two apps sync groups that have the same
`integration_specific_id`, a rule that names that ID matches the members of both groups.

In a workflow `branch` step, `identity.source_app.id` never has a value. Rules on it work only in
Access Policies.

### Custom attributes

Every custom attribute your organization defines on users is available by its label. The path
depends on the custom attribute's type:

| Custom attribute type | Path | Type | Holds |
| - | - | - | - |
| `string`, `integer`, `boolean`, `datetime` | `identity.custom_attributes["<label>"]` | String, Integer, Boolean, or Date-time | The attribute's value. |
| `user` | `identity.custom_attributes["<label>"].id` | String | The user ID of the identity it names. |
| `group` | `identity.custom_attributes["<label>"].external_id` | String | The ID of the group it names, in the app that syncs the group. |
| `json` | `identity.custom_attributes["<label>"]` | JSON | The attribute's value. No operator is offered for this type, so any rule on it returns `400`. |

Custom attributes of other types aren't offered.

Inside a JSON string, escape the double quotes around the label
(`"identity.custom_attributes[\"Department\"]"`) or use single quotes
(`"identity.custom_attributes['Department']"`). Both work, and the examples on this page use single
quotes.

To find a label, call
[List Custom Attribute Definitions](https://docs.lumos.com/api-reference/v1/custom-attribute-definitions/list-custom-attribute-definitions.md)
with `config_type=user`. The `label` field is what goes in the path.
[The Custom Attribute Definition object](https://docs.lumos.com/reference/v1/objects/custom-attribute-definition.md) covers
the other fields.

To see one identity's values, call
[`GET /v1/users/{user_id}?expand=custom_attributes`](https://docs.lumos.com/api-reference/v1/users/get-user). Its
`custom_attributes` map is keyed by the same labels.

`identity.team`, `identity.title`, `identity.start_date`, and `identity.end_date` have a value only
for identities that hold the matching custom attribute. To write a rule on an identity's manager,
use `identity.custom_attributes['Manager'].id`, which is offered as a string, rather than
`identity.manager.uuid`.

## Values

* **Date-times** are ISO 8601 strings with a time zone, such as `2025-01-01T00:00:00Z`.
* **Booleans** are JSON `true` and `false`. The string `"true"` never equals `true`.
* **Numbers** and text compare loosely in `equals` and `in`, so `5030` matches `"5030"`.

A date-time without a time zone, such as `2025-01-01` or `2025-01-01T00:00:00`, returns `400`, and
so does a value that isn't ISO 8601. `exists` takes `true` or `false`, so this doesn't apply to it.

## Missing values

An attribute has no value when the identity has nothing for it, such as no team or no end date. A
list with no items counts as no value too, so an identity in no groups has no value for
`identity.groups[*].external_id`. An empty string is a value.

`exists` tests for this. `true` matches identities that have a value, and `false` matches those
that don't.

Every other operator treats a missing value as a mismatch, except `!=` and negated operators, which
match it:

| Rule | An identity with no team |
| - | - |
| `{"attribute": "identity.team", "equals": "Sales"}` | Doesn't match |
| `{"attribute": "identity.team", "in": ["Sales"]}` | Doesn't match |
| `{"attribute": "identity.team", "!=": "Sales"}` | Matches |
| `{"attribute": "identity.team", "not": {"in": ["Sales"]}}` | Matches |

A rule for "everyone not in Sales" includes identities with no team. To exclude them, add an
`exists` rule with `and`:

```json theme={null}
{
  "and": [
    { "attribute": "identity.team", "exists": true },
    { "attribute": "identity.team", "!=": "Sales" }
  ]
}
```

## Examples

### Department is Engineering

```json theme={null}
{ "attribute": "identity.custom_attributes['Department']", "equals": "Engineering" }
```

Matches identities whose `Department` custom attribute is exactly `Engineering`. An identity with
no department doesn't match.

### Member of any of these groups

```json theme={null}
{
  "attribute": "identity.groups[*].external_id",
  "contains_any": ["grp-engineering", "grp-design"]
}
```

Matches any identity in at least one of the two groups. Replace the IDs with each group's
`integration_specific_id` from [`GET /v1/groups`](https://docs.lumos.com/api-reference/v1/groups/list-groups).

### Everyone

Don't write this condition yourself. On an Access Policy, set
[`is_everyone_condition`](https://docs.lumos.com/reference/v1/objects/access-policy#conditions) to `true`, and Lumos
stores this condition:

```json theme={null}
{
  "attribute": { "label": "User UUID", "value": "identity.stable_identifier" },
  "exists": true
}
```

On an Access Policy, you can also send this condition back as `access_condition`, and Lumos reads
it as `is_everyone_condition: true`. A workflow `branch` step rejects it, because
`identity.stable_identifier` isn't an attribute it offers.

### Everyone except contractors

```json theme={null}
{
  "attribute": "identity.groups[*].external_id",
  "not": { "contains_any": ["grp-contractors"] }
}
```

Matches every identity outside the contractors group, including identities in no groups at all.

> [!WARNING]
>
> A negated rule excludes any identity that the inner rule matches by mistake. If a group in another
> app has the same ID as a group you list, the rule quietly excludes that group's members too. A
> mistyped ID has the opposite effect, and the rule excludes no identity. Check each ID against
> [`GET /v1/groups`](https://docs.lumos.com/api-reference/v1/groups/list-groups) before you publish.
