# 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.

Guide - Since version 1.7.0 - Flexway v1.7.1 - https://flexway.io/en/docs/operations-policy-fragments/

> Operations guide: configure, deploy and observe the gateway. What the feature does, product side: [product guide](https://flexway.io/en/docs/product-policies/); calling it: [developer guide](https://flexway.io/en/docs/developer-overview/).

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:

<!-- example: examples/policy-fragments/security-baseline.yaml -->
```yaml
# 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
```

2. Compute its digest - the `sha256` of the file's bytes, what `sha256sum` prints, no canonical form:

   ```bash
   make fragment-digest FILE=security-baseline.yaml    # or: flexway fragment digest security-baseline.yaml
   # sha256:1c2f91d4…
   ```

3. 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:

<!-- example: examples/policy-fragments/flexway.yaml -->
```yaml
# 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:

<!-- example: examples/policy-fragments/partners/flexway.yaml -->
```yaml
# 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]
```

4. 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.

5. 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.

## Signing a fragment and verifying its signature

> [!NOTE]
> Planned in 1.8

> **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.

## Mounting a fragment from an OCI artifact (image volume)

> [!NOTE]
> Planned in 1.8

> **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.

## Console: fragments in force, origin, drift between replicas

> [!NOTE]
> Planned in 1.8

> **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](https://flexway.io/en/docs/operations-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`.*
