cc serve — HTTP PDP mode
cc serve turns CrowdControl into a stateless HTTP policy decision point: load your .cc files once, evaluate JSON documents over HTTP, and stream structured audit logs. Single binary, zero dependencies, safe concurrent reads.
Most policy engines with a service mode (Cerbos, OPA) run as sidecars and ship audit logs by default. CrowdControl started as a library. cc serve closes that gap so non-Go services can get decisions without embedding an SDK — and so audit trails are a day-one feature, not a future roadmap item.
Quickstart
cc serve --policy ./policies --addr :8080 --audit-log /var/log/cc.jsonl
That's it. The server loads every .cc file under ./policies, listens on :8080, and streams one JSON-line audit record per decision to /var/log/cc.jsonl.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/evaluate | Evaluate a document and return per-rule results |
| GET | /v1/policies | List loaded rules (metadata only — names, kinds, owners, links) |
| GET | /healthz | Liveness probe: version, rule count, shadow flag |
| GET | /readyz | Readiness probe |
| GET | /metrics | Prometheus-format metrics |
| GET | / | Server info + endpoint list |
POST /v1/evaluate
Request
{
"input": {
"user": { "name": "alex", "role": "intern" },
"request": { "action": "delete" },
"resource": { "environment": "production" }
},
"explain": false
}
Set "explain": true (or query string ?explain=1) to include per-condition traces.
Response
{
"request_id": "9b1c3f2e...",
"decision": "deny",
"results": [
{
"rule": "no-interns-delete-prod",
"kind": "forbid",
"passed": false,
"message": "alex cannot delete prod",
"description": "Interns cannot delete prod resources",
"owner": "platform-security"
}
],
"elapsed_us": 45,
"evaluated_at": "2026-04-10T14:19:24Z"
}
HTTP status follows the decision:
200—allow403—deny400— invalid JSON or malformed input401— missing/wrong bearer token (only if--auth-tokenis set)503— not ready (policies not loaded yet)
Shadow mode
Shadow mode evaluates policies normally and emits full audit records, but always returns allow to the caller — even when a forbid rule fires. Use it to dark-launch a new policy and verify it doesn't break production before enforcing it.
cc serve --policy ./policies --shadow --audit-log -
Audit records in shadow mode set "shadow_masked": true so you can grep for "would have denied" events.
Audit log format
Each evaluated request emits at least one JSON-line record plus one additional record per fired forbid/warn rule.
{"ts":"2026-04-10T14:19:24.064Z","request_id":"9b1c...","decision":"deny","elapsed_us":45,"input_sha256":"f28278..."}
{"ts":"2026-04-10T14:19:24.064Z","request_id":"9b1c...","decision":"rule_fired","rule":"no-interns-delete-prod","kind":"forbid","owner":"platform-security","message":"alex cannot delete prod","elapsed_us":45,"input_sha256":"f28278..."}
--audit-log accepts:
/path/to/file.jsonl— append to a file (created if missing)-orstdout— write to standard output (for container logs)stderr— write to standard error- empty (flag omitted) — audit logging disabled
The input_sha256 field lets you correlate audit records with inputs without storing the raw input (which may contain PII).
Atomic reload with SIGHUP
Send SIGHUP to reload policies from disk without dropping connections:
kill -HUP $(pgrep cc)
The new engine is constructed off the hot path and swapped atomically. In-flight requests finish on the old engine; new requests see the new one. If the reload fails (syntax error in a new file), the server logs the error, increments cc_reload_errors_total, and keeps serving with the previous policies.
Metrics
GET /metrics returns a small set of Prometheus-format counters and gauges:
# HELP cc_requests_total Total evaluate requests by decision.
# TYPE cc_requests_total counter
cc_requests_total{decision="allow"} 1234
cc_requests_total{decision="deny"} 56
cc_requests_total{decision="shadow_masked_deny"} 0
cc_requests_total{decision="error"} 2
# HELP cc_rules_loaded Number of rules currently loaded.
# TYPE cc_rules_loaded gauge
cc_rules_loaded 3
# HELP cc_reload_total Number of successful policy reloads since start.
# TYPE cc_reload_total counter
cc_reload_total 4
# HELP cc_reload_errors_total Number of failed policy reloads since start.
# TYPE cc_reload_errors_total counter
cc_reload_errors_total 0
# HELP cc_shadow_mode 1 if shadow mode is enabled, 0 otherwise.
# TYPE cc_shadow_mode gauge
cc_shadow_mode 0
Authentication
Set --auth-token <token> to require a bearer token on every request to /v1/*. Health and readiness probes bypass auth so container orchestrators can still probe the server.
cc serve --policy ./policies --auth-token "$(cat /etc/secrets/cc-token)"
curl -X POST http://localhost:8080/v1/evaluate \
-H "Authorization: Bearer $(cat /etc/secrets/cc-token)" \
-H "Content-Type: application/json" \
-d '{"input":{"user":{"role":"admin"}}}'
Token comparison is constant-time (crypto/subtle.ConstantTimeCompare) to resist timing attacks. For anything beyond a single shared token — mTLS, JWT, OIDC — put cc serve behind a reverse proxy.
CORS
Pass --cors <origin> to add Access-Control-Allow-Origin headers and respond to preflight requests. Useful when a browser-based playground or admin UI needs to hit the PDP directly:
cc serve --policy ./policies --cors "https://admin.example.com"
Deployment patterns
Kubernetes sidecar
Run cc serve as a sidecar next to the app that needs decisions. Mount policies from a ConfigMap and audit logs to a shared volume or stdout for your cluster's log aggregator.
- name: cc
image: crowdcontrol-go:0.1.0
args:
- serve
- --policy=/etc/cc/policies
- --addr=:8080
- --audit-log=-
volumeMounts:
- name: policies
mountPath: /etc/cc/policies
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
Docker
docker run --rm -p 8080:8080 \
-v "$PWD/policies:/policies:ro" \
crowdcontrol-go cc serve --policy /policies
systemd
[Unit]
Description=CrowdControl PDP
After=network.target
[Service]
ExecStart=/usr/local/bin/cc serve --policy /etc/cc/policies --audit-log /var/log/cc.jsonl
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
User=cc
Group=cc
[Install]
WantedBy=multi-user.target
Operational caveats
- Request body cap. The server reads at most 4 MiB per request. Raise it only if your input documents are genuinely that large — most real policies evaluate plans or events well under 100 KiB.
- Audit backpressure. Audit writes are synchronous. If your audit sink is slow (e.g. a network-mounted log file), evaluation latency will track the write latency. Use
-for stdout in containerized environments and let the runtime handle it. - No rate limiting. Put
cc servebehind a reverse proxy if you need it. - Shadow mode is per-server. There's no per-rule shadow setting yet — the whole server is either enforcing or not.