Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 202 additions & 52 deletions schematics/authorize-docker-requests/SCHEMATIC.md

Large diffs are not rendered by default.

43 changes: 30 additions & 13 deletions schematics/authorize-docker-requests/modules/opa-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ authorization plugin on every daemon request.

## Purpose

Implements the authorization rules defined in R-1 through R-18. The policy
Implements the authorization rules defined in R-1 through R-19. The policy
distinguishes sandbox clients (by TLS certificate CN or HTTP header) from local
host users, grants the sandbox read-only access by default, and then selectively
permits compose project operations, image builds, pulls, and a closed lifecycle
Expand All @@ -32,6 +32,11 @@ container, in its project" are different grants:
`/exec` must not satisfy the create rule.
- **R-18** — network and volume deletes are scoped by the project as a whole
path segment, the same way creation is scoped by name.
- **R-19** — path matching is version-independent. `path` is `PathPlain` with
one optional `/v<major>[.<minor>]` prefix removed and no `..` segment; every
rule matches that, so the same policy decides the same on a live daemon (which
sends `/v1.56/containers/create`) and for a client that omits the version
(which sends `/containers/create`).

## Inputs

Expand All @@ -44,11 +49,18 @@ before evaluation. Fields the policy uses:
- `input.AuthMethod` — string. `TLS` when the client authenticated with a
certificate.
- `input.Method` — string. HTTP method: `GET`, `HEAD`, `POST`, `DELETE`.
- `input.PathPlain` — string. Request path without the query string, e.g.
`/containers/json`, `/images/create`, `/build`. Grants are matched against it
with `==` (R-17).
- `input.PathArr` — array. `PathPlain` split into path elements. Used to match a
resource named in the path as a whole segment rather than as a substring.
- `input.PathPlain` — string. The **raw request path**: API version prefix
included, query string excluded (`u.Path`), e.g. `/v1.56/containers/json`.
Nothing strips the version, so the policy derives `path` from it
and matches that with `==` (R-17, R-19) — the raw field is read for nothing
else.
- `input.PathArr` — array. `PathPlain` split into path elements, so its second
element is the version. Not read by the policy: `path_segments` is the derived
path split the same way, which is what matches a resource named in the path as
a whole segment rather than as a substring.
- `input.Query` — object. The parsed query string as a map of arrays. A
container create's name arrives here (Docker takes it from the `name` query
parameter, not the body); no grant depends on it.
- `input.Headers` — object. Header names to header values, as plain strings
(`map[string]string` in Docker's own message type, so a value is never an
array). The header named by `P-9` with the value `true` is the secondary
Expand Down Expand Up @@ -94,8 +106,8 @@ so the plugin release decides the language version:

| Plugin image | Embedded OPA | Result |
|--------------|--------------|--------|
| `ghcr.io/open-policy-agent/opa-docker-authz:v0.10` | **v1.3.0** | loads; every one of the 71 probe rows decides as specified |
| `openpolicyagent/opa-docker-authz-v2:0.9` | v0.60.0 | loads; identical decisions on all 71 probe rows |
| `ghcr.io/open-policy-agent/opa-docker-authz:v0.10` | **v1.3.0** | loads; every one of the 78 probe rows decides as specified |
| `openpolicyagent/opa-docker-authz-v2:0.9` | v0.60.0 | loads; identical decisions on all 78 probe rows |
| `openpolicyagent/opa-docker-authz-v2:0.8` | v0.30.0 | does **not** load — `import rego.v1` is rejected |

The embedded versions are read from each release's own `go.mod` at its tag
Expand All @@ -115,9 +127,9 @@ A leftover is a silent full-access bug, not a cosmetic one: `is_sandbox` then
never matches a real client, so the sandbox is classified as a host user and
every request is allowed. See Phase 6 and its acceptance test.

(`P-11`'s `BUILDKIT_PREFIX` token is retired — R-17 removed the BuildKit
carve-out — but the leftover check still greps for it, so deploying a copy of
the pre-R-17 template is caught rather than silently accepted.)
(`P-11`'s `BUILDKIT_PREFIX` token is not read by any rule, but the leftover
check still greps for it, so a copy that carries the token is rejected rather
than silently deployed.)

## Limitations

Expand All @@ -138,6 +150,12 @@ limitations):
of those has a form that is allowed (no `DriverOpts`, an explicit network, a
name); a deployment that needs the refused form must extend the policy
deliberately, and the probe table must grow a row with it.
- **The table is not the daemon.** A probe row decides about the input it
carries; if that input is not the plugin's, the table agrees with a fiction.
The plugin always sends the API version prefix in `PathPlain`, so a row fed a
version-less path exercises a request that never arrives — run rows with the
plugin's own values (`main.go`'s `makeInput`), and treat a live test as what
proves those values are the plugin's.
- **Host port publishing is not part of the gate.** A project container may
publish a host port (`ports:`), which does not read the host filesystem but
can occupy a free port and answer for it. Closing that is the daemon
Expand All @@ -160,8 +178,7 @@ limitations):
## Dependencies

- D-1, D-3, D-4 (from SCHEMATIC.md)
- Parameters P-3, P-4, P-9, P-12, P-15 (P-11 is retired with the BuildKit
carve-out, R-17)
- Parameters P-3, P-4, P-9, P-12, P-15 (P-11 is not read by this policy)

## Failure Behavior

Expand Down
133 changes: 92 additions & 41 deletions schematics/authorize-docker-requests/modules/policy-reload.md
Original file line number Diff line number Diff line change
@@ -1,53 +1,76 @@
# Module: Policy Reload

Host-side procedure to apply a Rego policy change without restarting the Docker
daemon. The daemon restart is the expensive move (it stops containers); the
plugin is the cheap one.
Host-side procedure to apply a Rego policy change. On this package's default
install there is nothing to reload: **the plugin re-reads the policy file on
every request**, so the change is live on the next API call.

## Purpose

Policy updates should not require a daemon restart. What a policy update *does*
cost depends on how the plugin was installed, and the difference matters
operationally:
A policy update must not cost a daemon restart, and on the file path it does not
even cost a plugin bounce. `main.go` (`evaluatePolicyFile`) reads the policy
file, compiles it, and evaluates it *inside the handling of a single request*:

| Path | How it reloads | What the window looks like |
|------|----------------|---------------------------|
```go
bs, err := os.ReadFile(p.policyFile)
...
eval := rego.New(rego.Query(p.allowPath), rego.Input(input),
rego.Module(p.policyFile, string(bs)))
```

and it logs a sha256 of the bytes it read as the decision log's `config_hash`.
So the deployed file *is* the live policy, per request. Two properties of that
code decide the whole procedure:

| Deployment path | How a change takes effect | What the window looks like |
|---|---|---|
| `-policy-file` (**this package's install**) | The file is read per request: replace it and the next API call uses the new policy | No window, **provided the file is replaced atomically** — see below |
| `-config-file` with a bundle service | The plugin long-polls its bundle endpoint; a new bundle applies without touching the plugin process | No window: the old policy is in force until the new bundle is fetched |
| Managed plugin, `-policy-file` | Disable and re-enable the plugin so it re-reads the file | **Denial window**: while the plugin is unavailable, the daemon's authorization middleware fails closed and every API call is denied, host users included |
| Legacy plugin container | Restart the container | Same denial window |
| `docker plugin disable` → `enable` | Not a reload path at all: it stops the plugin the daemon is using | **Fatal** — see the next section |

The plugin's own documentation recommends the bundle form for exactly this
reason (plus decision logging). The file form is simpler to deploy and is what
this package's default install uses.
**What a policy change is not:**

**What a reload is not:** removing the plugin reference from the live daemon
configuration file (P-13) and
sending SIGHUP is not a reload, it is a return to an unrestricted daemon — every
request is allowed while the entry is absent. Use it only as the deliberate
rollback, never as a step in a policy update.
- **It is not a plugin bounce.** Disabling, removing, or upgrading a plugin that
the running daemon references makes dockerd treat its own configuration as
invalid and **exit**: `level=fatal msg="Error validating authorization
plugin" error="plugin \"<P-14>\" not found"` (measured on Docker 29.6.1, snap
install; Q-4 in SCHEMATIC.md's decisions).
- **It is not removing the plugin reference.** Deleting `P-14` from
`authorization-plugins` and sending SIGHUP leaves an unrestricted daemon:
every request is allowed while the entry is absent. That is the deliberate
rollback of the whole capability, never a step in a policy update.

## Inputs

- Parameters P-6, P-7, P-13 from SCHEMATIC.md.
- The updated policy source (the implementer's working copy, e.g. in a git
repository), with every placeholder substituted.
- The plugin installed and enabled (Phase 3).
- The plugin installed and enabled (Phase 3). Its `-policy-file` argument and
mount are read to find P-7 — `docker plugin inspect <P-14>` names both, so the
path is derivable rather than guessed, and
`scripts/reload-opa-policy.sh --discover` prints it read-only.

## Outputs

- `P-7/agent.rego` — the deployed policy, copied from the substituted source.
- Plugin state: bounces through disable → enable on the file path; unchanged on
the bundle path.
- `P-7/agent.rego` — the deployed policy, installed by atomic rename.
- `P-7/agent.rego.previous` — the policy it replaced, for rollback.
- Plugin state: **unchanged**. Nothing in this procedure disables, restarts, or
reinstalls the plugin, and no daemon restart is involved.

## Pre-flight checks (before touching the running plugin)
## Pre-flight checks (before touching the deployed file)

1. **No placeholders left**: `grep -nE 'SANDBOX_USERNAME|AUTH_HEADER_NAME|PROJECT_NAME|PROJECT_DIR_PATH|BUILDKIT_PREFIX|TESTCONTAINERS_LABEL' <source>` must print nothing. A leftover token would be deployed as a literal and silently turn the policy into "allow everything". (`BUILDKIT_PREFIX` is retired — R-17 removed the BuildKit carve-out — and is kept in this pattern on purpose: the check is a superset of the template's tokens, so a source copied from the pre-0.4.0 template is still caught.)
2. **It parses**: `opa check <source>` with an `opa` binary at or below the
plugin's engine version (see `skeleton/agent.rego.schema`).
1. **No placeholders left**: `grep -nE 'SANDBOX_USERNAME|AUTH_HEADER_NAME|PROJECT_NAME|PROJECT_DIR_PATH|BUILDKIT_PREFIX|TESTCONTAINERS_LABEL' <source>` must print nothing. A leftover token would be deployed as a literal and silently turn the policy into "allow everything". (`BUILDKIT_PREFIX` is not read by the policy and is kept in this pattern on purpose: the check is a superset of the template's tokens, so a source that carries the token is caught rather than deployed.)
2. **It parses**, under an engine **no newer than the plugin's** (P-10):
`opa check <source>`, or the engine's own image when no binary is installed —
`docker run --rm -v "$(pwd):/w:ro" -w /w openpolicyagent/opa:1.3.0 check <source>`.
A newer engine accepts syntax the plugin's engine rejects.
3. **It decides correctly**: the `opa eval` probes in
`skeleton/agent.rego.schema` still produce the expected allow/deny results.
4. **A copy of the currently deployed policy is kept**, so the change can be
reverted with the same procedure.
`skeleton/agent.rego.schema` still produce the expected allow/deny results —
**with the plugin's real input shape**: `PathPlain` carries the API version
prefix (`"PathPlain": u.Path` in `main.go`), so a probe table written with a
version-less `PathPlain` proves nothing about a live daemon. Every path in
the table is `/v1.<n>/…`, and the runner records the version it used.
4. **A copy of the currently deployed policy is kept** before the replacement,
so the change can be reverted with the same procedure.

## Dependencies

Expand All @@ -56,27 +79,55 @@ rollback, never as a step in a policy update.

## Failure Behavior

- **Policy syntax error**: the plugin does not serve a decision, so the daemon
fails closed and all API calls are denied until a valid policy is deployed.
This is an outage, not a security hole. Recovery: restore the previous file
and re-apply the reload.
- **Plugin already disabled**: the disable step fails or is skipped; the script
in `scripts/reload-opa-policy.sh` checks the state before acting.
- **Plugin not found**: it was never installed. Run Phase 3 first — and note
that a plugin installed without `opa-args` answers "allow" to everything, so
"install it and move on" is not a valid recovery.
- **Source file not found**: the copy fails. Pass an explicit path.
- **Policy syntax error**: the plugin cannot compile it, so it returns an error
and the daemon fails closed — an outage, not a security hole. Recovery:
`--rollback` (restores `agent.rego.previous`) or re-deploy a valid file.
- **The policy file is missing when a request arrives**: the plugin **fails
open** (`OPA policy file %s does not exist, failing open and allowing
request`). This is the one open direction in this procedure, and it is why
the deployed file is replaced by `install` to a temporary name followed by
`mv -f` — one atomic rename — and never by copying or truncating the live
path.
- **A partially written policy file**: it does not compile, so the request is
denied and logged; the atomic rename makes this unreachable in the normal
flow.
- **The plugin is missing, disabled, or was removed from the daemon's
configuration**: dockerd will not complete a start or a reload — it exits
with `Error validating authorization plugin`. If that state exists while the
daemon is configured to reference the plugin, the daemon crash-loops, and
`docker plugin enable` cannot help because it needs a running daemon:
recovery is to remove the reference from the live daemon configuration file
(P-13), start the daemon (unrestricted for the moment), `docker plugin
enable <P-14>`, then put the reference back and apply it with SIGHUP. This is
why nothing in this procedure touches plugin state.
- **Source file not found**: nothing is replaced. Pass an explicit path.
- **Bundle path unavailable**: with `-config-file`, the plugin keeps serving the
last bundle it fetched; a decision-log or plugin-log line reports the fetch
failure. The daemon is not disrupted, and the policy is stale rather than
absent — state the staleness in the change record.

## Verification (not just "the file looks right")

0. `scripts/reload-opa-policy.sh --discover` prints the host path the plugin
actually reads, derived from its `-policy-file` argument and the mount that
carries it. Compare that with P-7 before deploying: a policy written to a
directory the plugin does not mount is installed perfectly and read by
nobody.
1. `sha256sum P-7/agent.rego`, then read the plugin's decision log for the next
request and compare with its `config_hash` — the plugin logs the hash of the
bytes it actually evaluated, so this is the difference between "the file on
disk changed" and "the live policy changed".
2. A decision that the change was about: one that must be allowed and one that
must be denied, over the TLS listener (Phase 8's tests).

## Idempotency Notes

- Copying the policy file is idempotent (it always overwrites with the source).
- Disable-then-enable is idempotent only if the plugin is currently enabled.
- Installing the file is idempotent: the same source always produces the same
deployed bytes, and `agent.rego.previous` is a copy of what was replaced.
- Substitution is idempotent, and the no-placeholder check makes a second run
against an already-deployed file a no-op rather than a corruption.
- Re-running the procedure never leaves the plugin in a different state from
the one it started in.

## Removal Notes

Expand Down
Loading
Loading