Skip to content

ZPA data model quirks

If you are building anything against the ZPA API, save yourself the rediscovery tax and read this page first. Every item below cost real time to figure out.

Operand value lives on RHS

Across every operand ObjectType (SCIM_GROUP, SCIM, APP, APP_GROUP, CLIENT_TYPE, PLATFORM, TRUSTED_NETWORK, …), the single source of truth for the value is the RHS string field.

The struct also has Values []string. It is always nil. Do not read it. Do not write it. Trust RHS.

Why: the SDK type is generic across an old and new operand schema. Only the new shape is in use.

Rule ordering uses RuleOrder, not Priority

RuleOrder is a string. Parse it with strconv.Atoi. Sort ascending.

Priority exists on the struct but does not drive evaluation order in any context we have observed. Ignore it.

Disabled is a string, not a bool

"0" means enabled. "1" means disabled. Anything else is a bug somewhere.

SCIM operand ID types

SCIM group IDs are stored as int64 in the index (Index.ScimGroups map[int64]*ScimGroup). On the wire and in policy operands they appear as strings. Convert at the boundary - convert once, fail loudly if it does not parse.

SCIM attribute headers and values are strings throughout. Don’t try to be clever and unify them.

Segment / segment-group is one-to-one

A given application segment belongs to exactly one segment group. ZPA enforces this. The backlink index relies on it: SegmentToGroup is a map[string]string, not map[string][]string.

If you ever observe a segment in two groups simultaneously, something is broken upstream. File it.

Domain matching has wildcards

Segments declare an array of domain entries. Each entry is either:

  • An exact hostname (db.prod.example.com).
  • A wildcard (*.prod.example.com) which matches any single-or-multi-label prefix.

The reachability query walks parents bottom-up: exact -> *.parent -> *.grandparent -> *.tld. First match wins.

Multiple segments can declare the same domain or wildcard. That is the basis of the domain overlap report.

Empty condition list = match everyone

A policy rule with zero conditions matches every user. ZPA actually behaves this way - it is not an SDK quirk. The simulator surfaces it as a warning because it is rarely intentional.

Unknown ObjectType

The SDK ships strings for object types but the union is not closed - new ones appear. The simulator’s policy is “skip with a warning” rather than “guess”. When you see a SkipReason mentioning an unknown type, that is the simulator refusing to invent semantics.

Authentication

ZPA uses ZIdentity (ZID) OAuth2. The same credentials also unlock the OneAPI / BI endpoint at https://api.zsapi.net/bi/api/v1/report/all, provided you grant the right scopes on the ZIdentity client. PainScaler does not yet use that endpoint - it is on the roadmap.

The things ZPA does not give you

  • SCIM group membership: there is no management-API call that returns “user X is in groups Y, Z”. The SCIM API requires a per-IdP static bearer token, which is operationally hostile. The realistic answer is going direct to the IdP (Azure AD Graph, Okta API). Not implemented yet.
  • Per-rule hit counts: ZPA does not expose how often each access policy rule actually fires. You can guess from the BI endpoint logs if you have them; you cannot ask the management API.
  • Backlinks of any kind: the API gives you forward edges only. Everything in PainScaler’s index that ends in *ToPolicies is computed, not fetched.