Onboard Zscaler ZIA, ZPA, and ZDX as a managed enforcement plane in Versus — connect over OneAPI or legacy auth, ingest cloud firewall and access policy into the normalized rule model, stream NSS/LSS usage logs, and drive activation-aware change automation.
Zscaler is not another firewall appliance — it is a cloud-delivered SSE/SASE enforcement plane. Versus models it that way. Instead of forcing Zscaler into the per-device, 5-tuple firewall mold, the integration extends the platform's own primitives: identity- and application-aware selectors on the normalized rule model, topology nodes for enforcement points, log-derived usage, and an activation-aware change path that respects how ZIA actually commits configuration.
Three products are in scope, selected per source:
| Product | Key | Role in Versus |
|---|---|---|
| Zscaler Internet Access | zia | Cloud firewall, URL filtering, SSL inspection policy |
| Zscaler Private Access | zpa | App segments and access policy |
| Zscaler Digital Experience | zdx | Telemetry (read-only) |
Everything below is exposed under /api/zscaler/ and, for administrators, under the Zscaler area of the console (Setup, Rules, Topology, Ingest).
A Zscaler Source (ZscalerSource) represents one Zscaler org/tenant and holds the credentials for every product you enable on it. You create one per org from Zscaler → Setup, or over the API:
POST /api/zscaler/sources/
Authorization: Bearer …
Content-Type: application/json
{
"name": "acme-prod",
"auth_mode": "oneapi",
"vanity_domain": "acme.zslogin.net",
"client_id": "…",
"client_secret": "…",
"zpa_customer_id": "…",
"products_enabled": ["zia", "zpa"],
"device": 128
}
Secret fields (client_secret, zia_password, zia_api_key, and NSS/LSS shared_secret) are write-only and stored encrypted at rest. They never appear in a GET response — the Versus Vault posture applies here as everywhere else.
auth_mode: "oneapi" uses ZIdentity OAuth 2.0 client-credentials against your vanity domain. A single bearer token spans ZIA, ZPA, and ZDX, and the connector refreshes it automatically before expiry. The connector derives each product base from the source:
https://zsapi.{zia_cloud}/api/v1https://{zpa_cloud}/mgmtconfig/v1/admin/customers/{zpa_customer_id} (default cloud config.private.zscaler.com)ZPA calls additionally carry the zpa_customer_id (and optional zpa_microtenant_id).
auth_mode: "legacy" is the path for tenants not migrated to ZIdentity and for GOV/GOVUS clouds, which OneAPI does not serve. In legacy mode:
/authenticatedSession, yielding a JSESSIONID cookie that subsequent calls carry. The connector logs out at the end of a mutating session./signin.Setting gov_cloud: true forces legacy auth and is treated as read-only — a deliberate guardrail until a write path is validated against a GOV tenant.
Whichever mode a source uses is invisible to callers: get_zia_client() / get_zpa_client() hand the rest of the platform one uniform client.
Ingestion is a full pull per product, versioned as a ZscalerSnapshot. Each sync creates a snapshot, records the raw pull in data, computes a lightweight diff against the prior successful snapshot, and — when a change is detected — emits a rulebase_changed signal that the rest of the platform consumes.
ZIA pulls locations and location groups, cloud firewall filtering rules, URL filtering and SSL inspection rules, IP source/destination groups, network services, URL categories, departments, and groups. ZPA pulls application segments, segment groups, server groups, app connector groups, access policy rules, and timeout policies. Pagination is handled inside the connector.
Trigger a sync inline for one source:
POST /api/zscaler/sources/{id}/sync/
200 OK
{
"ok": true,
"results": {
"zia": { "snapshot_id": 51, "firewall_rules": 42 },
"zpa": { "snapshot_id": 52, "access_policies": 18 }
},
"errors": []
}
For steady-state operation, the zscaler_sync management command pulls every active source and is meant to run on a scheduler (roughly every 30 minutes — Zscaler config data is small and ZIA has no config-change webhooks, so polling is the reliable floor):
python manage.py zscaler_sync [--source-id <id>]
Snapshots are read-only through GET /api/zscaler/snapshots/?source={id}.
When a source is tied to a Device (vendor zscaler), each pulled rulebase is normalized into ordinary FirewallRule rows so Zscaler policy is searchable, reportable, and governable alongside your F5, NetScaler, and firewall estate. ZIA cloud firewall rules land in a zia_cloud_firewall rule set; ZPA access policy rules land in a zpa_access_policy rule set. Rules deleted upstream are removed on the next sync.
Because Zscaler rules are identity- and application-based rather than pure 5-tuple, the normalizer populates three extra selector dimensions on each rule:
| Selector | Dimension | Populated from |
|---|---|---|
principal_selector | Who | ZIA departments/groups/users; ZPA SCIM group/user conditions |
app_selector | What | ZIA URL categories and cloud apps; ZPA app segments |
enforcement_scope | Where | ZIA locations/location groups; tenant_wide vs scoped |
ZPA access policies can carry arbitrary operator trees (posture, trusted-network, SAML/IdP, platform, client-type). Rules whose conditions reduce cleanly to group membership are normalized in full; anything more complex is kept as an opaque attribute_expr and flagged partial_normalization — it still participates in inventory, search, and metadata compliance, but is excluded from set-containment (shadow/redundancy) analysis where a lossy comparison would be misleading.
Zscaler enforcement points are modeled as typed PEP nodes (ZscalerPEPNode) so they can attach to your existing site/zone topology:
| Node type | Represents |
|---|---|
zscaler_zia_tenant | The ZIA cloud enforcement plane (one per org) |
zscaler_zia_location | A configured ZIA location / sub-location (branch, GRE/IPSec source) |
zscaler_zpa_tenant | The ZPA broker plane |
zscaler_app_connector_group | ZPA connector groups fronting app segments |
ZIA locations attach as egress points on the zones whose traffic exits through them; ZPA connector groups attach to the zones containing the apps they serve. This is metadata-level topology — Versus does not attempt to compute a packet path through the Zscaler cloud, because that is unknowable from the API and adds no decision value. Nodes carry a zone label mapping them into the Versus zone grid, so a unified segmentation matrix can span on-prem firewalls and Zscaler together.
Zscaler exposes no per-rule hit counters, so usage is log-derived. You register an ingest endpoint (NSSLSSEndpoint) per source/product and point ZIA Cloud NSS (firewall/web/dns) or ZPA LSS (user activity) at it:
POST /api/zscaler/ingest/{endpoint_key}/
X-Zscaler-Signature: <hmac-sha256 of body, keyed by the shared secret>
{"rulename":"Contractors-SaaS","action":"ALLOW", …}
{"rulename":"Block-Malware","action":"DROP", …}
The receiver accepts newline-delimited JSON, verifies the optional HMAC signature against the endpoint's shared secret, extracts the matched rule identifier from each record (rulelabel / rulename for ZIA, policyname / ruleid for ZPA), and aggregates hit counts into RuleUsageState (hits_30d, hits_90d, hits_365d, last_hit_at). It never stores raw log bytes — only the aggregated counters and last-seen timestamps that policy governance needs.
The Zscaler usage provider reads those pre-aggregated rows, which keeps usage collection fast and stateless. Where Cloud NSS/LSS is not available, it falls back to Z-Insights GraphQL analytics at daily (coarse) granularity — and marks those samples coarse so unused-rule auto-delete is suppressed on GraphQL-only tenants. This is the same capability-flag branching that governs low-fidelity usage sources elsewhere on the platform.
ZIA writes are staged: a mutation returns 200 but has no live effect until you POST /status/activate. Activation is queued, cannot be reverted, and can collide with concurrent portal edits (409 EDIT_LOCK_NOT_AVAILABLE) or co-activate another admin's pending changes. Versus handles this structurally rather than by convention.
The ZIA write path stages a batch of mutations and applies them under one invariant: any apply that mutates must end in activate-or-compensate — never leave staged-but-unactivated ZIA state.
POST /api/zscaler/apply/
Authorization: Bearer …
Content-Type: application/json
{
"source_id": 7,
"dry_run": false,
"mutations": [
{ "method": "POST", "path": "/firewallFilteringRules", "body": { … } },
{ "method": "PUT", "path": "/firewallFilteringRules/123", "body": { … }, "object_id": "123" }
]
}
The apply runs in order:
/status. If another session has pending changes awaiting activation, policy is tenant-configurable — abort (the default) or proceed_with_warning (set via zia_foreign_pending_policy on the source's device config). Co-activating someone else's half-finished work is an unacceptable silent side effect, so aborting is the default.409 edit-lock collisions with exponential backoff up to a bounded 10-minute window.The response is an auditable record of exactly what happened:
200 OK
{
"success": true,
"mutations_applied": 2,
"activated": true,
"compensated": false,
"foreign_warning": "",
"error": "",
"audit_log": [
"POST /firewallFilteringRules obj=456 — OK",
"PUT /firewallFilteringRules/123 obj=123 — OK",
"Activating staged changes...",
"Activation successful."
]
}
To check for foreign pending changes without applying anything, call POST /api/zscaler/activate-pending/ with { "source_id": <id> }.
ZPA is different: its writes are immediate with no activation step, so the ZPA write path is a standard apply plus post-verify. Rollback for either product is a compensating change computed from the pre-change snapshot and pushed through the same path — there is no vendor-side revert.
Beyond policy change, Zscaler ships as a set of first-class workflow steps (keys under zscaler.*) for day-two operations, all driving the same REST clients:
CONNECTED after an upgrade.Seeded workflows assemble these into ready-to-run playbooks — a ZIA health check, a ZPA health check, a ZIA activation, and a combined ZIA + ZPA upgrade. Any of them can be launched from the console or driven from a pipeline exactly like any other automation; see Running Workflows via the API.
The Zscaler integration is deliberately additive. A Zscaler Source binds to an ordinary Device, its rules become ordinary FirewallRule rows, its enforcement points become topology nodes, and its usage feeds the same governance analyzers. Nothing about Zscaler requires a parallel stack:
zscaler PEP driver, extended only where the activation model demands it.The result: your cloud SSE plane and your on-prem estate are governed side by side, in one rule browser, one segmentation matrix, and one change pipeline.