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_ruleStates 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,ActionMatched- did this rule decide the case?SkipReason- if the rule was skipped, the whyConditions[]- 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.
| Thing | Reality |
|---|---|
| Operand value field | RHS is the universal value field. Values []string is always nil. Trust RHS. |
| Rule order field | RuleOrder (string), parsed via strconv.Atoi. Not Priority. |
| Disabled flag | "0" / "1" string encoding, not boolean. |
Unknown ObjectType | Marked skipped with a warning. We do not guess. |
| Empty conditions | Match all users. With a warning. ZPA actually behaves this way. |
| First match wins | After 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
SkipReasonfirst - usually “all conditions false” or an unknown object type. - Match on the wrong rule: ZPA’s rule order is what bit you. Check
RuleOrdervalues 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.