Skip to content

Conditional Approval with CEL

Learn how to use Common Expression Language (CEL) to create smart, conditional approval workflows.

Availability

Conditional approvals using CEL are an Enterprise feature.


What is CEL?

CEL (Common Expression Language) is a simple, fast, and safe expression language designed for evaluating conditions.

Why CEL for Approval Workflows?

Instead of approving everything, use CEL to approve only when conditions match:

Without CEL:

tool: deploy
approval: required  # ALWAYS require approval
Every deploy requires approval, even to dev/staging.

With CEL:

tool: deploy
condition: args.environment == "production"
approval: required  # ONLY require approval for production
Only production deploys require approval.


Access Rule CEL Examples

Common CEL conditions for access rules:

Expression Use case
args.environment == "production" Match production deployments
args.amount > 1000 && args.currency == "USD" Match high-value USD transactions
args.command.contains("rm") \|\| args.command.contains("delete") Match destructive commands

OSS vs Enterprise

In the open-source edition, only a single simple condition per rule is supported. The Enterprise Edition adds multiple conditions with AND/OR operators and a CEL editor for manual expression editing.


Basic Syntax

Comparison Operators

Operator Meaning Example
== Equals args.amount == 1000
!= Not equals args.environment != "dev"
> Greater than args.amount > 1000
>= Greater or equal args.amount >= 1000
< Less than args.amount < 1000
<= Less or equal args.amount <= 1000

Logical Operators

Operator Meaning Example
&& AND args.amount > 1000 && args.environment == "production"
\|\| OR args.priority == "critical" \|\| args.priority == "high"
! NOT !args.dry_run

Membership Operators

Operator Meaning Example
in Member of list args.environment in ["production", "staging"]
has() Has property has(args.rollback_on_failure)

Common Patterns

Pattern 1: Environment-Based Approval

Approve only production deploys:

args.environment == "production"

Approve production OR staging:

args.environment in ["production", "staging"]

Approve anything except dev:

args.environment != "dev"

Pattern 2: Amount-Based Approval

Approve payments over $1000:

args.amount > 1000

Approve payments between $1000 and $10000:

args.amount >= 1000 && args.amount <= 10000

Approve large OR international payments:

args.amount > 10000 || args.currency != "USD"

Pattern 3: Priority-Based Approval

Approve critical or high priority:

args.priority in ["critical", "high"]

Approve if priority is set and critical:

has(args.priority) && args.priority == "critical"

Pattern 4: Time-Based Approval

Approve outside business hours:

timestamp(now()).getHours() < 9 || timestamp(now()).getHours() > 17

Approve on weekends:

timestamp(now()).getDayOfWeek() in [0, 6]

Approve during maintenance window:

timestamp(now()).getHours() >= 2 && timestamp(now()).getHours() <= 4

Pattern 5: String Matching

Approve if contains substring:

args.message.contains("urgent")

Approve if starts with prefix:

args.branch.startsWith("hotfix/")

Approve if matches pattern:

args.email.matches("@acme\\.com$")

Pattern 6: List Operations

Approve if list is non-empty:

size(args.recipients) > 0

Approve if specific item in list:

"admin@acme.com" in args.recipients

Approve if list exceeds size:

size(args.recipients) > 10

Real-World Examples

Example 1: Safe Production Deployments

Requirement: Require approval for production deploys, except during maintenance windows

tool: deploy
condition: |
  args.environment == "production" &&
  !(timestamp(now()).getHours() >= 2 && timestamp(now()).getHours() <= 4)
approval: required
approvers: [sre_team]

Explanation:

  • args.environment == "production" - Must be production
  • !(...) - NOT during...
  • timestamp(now()).getHours() >= 2 && timestamp(now()).getHours() <= 4 - Between 2-4 AM
  • Result: Approve production deploys EXCEPT 2-4 AM (maintenance window)

Example 2: Tiered Payment Approval

Requirement: Different approval levels based on amount

# Tier 1: $1000-$10000 → Finance team
tool: pay
condition: args.amount >= 1000 && args.amount < 10000
approval: required
approvers: [finance_team]
quorum: 1

---

# Tier 2: $10000+ → Finance + CEO
tool: pay
condition: args.amount >= 10000
approval: required
approvers: [finance_team, ceo@acme.com]
quorum: 2

Example 3: Database Operations

Requirement: Approve destructive operations on production databases

tool: drop_table
condition: args.database.startsWith("prod_")
approval: required
approvers: [database_team]
quorum: 2

Example 4: Critical Bug Fixes

Requirement: Fast-track critical bugs, approve normal priority changes

tool: update_issue
condition: |
  has(args.priority) &&
  args.priority in ["critical", "high"] &&
  args.status == "in_progress"
approval: required
approvers: [team_lead@acme.com]
timeout: 300  # 5 minutes for fast response

Example 5: Bulk Operations

Requirement: Approve operations affecting many items

tool: send_email
condition: size(args.recipients) > 50
approval: required
approvers: [marketing_team]

Example 6: Off-Hours Deployments

Requirement: Extra approval for deployments outside business hours

tool: deploy
condition: |
  args.environment == "production" &&
  (timestamp(now()).getHours() < 9 ||
   timestamp(now()).getHours() > 17 ||
   timestamp(now()).getDayOfWeek() in [0, 6])
approval: required
approvers: [sre_team, cto@acme.com]
escalation:
  enabled: true
  escalate_to: [ceo@acme.com]
  escalation_delay: 600

Available Variables

args - Tool Arguments

Access any argument passed to the tool:

args.amount          # Numeric argument
args.environment     # String argument
args.dry_run         # Boolean argument
args.recipients      # List argument
args.metadata.key    # Nested object

user - Current User

Information about who is calling the tool:

user.email           # User's email
user.role            # User's role (owner, admin, editor, viewer)
user.teams           # List of teams user belongs to
user.id              # User ID

Example:

# Only require approval if user is not admin
user.role != "admin"

tool - Tool Information

Metadata about the tool being called:

tool.name            # Tool name (e.g., "deploy")
tool.source          # "builtin" or external MCP server name

Example:

# Approve all external tools
tool.source != "builtin"

timestamp() - Time Functions

Current time and date functions:

timestamp(now()).getHours()        # 0-23
timestamp(now()).getMinutes()      # 0-59
timestamp(now()).getDayOfWeek()    # 0=Sunday, 6=Saturday
timestamp(now()).getDayOfMonth()   # 1-31
timestamp(now()).getMonth()        # 0-11 (0=January)
timestamp(now()).getFullYear()     # 2025

Example:

# Approve only on weekdays during business hours
timestamp(now()).getDayOfWeek() >= 1 &&
timestamp(now()).getDayOfWeek() <= 5 &&
timestamp(now()).getHours() >= 9 &&
timestamp(now()).getHours() <= 17

Advanced Techniques

Multi-Condition Policies

Use multiple policies for the same tool with different conditions:

# Policy 1: Small amounts, anyone can approve
tool: pay
condition: args.amount < 1000
approval: required
approvers: [finance_team]
quorum: 1

---

# Policy 2: Large amounts, need 2 approvals
tool: pay
condition: args.amount >= 1000 && args.amount < 10000
approval: required
approvers: [finance_team]
quorum: 2

---

# Policy 3: Very large amounts, need CFO
tool: pay
condition: args.amount >= 10000
approval: required
approvers: [cfo@acme.com]

Evaluation order: First matching policy wins.

Nested Conditions

Access nested object properties:

tool: deploy
condition: |
  args.config.replicas > 10 &&
  args.config.resources.cpu > "2000m"
approval: required

List Comprehensions

Check if all/any items match condition:

# Approve if ANY recipient is external
args.recipients.exists(r, !r.endsWith("@acme.com"))

# Approve if ALL amounts are over $100
args.items.all(i, i.amount > 100)

Default Values

Handle missing arguments:

# Use default value if argument missing
args.dry_run || false

# Check if argument exists first
has(args.environment) && args.environment == "production"

Testing CEL Expressions

Expression Tester (Web UI)

  1. Navigate to ApprovalsPolicies
  2. Click Test Expression
  3. Enter your CEL expression
  4. Provide sample arguments
  5. Click Evaluate

Example test:

Expression: args.amount > 1000 && args.environment == "production"

Sample Arguments:
{
  "amount": 5000,
  "environment": "production"
}

Result:  true (approval required)

📸 Screenshot needed: cel-expression-tester.png

Command Line Testing

Use the Preloop CLI:

preloop cel test \
  --expression 'args.amount > 1000' \
  --args '{"amount": 5000}'

# Output: true

Common Test Cases

Always test these scenarios:

  1. Boundary values - Test exactly at thresholds (amount == 1000)
  2. Missing arguments - Test with required args missing
  3. Edge cases - Test with empty lists, null values
  4. Type mismatches - Test with wrong types (string instead of number)

Common Mistakes

Mistake 1: Using assignment instead of comparison

Wrong:

args.environment = "production"  # Single =

Correct:

args.environment == "production"  # Double ==

Mistake 2: Not checking if argument exists

Wrong:

args.optional_field == "value"  # Errors if field missing

Correct:

has(args.optional_field) && args.optional_field == "value"

Mistake 3: String comparison with wrong case

Wrong:

args.environment == "Production"  # Case-sensitive!

Correct:

args.environment.lowerAscii() == "production"

Mistake 4: Confusing OR and AND

Wrong:

args.environment == "production" || "staging"  # Doesn't work

Correct:

args.environment in ["production", "staging"]
# OR
args.environment == "production" || args.environment == "staging"

Mistake 5: Forgetting parentheses with complex logic

Wrong:

args.amount > 1000 && args.environment == "production" || args.priority == "critical"
# Evaluates as: (amount > 1000 && env == prod) || priority == critical

Correct:

args.amount > 1000 && (args.environment == "production" || args.priority == "critical")


CEL Function Reference

String Functions

Function Description Example
contains(str) Check if contains substring args.message.contains("urgent")
startsWith(str) Check if starts with args.branch.startsWith("hotfix/")
endsWith(str) Check if ends with args.email.endsWith("@acme.com")
matches(regex) Match regex pattern args.email.matches("^[a-z]+@")
lowerAscii() Convert to lowercase args.env.lowerAscii() == "production"
upperAscii() Convert to uppercase args.priority.upperAscii() == "HIGH"
size() Length of string size(args.message) > 100

List Functions

Function Description Example
size(list) Length of list size(args.recipients) > 10
in Check membership "admin" in args.roles
has(list) Check if list exists has(args.tags)
exists(var, cond) Check if any match args.items.exists(i, i.amount > 1000)
all(var, cond) Check if all match args.items.all(i, i.verified == true)

Numeric Functions

Function Description Example
int() Convert to integer int(args.amount) > 1000
double() Convert to double double(args.percentage) > 0.5
abs() Absolute value abs(args.delta) > 100

Timestamp Functions

Function Description Example
timestamp(now()) Current timestamp timestamp(now()).getHours()
getHours() Hour (0-23) timestamp(now()).getHours() >= 9
getMinutes() Minutes (0-59) timestamp(now()).getMinutes() < 30
getDayOfWeek() Day (0=Sun, 6=Sat) timestamp(now()).getDayOfWeek() >= 1
getMonth() Month (0-11) timestamp(now()).getMonth() == 11
getFullYear() Year timestamp(now()).getFullYear() == 2025