Skip to main content

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.

Written for whoever runs IT10 min readUpdated

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.

The three scopes and the variables available in each.
ScopeUsed byVariables
group_ruleDynamic group membership rulesuser, now
policySign-on policy conditionsuser, context, app, now
mappingProfile 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 lets user.isMemberOf({'group.id': {'…'}}) parse the way Okta's own documentation writes it.
  • Time.now() reads as the now variable.
  • Groups.fn(...) becomes Groups.fn(user, ...) — Okta's Groups.* 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

Every namespaced function the engine registers, grouped as Okta groups them.
NamespaceFunctions
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

Every enforced limit on an expression, at compile time or at evaluation time.
LimitValue
Source size4,096 bytes
Compile cacheup 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 evaluation10,000 CEL cost units; a compile that can already prove it will exceed this is refused before it is ever saved
Evaluation deadline50ms in normal use, 250ms inside a preview
Regexliteral only, ≤512 bytes, RE2, ≤2,000 instructions
Rules/mappings per organisation with an expression2,000 each
I/Onone — 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_rule scope.
  • Sign-on policies and the decision trace — policy conditions, policy scope.
  • Profile and app-attribute mappings (mapping scope) 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 over GET/POST /organizations/{orgID}/profile-mappings and .../profile-mappings/preview today.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in identity and access