Policy Gateway (Merged into Policy Engine)
Audience: Operators and integrators calling the Stella Ops Policy HTTP API (Console, CLI, and automation). Status: The
StellaOps.Policy.Gatewayservice has been merged intoStellaOps.Policy.Engine, and its source was withdrawn tosrc/__Obsoleted/Policy/on 2026-09-27 (PCD-73). All gateway endpoints (exceptions, deltas, gates, governance, tool-lattice, pack CRUD, activation) are now served directly by the policy-engine process. The separate gateway container, HTTP proxy layer, DPoP token flow, andPolicyEngineClienthave been removed.
This guide documents the HTTP surface and operational behaviour of the policy-engine endpoints that the former gateway exposed. For the full DTO and schema reference, see the Policy Engine API doc.
1 - Responsibilities (now handled by Policy Engine)
- Policy pack CRUD and activation endpoints enforcing scope policies (
policy:read,policy:author,policy:review,policy:operate,policy:activate). - Normalised responses (DTO +
ProblemDetails) so Console, CLI, and automation receive consistent payloads. - Structured logging and metrics for activation actions so approvals are auditable.
- Exception management, delta computation, gate evaluation, governance, and tool-lattice endpoints.
2 - Endpoints
All endpoints below are served by the policy-engine service.
| Route | Method | Description | Required scope(s) |
|---|---|---|---|
/api/policy/packs | GET | List policy packs and revisions for the active tenant. | policy:read |
/api/policy/packs | POST | Create a policy pack shell or upsert display metadata. | policy:author |
/api/policy/packs/{packId}/revisions | POST | Create or update a policy revision (draft/approved). | policy:author |
/api/policy/packs/{packId}/revisions/{version}:activate | POST | Activate a revision, enforcing single/two-person approvals. | policy:operate, policy:activate |
Response shapes
- Successful responses return camel-case DTOs matching
PolicyPackDto,PolicyRevisionDto, orPolicyRevisionActivationDtoas described in the Policy Engine API doc (/docs/api/policy.md). - Errors always return RFC 7807
ProblemDetailswith deterministic fields (title,detail,status).
Dual-control activation
- Config-driven. Set
PolicyEngine.activation.forceTwoPersonApproval=truewhen every activation must collect two distinctpolicy:activateapprovals. When false, operators can opt into dual-control per revision (requiresTwoPersonApproval: true). - Defaults.
PolicyEngine.activation.defaultRequiresTwoPersonApprovalfeeds the default when callers omit the checkbox/flag. - Statuses. First approval on a dual-control revision returns
202 pending_second_approval; duplicate actors get400 duplicate_approval; the second distinct approver receives the usual200 activated. - Audit trail. With
PolicyEngine.activation.emitAuditLogson, Policy Engine emits structuredpolicy.activation.*scopes (pack id, revision, tenant, approver IDs, comments).
Activation configuration wiring
- Compose/Offline Kit. Set
STELLAOPS_POLICY_ENGINE__ACTIVATION__*variables through the supported Compose profile, Offline Kit overlay, or host service configuration. - File-based overrides (optional). The Policy Engine host probes
/config/policy-engine/activation.yaml,../etc/policy-engine.activation.yaml, and ambientpolicy-engine.activation.yamlfiles beside the binary. - Unsupported deployment path. Helm charts, Kubernetes ConfigMaps, and pod
envFromwiring are retired and must not be used as Stella Ops deployment guidance.
3 - Authentication & headers
| Header | Source | Notes |
|---|---|---|
Authorization | Caller token. | Caller tokens must include tenant scope. |
X-StellaOps-TenantId | Caller | Tenant isolation header. |
Note: The previous DPoP proxy layer (gateway client credentials,
PolicyEngineClient,PolicyGatewayDpopHandler) has been removed. Callers authenticate directly with Policy Engine using standard StellaOps resource server authentication.
4 - Metrics & logging
All activation calls emit:
policy_engine_activation_requests_total{outcome,source}– counter labelled withoutcome(activated,pending_second_approval,already_active,bad_request,not_found,unauthorized,forbidden,error) andsource(caller,service).
Structured logs (category StellaOps.Policy.Engine.Activation) include PackId, Version, Outcome, Source, and status code for audit trails.
5 - Release Gate Runtime Integrity
POST /api/v1/policy/gate/evaluate and the scheduler-backed async gate worker are fail-closed for missing baselines. A request that omits baselineRef and has no tenant baseline now returns a denied gate by default with baselineStatus: "missing:block", blockedBy: "missing-baseline", and evidence describing the selected missing-baseline mode.
A token whose tenant does not resolve in Policy’s tenant catalog is refused with 400 (title Unknown tenant, the tenant named in detail) before any baseline lookup, bootstrap or Scanner call, and nothing is recorded. Before, such a request was evaluated and then failed with 500 when its decision was recorded.
Callers that need first-build behavior must choose it explicitly:
missingBaselineMode | Result when no baseline exists | Use case |
|---|---|---|
block or omitted | Deny/fail the gate | Protected and production profiles |
warn | Return a warning with evidence | Non-prod migration windows |
bootstrap | Create the initial baseline when no explicit baselineRef is supplied: from completed policy-run data, or, when the tenant has none, from an automatic policy run over the evaluated image’s scanned components (below); deny like block while neither yields a measured result | Audited first-build enrollment |
Bootstrap is never implicit. Explicit baselineRef values remain strict: a missing referenced snapshot is an error rather than a fabricated baseline. Evaluation requires policy:run. Opting into initial-baseline bootstrap also requires explicit policy:write authority in both synchronous and generic asynchronous paths. A caller without write authority is refused before scan enrollment, ledger export or snapshot creation; ordinary evaluation of an existing baseline remains available with run authority.
First baseline on a fresh install
For an operator-enabled generic registry webhook, POST /api/v1/webhooks/registry/generic accepts the same optional missingBaselineMode (block, warn, or bootstrap) and preserves it in the durable evaluation request. Send bootstrap without a baselineRef for an auditable first build. Omitting the mode keeps the worker fail-closed. An authenticated, tenant-bound registered CI/registry client and its purpose-key signature are both required; the webhook remains disabled by default. Read its result through GET /api/v1/policy/gate/jobs/{jobId}. This forwards the existing gate behavior into the asynchronous producer rather than relying on an executor-only request that a registry cannot submit.
Generic admission resolves an existing purpose key through the configured cryptography boundary. Enable Policy:Gateway:RegistryWebhooks:Generic:Enabled and bind one Crypto:PurposeKeys:ContextKeys:<binding> entry with Purpose: webhook, CallerTenantId, SenderId (the authenticated Authority client_id), KeyId and Reference. Managed custody also needs CustodyTenantId and the installed managed key source. Custody tenant does not authorize execution in the caller tenant. Duplicate context registrations refuse; a host-global purpose binding cannot substitute for a missing caller binding. The purpose is the literal webhook (HmacPurpose.WebhookInterop). The central profile keeps HMAC-SHA256 for this external wire contract; the installed provider owns verification and may refuse it. A missing purpose key or unavailable provider refuses admission and queues no work. Policy never generates or loads a feature-local key. See purpose key resolution for supported operator references and managed enrollment.
For Platform-owned builtin custody, enable Crypto:PurposeKeys:OwnerOperations:Enabled and configure BaseAddress to the owned Platform service. Policy obtains an exact-tenant crypto:purpose:verify token as policy-engine; it receives an asynchronous opaque operation handle. Enroll the purpose through the owner’s authenticated crypto-administrator workflow and use its returned public reference, key ID and canonical custody UUID in the caller registration. Signing live producer proofs uses that owner’s compute operation. Policy receives no crypto administration or key material. Make the owner API ready before enabling this consumer configuration.
Generic delivery wire v1 requires X-StellaOps-Integration-Id equal to the authenticated client, X-StellaOps-Key-Id, a canonical UUID X-StellaOps-Delivery-Id, and decimal Unix seconds in X-StellaOps-Delivery-Timestamp. X-StellaOps-Signature is sha256=<hex> over the UTF-8 prefix below followed immediately by the exact request body bytes; each prefix line ends with LF:
stellaops.registry.generic.v1
POST
/api/v1/webhooks/registry/generic
<authenticated caller tenant>
<integration/client id>
<key id>
<delivery uuid>
<timestamp seconds>
Admission accepts timestamps at most five minutes old or thirty seconds in the future. Identity, timestamp and body are all signed. Policy commits the receipt and queued work atomically; failure to enqueue leaves the delivery retryable. Identical retries return the same job, including across process restart and key rotation. A correctly signed different body under a retained delivery identity returns 409. Receipts are retained for at least 24 hours independently of terminal-job pruning; expired original signatures remain refused after reclamation. Send a new delivery UUID for each legitimate reevaluation. A CI/registry client receives deliberate tenant membership and run/write grants; it is not declared first-party merely to obtain cross-tenant access.
PCD-340’s enrollment diagnostics are source-verified against 76be17ca49 (2026-10-05). Re-verify with PolicyEngineGateFirstBaselineEnrollmentTests through tools/scripts/test-targeted-xunit.ps1; the source test does not assert a live image swap.
A gate baseline is a tenant snapshot of the policy ledger, and the ledger is built only from completed policy orchestrator jobs that produced results. A fresh install has none.
Automatic path. Evaluate the image with missingBaselineMode: "bootstrap" and no baselineRef (scope policy:run). When the tenant has no completed policy run, policy-engine enrolls the evaluated image itself:
- It reads the image’s latest successful scan from Scanner’s tenant-scoped digest-detail API (
GET /api/v1/scans/by-digest/{imageDigest}/detail, called with policy-engine’s own tenant-scopedscanner:readtoken), taking the scan’s component purls. - It looks up the advisories Policy’s gate projection holds for those components, matched the way the release findings lookup matches them (package name and ecosystem, so
pkg:alpine/...meetspkg:apk/alpine/...and NuGet names match regardless of case), and forms the (component_purl,advisory_id) pairs with each component’s scanned purl. - It runs one policy orchestrator job over those pairs (context
gate-first-baseline:<imageDigest>, profiledefault), executed inline by the policy worker; each pair gets its gate-projection status, refined by the component version. - When at least one pair is measured, bootstrap builds the ledger export from that run, snapshots it as the tenant baseline (overlay
bootstrap), and evaluates the image against it. The response carriesbaselineStatus: "resolved".
curl -sS -X POST "$STELLA/api/v1/policy/gate/evaluate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "imageDigest": "sha256:...", "repository": "library/nginx", "tag": "1.27", "missingBaselineMode": "bootstrap" }'
The automatic path stays fail-closed. The gate answers 403 with baselineStatus: "missing:bootstrap" and blockedBy: "missing-baseline", and creates no job, ledger export or snapshot, when Scanner has no successful scan for the image, the Scanner read fails (owner unavailable, token refused, malformed answer), the gate projection holds no advisory for any scanned component, or the image yields more than 10,000 pairs. A run whose pairs are all unmatched creates no baseline either. Policy-engine logs the reason (First-baseline enrollment refused for tenant <tenant> image <digest>: ...) followed by Skipping baseline bootstrap for tenant <tenant>: no completed policy result data exists yet and image <digest> could not be enrolled from its scan. This is deliberate: an empty baseline compared with an equally empty ledger would produce an empty delta and pass every image, so bootstrap never fabricates one.
The synchronous gate and asynchronous evaluation result expose the refusal in evidence.baselineEnrollmentFailure. scan_not_found means Scanner has no attempt; no_successful_scan means attempts exist without successful evidence. authentication_rejected means Policy could not authenticate the tenant-scoped Scanner read. Other typed read failures retain their stable code (owner_unavailable, owner_http_error, malformed_response, response_too_large, or invalid_request); unexpected read failures use scanner_read_failed. Exception messages and credentials are excluded. Scanning the image again does not repair an authentication refusal.
Authority grants enabled tenants to the configured auto-grant client set, defaulting to clients declared firstParty, including policy-engine. Tenant creation applies the grant before returning success; Authority startup reconciles enabled tenants after client bootstrap, covering seeded tenants and clients declared later. Explicit target lists and the global disable still apply. See tenant onboarding.
Manual fallback. When the automatic path is refused (for example, the image was scanned outside this installation, or its pairs exceed the bound), submit the first policy run yourself:
Submit a policy run over the image’s component and advisory pairs (scope
policy:run). The run belongs to the tenant of$TOKEN, which must be the tenant the gate is evaluated for; the body has no tenant field. Each batch item is one (component_purl,advisory_id) pair from the image’s scan.curl -sS -X POST "$STELLA/policy/orchestrator/jobs" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "context_id": "first-baseline-nginx", "policy_profile_hash": "default", "batch_items": [ { "component_purl": "pkg:deb/debian/openssl@3.0.15-1~deb12u1", "advisory_id": "CVE-2024-13176" } ] }'The response is the queued job; keep its
job_id.Poll the job (scope
policy:read) untilstatusiscompleted. Afailedstatus means no baseline data was produced; fix the cause and submit again.curl -sS "$STELLA/policy/orchestrator/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN"The worker writes one result per batch item: the pair’s status from Policy’s gate projection (the row whose product key is the purl without its version, or else the advisory’s rows for the same package name and ecosystem), or
unmatchedwhen Policy has no projection row for it.GET /policy/worker/jobs/{jobId}returns those results.Evaluate the image with
missingBaselineMode: "bootstrap"and nobaselineRef(scopepolicy:run). Bootstrap builds a ledger export from the completed run, snapshots it as the tenant baseline (overlaybootstrap), and evaluates the image against it.curl -sS -X POST "$STELLA/api/v1/policy/gate/evaluate" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "imageDigest": "sha256:...", "repository": "library/nginx", "tag": "1.27", "missingBaselineMode": "bootstrap" }'Read the decision back by its
decisionId(scopepolicy:read); later evaluations find the baseline withoutmissingBaselineMode.curl -sS "$STELLA/api/v1/policy/gate/decision/$DECISION_ID" -H "Authorization: Bearer $TOKEN"
The CLI wraps the automatic path and the read-back: stella gate evaluate --image <digest> --missing-baseline bootstrap sends missingBaselineMode: "bootstrap" without a baselineRef (the CLI refuses --missing-baseline bootstrap together with --baseline, exit 2), and stella gate status --decision-id <id> reads GET /api/v1/policy/gate/decision/{decisionId} and exits with the decision’s outcome code (pass 0, warn 10, block 1; an unknown or expired decision 12). Without --missing-baseline the CLI sends no mode, so the server default block applies. No stella command submits policy orchestrator jobs: the manual fallback’s step 1 stays on the HTTP API. Request and response fields for step 1 are in the Policy API reference.
Every decision the gate returns, including a missing-baseline denial or warning, carries a decisionId that GET /api/v1/policy/gate/decision/{decisionId} answers with the same response for 30 minutes after evaluation (in-memory, per policy-engine instance). The durable record of each decision is read through POST /api/v1/policy/gate/decisions/by-digest, which returns the newest decision per image digest.
Score-gate signing also fails closed. Operators must configure PolicyGate:ScoreGate:SigningSecretBase64 or STELLA_GATE_SIGNING_SECRET with at least 32 bytes of non-zero decoded key material. Empty, invalid, or all-zero secrets cause the request path to return 503 problem+json.
Transparency mode is explicit:
PolicyGate:ScoreGate:TransparencyMode | Behavior |
|---|---|
Offline | Sign locally and do not submit to Rekor |
OnlineRekor | Require PolicyGate:ScoreGate:RekorUrl or STELLA_REKOR_URL, and anchor requested verdicts to that endpoint |
There is no public Rekor default. Online Rekor is an opt-in profile and the endpoint must be configured by the deployment.
6 - Sample curl workflows
Assuming you already obtained an access token ($TOKEN) for tenant acme:
curl -sS https://gateway.example.com/api/policy/packs \
-H "Authorization: Bearer $TOKEN" \
-H "X-StellaOps-TenantId: acme"
# Draft a new revision
curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions \
-H "Authorization: Bearer $TOKEN" \
-H "X-StellaOps-TenantId: acme" \
-H "Content-Type: application/json" \
-d '{"version":5,"requiresTwoPersonApproval":true,"initialStatus":"Draft"}'
# Activate revision 5 (returns 202 when awaiting the second approver)
curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \
-H "Authorization: Bearer $TOKEN" \
-H "X-StellaOps-TenantId: acme" \
-H "Content-Type: application/json" \
-d '{"comment":"Rollout baseline"}'
7 - Offline Kit guidance
- Include
policy-engine.yaml.sampleand the resolved runtime config in the Offline Kit’sconfig/tree. - When building verification scripts, use the policy-engine endpoints above. The Offline Kit validator expects
policy_engine_activation_requests_totalmetrics in the Prometheus snapshot.
8 - Backwards compatibility
- The
policy-gateway.stella-ops.localhostname is preserved as a network alias on the policy-engine container in both docker-compose and hosts files. Existing configurations referencingpolicy-gateway.stella-ops.localwill continue to work. - The
STELLAOPS_POLICY_GATEWAY_URLenvironment variable has been removed from service defaults. Services that previously used it should useSTELLAOPS_POLICY_ENGINE_URLinstead.
9 - Change log
- 2026-10-01 – CLI first baseline and decision read-back:
stella gate evaluate --missing-baseline block|warn|bootstrapsendsmissingBaselineMode, andstella gate status --decision-idreadsGET /api/v1/policy/gate/decision/{decisionId}(§5). - 2026-04-25 – Gate integrity fail-closed: Missing baselines deny by default, first-build bootstrap is explicit, score-gate signing rejects missing/zero secrets, and Rekor use requires an explicit transparency mode and endpoint.
- 2026-04-08 – Gateway merge: Gateway merged into Policy Engine. Separate container, HTTP proxy layer, DPoP flow, and
PolicyEngineClientremoved. All endpoints served directly by policy-engine. - 2025-10-27 – Sprint 18.5: Initial gateway bootstrap + activation metrics + DPoP client credentials.
