flexway
FRENTalk to an expert

Policy fragments shared between gateways

Write shared policies once in a fragment, which each gateway pins by its digest and some of whose policies are enforced.

GuideOperationsSince version 1.7.0

Markdown

On this page

Operations guide: configure, deploy and observe the gateway. What the feature does, product side: product guide; calling it: developer guide.

When several gateways run (one per department, per product, per site), each copies the same policies: the company's CORS, headers removed, a call to the risk service. The copies drift apart and nobody sees it. A policy fragment is a file written once - by the security team, say - that each gateway pins by its digest in its own repository. No gateway waits for a central server to start: it reads a mounted file, never the network.

Important

Available since 1.7.0, in part - fragments arrive in three steps. What this page describes is available: the kind: PolicyFragment file, the import pinned by sha256, enforced policies, refusal at load and at reload, /config, the metric, flexway fragment digest, flexway validate. Planned for 1.8.0: signing a fragment (declaring verify refuses the load until then), mounting from an OCI artifact, and the console screen.

Steps

  1. Write the fragment. It holds policy definitions only, the ones it enforces, and what it requires of the gateway that imports it - no host, route, chain, upstream, issuer, nor import of another fragment:
# A policy fragment (1.7.0): the policies a security team writes once, for every gateway of the
# company. Each gateway copies this file into its own repository and pins it by its digest
# (flexway fragment digest security-baseline.yaml); nothing else may live here - no host, no route,
# no upstream, no import.
kind: PolicyFragment
fragment: security-baseline
version: 1.4.0                                   # what changed, for people; the digest says what runs
requires:
  flexway: ">=1.7.0"                             # the oldest gateway this fragment is written for
  upstreams: [risk]                              # the importer declares it; the fragment never does
definitions:
  strip-internal:
    type: header-remove
    phase: response
    config: { names: [server, x-powered-by] }
  trace-gateway:
    type: header-inject
    config: { set: { x-request-origin: gateway } }
  cors-corp:
    type: cors
    config: { allow_origins: ["https://app.example.com"], allow_methods: [GET, POST] }
  risk-check:
    type: external-call
    when: "request.method == 'POST'"
    config:
      upstream: risk
      path: /score
      timeout: 150ms
      on_failure: fail-closed
      deny: { when: "response.status == 403", status: 403, code: risk_refused }
enforce:
  global: [strip-internal, trace-gateway]        # at the head of every importer's chain, never removed
  1. Compute its digest - the sha256 of the file's bytes, what sha256sum prints, no canonical form:

    make fragment-digest FILE=security-baseline.yaml    # or: flexway fragment digest security-baseline.yaml
    # sha256:1c2f91d4…
  2. Import it in each gateway under an import name of its choosing, which prefixes its definitions (corp/cors-corp), and pin it by its digest:

# One base of policies for several gateways (1.7.0): this gateway - the orders API - imports the
# security team's fragment, pinned by the sha256 of its bytes. Its definitions are known here as
# corp/<name>; the two it enforces run first on every operation, and no local line removes them.
# A fragment file changed without its digest is refused - at start, and at a reload, where the
# configuration in force keeps serving.
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://orders.internal:8080
  risk:
    url: http://risk.internal:8080               # required by the fragment, declared here
policies:
  fragments:
    corp:                                        # the import name: the prefix of its definitions
      path: ./security-baseline.yaml             # a mounted file, relative to this one
      digest: sha256:1c2f91d4f7479525940a2567a49377425a41621984de8f0ec016fe2eb6c21485
  definitions:
    tenant-header:
      type: header-inject
      config: { set: { x-gateway: orders } }
  chains:
    global: [corp/cors-corp, tenant-header]      # corp/strip-internal and corp/trace-gateway come first

The second repository pins its own copy of the same file and calls the risk service the fragment defines:

# The second repository (1.7.0): the partners API pins the same fragment, copied into it, and calls
# the risk service the fragment defines on its own operation. Same file, same digest, same enforced
# head - each gateway decides alone, nothing is pushed to it.
version: 1
contracts:
  openapi: ./openapi.yaml
upstreams:
  default:
    url: http://partners.internal:8080
  risk:
    url: http://risk.internal:8080
policies:
  fragments:
    corp:
      path: ./security-baseline.yaml             # its own copy: a fragment is read from this directory or below
      digest: sha256:1c2f91d4f7479525940a2567a49377425a41621984de8f0ec016fe2eb6c21485
  chains:
    global: [corp/risk-check]
  1. Mount the file beside flexway.yaml, in its directory or below - never above (.. refused), nor an absolute path outside that directory; a regular file of 1 MiB at most. With the chart, it is one more file under contracts (file name -> content, mounted in /etc/flexway, the very directory of flexway.yaml); the path is then flat (./security-baseline.yaml). The file comes in the same pull request as its digest: copied by hand, by Renovate, or by the CI of the team that publishes the fragment.

  2. In the repository's CI, make validate CONFIG=flexway.yaml compiles the fragment with the configuration that imports it and prints one line per fragment (fragment corp: security-baseline@1.4.0 sha256:…).

Verify

Where What it shows
/config (admin port) -> fragments Per import: fragment, version, digest, path, signature (none: the digest alone), requires, requires_unchecked (true: a development build loaded it without comparing requires.flexway, a warning in the log), definitions, enforced, unused
/config -> chains Each step that came from a fragment carries origin (import, fragment, version, enforced); an enforced one is attached by policies.fragments and runs first
Metric flexway.config.fragment.info 1 per fragment, labels import, fragment, version, digest (12 characters): two replicas on two digests give two series

A fragment file changed without changing its digest must be refused: last_reload.result: rejected, digest mismatch in last_reload.cause, and the chain in force keeps serving.

Planned in 1.8

Signing a fragment and verifying its signature

Planned for 1.8.0 - not built yet. Today, policies.fragments.<import>.verify refuses the load: the digest alone pins a fragment.

The team that publishes the fragment will sign it with cosign sign-blob --bundle; each importing gateway will declare the bundle and the expected public key under verify, and will verify the signature offline, at load and at reload, without querying a transparency log. A keyless signature (expected issuer and identity, trusted root provided) is planned for the same milestone. The digest stays mandatory: the signature says who wrote the fragment, the digest what runs. The signature is what will protect against an importing team that pins another digest in its own repository.

Planned in 1.8

Mounting a fragment from an OCI artifact (image volume)

Planned for 1.8.0 - not built yet. Today, the fragment is a file copied into each gateway's repository and mounted with its configuration.

The chart will mount a fragment published as an OCI artifact as a Kubernetes image volume (volumes[].image), referenced by its digest - never by a tag alone -, or from a ConfigMap. The gateway will still read a mounted file only: it never contacts the registry. The image volume needs Kubernetes 1.36 or later (otherwise the ImageVolume feature gate), and a new digest recreates the pods; the copy in the repository stays the default path, and the one to choose for an isolated site.

Planned in 1.8

Console: fragments in force, origin, drift between replicas

Planned for 1.8.0 - not built yet. Today, this information is read in /config and in the flexway.config.fragment.info metric (the "Verify" section above).

The console will show, for each gateway, the fragments in force (version, digest, signature, enforced policies), the origin of each step of the pipeline, and the drift between replicas or with the repository - two digests served at the same time -, with a fragment-drift alert rule. It never changes the configuration: a new digest always goes through a reviewed pull request.

Good to know

The keys

Key Rule Default
policies.fragments Import name (lower-case letters, digits, -, 63 at most) -> pinned file absent: no fragment
policies.fragments.<import>.path A mounted regular file (1 MiB at most), in flexway.yaml's directory or below: .. and an absolute path outside that directory refused required
policies.fragments.<import>.digest sha256: and 64 lower-case hex characters required
policies.fragments.<import>.verify Signature: not built yet - declared, it refuses the load absent
File: kind Always PolicyFragment required
File: fragment The fragment's name (lower-case letters, digits, -); a gateway imports one version of it required
File: version Semantic version (1.4.0), read by people and /config; the digest says what runs required
File: requires.flexway >=MAJOR.MINOR.PATCH; a development build (dev) always satisfies it, and says so in the log (Warn) and on /config (requires_unchecked); a version served that is not semantic is refused any version
File: requires.upstreams Upstreams the importer must declare (those an external-call names) none
File: definitions At least one policy, written as in policies.definitions, of a type the gateway executes (an unknown type is refused, never skipped); an ASCII name: letters, digits, ., _, - required
File: enforce.global Enforced policies, at the head of every chain of the importer (HTTP listener) nothing enforced

A secret is written as a reference (${VAR}, file:/path), resolved by the importer; a literal value under a secret key is refused. YAML anchors, aliases and merge keys are refused in a fragment and under policies.fragments: each value is written where it applies. The file's schema is published with the others (configuration reference): policy-fragment.schema.json.

What is enforced

The policies of enforce.global run before global, in the fragment's order, then the fragments in the order of policies.fragments. After that, nothing changes: global -> host -> routes -> operation, the most precise wins. Chains - enforced policies included - apply to the requests the gateway admits on its HTTP listener (REST, gRPC, MCP tools, inference, the asynchronous plane's HTTP doors); the native Kafka listener runs no chain: an enforced policy does not apply there.

Case Effect
An imported definition named by a local chain Like a local definition
!corp/strip-internal (removing an enforced one), at any level Refused at load, naming the chain or the operation
A local instance of a single-instance type (cors, rate-limit, quota, cache, rewrite-path, transform-body, map-status) where an enforced one of that type applies Refused at load: an enforced policy is never replaced
A policy that is not enforced (header-*, query-*, an external-call's set_headers) setting, renaming or removing, in the same phase, a header (any case) or a parameter an enforced policy sets or removes Refused at load: a local line never undoes an enforced policy
Two enforced policies of one single-instance type, from two fragments Refused at load, named elsewhere or not
An enforced policy also named by a local chain Runs once, at its enforced place
An imported definition no chain names Loaded and reported (unused on /config), never an error

What lot 1 guarantees: a file changed outside the review never applies (digest), and no local line removes, replaces or undoes an enforced policy. It does not protect against the importing team re-pinning another digest or removing the import in its own repository: that is the job of the signature (verify, to come, with a key the security team holds) and of an admission guard in your GitOps tool.

Moving to a new version, rolling back

Moving to a new version: the publishing team changes the file and its version; in each importing repository, a pull request copies the file and changes the digest; once merged, the usual reload applies it, without a restart. Rolling back: restore the previous file and its previous digest, by a pull request.

Refusals at load and at reload

Any discrepancy refuses the configuration whole; every problem is reported at once. At start, the gateway does not start; at a reload, the configuration in force keeps serving and the refusal is reported (log, flexway.config.reloads{result="rejected"}, last_reload on /config). The gateway re-reads the fragment files with the configuration every server.reload.interval: a file changed beside an unchanged flexway.yaml is seen and refused.

Discrepancy Message (excerpt)
File missing or unreadable policies.fragments.corp: the fragment file cannot be read
Different digest digest mismatch for ./security-baseline.yaml - pinned sha256:…, read sha256:…
Host, route, chain, upstream in the fragment hosts: a fragment holds kind, fragment, version, requires, definitions, enforce only
Import of another fragment fragments: a fragment imports no fragment
Two imports of the same fragment fragment security-baseline is already imported as corp
/ in a local name a local name holds no /
Required upstream not declared requires.upstreams names "risk", which upstreams does not declare
Gateway too old requires flexway >=1.8.0, and this gateway is v1.7.0
Removing or replacing an enforced policy ... which fragment corp enforces - an enforced policy is never removed
A local line undoing an enforced policy "mine" writes the header Server in phase response, which "corp/strip-internal" ... sets or removes
Two enforced policies of one single-instance type two enforced cors policies, "corp/cors" (fragment corp) and "team/lax" (fragment team)
Unknown policy type in a fragment ... is not a policy type this version executes - an imported policy is never skipped
Path outside the directory, file not regular or over 1 MiB never above it (..), outside flexway.yaml's directory, not a regular file, a fragment may hold
Anchor, alias or merge key anchors, aliases and merge keys are refused
Version served not semantic this gateway's version "..." cannot be compared

Available since 1.7.0.

Next steps