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 index | Answers |
|---|---|
SegmentToPolicies | ”Which policies reference this segment?” |
GroupToPolicies | ”Which policies reference this segment group?” |
DomainToSegments | ”Which segments cover this hostname?” |
ScimAttrNameToID | Name lookup for SCIM attribute headers |
OrphanSegments | Segments with zero policy coverage |
DisabledSegments | Segments flagged disabled |
OverlappingDomains | Domains that appear in more than one segment |
PolicyToScimGroups | Reverse of “policy applies to which user groups” |
ConnectorGroupToPolicies | ”If this connector group dies, which policies break?” |
PolicyToConnectorGroups | The other direction |
ConnectorGroupNames | ID -> 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:
| Tool | Input | Output |
|---|---|---|
go run ./apigen | //api:route and //api:header comments in internal/server/handlers.go | Gin routes, OpenAPI JSON, TS types and client |
sqlc generate | internal/storage/schema.sql + query.sql | Go 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/allis 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:
SimContextFormruns a simulation but the result rendering for the new graph component is not fully wired. The text view works.