Skip to content

Policy simulator

The simulator is the headline feature. It answers “would this user reach this segment, and if not, why?” without you guessing.

How it works

A finite-state machine drives one evaluation:

idle
-> validate_context
(rejects: missing client_type, both segment_id + fqdn,
neither segment_id nor fqdn)
-> resolve_segment
(segment_id: direct lookup;
fqdn: exact-match domain index, falls back to wildcard parent walk)
-> sort_rules
(RuleOrder ascending; Disabled rules dropped here)
-> next_rule ----------+
| | (no rules left)
v v
eval_conditions decided (DEFAULT_DENY)
|
+--> matched: decided (rule.Action)
+--> no match: back to next_rule

States are constants in internal/simulator/states.go. The implementation uses looplab/fsm so adding a new state means a new transition entry plus a new callback - the dispatcher does not branch on state strings.

Inputs - SimContext

type SimContext struct {
ScimGroupIDs []string // user's SCIM group IDs
ScimAttrs map[string]string // attrDefID -> value
SegmentID string // either this...
SegmentGroupID string // ...or covered via group
FQDN string // ...or this hostname
ClientType string // required
TrustedNetwork string // optional
Platform string // optional
}

SegmentID and FQDN are mutually exclusive. Provide one. ClientType is always required - ZPA conditions can match on it, and an empty string makes several rules silently behave wrong.

Outputs - DecisionResult

type DecisionResult struct {
Action string // ALLOW | DENY | DEFAULT_DENY | NO_SEGMENT | INVALID_CONTEXT
MatchedRule *PolicyRule // populated when Action is ALLOW or DENY
Trace []RuleTrace // every rule the FSM looked at
Warnings []string // soft issues worth flagging
}

Trace is the part you actually want most of the time. Each entry has:

  • RuleID, RuleName, RuleOrder, Action
  • Matched - did this rule decide the case?
  • SkipReason - if the rule was skipped, the why
  • Conditions[] - per-condition results, each with operands, the operator used to combine them, whether the condition was negated, and the boolean outcome

This is what powers the rule-by-rule trace in the UI.

Locked-in details

These were costly to discover. Do not change them without reading the git history first.

ThingReality
Operand value fieldRHS is the universal value field. Values []string is always nil. Trust RHS.
Rule order fieldRuleOrder (string), parsed via strconv.Atoi. Not Priority.
Disabled flag"0" / "1" string encoding, not boolean.
Unknown ObjectTypeMarked skipped with a warning. We do not guess.
Empty conditionsMatch all users. With a warning. ZPA actually behaves this way.
First match winsAfter sorting by RuleOrder. No tie-breaking logic.

Interpreting the trace

Common shapes you will see:

  • Default deny with a long trace: every rule was either skipped or failed at least one condition. Look at SkipReason first - usually “all conditions false” or an unknown object type.
  • Match on the wrong rule: ZPA’s rule order is what bit you. Check RuleOrder values across the trace; the lowest matching one wins.
  • NO_SEGMENT: the segment ID does not exist or the FQDN does not match any indexed domain (exact or wildcard parent). Index might be stale. Restart the binary to refresh.
  • INVALID_CONTEXT: the SimContext failed validation before the FSM even started. The error message tells you which field.

Persistence

When Action is anything other than INVALID_CONTEXT, the run is persisted to simulation_runs in SQLite, attributed to the Remote-User header (or empty if running natively without a proxy). See API reference for the CRUD endpoints.

Tests

internal/simulator/simulator_test.go covers the FSM transitions and the condition evaluator end-to-end (~25 cases). When you change anything in this package, run those first.