The expression language behind group rules, policies and mappings
One CEL-based expression engine, shared by dynamic group rules, sign-on policy conditions and profile/app mappings — the syntax, the function table, and the sandbox limits that make it fail closed.
Three places in Qorionix now accept a written expression instead of, or alongside, a fixed set of dropdowns: a dynamic group rule's membership condition, a sign-on policy rule's condition, and a profile or app-attribute mapping. All three are compiled and run by the same engine, in a syntax modelled on Okta's own expression language, so what you learn here applies wherever you see an expression box.
This article covers the engine itself: the syntax it accepts, what variables are available in each scope, the function table, and the limits that keep a badly written expression from doing anything worse than failing to match. The places that actually use it — group rules, sign-on policy conditions, mappings — each get their own article or section; this one is the shared reference.
Three scopes, three variable sets
An expression is compiled for exactly one scope, and the scope decides which variables it can read. Using a variable from another scope is a compile error, caught before the expression is ever saved.
| Scope | Used by | Variables |
|---|---|---|
group_rule | Dynamic group membership rules | user, now |
policy | Sign-on policy conditions | user, context, app, now |
mapping | Profile and app-attribute mappings (SAML statements, OIDC claims, SCIM inbound/outbound, app username) | user, appuser, idp, app, now |
user is a map: your core profile fields sit flat (user.department, user.email), every custom attribute is readable both under user.profile.<name> and aliased flat as user.<name> (a core name always wins the flat alias), and user.groups / user.groupIds list the groups you belong to. user.manager.<attr> resolves through your manager, when one is recorded, by looking them up inside the same organisation. A key nobody has declared is still readable — a rule over an undeclared attribute keeps working — but every value is typed dyn, so a type mismatch is a runtime failure, not a compile-time one, unless the attribute is one of the 31 governed base attributes described in Profile fields and custom attributes, which do carry a real declared type.
has(user.x) is the null-safe presence test. Reading a bare user.x when the key is missing is a runtime error rather than an empty string or a false — and for a predicate (a group rule or a policy condition) a runtime error is simply treated as not-matched, never as a crash and never as a match. Write has(...) first if the field is only sometimes present.
The syntax: Okta-EL words, CEL underneath
What you type is not literally CEL (Common Expression Language) — it is Okta's own expression vocabulary, which is rewritten into CEL before it is compiled. The rewrite is lexical and it keeps track of where every token came from, so a compile error is reported at the column you typed, not at the column the rewritten CEL ended up at.
AND,OR,NOT(upper case, whole word) work as you'd expect and become&&,||,!.- A brace group with no top-level colon is a list:
{'a', 'b'}is the same as['a', 'b'], which letsuser.isMemberOf({'group.id': {'…'}})parse the way Okta's own documentation writes it. Time.now()reads as thenowvariable.Groups.fn(...)becomesGroups.fn(user, ...)— Okta'sGroups.*functions implicitly read the current user, and the underlying engine's functions are pure, so the user is threaded in for you.- Single-quoted strings are left alone; CEL accepts them natively.
CEL's own operators and macros are all available once the rewrite is done: ==, !=, comparisons, &&, ||, !, the ternary ? :, in, indexing, has(), size(), s.matches(re), s.contains/startsWith/endsWith, and the .filter/.exists/.all/.map macros over lists.
The function table
| Namespace | Functions |
|---|---|
String. | len, substring, substringBefore, substringAfter, stringContains, startsWith, endsWith, toUpperCase, toLowerCase, removeSpaces, replace, replaceFirst, append, join, split, indexOf, lastIndexOf, stringSwitch(s, {k: v}, default) |
Arrays. | contains, add, remove, clear, size, isEmpty, flatten, toCsvString, get |
Convert. | toInt, toNum, toBool — strings, numbers and booleans are accepted; anything else is an error |
Time. | now(), isBetween(t, a, b), format(t, layout), toWindowsTime(t), toUnixTime(t); CEL's own timestamp("…"), duration("…") and arithmetic on timestamps also work |
Groups. | contains(appType, pattern, limit), startsWith(...), endsWith(...) over your group names — appType is accepted for Okta-compatibility and ignored |
user. | isMemberOf({...}), isMemberOfGroup(id), isMemberOfAnyGroup(id, …), isMemberOfGroupName(name), isMemberOfGroupNameStartsWith(p), isMemberOfGroupNameContains(p), isMemberOfGroupNameRegex(re), getInternalProperty(name) |
String.stringSwitch is built as stringSwitch(s, {k: v}, default). Okta's own signature is a variadic (s, default, k1, v1, …) — if you're porting a rule from Okta, the argument order is the one difference to watch for.
Regular expressions: RE2, literal, bounded
Anywhere a regular expression is accepted — .matches(re), isMemberOfGroupNameRegex(re) — three rules apply and are enforced at compile time, not at run time:
- The pattern must be a string literal. A regex built at run time from a variable is refused; a validator has to be able to see what it is checking.
- 512 bytes maximum.
- RE2 syntax, ≤ 2,000 compiled instructions. Backreferences and the catastrophic-backtracking constructs some other engines allow are simply not available in RE2, which is also what makes the instruction-count cap meaningful — you cannot build a pattern that blows up at match time.
The sandbox limits, all fail closed
| Limit | Value |
|---|---|
| Source size | 4,096 bytes |
| Compile cache | up to 1,024 compiled programs, keyed by scope and a hash of the source — a rule you save once is not recompiled on every evaluation |
| Cost budget per evaluation | 10,000 CEL cost units; a compile that can already prove it will exceed this is refused before it is ever saved |
| Evaluation deadline | 50ms in normal use, 250ms inside a preview |
| Regex | literal only, ≤512 bytes, RE2, ≤2,000 instructions |
| Rules/mappings per organisation with an expression | 2,000 each |
| I/O | none — every function is pure; groups, profile and manager data are all loaded before the expression runs, never fetched by it |
Validating an expression before you save it
POST /api/v1/organizations/{orgID}/expressions/validate takes {scope, expression} and returns whether it compiles, a list of issues (each with a line and column in your original text, not the rewritten CEL), the rewritten CEL for reference, the static list of attribute paths the expression reads, the inferred output type, a SHA-256 of the source, and a conservative minimum cost. Nothing is evaluated against real data — this is a compile-only check, and it needs only organizations:read.
Every screen that accepts an expression — the group rule editor, the sign-on policy condition editor, the mapping editor — uses the same ExpressionEditor component: a code box, a palette of ready-made snippets for the current scope, a debounced (350ms) call to the validate endpoint as you type, issues rendered as a caret under the exact column with click-to-jump, a live byte counter against the 4KB ceiling, and attribute-name suggestions drawn from your organisation's profile schema. Anything you type into it is rendered back to you as plain text, never interpreted, so pasting somebody else's expression cannot execute anything by itself.
Where this shows up
- Group rules with expressions — dynamic group membership,
group_rulescope. - Sign-on policies and the decision trace — policy conditions,
policyscope. - Profile and app-attribute mappings (
mappingscope) drive SAML attribute and group statements, custom NameID, OIDC custom claims, and per-app username format — see Registering an application for sign-in for the username-format use of it. A dedicated mapping-editor console screen is not built yet; mappings are created and previewed overGET/POST /organizations/{orgID}/profile-mappingsand.../profile-mappings/previewtoday.