Scenario 02 · Zero-trust API gateway

"A network position should buy an attacker nothing."

Partner and internal APIs sit on a network you can't trust — shared VPCs, third-party integrators, a compromised pod one hop away. A firewall rule or a bearer token in a header isn't identity. Every call has to prove who it is (cryptographic mTLS / SPIFFE) and what it's allowed to do (policy), at the edge, before it ever reaches a backend — and the default answer must be no.

Architecture

Identity in, decision at the door

Partner client X.509 SVID spiffe://bank/pay ZenoIngress · zero-trust edge ① mTLS verify ClientAuthConfig: Required trustDomain: bank.example ② Cedar PDP principal = SVID URI onError: deny payments API mTLS · SAN-pinned 403 Forbidden not in Group::payments client cert x-spiffe-id permit ✓ deny ✗

The SPIFFE ID extracted from the client certificate becomes the Cedar principal. The first explicit permit wins; everything else is denied. No OPA sidecar, no external PDP round-trip.

The setup

Prove identity, then prove permission

First, require a client certificate on the listener (GEP-91 frontend validation) and map the SPIFFE SVID to the principal:

clientauth.yaml zenoingress.io/v1alpha1
apiVersion: zenoingress.io/v1alpha1
kind: ClientAuthConfig
metadata:
  name: partner-mtls
  namespace: default
spec:
  mode: Required               # GEP-91 frontend client-cert validation
  caCertificateRefs:
    - name: partner-ca
      group: ""
      kind: ConfigMap
  spiffe:
    trustDomain: bank.example   # SVID URI SAN → principal

Then authorize. The AuthzPolicy carries inline Cedar — the same engine behind AWS Verified Permissions — evaluated in-process. onError: deny makes the policy fail closed:

authz.yaml zenoingress.io/v1alpha1
apiVersion: zenoingress.io/v1alpha1
kind: AuthzPolicy
metadata:
  name: payments-authz
  namespace: default
spec:
  principalHeader: x-spiffe-id
  onError: deny                 # no match / eval error ⇒ 403
  policies: |
    permit(
      principal in Group::"payments",
      action == Action::"POST",
      resource
    ) when { context.path like "/api/payments*" };

Attach the policy to a route with an ExtensionRef filter, and pin the backend's identity on the way out with a SAN-checked BackendTLSPolicy — mutual TLS in both directions:

route-and-backend.yaml gateway.networking.k8s.io/v1 · zenoingress.io/v1alpha1
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: payments
  namespace: default
spec:
  parentRefs:
    - name: zeno-gateway
  rules:
    - matches:
        - path: { type: PathPrefix, value: /api/payments }
      filters:
        - type: ExtensionRef
          extensionRef:
            group: zenoingress.io
            kind: AuthzPolicy
            name: payments-authz
      backendRefs:
        - name: payments
          port: 8443
---
apiVersion: zenoingress.io/v1alpha1
kind: BackendTLSPolicy
metadata: { name: payments-backend-mtls, namespace: default }
spec:
  targetRefs:
    - group: ""
      kind: Service
      name: payments
  validation:
    caCertificateRefs:
      - { name: payments-ca, group: "", kind: ConfigMap }
    subjectAltNames:
      - type: Hostname
        hostname: payments.internal.svc   # SAN pin
  clientCertificateRef:
    name: zeno-client-cert                  # ZenoIngress' own SVID

What happens

Two gates, every request

  1. The caller opens an mTLS connection presenting its X.509 SVID. No certificate, or one outside the trust domain ⇒ the TLS handshake itself fails. no entry
  2. ZenoIngress validates the chain against partner-ca and extracts the SPIFFE ID from the certificate's URI SAN, surfacing it as x-spiffe-id.
  3. The request is mapped to a Cedar action (the HTTP method) and resource (the path), with the SPIFFE ID as principal.
  4. Cedar evaluates in-process: a POST /api/payments/... from a principal in Group::"payments" matches the permit. 200 · forwarded
  5. Anything else — wrong group, wrong method, wrong path, or any eval error — matches no permit and onError: deny returns 403. deny-by-default
  6. On allow, ZenoIngress dials the backend over mTLS and pins its SAN — so the identity is verified in both directions, end to end.

Outcome

Authenticated and authorized at the edge

deny-by-defaultfail-closed authorization
~µsin-process Cedar eval
0 sidecarsno OPA, no external PDP
Why ZenoIngress

SPIFFE mTLS and a formally-verified Cedar engine live inside the proxy — identity and authorization decided in microseconds at the door, with no OPA sidecar to deploy, scale, or round-trip to.

Run this yourself

Allowed identity in, everyone else out

Apply the manifests, then call the route with three different identities and watch the decision change:

terminal — same request, different identity
$ # 1) a client whose SVID is in Group::payments
$ curl --cert pay.crt --key pay.key https://api.bank.example/api/payments -d '{}'
HTTP/2 200

$ # 2) a valid SVID, but NOT in the payments group
$ curl --cert audit.crt --key audit.key https://api.bank.example/api/payments -d '{}'
HTTP/2 403   (no matching permit)

$ # 3) no client certificate at all
$ curl https://api.bank.example/api/payments -d '{}'
curl: (56) OpenSSL: alert handshake failure — certificate required

Deny-by-default, at the edge.

SPIFFE mTLS + Cedar in one binary. Free for one production cluster.

Deploy Free