Skip to content

Architecture

PainScaler is intentionally boring under the hood: stateless API on top, a shared cache layer in the middle, and the ZPA SDK at the bottom. The clever part is what gets cached and what gets indexed.

Three layers

+--------------------------------------------------------------+
| Frontend (React + Vite + PatternFly) |
| Typed fetch wrappers in api.gen.ts -> JSON endpoints |
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| Query layer (internal/server/handlers.go) |
| - Reads from index, runs simulator, returns reports |
| - No SDK calls in this layer |
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| Index layer (internal/index/index.go) |
| - In-memory Index struct: maps + inverted indexes |
| - Built by BuildIndex(ctx) which calls fetcher.CachedFetch|
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| Fetch layer (internal/fetcher/fetcher.go) |
| - Wraps zscaler-sdk-go calls |
| - Per-resource cache, on-demand load |
| - Authenticates once per process via ZIdentity |
+--------------------------------------------------------------+
|
v
+-----------------+
| ZPA Public API |
+-----------------+

Fetch layer

internal/fetcher exposes one CachedFetch[T] helper plus a LoadX function per ZPA resource. First call hits the SDK; subsequent calls hit the cache. The cache is process-scoped - restart the binary to refresh.

There is no automatic poll. We picked “explicit refresh” over “stale data without warning”. A daemon mode with periodic refresh is on the list.

Index layer

BuildIndex(ctx) pulls everything the index needs through CachedFetch, then constructs the Index struct. Direct maps (Segments, Policies, SegmentGroups, …) plus inverted indexes:

Inverted indexAnswers
SegmentToPolicies”Which policies reference this segment?”
GroupToPolicies”Which policies reference this segment group?”
DomainToSegments”Which segments cover this hostname?”
ScimAttrNameToIDName lookup for SCIM attribute headers
OrphanSegmentsSegments with zero policy coverage
DisabledSegmentsSegments flagged disabled
OverlappingDomainsDomains that appear in more than one segment
PolicyToScimGroupsReverse of “policy applies to which user groups”
ConnectorGroupToPolicies”If this connector group dies, which policies break?”
PolicyToConnectorGroupsThe other direction
ConnectorGroupNamesID -> name lookup

These backlinks are why search and reachability are fast and why the analytics layer can compute blast radius without re-traversing the world.

Query layer

internal/server/handlers.go is the only thing the frontend calls. Each handler is a method on *Server. The handlers are thin: they read from the index (or call into internal/analysis or internal/simulator) and return JSON. No business logic in HTTP land.

Storage

One SQLite database, one table:

CREATE TABLE simulation_runs (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
created_by TEXT,
context TEXT NOT NULL, -- JSON-encoded SimContext
result TEXT NOT NULL, -- JSON-encoded DecisionResult
segment_id TEXT,
fqdn TEXT,
action TEXT
);

Path: ${XDG_CONFIG_HOME}/painscaler/runs.db. In Docker, that resolves to /data/painscaler/runs.db on the painscaler_data named volume.

The schema uses sqlc for type-safe Go bindings. See internal/storage/ for query.sql and the generated query.sql.go.

store.Open runs an idempotent migrate() on startup that uses PRAGMA table_info to check whether columns exist before issuing ALTER TABLE. Adding columns is safe; renaming is not implemented (rare enough to do by hand).

Codegen

Two generators run as part of normal development:

ToolInputOutput
go run ./apigen//api:route and //api:header comments in internal/server/handlers.goGin routes, OpenAPI JSON, TS types and client
sqlc generateinternal/storage/schema.sql + query.sqlGo models.go + query.sql.go

Both outputs are committed. Both are regenerated by hand when their inputs change. Neither has tests around the generation step itself - the test is “the generated code compiles and the app runs”.

Open problems

These are known limitations, not bugs.

  • SCIM group membership: ZPA does not expose user-to-group membership through its management API. Per-IdP static SCIM bearer tokens are impractical at scale. The likely path is going direct to the IdP (Azure AD Graph, Okta API). Not implemented yet.
  • OneAPI / BI endpoint: https://api.zsapi.net/bi/api/v1/report/all is authenticated via ZIdentity OAuth2. Permissions are already added on the ZIdentity side; the integration code is not.
  • Daemon mode + scheduled refresh: the binary today builds the index on first request. A long-running mode that polls and rebuilds in the background would eliminate the cold-start wait. Not done.
  • Frontend simulator UI: SimContextForm runs a simulation but the result rendering for the new graph component is not fully wired. The text view works.