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
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
- 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 removedCompute its digest - the
sha256of the file's bytes, whatsha256sumprints, no canonical form:make fragment-digest FILE=security-baseline.yaml # or: flexway fragment digest security-baseline.yaml # sha256:1c2f91d4…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 firstThe 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]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 undercontracts(file name -> content, mounted in/etc/flexway, the very directory offlexway.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.In the repository's CI,
make validate CONFIG=flexway.yamlcompiles 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>.verifyrefuses 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
/configand in theflexway.config.fragment.infometric (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.