Pull-gate policy engine
The pull-gate is a synchronous policy gate evaluated before every artifact serve. The default state is fully off — no policy fires, no per-pull audit row is written, no per-request DB hit happens — so a fresh install behaves exactly as it did before the engine existed.
When enabled, the engine consults a layered policy set (global → project → repo, most specific wins) and returns one of three decisions per pull:
| Decision | Wire shape | Notes |
|---|---|---|
allow | normal serve | Fast-path; no body changes, no extra headers. |
bypass | normal serve, X-Orbital-PullGate-Decision: bypass | A whitelist pattern matched. Audit row still written. |
block | 403, X-Orbital-PullGate-* headers | Policy match criteria failed. Body explains the reason. |
Admin page
/admin/pull-gate is the operator surface. The page renders six cards:
- Master toggle —
enabledflag, sync-mode strategy, grace period, fallback action. Flipping the toggle while no policies exist is harmless: the engine no-ops on an empty policy set. - Live preview — runs the engine in simulate mode against the most-recent 200 artifacts so you see "if I flipped this on right now, X% of the next pulls would block, Y% would bypass" before you actually flip the switch.
- Block stats — Today / Last 7d / Last 30d / All-time block counters plus a 24h hourly sparkline plus the top five policies that fired most often in the last 30 days. Populated from
pull_gate_audit; refreshes every 30 s. - Live decision stream — Server-Sent Events tail of every gate decision. Allows are green, blocks red, bypasses amber. Auto- reconnects on transport errors; a 30 s heartbeat keeps proxies from reaping the connection. Best-effort — slow connections drop events rather than block the artifact-serve hot path.
- Policies — table + create/edit form. Scopes layer
global → project → repo; the engine fails closed so any layer can block. - Audit log — paginated tail of recent decisions, filterable by action chip (all / allow / block / bypass).
Sync modes
| Mode | Missing scan data |
|---|---|
pre-scan-cache (default) | Drives fallback_action — block (fail closed) or allow. |
block-until-scan | Always block. No fallback, no grace. |
optimistic-with-quarantine | Allow the first pull; subsequent pulls re-evaluate via auto-quarantine. |
grace_period_hours short-circuits all three modes: newly-uploaded artifacts skip the gate for N hours so the async scan worker has time to land. Default 24 h; set to 0 to gate every pull immediately.
Policy DSL
A policy row is a flat set of match-criteria fields, not a hand-written DSL. Each field is independent — the first failure wins; an empty field means "no rule on this dimension". The engine evaluates the most-specific scope first (repo → project → global) and short- circuits on the first block.
| Field | Effect |
|---|---|
require_signed | Block if the artifact has no Sigstore/CMS signature. Acknowledged in schema; the per-artifact signing record lands in a future polish batch. |
max_severity | LOW / MEDIUM / HIGH / CRITICAL. Block when the artifact's worst open scan finding crosses this ceiling. |
max_age_days | Block when the artifact is older than N days. 0 = no cap. |
require_sbom | Block when no row exists in sboms for the artifact. |
allowed_licenses | SPDX IDs. Non-empty list acts as an allow-list — block any other license. |
forbidden_licenses | SPDX IDs. Always block when the artifact's license matches. |
bypass_patterns | Path globs (?, *, **). Match short-circuits to bypass even if other criteria would block. |
block_message_template | Free-form string appended to the 403 body and surfaced to the developer console. |
Sample policies
Five common shapes, ready to paste into the policy editor:
// 1. Block CRITICAL across the whole instance.
{
"scope_kind": "global",
"name": "no-criticals",
"max_severity": "HIGH",
"block_message_template": "Pull blocked — see #platform-security."
}// 2. Forbid copyleft on a specific repo.
{
"scope_kind": "repo",
"scope_id": "00000000-0000-0000-0000-000000000000",
"name": "no-gpl-on-prod-images",
"forbidden_licenses": ["GPL-3.0-only", "AGPL-3.0-only"]
}// 3. Allow-list licenses in a regulated project.
{
"scope_kind": "project",
"scope_id": "00000000-0000-0000-0000-000000000000",
"name": "allowlist-only",
"allowed_licenses": ["Apache-2.0", "MIT", "BSD-3-Clause"]
}// 4. Require SBOM on every artifact globally.
{
"scope_kind": "global",
"name": "sbom-required",
"require_sbom": true,
"block_message_template": "Attach an SBOM via the publish step."
}// 5. Carve out distroless and an internal repo from severity rules.
{
"scope_kind": "global",
"name": "carveouts",
"max_severity": "HIGH",
"bypass_patterns": ["docker/distroless/**", "npm/internal/*"]
}A/B-testing a new policy
- Stand up the policy with
enabled: false. - Edit it. The simulator reads disabled rows too — but the engine skips them on the live serve path.
- Open Live preview; the page calls the engine in simulate mode against the most recent 200 artifacts. Inspect the
block_pctand theby_reasonhistogram. - Once the simulated outcome matches what you want, flip the policy
enabledtoggle. Real pulls now feel it. - Open Live decision stream and watch the next minute of pulls to confirm the rule fires only where you intended.
The simulator and the engine share one code path (internal/security/pullgate/evaluate.go), so a simulated outcome is byte-identical to the real outcome modulo the cached snapshot age (≤ 5 s; the page surfaces the snapshot age in the preview footer).
API surface
All endpoints under /api/admin/pull-gate/ are admin-gated (admin / org_admin):
| Method | Path | Purpose |
|---|---|---|
| GET | /settings | Single-row config. |
| PUT | /settings | Update toggle, sync_mode, grace, fallback. |
| GET | /policies | List enabled + disabled policies, ordered repo→global. |
| POST | /policies | Create. |
| PUT | /policies/{id} | Full update. |
| DELETE | /policies/{id} | Delete. |
| GET | /audit | Paginated audit tail. ?decision=block to filter. |
| GET | /preview | Simulate against the recent N artifacts. |
| GET | /stats | Block counters + 24h sparkline + top policies. |
| GET | /stream | Server-Sent Events tail of every decision. |
After every settings or policy mutation the cached snapshot is force-refreshed so the next preview / next pull sees the new state without waiting on the 5 s poll cadence.
Headers on a block response
A block returns 403 plus:
| Header | Value |
|---|---|
X-Orbital-PullGate-Decision | block |
X-Orbital-PullGate-Reason | Single-line, ≤ 240 chars. |
X-Orbital-PullGate-Policy | Matched policy UUID. |
X-Orbital-PullGate-Policy-Name | Matched policy name (≤ 120 chars). |
X-Orbital-PullGate-Artifact | Artifact UUID. |
X-Orbital-PullGate-Severity | Worst open scan severity, when applicable. |
CI agents that strip the body but log headers (GitLab, some Jenkins runners) still see the reason. The body is text by default and JSON when the request Accept: application/json.
Operational defaults
- Off until you opt in. Safe for any existing deployment.
- Cached snapshot, refreshed every 5 s. No per-request DB hit.
- Audit table is durable. The live stream is best-effort tail — slow connections drop events rather than block the serve path.
- Bypass takes precedence over block, so an emergency carve-out doesn't require disabling the whole policy.