Skip to content

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 (globalprojectrepo, most specific wins) and returns one of three decisions per pull:

DecisionWire shapeNotes
allownormal serveFast-path; no body changes, no extra headers.
bypassnormal serve, X-Orbital-PullGate-Decision: bypassA whitelist pattern matched. Audit row still written.
block403, X-Orbital-PullGate-* headersPolicy match criteria failed. Body explains the reason.

Admin page

/admin/pull-gate is the operator surface. The page renders six cards:

  1. Master toggleenabled flag, 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.
  2. 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.
  3. 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.
  4. 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.
  5. Policies — table + create/edit form. Scopes layer global → project → repo; the engine fails closed so any layer can block.
  6. Audit log — paginated tail of recent decisions, filterable by action chip (all / allow / block / bypass).

Sync modes

ModeMissing scan data
pre-scan-cache (default)Drives fallback_action — block (fail closed) or allow.
block-until-scanAlways block. No fallback, no grace.
optimistic-with-quarantineAllow 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.

FieldEffect
require_signedBlock if the artifact has no Sigstore/CMS signature. Acknowledged in schema; the per-artifact signing record lands in a future polish batch.
max_severityLOW / MEDIUM / HIGH / CRITICAL. Block when the artifact's worst open scan finding crosses this ceiling.
max_age_daysBlock when the artifact is older than N days. 0 = no cap.
require_sbomBlock when no row exists in sboms for the artifact.
allowed_licensesSPDX IDs. Non-empty list acts as an allow-list — block any other license.
forbidden_licensesSPDX IDs. Always block when the artifact's license matches.
bypass_patternsPath globs (?, *, **). Match short-circuits to bypass even if other criteria would block.
block_message_templateFree-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:

jsonc
// 1. Block CRITICAL across the whole instance.
{
  "scope_kind": "global",
  "name": "no-criticals",
  "max_severity": "HIGH",
  "block_message_template": "Pull blocked — see #platform-security."
}
jsonc
// 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"]
}
jsonc
// 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"]
}
jsonc
// 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."
}
jsonc
// 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

  1. Stand up the policy with enabled: false.
  2. Edit it. The simulator reads disabled rows too — but the engine skips them on the live serve path.
  3. Open Live preview; the page calls the engine in simulate mode against the most recent 200 artifacts. Inspect the block_pct and the by_reason histogram.
  4. Once the simulated outcome matches what you want, flip the policy enabled toggle. Real pulls now feel it.
  5. 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):

MethodPathPurpose
GET/settingsSingle-row config.
PUT/settingsUpdate toggle, sync_mode, grace, fallback.
GET/policiesList enabled + disabled policies, ordered repo→global.
POST/policiesCreate.
PUT/policies/{id}Full update.
DELETE/policies/{id}Delete.
GET/auditPaginated audit tail. ?decision=block to filter.
GET/previewSimulate against the recent N artifacts.
GET/statsBlock counters + 24h sparkline + top policies.
GET/streamServer-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:

HeaderValue
X-Orbital-PullGate-Decisionblock
X-Orbital-PullGate-ReasonSingle-line, ≤ 240 chars.
X-Orbital-PullGate-PolicyMatched policy UUID.
X-Orbital-PullGate-Policy-NameMatched policy name (≤ 120 chars).
X-Orbital-PullGate-ArtifactArtifact UUID.
X-Orbital-PullGate-SeverityWorst 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.

Released under the Apache-2.0 License.