detached c9e9163

Merge pull request #301 from bnema/feat/app-capabilities

feat(apps): operator-gated Linux capabilities for app services
commit
c9e9163d8e10ed27f6d17474eb94538be54e665b
author
bnema · · 2026-10-18 20:49 UTC
committer
GitHub · · 2026-10-18 20:49 UTC
merge of
de8f68e + 694968b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
checks
4/4
33 files · +1578 -46
M docs/config/apps.md +3 -0
@@ -32,6 +32,7 @@ image = "gordon.mydomain.com/blog:1.4.2"
3232 command = ["node", "server.js"] # optional override
3333 stop_grace = "30s" # optional, default 30s
3434 devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>]
35+capabilities = ["vm-net"] # 0..n logical names from [app_capabilities.<name>]
3536
3637 [services.web.readiness] # optional explicit readiness
3738 type = "http"
@@ -86,6 +87,7 @@ services = ["web"]
8687 - Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected.
8788 - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
8889 - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
90+- Capability policy name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. It names an `[app_capabilities.<name>]` policy, not a Linux capability: `capabilities = ["NET_ADMIN"]` is rejected.
8991 - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes.
9092
9193 ## Services
@@ -135,6 +137,7 @@ A deploy recreates a service whose image digest, spec, environment, or secret va
135137 - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them.
136138 - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time.
137139 - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations.
140+- `capabilities` lists logical capability-policy names granted by `[app_capabilities.<name>]` policy the operator declares in `gordon.toml`; a name with no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to Linux capabilities at activation time and adds them on top of its default capability set; `cap_drop = ALL`, `no-new-privileges`, and non-privileged mode always remain. Only a documented safe set of capabilities can be granted (never `ALL` or `SYS_ADMIN`; see [Security Hardening](security-hardening.md#administrative-app-capabilities)). Revisions persist the logical names, never the resolution. An add/remove shows as `service/<name>/capabilities` in diffs; reorder-only input is a no-op. Revoking a grant fails subsequent deploys, restarts, and recoveries closed while the running service is untouched.
138141 - A service with any bind runs as a single writer, like a volume-backed service: replacements never run two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status).
139142 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
140143 - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`).
@@ -32,6 +32,7 @@ image = "gordon.mydomain.com/blog:1.4.2"
32 command = ["node", "server.js"] # optional override32 command = ["node", "server.js"] # optional override
33 stop_grace = "30s" # optional, default 30s33 stop_grace = "30s" # optional, default 30s
34 devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>]34 devices = ["transcode-gpu"] # 0..n logical names from [app_devices.<name>]
35+capabilities = ["vm-net"] # 0..n logical names from [app_capabilities.<name>]
35 36
36 [services.web.readiness] # optional explicit readiness37 [services.web.readiness] # optional explicit readiness
37 type = "http"38 type = "http"
@@ -86,6 +87,7 @@ services = ["web"]
86 - Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected.87 - Service name: the `[services.<name>]` table key — `[a-z0-9_.-]`, max 63, quoted when it contains a dot. TOML keys are unique, so one table exists per name; names that share a runtime identifier after normalization (`web.api` and `web-api`) are rejected.
87 - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.88 - Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
88 - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.89 - Device name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
90+- Capability policy name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service. It names an `[app_capabilities.<name>]` policy, not a Linux capability: `capabilities = ["NET_ADMIN"]` is rejected.
89 - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes.91 - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes.
90 92
91 ## Services93 ## Services
@@ -135,6 +137,7 @@ A deploy recreates a service whose image digest, spec, environment, or secret va
135 - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them.137 - Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them.
136 - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time.138 - `[[services.<name>.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time.
137 - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations.139 - `devices` lists logical device names granted by `[app_devices.<name>]` policy the operator declares in `gordon.toml`; a device whose name has no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to explicit CDI IDs at activation time and encodes them as one native CDI `DeviceRequest`. Revisions persist the logical names, never the host resolution. A device add/remove shows as `service/<name>/devices` in diffs; reorder-only input is a no-op. Changing a mapping never recreates a running container: the next deploy serves the new resolution. Revoking a grant fails subsequent deploys closed while the running service is untouched. Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured; older or unrecognized engines return a structured `runtime-unsupported` error and never run without devices. Gordon installs no drivers, manages no quotas, and injects no `NVIDIA_*` environment: images carry their own runtime expectations.
140+- `capabilities` lists logical capability-policy names granted by `[app_capabilities.<name>]` policy the operator declares in `gordon.toml`; a name with no matching policy, or whose policy does not allow the app+service pair, is rejected at apply time. Gordon resolves each name to Linux capabilities at activation time and adds them on top of its default capability set; `cap_drop = ALL`, `no-new-privileges`, and non-privileged mode always remain. Only a documented safe set of capabilities can be granted (never `ALL` or `SYS_ADMIN`; see [Security Hardening](security-hardening.md#administrative-app-capabilities)). Revisions persist the logical names, never the resolution. An add/remove shows as `service/<name>/capabilities` in diffs; reorder-only input is a no-op. Revoking a grant fails subsequent deploys, restarts, and recoveries closed while the running service is untouched.
138 - A service with any bind runs as a single writer, like a volume-backed service: replacements never run two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status).141 - A service with any bind runs as a single writer, like a volume-backed service: replacements never run two generations at once. `gordon apps status` and `apps show` mark such services `single-writer` (`restart_unsafe` in JSON); the mark is informational. See [apps status](../cli/apps.md#gordon-apps-status).
139 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.142 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
140 - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`).143 - `[[services.<name>.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`).
M docs/config/reference.md +20 -0
@@ -172,6 +172,23 @@ preserve = true # Keep volumes when containers are
172172 # allowed_apps = ["video"] # Required; exact, non-empty
173173 # allowed_services = ["transcoder"] # Required; exact, non-empty
174174
175+# =============================================================================
176+# ADMINISTRATIVE APP CAPABILITIES
177+# =============================================================================
178+# A named capability grant is the only way an app manifest may add Linux
179+# capabilities. The manifest declares capabilities = ["<name>"] (a policy
180+# name, not a capability). Granted capabilities are added on top of Gordon's
181+# defaults; cap_drop = ALL, no-new-privileges, and non-privileged mode always
182+# remain. Allowed names (no CAP_ prefix): AUDIT_WRITE, CHOWN, DAC_OVERRIDE,
183+# FOWNER, FSETID, IPC_LOCK, KILL, MKNOD, NET_ADMIN, NET_BIND_SERVICE,
184+# NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, SYS_NICE,
185+# SYS_RESOURCE. ALL, SYS_ADMIN, SYS_PTRACE, SYS_MODULE and the rest are
186+# rejected. allowed_apps and allowed_services are exact and non-empty.
187+# [app_capabilities.vm-net]
188+# add = ["NET_ADMIN"] # Required; allowed set only
189+# allowed_apps = ["myapp"] # Required; exact, non-empty
190+# allowed_services = ["worker"] # Required; exact, non-empty
191+
175192 # REMOVED in v3: [routes], [attachments], [network_groups],
176193 # app-like [[services]], [service_routes], [auto_route], [previews].
177194 # Declare apps in standalone files (see ./apps.md). The standalone
@@ -324,6 +341,9 @@ keep_last = 3 # Keep N newest tags per repository
324341 | `app_devices.<name>.cdi` | none | Required explicit, non-empty CDI device IDs granted under this logical name (no `=all` aggregate form) |
325342 | `app_devices.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this grant (no wildcard or empty-means-all form) |
326343 | `app_devices.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this grant |
344+| `app_capabilities.<name>.add` | none | Required explicit, non-empty Linux capability names (no `CAP_` prefix) added on top of Gordon's defaults. Only the documented safe set is accepted; `ALL` and `SYS_ADMIN` are rejected |
345+| `app_capabilities.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this grant |
346+| `app_capabilities.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this grant |
327347 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
328348 | `services[].image` | none | Container image for enabled standalone services |
329349 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |
@@ -172,6 +172,23 @@ preserve = true # Keep volumes when containers are
172 # allowed_apps = ["video"] # Required; exact, non-empty172 # allowed_apps = ["video"] # Required; exact, non-empty
173 # allowed_services = ["transcoder"] # Required; exact, non-empty173 # allowed_services = ["transcoder"] # Required; exact, non-empty
174 174
175+# =============================================================================
176+# ADMINISTRATIVE APP CAPABILITIES
177+# =============================================================================
178+# A named capability grant is the only way an app manifest may add Linux
179+# capabilities. The manifest declares capabilities = ["<name>"] (a policy
180+# name, not a capability). Granted capabilities are added on top of Gordon's
181+# defaults; cap_drop = ALL, no-new-privileges, and non-privileged mode always
182+# remain. Allowed names (no CAP_ prefix): AUDIT_WRITE, CHOWN, DAC_OVERRIDE,
183+# FOWNER, FSETID, IPC_LOCK, KILL, MKNOD, NET_ADMIN, NET_BIND_SERVICE,
184+# NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, SYS_NICE,
185+# SYS_RESOURCE. ALL, SYS_ADMIN, SYS_PTRACE, SYS_MODULE and the rest are
186+# rejected. allowed_apps and allowed_services are exact and non-empty.
187+# [app_capabilities.vm-net]
188+# add = ["NET_ADMIN"] # Required; allowed set only
189+# allowed_apps = ["myapp"] # Required; exact, non-empty
190+# allowed_services = ["worker"] # Required; exact, non-empty
191+
175 # REMOVED in v3: [routes], [attachments], [network_groups],192 # REMOVED in v3: [routes], [attachments], [network_groups],
176 # app-like [[services]], [service_routes], [auto_route], [previews].193 # app-like [[services]], [service_routes], [auto_route], [previews].
177 # Declare apps in standalone files (see ./apps.md). The standalone194 # Declare apps in standalone files (see ./apps.md). The standalone
@@ -324,6 +341,9 @@ keep_last = 3 # Keep N newest tags per repository
324 | `app_devices.<name>.cdi` | none | Required explicit, non-empty CDI device IDs granted under this logical name (no `=all` aggregate form) |341 | `app_devices.<name>.cdi` | none | Required explicit, non-empty CDI device IDs granted under this logical name (no `=all` aggregate form) |
325 | `app_devices.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this grant (no wildcard or empty-means-all form) |342 | `app_devices.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this grant (no wildcard or empty-means-all form) |
326 | `app_devices.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this grant |343 | `app_devices.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this grant |
344+| `app_capabilities.<name>.add` | none | Required explicit, non-empty Linux capability names (no `CAP_` prefix) added on top of Gordon's defaults. Only the documented safe set is accepted; `ALL` and `SYS_ADMIN` are rejected |
345+| `app_capabilities.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this grant |
346+| `app_capabilities.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this grant |
327 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |347 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
328 | `services[].image` | none | Container image for enabled standalone services |348 | `services[].image` | none | Container image for enabled standalone services |
329 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |349 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |
M docs/config/security-hardening.md +64 -0
@@ -83,6 +83,70 @@ The manifest references only the logical name: `devices = ["transcode-gpu"]`.
8383 - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.
8484 - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.
8585
86+### Plain device nodes under rootless Podman
87+
88+> **Verified by source reading only. Confirm on the host:** create a throwaway container with the same device IDs (`podman run --rm --device example.com/vm=kvm --device example.com/vm=net-tun alpine ls -l /dev/kvm /dev/net/tun`) as the rootless user before the first Gordon deploy.
89+
90+CDI is not GPU-specific. Any device node can be exposed through a user-level CDI spec, so `/dev/kvm` and `/dev/net/tun` reach a rootless Podman service without raw `/dev` paths in manifests and without any privilege:
91+
92+```yaml
93+# ~/.config/cdi/vm.yaml (rootless user, mode 0644)
94+cdiVersion: "0.6.0"
95+kind: example.com/vm
96+devices:
97+ - name: kvm
98+ containerEdits:
99+ deviceNodes:
100+ - path: /dev/kvm
101+ - name: net-tun
102+ containerEdits:
103+ deviceNodes:
104+ - path: /dev/net/tun
105+```
106+
107+```toml
108+# ~/.config/containers/containers.conf (the rootless user that runs the Podman API service)
109+[engine]
110+# Setting cdi_spec_dirs replaces the defaults: keep /etc/cdi and /var/run/cdi if you use them.
111+cdi_spec_dirs = ["/etc/cdi", "/var/run/cdi", "/home/<user>/.config/cdi"]
112+```
113+
114+```toml
115+# gordon.toml
116+[app_devices.my-vm-device]
117+cdi = ["example.com/vm=kvm", "example.com/vm=net-tun"]
118+allowed_apps = ["myapp"]
119+allowed_services = ["worker"]
120+```
121+
122+- **Status: verified by source reading (Podman 5.7.1), not by a live run. Confirm on your host before relying on it.** No Gordon change appeared necessary. Gordon sends the IDs as a native `cdi` `DeviceRequest`; the Podman compat API forwards them to the same CDI injection as `podman run --device <id>`, which reads `cdi_spec_dirs` from the containers.conf of the user running the API service. Restart that service (`systemctl --user restart podman.socket podman.service`) after editing `containers.conf`; edits to the spec files themselves are picked up on the next container create.
123+- Under rootless Podman the OCI runtime (crun or runc) cannot `mknod` in a user namespace and falls back to bind-mounting the host device node into the container. The node must therefore be accessible to the rootless user on the host: `/dev/kvm` and `/dev/net/tun` are usually `0666` or group-owned (`kvm`). Supplementary groups do not carry into the user namespace, so prefer a `0666`/ACL-based permission over group membership.
124+- CDI device names Gordon accepts match `[A-Za-z0-9][A-Za-z0-9_.-]*` (so `kvm` and `net-tun` are valid; names containing `:` are not).
125+- A missing spec dir entry, an unreadable spec, or an unknown device name surfaces as a redacted create failure (`runtime error redacted`); check the Podman service journal for the CDI error.
126+
127+## Administrative app capabilities
128+
129+App manifests cannot add Linux capabilities. A grant exists only when the operator declares a named policy in `gordon.toml`:
130+
131+```toml
132+[app_capabilities.vm-net]
133+add = ["NET_ADMIN"]
134+allowed_apps = ["myapp"] # exact, non-empty
135+allowed_services = ["worker"] # exact, non-empty
136+```
137+
138+The manifest references only the logical name: `capabilities = ["vm-net"]`.
139+
140+- `add` holds explicit Linux capability names, written without the `CAP_` prefix. Only this allowed set is accepted: `AUDIT_WRITE`, `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID`, `IPC_LOCK`, `KILL`, `MKNOD`, `NET_ADMIN`, `NET_BIND_SERVICE`, `NET_RAW`, `SETFCAP`, `SETGID`, `SETPCAP`, `SETUID`, `SYS_CHROOT`, `SYS_NICE`, `SYS_RESOURCE`. Everything else is rejected when the config loads, including `ALL`, `SYS_ADMIN`, `SYS_PTRACE`, `SYS_MODULE`, `SYS_RAWIO`, `SYS_BOOT`, `SYS_TIME`, `DAC_READ_SEARCH`, `BPF`, `PERFMON`, `MAC_ADMIN`, `MAC_OVERRIDE`, `AUDIT_CONTROL`, and `SYSLOG`. These can reach the host or defeat container confinement; a workload that needs one is not an unprivileged service.
141+- **The allowed capabilities are not harmless.** The list only removes capabilities that clearly break confinement; each remaining one still needs a workload-specific assessment before you grant it. In particular: `SETFCAP` lets a process write file capabilities, which is dangerous together with writable binds or volumes shared with another service or the host; `MKNOD` creates device nodes (inert in a user namespace, but a risk if the runtime ever runs without one); `NET_RAW` allows packet spoofing and raw sockets inside the container's network namespace and on any shared network; `SYS_CHROOT`, `SETPCAP`, `KILL`, `DAC_OVERRIDE`, `FOWNER` and `FSETID` widen what a compromised process can do to files and processes it can reach; `NET_ADMIN` controls every interface, route, and firewall rule in the container's network namespace, including a shared network attachment. Grant the smallest set to the narrowest app+service pair.
142+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists. There is no wildcard, prefix, or empty-means-all form. Unknown keys inside `[app_capabilities.<name>]` fail closed.
143+- Granted capabilities are added on top of Gordon's default set (`CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID`, `SETGID`, `NET_BIND_SERVICE`). Gordon's `cap_drop = ALL` and `no-new-privileges` stay in force, and containers are never privileged. The runtime guard re-checks the resolved list before every create.
144+- Capabilities apply inside the container's own user and network namespaces. Under rootless Podman, `NET_ADMIN` lets the service create TAP devices and nftables rules in its own network namespace, not on the host. Because `no-new-privileges` stays on, a process only holds the added capabilities when it runs as container root (uid 0 in the user namespace, which is an unprivileged host user under rootless Podman); a non-root container user cannot gain them through file capabilities.
145+- Gordon resolves logical names at activation time. Revisions persist the logical names, never the resolution. The grant is re-resolved before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys and restarts while the running service is untouched.
146+- **Narrowing a policy is enforced on reuse.** A container keeps the capabilities it was created with, so before Gordon restarts, starts, or health-restarts an existing capability-bearing container it inspects the container's configured `CapAdd` and refuses the operation (`capability-policy-violation`, naming the excess capabilities) when it exceeds Gordon's defaults plus the currently authorized grant. If the container cannot be inspected the operation fails closed. Run `gordon apps deploy` to recreate the service with the narrowed set; the superseded container is retired by the normal replacement. A container missing from the runtime is rebuilt from the pinned revision under the current policy.
147+- **Device grants have the same gap, documented rather than fixed.** Narrowing an `[app_devices]` mapping (removing a CDI ID) does not remove the device from an already created container, and a restart or recovery of that container keeps it. Resolution still fails closed for revoked or unknown policies, but Gordon cannot reliably read back the injected CDI devices (Podman's Docker-compatible inspect does not report them), so it cannot verify a narrowed mapping on reuse. After narrowing or revoking a device mapping, run `gordon apps deploy` for the affected service (or stop it) so the container is recreated with the current mapping.
148+- Adding or removing a policy name shows as `service/<name>/capabilities` in diffs and recreates the container on the next deploy; reorder-only input is a no-op. Changing what a policy grants does not recreate a running container: the next deploy serves the new resolution.
149+
86150 ## External image registries
87151
88152 Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
@@ -83,6 +83,70 @@ The manifest references only the logical name: `devices = ["transcode-gpu"]`.
83 - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.83 - Device-bearing creates require Podman 5.4+ or Docker 28.3+ with native CDI configured. Older or unrecognized engines fail with a structured `runtime-unsupported` error and never run without devices.
84 - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.84 - Gordon installs no drivers, manages no quotas, and injects no device environment: images carry their own runtime expectations.
85 85
86+### Plain device nodes under rootless Podman
87+
88+> **Verified by source reading only. Confirm on the host:** create a throwaway container with the same device IDs (`podman run --rm --device example.com/vm=kvm --device example.com/vm=net-tun alpine ls -l /dev/kvm /dev/net/tun`) as the rootless user before the first Gordon deploy.
89+
90+CDI is not GPU-specific. Any device node can be exposed through a user-level CDI spec, so `/dev/kvm` and `/dev/net/tun` reach a rootless Podman service without raw `/dev` paths in manifests and without any privilege:
91+
92+```yaml
93+# ~/.config/cdi/vm.yaml (rootless user, mode 0644)
94+cdiVersion: "0.6.0"
95+kind: example.com/vm
96+devices:
97+ - name: kvm
98+ containerEdits:
99+ deviceNodes:
100+ - path: /dev/kvm
101+ - name: net-tun
102+ containerEdits:
103+ deviceNodes:
104+ - path: /dev/net/tun
105+```
106+
107+```toml
108+# ~/.config/containers/containers.conf (the rootless user that runs the Podman API service)
109+[engine]
110+# Setting cdi_spec_dirs replaces the defaults: keep /etc/cdi and /var/run/cdi if you use them.
111+cdi_spec_dirs = ["/etc/cdi", "/var/run/cdi", "/home/<user>/.config/cdi"]
112+```
113+
114+```toml
115+# gordon.toml
116+[app_devices.my-vm-device]
117+cdi = ["example.com/vm=kvm", "example.com/vm=net-tun"]
118+allowed_apps = ["myapp"]
119+allowed_services = ["worker"]
120+```
121+
122+- **Status: verified by source reading (Podman 5.7.1), not by a live run. Confirm on your host before relying on it.** No Gordon change appeared necessary. Gordon sends the IDs as a native `cdi` `DeviceRequest`; the Podman compat API forwards them to the same CDI injection as `podman run --device <id>`, which reads `cdi_spec_dirs` from the containers.conf of the user running the API service. Restart that service (`systemctl --user restart podman.socket podman.service`) after editing `containers.conf`; edits to the spec files themselves are picked up on the next container create.
123+- Under rootless Podman the OCI runtime (crun or runc) cannot `mknod` in a user namespace and falls back to bind-mounting the host device node into the container. The node must therefore be accessible to the rootless user on the host: `/dev/kvm` and `/dev/net/tun` are usually `0666` or group-owned (`kvm`). Supplementary groups do not carry into the user namespace, so prefer a `0666`/ACL-based permission over group membership.
124+- CDI device names Gordon accepts match `[A-Za-z0-9][A-Za-z0-9_.-]*` (so `kvm` and `net-tun` are valid; names containing `:` are not).
125+- A missing spec dir entry, an unreadable spec, or an unknown device name surfaces as a redacted create failure (`runtime error redacted`); check the Podman service journal for the CDI error.
126+
127+## Administrative app capabilities
128+
129+App manifests cannot add Linux capabilities. A grant exists only when the operator declares a named policy in `gordon.toml`:
130+
131+```toml
132+[app_capabilities.vm-net]
133+add = ["NET_ADMIN"]
134+allowed_apps = ["myapp"] # exact, non-empty
135+allowed_services = ["worker"] # exact, non-empty
136+```
137+
138+The manifest references only the logical name: `capabilities = ["vm-net"]`.
139+
140+- `add` holds explicit Linux capability names, written without the `CAP_` prefix. Only this allowed set is accepted: `AUDIT_WRITE`, `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID`, `IPC_LOCK`, `KILL`, `MKNOD`, `NET_ADMIN`, `NET_BIND_SERVICE`, `NET_RAW`, `SETFCAP`, `SETGID`, `SETPCAP`, `SETUID`, `SYS_CHROOT`, `SYS_NICE`, `SYS_RESOURCE`. Everything else is rejected when the config loads, including `ALL`, `SYS_ADMIN`, `SYS_PTRACE`, `SYS_MODULE`, `SYS_RAWIO`, `SYS_BOOT`, `SYS_TIME`, `DAC_READ_SEARCH`, `BPF`, `PERFMON`, `MAC_ADMIN`, `MAC_OVERRIDE`, `AUDIT_CONTROL`, and `SYSLOG`. These can reach the host or defeat container confinement; a workload that needs one is not an unprivileged service.
141+- **The allowed capabilities are not harmless.** The list only removes capabilities that clearly break confinement; each remaining one still needs a workload-specific assessment before you grant it. In particular: `SETFCAP` lets a process write file capabilities, which is dangerous together with writable binds or volumes shared with another service or the host; `MKNOD` creates device nodes (inert in a user namespace, but a risk if the runtime ever runs without one); `NET_RAW` allows packet spoofing and raw sockets inside the container's network namespace and on any shared network; `SYS_CHROOT`, `SETPCAP`, `KILL`, `DAC_OVERRIDE`, `FOWNER` and `FSETID` widen what a compromised process can do to files and processes it can reach; `NET_ADMIN` controls every interface, route, and firewall rule in the container's network namespace, including a shared network attachment. Grant the smallest set to the narrowest app+service pair.
142+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists. There is no wildcard, prefix, or empty-means-all form. Unknown keys inside `[app_capabilities.<name>]` fail closed.
143+- Granted capabilities are added on top of Gordon's default set (`CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID`, `SETGID`, `NET_BIND_SERVICE`). Gordon's `cap_drop = ALL` and `no-new-privileges` stay in force, and containers are never privileged. The runtime guard re-checks the resolved list before every create.
144+- Capabilities apply inside the container's own user and network namespaces. Under rootless Podman, `NET_ADMIN` lets the service create TAP devices and nftables rules in its own network namespace, not on the host. Because `no-new-privileges` stays on, a process only holds the added capabilities when it runs as container root (uid 0 in the user namespace, which is an unprivileged host user under rootless Podman); a non-root container user cannot gain them through file capabilities.
145+- Gordon resolves logical names at activation time. Revisions persist the logical names, never the resolution. The grant is re-resolved before every preflight, container create, restart, and recovery. Revoking a grant blocks future deploys and restarts while the running service is untouched.
146+- **Narrowing a policy is enforced on reuse.** A container keeps the capabilities it was created with, so before Gordon restarts, starts, or health-restarts an existing capability-bearing container it inspects the container's configured `CapAdd` and refuses the operation (`capability-policy-violation`, naming the excess capabilities) when it exceeds Gordon's defaults plus the currently authorized grant. If the container cannot be inspected the operation fails closed. Run `gordon apps deploy` to recreate the service with the narrowed set; the superseded container is retired by the normal replacement. A container missing from the runtime is rebuilt from the pinned revision under the current policy.
147+- **Device grants have the same gap, documented rather than fixed.** Narrowing an `[app_devices]` mapping (removing a CDI ID) does not remove the device from an already created container, and a restart or recovery of that container keeps it. Resolution still fails closed for revoked or unknown policies, but Gordon cannot reliably read back the injected CDI devices (Podman's Docker-compatible inspect does not report them), so it cannot verify a narrowed mapping on reuse. After narrowing or revoking a device mapping, run `gordon apps deploy` for the affected service (or stop it) so the container is recreated with the current mapping.
148+- Adding or removing a policy name shows as `service/<name>/capabilities` in diffs and recreates the container on the next deploy; reorder-only input is a no-op. Changing what a policy grants does not recreate a running container: the next deploy serves the new resolution.
149+
86 ## External image registries150 ## External image registries
87 151
88 Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:152 Docker Hub, `ghcr.io`, `quay.io`, and Gordon's configured registry are always allowed. Add every other registry hostname and non-default port explicitly:
M gordon.toml.example +17 -0
@@ -142,6 +142,23 @@ syslog_identifier = "gordon-access"
142142 # allowed_apps = ["video"] # Required; exact, non-empty
143143 # allowed_services = ["transcoder"] # Required; exact, non-empty
144144
145+# =============================================================================
146+# ADMINISTRATIVE APP CAPABILITIES
147+# =============================================================================
148+# A named capability grant is the only way an app manifest may add Linux
149+# capabilities. The manifest declares capabilities = ["<name>"] (a policy
150+# name, not a capability). Granted capabilities are added on top of Gordon's
151+# defaults; cap_drop = ALL, no-new-privileges, and non-privileged mode always
152+# remain. Allowed names (no CAP_ prefix): AUDIT_WRITE, CHOWN, DAC_OVERRIDE,
153+# FOWNER, FSETID, IPC_LOCK, KILL, MKNOD, NET_ADMIN, NET_BIND_SERVICE,
154+# NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, SYS_NICE,
155+# SYS_RESOURCE. ALL, SYS_ADMIN, SYS_PTRACE, SYS_MODULE and the rest are
156+# rejected. allowed_apps and allowed_services are exact and non-empty.
157+# [app_capabilities.vm-net]
158+# add = ["NET_ADMIN"] # Required; allowed set only
159+# allowed_apps = ["myapp"] # Required; exact, non-empty
160+# allowed_services = ["worker"] # Required; exact, non-empty
161+
145162 [telemetry]
146163 enabled = false
147164 # endpoint = "http://localhost:4318"
@@ -142,6 +142,23 @@ syslog_identifier = "gordon-access"
142 # allowed_apps = ["video"] # Required; exact, non-empty142 # allowed_apps = ["video"] # Required; exact, non-empty
143 # allowed_services = ["transcoder"] # Required; exact, non-empty143 # allowed_services = ["transcoder"] # Required; exact, non-empty
144 144
145+# =============================================================================
146+# ADMINISTRATIVE APP CAPABILITIES
147+# =============================================================================
148+# A named capability grant is the only way an app manifest may add Linux
149+# capabilities. The manifest declares capabilities = ["<name>"] (a policy
150+# name, not a capability). Granted capabilities are added on top of Gordon's
151+# defaults; cap_drop = ALL, no-new-privileges, and non-privileged mode always
152+# remain. Allowed names (no CAP_ prefix): AUDIT_WRITE, CHOWN, DAC_OVERRIDE,
153+# FOWNER, FSETID, IPC_LOCK, KILL, MKNOD, NET_ADMIN, NET_BIND_SERVICE,
154+# NET_RAW, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, SYS_NICE,
155+# SYS_RESOURCE. ALL, SYS_ADMIN, SYS_PTRACE, SYS_MODULE and the rest are
156+# rejected. allowed_apps and allowed_services are exact and non-empty.
157+# [app_capabilities.vm-net]
158+# add = ["NET_ADMIN"] # Required; allowed set only
159+# allowed_apps = ["myapp"] # Required; exact, non-empty
160+# allowed_services = ["worker"] # Required; exact, non-empty
161+
145 [telemetry]162 [telemetry]
146 enabled = false163 enabled = false
147 # endpoint = "http://localhost:4318"164 # endpoint = "http://localhost:4318"
M internal/adapters/in/appmanifest/parse.go +9 -4
@@ -58,10 +58,13 @@ type rawService struct {
5858 Volumes []rawVolume `toml:"volume"`
5959 Binds []rawBind `toml:"bind"`
6060 // Devices lists logical device names from `devices = [...]`.
61- Devices []string `toml:"devices"`
62- Databases []rawDatabase `toml:"database"`
63- Backup rawBackup `toml:"backup"`
64- Telemetry rawTelemetry `toml:"telemetry"`
61+ Devices []string `toml:"devices"`
62+ // Capabilities lists logical capability-policy names from
63+ // `capabilities = [...]`.
64+ Capabilities []string `toml:"capabilities"`
65+ Databases []rawDatabase `toml:"database"`
66+ Backup rawBackup `toml:"backup"`
67+ Telemetry rawTelemetry `toml:"telemetry"`
6568 }
6669
6770 // rawReadiness mirrors [services.<name>.readiness].
@@ -346,6 +349,8 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
346349 // Preserve manifest order; domain equalDevices treats the slice as a
347350 // set, so reorder-only input is a no-op in diffs.
348351 svc.Devices = append([]string(nil), raw.Devices...)
352+ // Same set semantics for capability-policy names.
353+ svc.Capabilities = append([]string(nil), raw.Capabilities...)
349354 for _, db := range raw.Databases {
350355 svc.Databases = append(svc.Databases, domain.AppDatabase{
351356 Name: db.Name,
@@ -58,10 +58,13 @@ type rawService struct {
58 Volumes []rawVolume `toml:"volume"`58 Volumes []rawVolume `toml:"volume"`
59 Binds []rawBind `toml:"bind"`59 Binds []rawBind `toml:"bind"`
60 // Devices lists logical device names from `devices = [...]`.60 // Devices lists logical device names from `devices = [...]`.
61- Devices []string `toml:"devices"`61+ Devices []string `toml:"devices"`
62- Databases []rawDatabase `toml:"database"`62+ // Capabilities lists logical capability-policy names from
63- Backup rawBackup `toml:"backup"`63+ // `capabilities = [...]`.
64- Telemetry rawTelemetry `toml:"telemetry"`64+ Capabilities []string `toml:"capabilities"`
65+ Databases []rawDatabase `toml:"database"`
66+ Backup rawBackup `toml:"backup"`
67+ Telemetry rawTelemetry `toml:"telemetry"`
65 }68 }
66 69
67 // rawReadiness mirrors [services.<name>.readiness].70 // rawReadiness mirrors [services.<name>.readiness].
@@ -346,6 +349,8 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
346 // Preserve manifest order; domain equalDevices treats the slice as a349 // Preserve manifest order; domain equalDevices treats the slice as a
347 // set, so reorder-only input is a no-op in diffs.350 // set, so reorder-only input is a no-op in diffs.
348 svc.Devices = append([]string(nil), raw.Devices...)351 svc.Devices = append([]string(nil), raw.Devices...)
352+ // Same set semantics for capability-policy names.
353+ svc.Capabilities = append([]string(nil), raw.Capabilities...)
349 for _, db := range raw.Databases {354 for _, db := range raw.Databases {
350 svc.Databases = append(svc.Databases, domain.AppDatabase{355 svc.Databases = append(svc.Databases, domain.AppDatabase{
351 Name: db.Name,356 Name: db.Name,
M internal/adapters/in/appmanifest/parse_test.go +37 -0
@@ -620,3 +620,40 @@ func TestParse_TelemetryLogsDefaultOn(t *testing.T) {
620620 require.NoError(t, err)
621621 assert.False(t, spec.Services[0].LogExportDisabled)
622622 }
623+
624+func TestParse_Capabilities(t *testing.T) {
625+ doc := `
626+name = "demo"
627+[services.worker]
628+image = "registry.example.com/demo/worker:1"
629+capabilities = ["net-admin", "ipc-lock"]
630+`
631+ spec, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
632+ require.NoError(t, err)
633+ require.Len(t, spec.Services, 1)
634+ assert.Equal(t, []string{"net-admin", "ipc-lock"}, spec.Services[0].Capabilities)
635+}
636+
637+func TestParse_CapabilitiesWrongType(t *testing.T) {
638+ doc := `
639+name = "demo"
640+[services.worker]
641+image = "registry.example.com/demo/worker:1"
642+capabilities = "net-admin"
643+`
644+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
645+ require.Error(t, err)
646+}
647+
648+func TestParse_CapabilitiesMalformedAndDuplicateRejected(t *testing.T) {
649+ for _, list := range []string{`["Bad Name!"]`, `["NET_ADMIN"]`, `["net-admin", "net-admin"]`} {
650+ doc := `
651+name = "demo"
652+[services.worker]
653+image = "registry.example.com/demo/worker:1"
654+capabilities = ` + list + `
655+`
656+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
657+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec, list)
658+ }
659+}
@@ -620,3 +620,40 @@ func TestParse_TelemetryLogsDefaultOn(t *testing.T) {
620 require.NoError(t, err)620 require.NoError(t, err)
621 assert.False(t, spec.Services[0].LogExportDisabled)621 assert.False(t, spec.Services[0].LogExportDisabled)
622 }622 }
623+
624+func TestParse_Capabilities(t *testing.T) {
625+ doc := `
626+name = "demo"
627+[services.worker]
628+image = "registry.example.com/demo/worker:1"
629+capabilities = ["net-admin", "ipc-lock"]
630+`
631+ spec, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
632+ require.NoError(t, err)
633+ require.Len(t, spec.Services, 1)
634+ assert.Equal(t, []string{"net-admin", "ipc-lock"}, spec.Services[0].Capabilities)
635+}
636+
637+func TestParse_CapabilitiesWrongType(t *testing.T) {
638+ doc := `
639+name = "demo"
640+[services.worker]
641+image = "registry.example.com/demo/worker:1"
642+capabilities = "net-admin"
643+`
644+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
645+ require.Error(t, err)
646+}
647+
648+func TestParse_CapabilitiesMalformedAndDuplicateRejected(t *testing.T) {
649+ for _, list := range []string{`["Bad Name!"]`, `["NET_ADMIN"]`, `["net-admin", "net-admin"]`} {
650+ doc := `
651+name = "demo"
652+[services.worker]
653+image = "registry.example.com/demo/worker:1"
654+capabilities = ` + list + `
655+`
656+ _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
657+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec, list)
658+ }
659+}
M internal/adapters/in/http/admin/handler_apps.go +3 -0
@@ -532,6 +532,7 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool {
532532 return errors.Is(err, domain.ErrInvalidAppSpec) ||
533533 errors.Is(err, domain.ErrBindPolicy) ||
534534 errors.Is(err, domain.ErrDevicePolicy) ||
535+ errors.Is(err, domain.ErrCapabilityPolicy) ||
535536 isPreflightErrorRest(err)
536537 }
537538
@@ -559,6 +560,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
559560 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560561 case errors.Is(err, domain.ErrDevicePolicy):
561562 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
563+ case errors.Is(err, domain.ErrCapabilityPolicy):
564+ h.sendAppError(w, http.StatusBadRequest, "capability-policy-violation", err.Error(), "", "check the administrative capability authorization")
562565 case errors.Is(err, domain.ErrRuntimeGuard):
563566 h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
564567 case errors.Is(err, domain.ErrRuntimeUnsupported):
@@ -532,6 +532,7 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool {
532 return errors.Is(err, domain.ErrInvalidAppSpec) ||532 return errors.Is(err, domain.ErrInvalidAppSpec) ||
533 errors.Is(err, domain.ErrBindPolicy) ||533 errors.Is(err, domain.ErrBindPolicy) ||
534 errors.Is(err, domain.ErrDevicePolicy) ||534 errors.Is(err, domain.ErrDevicePolicy) ||
535+ errors.Is(err, domain.ErrCapabilityPolicy) ||
535 isPreflightErrorRest(err)536 isPreflightErrorRest(err)
536 }537 }
537 538
@@ -559,6 +560,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")560 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560 case errors.Is(err, domain.ErrDevicePolicy):561 case errors.Is(err, domain.ErrDevicePolicy):
561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")562 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
563+ case errors.Is(err, domain.ErrCapabilityPolicy):
564+ h.sendAppError(w, http.StatusBadRequest, "capability-policy-violation", err.Error(), "", "check the administrative capability authorization")
562 case errors.Is(err, domain.ErrRuntimeGuard):565 case errors.Is(err, domain.ErrRuntimeGuard):
563 h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")566 h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
564 case errors.Is(err, domain.ErrRuntimeUnsupported):567 case errors.Is(err, domain.ErrRuntimeUnsupported):
M internal/adapters/in/http/admin/handler_apps_test.go +17 -0
@@ -713,3 +713,20 @@ func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
713713 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
714714 assert.Equal(t, "runtime-unsupported", envelope.Error)
715715 }
716+
717+func TestApplyApp_MapsCapabilityPolicyViolation(t *testing.T) {
718+ appSvc := inmocks.NewMockAppService(t)
719+ handler := appsTestHandler(t, appSvc)
720+
721+ appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
722+ nil, nil,
723+ fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", domain.ErrCapabilityPolicy, "blog", "web", "net-admin"),
724+ ).Once()
725+
726+ rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
727+ dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
728+ assert.Equal(t, http.StatusBadRequest, rec.Code)
729+ var envelope dto.AppError
730+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
731+ assert.Equal(t, "capability-policy-violation", envelope.Error)
732+}
@@ -713,3 +713,20 @@ func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
713 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))713 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
714 assert.Equal(t, "runtime-unsupported", envelope.Error)714 assert.Equal(t, "runtime-unsupported", envelope.Error)
715 }715 }
716+
717+func TestApplyApp_MapsCapabilityPolicyViolation(t *testing.T) {
718+ appSvc := inmocks.NewMockAppService(t)
719+ handler := appsTestHandler(t, appSvc)
720+
721+ appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
722+ nil, nil,
723+ fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", domain.ErrCapabilityPolicy, "blog", "web", "net-admin"),
724+ ).Once()
725+
726+ rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
727+ dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
728+ assert.Equal(t, http.StatusBadRequest, rec.Code)
729+ var envelope dto.AppError
730+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
731+ assert.Equal(t, "capability-policy-violation", envelope.Error)
732+}
M internal/adapters/out/appstate/store_test.go +30 -0
@@ -615,3 +615,33 @@ const legacyDesiredWithoutDevices = `{
615615 "secrets_env": {},
616616 "status": "pending"
617617 }`
618+
619+func TestStore_DesiredCapabilitiesRoundTrip(t *testing.T) {
620+ ctx := context.Background()
621+ store := newTestStore(t)
622+
623+ intent := testIntent("blog", "rev-1")
624+ intent.Spec.Services[0].Capabilities = []string{"net-admin"}
625+ require.NoError(t, store.StageApply(ctx, intent))
626+ require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
627+ require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
628+
629+ desired, ok, err := store.LoadDesired(ctx, "blog")
630+ require.NoError(t, err)
631+ require.True(t, ok)
632+ require.Len(t, desired.Spec.Services, 1)
633+ assert.Equal(t, []string{"net-admin"}, desired.Spec.Services[0].Capabilities, "logical names persist in revisions")
634+}
635+
636+// A record written before capabilities existed carries no Capabilities key:
637+// it must load without a phantom grant.
638+func TestStore_DesiredWithoutCapabilitiesLoads(t *testing.T) {
639+ ctx := context.Background()
640+ store := newTestStore(t)
641+
642+ require.NoError(t, appstate.SeedDesiredRecordForTest(store, "blog", []byte(legacyDesiredWithoutDevices)))
643+ desired, ok, err := store.LoadDesired(ctx, "blog")
644+ require.NoError(t, err)
645+ require.True(t, ok)
646+ assert.Nil(t, desired.Spec.Services[0].Capabilities)
647+}
@@ -615,3 +615,33 @@ const legacyDesiredWithoutDevices = `{
615 "secrets_env": {},615 "secrets_env": {},
616 "status": "pending"616 "status": "pending"
617 }`617 }`
618+
619+func TestStore_DesiredCapabilitiesRoundTrip(t *testing.T) {
620+ ctx := context.Background()
621+ store := newTestStore(t)
622+
623+ intent := testIntent("blog", "rev-1")
624+ intent.Spec.Services[0].Capabilities = []string{"net-admin"}
625+ require.NoError(t, store.StageApply(ctx, intent))
626+ require.NoError(t, store.CommitApply(ctx, "blog", "apply-test-rev-1"))
627+ require.NoError(t, store.MaterializeApply(ctx, "blog", "apply-test-rev-1"))
628+
629+ desired, ok, err := store.LoadDesired(ctx, "blog")
630+ require.NoError(t, err)
631+ require.True(t, ok)
632+ require.Len(t, desired.Spec.Services, 1)
633+ assert.Equal(t, []string{"net-admin"}, desired.Spec.Services[0].Capabilities, "logical names persist in revisions")
634+}
635+
636+// A record written before capabilities existed carries no Capabilities key:
637+// it must load without a phantom grant.
638+func TestStore_DesiredWithoutCapabilitiesLoads(t *testing.T) {
639+ ctx := context.Background()
640+ store := newTestStore(t)
641+
642+ require.NoError(t, appstate.SeedDesiredRecordForTest(store, "blog", []byte(legacyDesiredWithoutDevices)))
643+ desired, ok, err := store.LoadDesired(ctx, "blog")
644+ require.NoError(t, err)
645+ require.True(t, ok)
646+ assert.Nil(t, desired.Spec.Services[0].Capabilities)
647+}
M internal/adapters/out/docker/runtime.go +26 -1
@@ -19,6 +19,7 @@ import (
1919 "net/url"
2020 "os"
2121 "path/filepath"
22+ "slices"
2223 "sort"
2324 "strconv"
2425 "strings"
@@ -197,13 +198,14 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
197198 resources.PidsLimit = &config.PidsLimit
198199 }
199200 capDrop := []string{"ALL"}
200- capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
201+ capAdd := domain.DefaultAppCapAdd()
201202 if config.CapDrop != nil {
202203 capDrop = config.CapDrop
203204 }
204205 if config.CapAdd != nil {
205206 capAdd = config.CapAdd
206207 }
208+ capAdd = mergeAddedCapabilities(capAdd, config.AddedCapabilities)
207209 hostConfig := &container.HostConfig{
208210 PortBindings: portBindings,
209211 AutoRemove: config.AutoRemove,
@@ -496,6 +498,23 @@ func parseEngineVersionComponent(value string) (int, bool) {
496498 return n, true
497499 }
498500
501+// mergeAddedCapabilities appends the operator-granted capabilities to the
502+// base add set without duplicates. The base set, CapDrop, and the
503+// no-new-privileges option are never replaced or weakened: grants are
504+// strictly additive on top of Gordon's defaults.
505+func mergeAddedCapabilities(base, added []string) []string {
506+ if len(added) == 0 {
507+ return base
508+ }
509+ merged := append([]string(nil), base...)
510+ for _, name := range added {
511+ if !slices.Contains(merged, name) {
512+ merged = append(merged, name)
513+ }
514+ }
515+ return merged
516+}
517+
499518 // buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI
500519 // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the
501520 // explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed
@@ -779,6 +798,11 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
779798 })
780799 }
781800
801+ var capAdd []string
802+ if resp.HostConfig != nil {
803+ capAdd = append([]string(nil), resp.HostConfig.CapAdd...)
804+ }
805+
782806 return &domain.Container{
783807 ID: resp.ID,
784808 Image: resp.Config.Image,
@@ -792,6 +816,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
792816 Created: created,
793817 StartedAt: startedAt,
794818 Env: resp.Config.Env,
819+ CapAdd: capAdd,
795820 }, nil
796821 }
797822
@@ -19,6 +19,7 @@ import (
19 "net/url"19 "net/url"
20 "os"20 "os"
21 "path/filepath"21 "path/filepath"
22+ "slices"
22 "sort"23 "sort"
23 "strconv"24 "strconv"
24 "strings"25 "strings"
@@ -197,13 +198,14 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
197 resources.PidsLimit = &config.PidsLimit198 resources.PidsLimit = &config.PidsLimit
198 }199 }
199 capDrop := []string{"ALL"}200 capDrop := []string{"ALL"}
200- capAdd := []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}201+ capAdd := domain.DefaultAppCapAdd()
201 if config.CapDrop != nil {202 if config.CapDrop != nil {
202 capDrop = config.CapDrop203 capDrop = config.CapDrop
203 }204 }
204 if config.CapAdd != nil {205 if config.CapAdd != nil {
205 capAdd = config.CapAdd206 capAdd = config.CapAdd
206 }207 }
208+ capAdd = mergeAddedCapabilities(capAdd, config.AddedCapabilities)
207 hostConfig := &container.HostConfig{209 hostConfig := &container.HostConfig{
208 PortBindings: portBindings,210 PortBindings: portBindings,
209 AutoRemove: config.AutoRemove,211 AutoRemove: config.AutoRemove,
@@ -496,6 +498,23 @@ func parseEngineVersionComponent(value string) (int, bool) {
496 return n, true498 return n, true
497 }499 }
498 500
501+// mergeAddedCapabilities appends the operator-granted capabilities to the
502+// base add set without duplicates. The base set, CapDrop, and the
503+// no-new-privileges option are never replaced or weakened: grants are
504+// strictly additive on top of Gordon's defaults.
505+func mergeAddedCapabilities(base, added []string) []string {
506+ if len(added) == 0 {
507+ return base
508+ }
509+ merged := append([]string(nil), base...)
510+ for _, name := range added {
511+ if !slices.Contains(merged, name) {
512+ merged = append(merged, name)
513+ }
514+ }
515+ return merged
516+}
517+
499 // buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI518 // buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI
500 // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the519 // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the
501 // explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed520 // explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed
@@ -779,6 +798,11 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
779 })798 })
780 }799 }
781 800
801+ var capAdd []string
802+ if resp.HostConfig != nil {
803+ capAdd = append([]string(nil), resp.HostConfig.CapAdd...)
804+ }
805+
782 return &domain.Container{806 return &domain.Container{
783 ID: resp.ID,807 ID: resp.ID,
784 Image: resp.Config.Image,808 Image: resp.Config.Image,
@@ -792,6 +816,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
792 Created: created,816 Created: created,
793 StartedAt: startedAt,817 StartedAt: startedAt,
794 Env: resp.Config.Env,818 Env: resp.Config.Env,
819+ CapAdd: capAdd,
795 }, nil820 }, nil
796 }821 }
797 822
M internal/adapters/out/docker/runtime_create_test.go +32 -0
@@ -255,3 +255,35 @@ func TestBuildBindMounts(t *testing.T) {
255255 require.Error(t, err)
256256 })
257257 }
258+
259+func TestMergeAddedCapabilities(t *testing.T) {
260+ base := []string{"CHOWN", "NET_BIND_SERVICE"}
261+ assert.Equal(t, base, mergeAddedCapabilities(base, nil))
262+ assert.Equal(t, []string{"CHOWN", "NET_BIND_SERVICE", "NET_ADMIN"}, mergeAddedCapabilities(base, []string{"NET_ADMIN", "CHOWN"}))
263+ assert.Equal(t, []string{"CHOWN", "NET_BIND_SERVICE"}, base, "base slice is never mutated")
264+}
265+
266+// TestRuntime_CreateContainerAddsGrantedCapabilitiesOnTopOfDefaults proves
267+// resolved capabilities are added to Gordon's default set while the default
268+// drop, no-new-privileges, and non-privileged mode stay in force.
269+func TestRuntime_CreateContainerAddsGrantedCapabilitiesOnTopOfDefaults(t *testing.T) {
270+ createBody := createTestContainer(t, &domain.ContainerConfig{
271+ Image: "nginx:latest",
272+ Name: "gordon-app",
273+ AddedCapabilities: []string{"NET_ADMIN"},
274+ })
275+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
276+ require.True(t, ok)
277+ assert.Equal(t, []any{"ALL"}, hostConfig["CapDrop"])
278+ // The Docker client normalizes names to the CAP_ form and sorts them.
279+ assert.Equal(t, []any{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_NET_ADMIN", "CAP_NET_BIND_SERVICE", "CAP_SETGID", "CAP_SETUID"}, hostConfig["CapAdd"])
280+ assert.Equal(t, []any{"no-new-privileges:true"}, hostConfig["SecurityOpt"])
281+ assert.NotEqual(t, true, hostConfig["Privileged"])
282+}
283+
284+func TestRuntime_CreateContainerWithoutGrantsKeepsDefaultCapabilities(t *testing.T) {
285+ createBody := createTestContainer(t, &domain.ContainerConfig{Image: "nginx:latest", Name: "gordon-app"})
286+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
287+ require.True(t, ok)
288+ assert.Equal(t, []any{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_NET_BIND_SERVICE", "CAP_SETGID", "CAP_SETUID"}, hostConfig["CapAdd"])
289+}
@@ -255,3 +255,35 @@ func TestBuildBindMounts(t *testing.T) {
255 require.Error(t, err)255 require.Error(t, err)
256 })256 })
257 }257 }
258+
259+func TestMergeAddedCapabilities(t *testing.T) {
260+ base := []string{"CHOWN", "NET_BIND_SERVICE"}
261+ assert.Equal(t, base, mergeAddedCapabilities(base, nil))
262+ assert.Equal(t, []string{"CHOWN", "NET_BIND_SERVICE", "NET_ADMIN"}, mergeAddedCapabilities(base, []string{"NET_ADMIN", "CHOWN"}))
263+ assert.Equal(t, []string{"CHOWN", "NET_BIND_SERVICE"}, base, "base slice is never mutated")
264+}
265+
266+// TestRuntime_CreateContainerAddsGrantedCapabilitiesOnTopOfDefaults proves
267+// resolved capabilities are added to Gordon's default set while the default
268+// drop, no-new-privileges, and non-privileged mode stay in force.
269+func TestRuntime_CreateContainerAddsGrantedCapabilitiesOnTopOfDefaults(t *testing.T) {
270+ createBody := createTestContainer(t, &domain.ContainerConfig{
271+ Image: "nginx:latest",
272+ Name: "gordon-app",
273+ AddedCapabilities: []string{"NET_ADMIN"},
274+ })
275+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
276+ require.True(t, ok)
277+ assert.Equal(t, []any{"ALL"}, hostConfig["CapDrop"])
278+ // The Docker client normalizes names to the CAP_ form and sorts them.
279+ assert.Equal(t, []any{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_NET_ADMIN", "CAP_NET_BIND_SERVICE", "CAP_SETGID", "CAP_SETUID"}, hostConfig["CapAdd"])
280+ assert.Equal(t, []any{"no-new-privileges:true"}, hostConfig["SecurityOpt"])
281+ assert.NotEqual(t, true, hostConfig["Privileged"])
282+}
283+
284+func TestRuntime_CreateContainerWithoutGrantsKeepsDefaultCapabilities(t *testing.T) {
285+ createBody := createTestContainer(t, &domain.ContainerConfig{Image: "nginx:latest", Name: "gordon-app"})
286+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
287+ require.True(t, ok)
288+ assert.Equal(t, []any{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_NET_BIND_SERVICE", "CAP_SETGID", "CAP_SETUID"}, hostConfig["CapAdd"])
289+}
M internal/adapters/out/docker/runtime_health_status_test.go +20 -0
@@ -75,3 +75,23 @@ func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) {
7575 assert.False(t, hasHealthcheck)
7676 assert.Equal(t, "", status)
7777 }
78+
79+// TestRuntime_InspectContainer_ReportsConfiguredCapAdd proves the reuse
80+// gate can see the capability set a container was created with.
81+func TestRuntime_InspectContainer_ReportsConfiguredCapAdd(t *testing.T) {
82+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+ w.Header().Set("Content-Type", "application/json")
84+ _, _ = w.Write([]byte(`{
85+ "Id":"abc123","Name":"/c","State":{"Status":"running"},
86+ "Config":{"Image":"img"},
87+ "HostConfig":{"CapAdd":["CAP_CHOWN","NET_ADMIN"]}
88+ }`))
89+ }))
90+ defer server.Close()
91+
92+ runtime := newRuntimeForHTTPServer(t, server)
93+ ctr, err := runtime.InspectContainer(context.Background(), "abc123")
94+
95+ require.NoError(t, err)
96+ assert.Equal(t, []string{"CAP_CHOWN", "NET_ADMIN"}, ctr.CapAdd)
97+}
@@ -75,3 +75,23 @@ func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) {
75 assert.False(t, hasHealthcheck)75 assert.False(t, hasHealthcheck)
76 assert.Equal(t, "", status)76 assert.Equal(t, "", status)
77 }77 }
78+
79+// TestRuntime_InspectContainer_ReportsConfiguredCapAdd proves the reuse
80+// gate can see the capability set a container was created with.
81+func TestRuntime_InspectContainer_ReportsConfiguredCapAdd(t *testing.T) {
82+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83+ w.Header().Set("Content-Type", "application/json")
84+ _, _ = w.Write([]byte(`{
85+ "Id":"abc123","Name":"/c","State":{"Status":"running"},
86+ "Config":{"Image":"img"},
87+ "HostConfig":{"CapAdd":["CAP_CHOWN","NET_ADMIN"]}
88+ }`))
89+ }))
90+ defer server.Close()
91+
92+ runtime := newRuntimeForHTTPServer(t, server)
93+ ctr, err := runtime.InspectContainer(context.Background(), "abc123")
94+
95+ require.NoError(t, err)
96+ assert.Equal(t, []string{"CAP_CHOWN", "NET_ADMIN"}, ctr.CapAdd)
97+}
A internal/app/app_capabilities.go +90 -0
@@ -0,0 +1,90 @@
1+package app
2+
3+import (
4+ "fmt"
5+
6+ "github.com/spf13/viper"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// AppCapabilityPolicy is one administrative Linux capability grant declared
12+// under [app_capabilities.<name>]. The map key is the stable logical name an
13+// app manifest `capabilities` list references. Add holds the explicit
14+// capability names added on top of Gordon's default set (only the safe set
15+// documented in domain.AllowedAppCapabilities is accepted). AllowedApps and
16+// AllowedServices are exact, non-empty allowlists.
17+type AppCapabilityPolicy struct {
18+ Add []string `mapstructure:"add"`
19+ AllowedApps []string `mapstructure:"allowed_apps"`
20+ AllowedServices []string `mapstructure:"allowed_services"`
21+}
22+
23+// knownAppCapabilityKeys is the allowlist of [app_capabilities.<name>]
24+// sub-keys. Unknown keys (for example a typo like "adds") fail closed
25+// instead of silently dropping a grant.
26+var knownAppCapabilityKeys = map[string]struct{}{
27+ "add": {},
28+ "allowed_apps": {},
29+ "allowed_services": {},
30+}
31+
32+// validateAppCapabilityKeys rejects unknown sub-keys inside
33+// [app_capabilities.*]. Viper unmarshals leniently, so a typo would
34+// otherwise drop the grant silently.
35+func validateAppCapabilityKeys(caps map[string]any) error {
36+ for name, raw := range caps {
37+ entry, ok := raw.(map[string]any)
38+ if !ok {
39+ return fmt.Errorf("invalid app_capabilities.%s: entry must be a table", name)
40+ }
41+ for key := range entry {
42+ if _, ok := knownAppCapabilityKeys[key]; !ok {
43+ return fmt.Errorf("invalid app_capabilities.%s: unknown key %q (allowed: add, allowed_apps, allowed_services)", name, key)
44+ }
45+ }
46+ }
47+ return nil
48+}
49+
50+// buildAppCapabilityPolicies converts configured capability grants into
51+// validated domain policies. Config errors keep the field-level reason and
52+// the offending capability name (a public Linux name, not host inventory) so
53+// the operator can fix the list; app and service names are redacted.
54+func buildAppCapabilityPolicies(cfg Config) (map[string]domain.AppCapabilityPolicy, error) {
55+ policies := make(map[string]domain.AppCapabilityPolicy, len(cfg.AppCapabilities))
56+ for name, grant := range cfg.AppCapabilities {
57+ policy := domain.AppCapabilityPolicy{
58+ Name: name,
59+ Add: append([]string(nil), grant.Add...),
60+ AllowedApps: append([]string(nil), grant.AllowedApps...),
61+ AllowedServices: append([]string(nil), grant.AllowedServices...),
62+ }
63+ if err := policy.Validate(); err != nil {
64+ return nil, fmt.Errorf("invalid app_capabilities.%s: %w: %s", name, domain.ErrCapabilityPolicy, capabilityPolicyReason(err, policy))
65+ }
66+ policies[name] = policy
67+ }
68+ return policies, nil
69+}
70+
71+// capabilityPolicyReason keeps the actionable reason of a validation error.
72+// Capability names in the grant are public Linux names, so a rejected
73+// capability keeps its name and the allowed list; every other quoted value
74+// (app and service names) is stripped.
75+func capabilityPolicyReason(err error, policy domain.AppCapabilityPolicy) string {
76+ if capErr := domain.ValidateAddableCapabilities(policy.Add); capErr != nil && len(policy.Add) > 0 {
77+ return capErr.Error()
78+ }
79+ return redactedDevicePolicyReason(err)
80+}
81+
82+// validateCapabilityConfig validates the [app_capabilities] subtree:
83+// unknown keys fail closed, then the decoded policies validate.
84+func validateCapabilityConfig(v *viper.Viper, cfg Config) error {
85+ if err := validateAppCapabilityKeys(v.GetStringMap("app_capabilities")); err != nil {
86+ return err
87+ }
88+ _, err := buildAppCapabilityPolicies(cfg)
89+ return err
90+}
@@ -0,0 +1,90 @@
1+package app
2+
3+import (
4+ "fmt"
5+
6+ "github.com/spf13/viper"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// AppCapabilityPolicy is one administrative Linux capability grant declared
12+// under [app_capabilities.<name>]. The map key is the stable logical name an
13+// app manifest `capabilities` list references. Add holds the explicit
14+// capability names added on top of Gordon's default set (only the safe set
15+// documented in domain.AllowedAppCapabilities is accepted). AllowedApps and
16+// AllowedServices are exact, non-empty allowlists.
17+type AppCapabilityPolicy struct {
18+ Add []string `mapstructure:"add"`
19+ AllowedApps []string `mapstructure:"allowed_apps"`
20+ AllowedServices []string `mapstructure:"allowed_services"`
21+}
22+
23+// knownAppCapabilityKeys is the allowlist of [app_capabilities.<name>]
24+// sub-keys. Unknown keys (for example a typo like "adds") fail closed
25+// instead of silently dropping a grant.
26+var knownAppCapabilityKeys = map[string]struct{}{
27+ "add": {},
28+ "allowed_apps": {},
29+ "allowed_services": {},
30+}
31+
32+// validateAppCapabilityKeys rejects unknown sub-keys inside
33+// [app_capabilities.*]. Viper unmarshals leniently, so a typo would
34+// otherwise drop the grant silently.
35+func validateAppCapabilityKeys(caps map[string]any) error {
36+ for name, raw := range caps {
37+ entry, ok := raw.(map[string]any)
38+ if !ok {
39+ return fmt.Errorf("invalid app_capabilities.%s: entry must be a table", name)
40+ }
41+ for key := range entry {
42+ if _, ok := knownAppCapabilityKeys[key]; !ok {
43+ return fmt.Errorf("invalid app_capabilities.%s: unknown key %q (allowed: add, allowed_apps, allowed_services)", name, key)
44+ }
45+ }
46+ }
47+ return nil
48+}
49+
50+// buildAppCapabilityPolicies converts configured capability grants into
51+// validated domain policies. Config errors keep the field-level reason and
52+// the offending capability name (a public Linux name, not host inventory) so
53+// the operator can fix the list; app and service names are redacted.
54+func buildAppCapabilityPolicies(cfg Config) (map[string]domain.AppCapabilityPolicy, error) {
55+ policies := make(map[string]domain.AppCapabilityPolicy, len(cfg.AppCapabilities))
56+ for name, grant := range cfg.AppCapabilities {
57+ policy := domain.AppCapabilityPolicy{
58+ Name: name,
59+ Add: append([]string(nil), grant.Add...),
60+ AllowedApps: append([]string(nil), grant.AllowedApps...),
61+ AllowedServices: append([]string(nil), grant.AllowedServices...),
62+ }
63+ if err := policy.Validate(); err != nil {
64+ return nil, fmt.Errorf("invalid app_capabilities.%s: %w: %s", name, domain.ErrCapabilityPolicy, capabilityPolicyReason(err, policy))
65+ }
66+ policies[name] = policy
67+ }
68+ return policies, nil
69+}
70+
71+// capabilityPolicyReason keeps the actionable reason of a validation error.
72+// Capability names in the grant are public Linux names, so a rejected
73+// capability keeps its name and the allowed list; every other quoted value
74+// (app and service names) is stripped.
75+func capabilityPolicyReason(err error, policy domain.AppCapabilityPolicy) string {
76+ if capErr := domain.ValidateAddableCapabilities(policy.Add); capErr != nil && len(policy.Add) > 0 {
77+ return capErr.Error()
78+ }
79+ return redactedDevicePolicyReason(err)
80+}
81+
82+// validateCapabilityConfig validates the [app_capabilities] subtree:
83+// unknown keys fail closed, then the decoded policies validate.
84+func validateCapabilityConfig(v *viper.Viper, cfg Config) error {
85+ if err := validateAppCapabilityKeys(v.GetStringMap("app_capabilities")); err != nil {
86+ return err
87+ }
88+ _, err := buildAppCapabilityPolicies(cfg)
89+ return err
90+}
A internal/app/app_capabilities_test.go +103 -0
@@ -0,0 +1,103 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/spf13/viper"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func readCapConfig(t *testing.T, doc string) (*viper.Viper, Config) {
15+ t.Helper()
16+ v := viper.New()
17+ v.SetConfigType("toml")
18+ require.NoError(t, v.ReadConfig(strings.NewReader(doc)))
19+ var cfg Config
20+ require.NoError(t, v.Unmarshal(&cfg))
21+ return v, cfg
22+}
23+
24+func TestBuildAppCapabilityPolicies_ValidConfig(t *testing.T) {
25+ v, cfg := readCapConfig(t, `
26+[app_capabilities.vm-net]
27+add = ["NET_ADMIN"]
28+allowed_apps = ["myapp"]
29+allowed_services = ["worker"]
30+`)
31+ require.NoError(t, validateCapabilityConfig(v, cfg))
32+ policies, err := buildAppCapabilityPolicies(cfg)
33+ require.NoError(t, err)
34+ policy := policies["vm-net"]
35+ assert.Equal(t, "vm-net", policy.Name)
36+ assert.Equal(t, []string{"NET_ADMIN"}, policy.Add)
37+ assert.Equal(t, []string{"myapp"}, policy.AllowedApps)
38+ assert.Equal(t, []string{"worker"}, policy.AllowedServices)
39+}
40+
41+func TestBuildAppCapabilityPolicies_Invalid(t *testing.T) {
42+ apps, svcs := []string{"demo"}, []string{"worker"}
43+ cases := []struct {
44+ name string
45+ grant AppCapabilityPolicy
46+ reason string
47+ }{
48+ {"empty add", AppCapabilityPolicy{AllowedApps: apps, AllowedServices: svcs}, "add must not be empty"},
49+ {"ALL", AppCapabilityPolicy{Add: []string{"ALL"}, AllowedApps: apps, AllowedServices: svcs}, "not allowed"},
50+ {"SYS_ADMIN", AppCapabilityPolicy{Add: []string{"SYS_ADMIN"}, AllowedApps: apps, AllowedServices: svcs}, "SYS_ADMIN"},
51+ {"duplicate", AppCapabilityPolicy{Add: []string{"NET_ADMIN", "NET_ADMIN"}, AllowedApps: apps, AllowedServices: svcs}, "duplicate"},
52+ {"empty apps", AppCapabilityPolicy{Add: []string{"NET_ADMIN"}, AllowedServices: svcs}, "allowed apps must not be empty"},
53+ {"empty services", AppCapabilityPolicy{Add: []string{"NET_ADMIN"}, AllowedApps: apps}, "allowed services must not be empty"},
54+ }
55+ for _, tc := range cases {
56+ t.Run(tc.name, func(t *testing.T) {
57+ _, err := buildAppCapabilityPolicies(Config{AppCapabilities: map[string]AppCapabilityPolicy{"net": tc.grant}})
58+ require.Error(t, err)
59+ assert.Contains(t, err.Error(), "app_capabilities.net")
60+ assert.Contains(t, err.Error(), tc.reason)
61+ assert.ErrorIs(t, err, domain.ErrCapabilityPolicy)
62+ assert.NotContains(t, err.Error(), "demo", "app names are redacted")
63+ assert.NotContains(t, err.Error(), "worker", "service names are redacted")
64+ })
65+ }
66+}
67+
68+func TestValidateAppCapabilityKeys_UnknownKey(t *testing.T) {
69+ v, _ := readCapConfig(t, `
70+[app_capabilities.net]
71+adds = ["NET_ADMIN"]
72+allowed_apps = ["demo"]
73+allowed_services = ["worker"]
74+`)
75+ err := validateAppCapabilityKeys(v.GetStringMap("app_capabilities"))
76+ require.Error(t, err)
77+ assert.Contains(t, err.Error(), "unknown key")
78+ require.NoError(t, validateAppCapabilityKeys(nil))
79+}
80+
81+func TestValidateAppPolicies_IncludesCapabilities(t *testing.T) {
82+ v, cfg := readCapConfig(t, `
83+[app_capabilities.net]
84+add = ["SYS_ADMIN"]
85+allowed_apps = ["demo"]
86+allowed_services = ["worker"]
87+`)
88+ require.ErrorIs(t, validateAppPolicies(v, cfg), domain.ErrCapabilityPolicy)
89+}
90+
91+func TestBuildAppCapabilityPolicies_CopiesSlicesAndDefaultsEmpty(t *testing.T) {
92+ policies, err := buildAppCapabilityPolicies(Config{})
93+ require.NoError(t, err)
94+ assert.Empty(t, policies)
95+
96+ cfg := Config{AppCapabilities: map[string]AppCapabilityPolicy{
97+ "net": {Add: []string{"NET_ADMIN"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
98+ }}
99+ policies, err = buildAppCapabilityPolicies(cfg)
100+ require.NoError(t, err)
101+ policies["net"].Add[0] = "mutated"
102+ assert.Equal(t, []string{"NET_ADMIN"}, cfg.AppCapabilities["net"].Add)
103+}
@@ -0,0 +1,103 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/spf13/viper"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func readCapConfig(t *testing.T, doc string) (*viper.Viper, Config) {
15+ t.Helper()
16+ v := viper.New()
17+ v.SetConfigType("toml")
18+ require.NoError(t, v.ReadConfig(strings.NewReader(doc)))
19+ var cfg Config
20+ require.NoError(t, v.Unmarshal(&cfg))
21+ return v, cfg
22+}
23+
24+func TestBuildAppCapabilityPolicies_ValidConfig(t *testing.T) {
25+ v, cfg := readCapConfig(t, `
26+[app_capabilities.vm-net]
27+add = ["NET_ADMIN"]
28+allowed_apps = ["myapp"]
29+allowed_services = ["worker"]
30+`)
31+ require.NoError(t, validateCapabilityConfig(v, cfg))
32+ policies, err := buildAppCapabilityPolicies(cfg)
33+ require.NoError(t, err)
34+ policy := policies["vm-net"]
35+ assert.Equal(t, "vm-net", policy.Name)
36+ assert.Equal(t, []string{"NET_ADMIN"}, policy.Add)
37+ assert.Equal(t, []string{"myapp"}, policy.AllowedApps)
38+ assert.Equal(t, []string{"worker"}, policy.AllowedServices)
39+}
40+
41+func TestBuildAppCapabilityPolicies_Invalid(t *testing.T) {
42+ apps, svcs := []string{"demo"}, []string{"worker"}
43+ cases := []struct {
44+ name string
45+ grant AppCapabilityPolicy
46+ reason string
47+ }{
48+ {"empty add", AppCapabilityPolicy{AllowedApps: apps, AllowedServices: svcs}, "add must not be empty"},
49+ {"ALL", AppCapabilityPolicy{Add: []string{"ALL"}, AllowedApps: apps, AllowedServices: svcs}, "not allowed"},
50+ {"SYS_ADMIN", AppCapabilityPolicy{Add: []string{"SYS_ADMIN"}, AllowedApps: apps, AllowedServices: svcs}, "SYS_ADMIN"},
51+ {"duplicate", AppCapabilityPolicy{Add: []string{"NET_ADMIN", "NET_ADMIN"}, AllowedApps: apps, AllowedServices: svcs}, "duplicate"},
52+ {"empty apps", AppCapabilityPolicy{Add: []string{"NET_ADMIN"}, AllowedServices: svcs}, "allowed apps must not be empty"},
53+ {"empty services", AppCapabilityPolicy{Add: []string{"NET_ADMIN"}, AllowedApps: apps}, "allowed services must not be empty"},
54+ }
55+ for _, tc := range cases {
56+ t.Run(tc.name, func(t *testing.T) {
57+ _, err := buildAppCapabilityPolicies(Config{AppCapabilities: map[string]AppCapabilityPolicy{"net": tc.grant}})
58+ require.Error(t, err)
59+ assert.Contains(t, err.Error(), "app_capabilities.net")
60+ assert.Contains(t, err.Error(), tc.reason)
61+ assert.ErrorIs(t, err, domain.ErrCapabilityPolicy)
62+ assert.NotContains(t, err.Error(), "demo", "app names are redacted")
63+ assert.NotContains(t, err.Error(), "worker", "service names are redacted")
64+ })
65+ }
66+}
67+
68+func TestValidateAppCapabilityKeys_UnknownKey(t *testing.T) {
69+ v, _ := readCapConfig(t, `
70+[app_capabilities.net]
71+adds = ["NET_ADMIN"]
72+allowed_apps = ["demo"]
73+allowed_services = ["worker"]
74+`)
75+ err := validateAppCapabilityKeys(v.GetStringMap("app_capabilities"))
76+ require.Error(t, err)
77+ assert.Contains(t, err.Error(), "unknown key")
78+ require.NoError(t, validateAppCapabilityKeys(nil))
79+}
80+
81+func TestValidateAppPolicies_IncludesCapabilities(t *testing.T) {
82+ v, cfg := readCapConfig(t, `
83+[app_capabilities.net]
84+add = ["SYS_ADMIN"]
85+allowed_apps = ["demo"]
86+allowed_services = ["worker"]
87+`)
88+ require.ErrorIs(t, validateAppPolicies(v, cfg), domain.ErrCapabilityPolicy)
89+}
90+
91+func TestBuildAppCapabilityPolicies_CopiesSlicesAndDefaultsEmpty(t *testing.T) {
92+ policies, err := buildAppCapabilityPolicies(Config{})
93+ require.NoError(t, err)
94+ assert.Empty(t, policies)
95+
96+ cfg := Config{AppCapabilities: map[string]AppCapabilityPolicy{
97+ "net": {Add: []string{"NET_ADMIN"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
98+ }}
99+ policies, err = buildAppCapabilityPolicies(cfg)
100+ require.NoError(t, err)
101+ policies["net"].Add[0] = "mutated"
102+ assert.Equal(t, []string{"NET_ADMIN"}, cfg.AppCapabilities["net"].Add)
103+}
M internal/app/app_devices.go +6 -3
@@ -95,13 +95,16 @@ func validateDeviceConfig(v *viper.Viper, cfg Config) error {
9595 return nil
9696 }
9797
98-// validateAppPolicies validates both administrative app policy subtrees
99-// (bind mounts and device grants) before publication. Failed validation
98+// validateAppPolicies validates all administrative app policy subtrees
99+// (bind mounts, device grants, and capability grants) before publication. Failed validation
100100 // keeps the previous policies live. Split from applyLoadedConfig to keep
101101 // its complexity within budget.
102102 func validateAppPolicies(v *viper.Viper, cfg Config) error {
103103 if _, err := buildAppMountPolicies(cfg); err != nil {
104104 return err
105105 }
106- return validateDeviceConfig(v, cfg)
106+ if err := validateDeviceConfig(v, cfg); err != nil {
107+ return err
108+ }
109+ return validateCapabilityConfig(v, cfg)
107110 }
@@ -95,13 +95,16 @@ func validateDeviceConfig(v *viper.Viper, cfg Config) error {
95 return nil95 return nil
96 }96 }
97 97
98-// validateAppPolicies validates both administrative app policy subtrees98+// validateAppPolicies validates all administrative app policy subtrees
99-// (bind mounts and device grants) before publication. Failed validation99+// (bind mounts, device grants, and capability grants) before publication. Failed validation
100 // keeps the previous policies live. Split from applyLoadedConfig to keep100 // keeps the previous policies live. Split from applyLoadedConfig to keep
101 // its complexity within budget.101 // its complexity within budget.
102 func validateAppPolicies(v *viper.Viper, cfg Config) error {102 func validateAppPolicies(v *viper.Viper, cfg Config) error {
103 if _, err := buildAppMountPolicies(cfg); err != nil {103 if _, err := buildAppMountPolicies(cfg); err != nil {
104 return err104 return err
105 }105 }
106- return validateDeviceConfig(v, cfg)106+ if err := validateDeviceConfig(v, cfg); err != nil {
107+ return err
108+ }
109+ return validateCapabilityConfig(v, cfg)
107 }110 }
M internal/app/app_wiring.go +7 -1
@@ -127,14 +127,20 @@ func (si *serviceInit) initApps() error {
127127 if err != nil {
128128 return err
129129 }
130+ capabilityPolicies, err := buildAppCapabilityPolicies(si.cfg)
131+ if err != nil {
132+ return err
133+ }
130134 si.svc.appDeploySvc.SetBindPolicies(mountPolicies)
131135 si.svc.appDeploySvc.SetDevicePolicies(devicePolicies)
136+ si.svc.appDeploySvc.SetCapabilityPolicies(capabilityPolicies)
132137 appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
133138 WithEntrypoints(appEntrypointListeners(si.cfg)).
134139 WithGCBarrier(si.svc.gcBarrier).
135140 WithImagePolicy(imagePolicy).
136141 WithBindPolicies(mountPolicies).
137- WithDevicePolicies(devicePolicies)
142+ WithDevicePolicies(devicePolicies).
143+ WithCapabilityPolicies(capabilityPolicies)
138144 si.svc.appSvcImpl = appSvcImpl
139145 si.svc.appSvc = appSvcImpl
140146 // Health checks resolve from ACTIVE state (loopback backends).
@@ -127,14 +127,20 @@ func (si *serviceInit) initApps() error {
127 if err != nil {127 if err != nil {
128 return err128 return err
129 }129 }
130+ capabilityPolicies, err := buildAppCapabilityPolicies(si.cfg)
131+ if err != nil {
132+ return err
133+ }
130 si.svc.appDeploySvc.SetBindPolicies(mountPolicies)134 si.svc.appDeploySvc.SetBindPolicies(mountPolicies)
131 si.svc.appDeploySvc.SetDevicePolicies(devicePolicies)135 si.svc.appDeploySvc.SetDevicePolicies(devicePolicies)
136+ si.svc.appDeploySvc.SetCapabilityPolicies(capabilityPolicies)
132 appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).137 appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
133 WithEntrypoints(appEntrypointListeners(si.cfg)).138 WithEntrypoints(appEntrypointListeners(si.cfg)).
134 WithGCBarrier(si.svc.gcBarrier).139 WithGCBarrier(si.svc.gcBarrier).
135 WithImagePolicy(imagePolicy).140 WithImagePolicy(imagePolicy).
136 WithBindPolicies(mountPolicies).141 WithBindPolicies(mountPolicies).
137- WithDevicePolicies(devicePolicies)142+ WithDevicePolicies(devicePolicies).
143+ WithCapabilityPolicies(capabilityPolicies)
138 si.svc.appSvcImpl = appSvcImpl144 si.svc.appSvcImpl = appSvcImpl
139 si.svc.appSvc = appSvcImpl145 si.svc.appSvc = appSvcImpl
140 // Health checks resolve from ACTIVE state (loopback backends).146 // Health checks resolve from ACTIVE state (loopback backends).
M internal/app/config.go +8 -0
@@ -63,6 +63,11 @@ type Config struct {
6363 // `devices` list.
6464 AppDevices map[string]AppDevicePolicy `mapstructure:"app_devices"`
6565
66+ // AppCapabilities declares administrative Linux capability grants keyed
67+ // by stable logical name, referenced by name from an app manifest's
68+ // `capabilities` list.
69+ AppCapabilities map[string]AppCapabilityPolicy `mapstructure:"app_capabilities"`
70+
6671 Logging struct {
6772 Level string `mapstructure:"level"`
6873 Format string `mapstructure:"format"`
@@ -246,6 +251,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
246251 if err := validateDeviceConfig(v, cfg); err != nil {
247252 return nil, Config{}, err
248253 }
254+ if err := validateCapabilityConfig(v, cfg); err != nil {
255+ return nil, Config{}, err
256+ }
249257
250258 return v, cfg, nil
251259 }
@@ -63,6 +63,11 @@ type Config struct {
63 // `devices` list.63 // `devices` list.
64 AppDevices map[string]AppDevicePolicy `mapstructure:"app_devices"`64 AppDevices map[string]AppDevicePolicy `mapstructure:"app_devices"`
65 65
66+ // AppCapabilities declares administrative Linux capability grants keyed
67+ // by stable logical name, referenced by name from an app manifest's
68+ // `capabilities` list.
69+ AppCapabilities map[string]AppCapabilityPolicy `mapstructure:"app_capabilities"`
70+
66 Logging struct {71 Logging struct {
67 Level string `mapstructure:"level"`72 Level string `mapstructure:"level"`
68 Format string `mapstructure:"format"`73 Format string `mapstructure:"format"`
@@ -246,6 +251,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
246 if err := validateDeviceConfig(v, cfg); err != nil {251 if err := validateDeviceConfig(v, cfg); err != nil {
247 return nil, Config{}, err252 return nil, Config{}, err
248 }253 }
254+ if err := validateCapabilityConfig(v, cfg); err != nil {
255+ return nil, Config{}, err
256+ }
249 257
250 return v, cfg, nil258 return v, cfg, nil
251 }259 }
M internal/app/reload.go +7 -1
@@ -261,6 +261,7 @@ type reloadPlan struct {
261261 tlsConfig *tls.Config
262262 bindPolicies map[string]domain.AppBindPolicy
263263 devicePolicies map[string]domain.AppDevicePolicy
264+ capPolicies map[string]domain.AppCapabilityPolicy
264265 }
265266
266267 // Apply is the reloadCoordinator's runtime step.
@@ -301,6 +302,9 @@ func (r *reloadRuntime) prepare(ctx context.Context, cfg Config) (reloadPlan, er
301302 if plan.devicePolicies, err = buildAppDevicePolicies(cfg); err != nil {
302303 return plan, err
303304 }
305+ if plan.capPolicies, err = buildAppCapabilityPolicies(cfg); err != nil {
306+ return plan, err
307+ }
304308 }
305309 return plan, nil
306310 }
@@ -327,7 +331,7 @@ func (r *reloadRuntime) applyServing(ctx context.Context, cfg Config, plan reloa
327331 return nil
328332 }
329333
330-// applyAppPolicies publishes the reloaded bind and device policies to both
334+// applyAppPolicies publishes the reloaded bind, device, and capability policies to both
331335 // app services that enforce them.
332336 func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {
333337 if r.svc.appSvcImpl == nil {
@@ -335,9 +339,11 @@ func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {
335339 }
336340 r.svc.appSvcImpl.SetBindPolicies(plan.bindPolicies)
337341 r.svc.appSvcImpl.SetDevicePolicies(plan.devicePolicies)
342+ r.svc.appSvcImpl.SetCapabilityPolicies(plan.capPolicies)
338343 if r.svc.appDeploySvc != nil {
339344 r.svc.appDeploySvc.SetBindPolicies(plan.bindPolicies)
340345 r.svc.appDeploySvc.SetDevicePolicies(plan.devicePolicies)
346+ r.svc.appDeploySvc.SetCapabilityPolicies(plan.capPolicies)
341347 }
342348 }
343349
@@ -261,6 +261,7 @@ type reloadPlan struct {
261 tlsConfig *tls.Config261 tlsConfig *tls.Config
262 bindPolicies map[string]domain.AppBindPolicy262 bindPolicies map[string]domain.AppBindPolicy
263 devicePolicies map[string]domain.AppDevicePolicy263 devicePolicies map[string]domain.AppDevicePolicy
264+ capPolicies map[string]domain.AppCapabilityPolicy
264 }265 }
265 266
266 // Apply is the reloadCoordinator's runtime step.267 // Apply is the reloadCoordinator's runtime step.
@@ -301,6 +302,9 @@ func (r *reloadRuntime) prepare(ctx context.Context, cfg Config) (reloadPlan, er
301 if plan.devicePolicies, err = buildAppDevicePolicies(cfg); err != nil {302 if plan.devicePolicies, err = buildAppDevicePolicies(cfg); err != nil {
302 return plan, err303 return plan, err
303 }304 }
305+ if plan.capPolicies, err = buildAppCapabilityPolicies(cfg); err != nil {
306+ return plan, err
307+ }
304 }308 }
305 return plan, nil309 return plan, nil
306 }310 }
@@ -327,7 +331,7 @@ func (r *reloadRuntime) applyServing(ctx context.Context, cfg Config, plan reloa
327 return nil331 return nil
328 }332 }
329 333
330-// applyAppPolicies publishes the reloaded bind and device policies to both334+// applyAppPolicies publishes the reloaded bind, device, and capability policies to both
331 // app services that enforce them.335 // app services that enforce them.
332 func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {336 func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {
333 if r.svc.appSvcImpl == nil {337 if r.svc.appSvcImpl == nil {
@@ -335,9 +339,11 @@ func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {
335 }339 }
336 r.svc.appSvcImpl.SetBindPolicies(plan.bindPolicies)340 r.svc.appSvcImpl.SetBindPolicies(plan.bindPolicies)
337 r.svc.appSvcImpl.SetDevicePolicies(plan.devicePolicies)341 r.svc.appSvcImpl.SetDevicePolicies(plan.devicePolicies)
342+ r.svc.appSvcImpl.SetCapabilityPolicies(plan.capPolicies)
338 if r.svc.appDeploySvc != nil {343 if r.svc.appDeploySvc != nil {
339 r.svc.appDeploySvc.SetBindPolicies(plan.bindPolicies)344 r.svc.appDeploySvc.SetBindPolicies(plan.bindPolicies)
340 r.svc.appDeploySvc.SetDevicePolicies(plan.devicePolicies)345 r.svc.appDeploySvc.SetDevicePolicies(plan.devicePolicies)
346+ r.svc.appDeploySvc.SetCapabilityPolicies(plan.capPolicies)
341 }347 }
342 }348 }
343 349
A internal/domain/app_capability_policy.go +212 -0
@@ -0,0 +1,212 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+)
8+
9+// allowedAppCapabilities is the closed set of Linux capabilities an
10+// administrative capability policy may add to an app service. It is the
11+// Docker/Podman default container set plus the few extra capabilities an
12+// unprivileged network or VM workload legitimately needs:
13+//
14+// NET_ADMIN configure interfaces, TAP devices, nftables
15+// inside the container's own network namespace
16+// IPC_LOCK lock guest memory (mlock)
17+// SYS_NICE, SYS_RESOURCE scheduling priority and rlimit overrides
18+//
19+// Everything else is refused. In particular ALL, SYS_ADMIN, SYS_PTRACE,
20+// SYS_MODULE, SYS_RAWIO, SYS_BOOT, SYS_TIME, DAC_READ_SEARCH, BPF,
21+// PERFMON, MAC_ADMIN, MAC_OVERRIDE, AUDIT_CONTROL, and SYSLOG can reach
22+// the host or defeat container confinement and have no policy form: a
23+// workload that truly needs one is not an unprivileged service.
24+var allowedAppCapabilities = []string{
25+ "AUDIT_WRITE",
26+ "CHOWN",
27+ "DAC_OVERRIDE",
28+ "FOWNER",
29+ "FSETID",
30+ "IPC_LOCK",
31+ "KILL",
32+ "MKNOD",
33+ "NET_ADMIN",
34+ "NET_BIND_SERVICE",
35+ "NET_RAW",
36+ "SETFCAP",
37+ "SETGID",
38+ "SETPCAP",
39+ "SETUID",
40+ "SYS_CHROOT",
41+ "SYS_NICE",
42+ "SYS_RESOURCE",
43+}
44+
45+// defaultAppCapAdd is Gordon's base capability add set for app containers
46+// (on top of cap_drop = ALL). Granted capabilities are added to it.
47+var defaultAppCapAdd = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
48+
49+// DefaultAppCapAdd returns Gordon's base capability add set. The slice is a
50+// copy.
51+func DefaultAppCapAdd() []string {
52+ return slices.Clone(defaultAppCapAdd)
53+}
54+
55+// NormalizeCapability returns the canonical form of a capability name as
56+// reported by an engine: upper case without the CAP_ prefix.
57+func NormalizeCapability(name string) string {
58+ return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(name)), "CAP_")
59+}
60+
61+// ExcessCapabilities returns the sorted, normalized capabilities in actual
62+// that are not in authorized. Both sides are normalized first, so engines
63+// that report CAP_ prefixed names compare equal. A container holding fewer
64+// capabilities than authorized is not excess.
65+func ExcessCapabilities(actual, authorized []string) []string {
66+ allowed := make(map[string]struct{}, len(authorized))
67+ for _, name := range authorized {
68+ allowed[NormalizeCapability(name)] = struct{}{}
69+ }
70+ var excess []string
71+ for _, name := range actual {
72+ n := NormalizeCapability(name)
73+ if _, ok := allowed[n]; !ok {
74+ excess = append(excess, n)
75+ }
76+ }
77+ slices.Sort(excess)
78+ return slices.Compact(excess)
79+}
80+
81+// AllowedAppCapabilities returns the sorted capability names a policy may
82+// add. The slice is a copy.
83+func AllowedAppCapabilities() []string {
84+ return slices.Clone(allowedAppCapabilities)
85+}
86+
87+// AppCapabilityPolicy is one named administrative capability grant
88+// declared under [app_capabilities.<name>]. The installation maps a
89+// logical name to explicit Linux capabilities and exact app/service
90+// allowlists. App manifests request capabilities by logical name; Gordon
91+// resolves them at activation time and adds them to the container on top
92+// of its default capability set. Resolution is never persisted: revisions
93+// keep the logical names.
94+type AppCapabilityPolicy struct {
95+ Name string
96+ // Add holds the explicit capability names granted (for example
97+ // "NET_ADMIN"), restricted to the allowed set.
98+ Add []string
99+ // AllowedApps and AllowedServices are exact, non-empty allowlists.
100+ AllowedApps []string
101+ AllowedServices []string
102+}
103+
104+// ValidateCapabilityPolicyRef checks a logical capability-policy name used
105+// from a manifest (service charset plus -- ban).
106+func ValidateCapabilityPolicyRef(name string) error {
107+ if !serviceNamePattern.MatchString(name) {
108+ return fmt.Errorf("%w: capability name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
109+ }
110+ if strings.Contains(name, "--") {
111+ return fmt.Errorf("%w: capability name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
112+ }
113+ return nil
114+}
115+
116+// ValidateCapabilityPolicyName checks a stable policy name (service
117+// charset plus -- ban).
118+func ValidateCapabilityPolicyName(name string) error {
119+ if !serviceNamePattern.MatchString(name) {
120+ return fmt.Errorf("%w: capability policy name %q must match [a-z0-9_.-], max 63", ErrCapabilityPolicy, name)
121+ }
122+ if strings.Contains(name, "--") {
123+ return fmt.Errorf("%w: capability policy name %q must not contain -- (reserved separator)", ErrCapabilityPolicy, name)
124+ }
125+ return nil
126+}
127+
128+// Validate checks the policy shape without touching the runtime.
129+func (p AppCapabilityPolicy) Validate() error {
130+ if err := ValidateCapabilityPolicyName(p.Name); err != nil {
131+ return err
132+ }
133+ if err := validateCapabilityNames(p.Add); err != nil {
134+ return fmt.Errorf("%w: capability policy %q: %v", ErrCapabilityPolicy, p.Name, err)
135+ }
136+ if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
137+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
138+ }
139+ if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
140+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
141+ }
142+ return nil
143+}
144+
145+// validateCapabilityNames requires a non-empty list of unique names from
146+// the allowed set.
147+func validateCapabilityNames(names []string) error {
148+ if len(names) == 0 {
149+ return fmt.Errorf("add must not be empty")
150+ }
151+ return ValidateAddableCapabilities(names)
152+}
153+
154+// ValidateAddableCapabilities checks that every name is in the allowed set
155+// and unique. An empty list is valid. It is also the runtime guard's check
156+// on resolved capabilities, whatever produced them.
157+func ValidateAddableCapabilities(names []string) error {
158+ seen := map[string]struct{}{}
159+ for _, name := range names {
160+ if name == "" {
161+ return fmt.Errorf("capability must not be empty")
162+ }
163+ if !slices.Contains(allowedAppCapabilities, name) {
164+ return fmt.Errorf("capability %q is not allowed (allowed: %s)", name, strings.Join(allowedAppCapabilities, ", "))
165+ }
166+ if _, ok := seen[name]; ok {
167+ return fmt.Errorf("duplicate capability %q", name)
168+ }
169+ seen[name] = struct{}{}
170+ }
171+ return nil
172+}
173+
174+// ResolveAppCapabilities resolves the logical grant under this policy for
175+// the given app and service. It validates the policy shape, enforces the
176+// exact app/service allowlists, and returns the explicit capabilities.
177+func (p AppCapabilityPolicy) ResolveAppCapabilities(app, service string) ([]string, error) {
178+ if err := p.Validate(); err != nil {
179+ return nil, err
180+ }
181+ if !allowlistContains(p.AllowedApps, app) {
182+ return nil, fmt.Errorf("%w: capability policy %q does not allow app %q", ErrCapabilityPolicy, p.Name, app)
183+ }
184+ if !allowlistContains(p.AllowedServices, service) {
185+ return nil, fmt.Errorf("%w: capability policy %q does not allow service %q", ErrCapabilityPolicy, p.Name, service)
186+ }
187+ return slices.Clone(p.Add), nil
188+}
189+
190+// ResolveAppCapabilities resolves every logical capability policy in names
191+// for one app service. It returns the deterministic, de-duplicated union
192+// (sorted): two policies granting the same capability is harmless. Errors
193+// name only the app, service, and logical policy.
194+func ResolveAppCapabilities(app, service string, names []string, policies map[string]AppCapabilityPolicy) ([]string, error) {
195+ if len(names) == 0 {
196+ return nil, nil
197+ }
198+ var resolved []string
199+ for _, name := range names {
200+ policy, ok := policies[name]
201+ if !ok {
202+ return nil, fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured", ErrCapabilityPolicy, app, service, name)
203+ }
204+ caps, err := policy.ResolveAppCapabilities(app, service)
205+ if err != nil {
206+ return nil, fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", ErrCapabilityPolicy, app, service, name)
207+ }
208+ resolved = append(resolved, caps...)
209+ }
210+ slices.Sort(resolved)
211+ return slices.Compact(resolved), nil
212+}
@@ -0,0 +1,212 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+)
8+
9+// allowedAppCapabilities is the closed set of Linux capabilities an
10+// administrative capability policy may add to an app service. It is the
11+// Docker/Podman default container set plus the few extra capabilities an
12+// unprivileged network or VM workload legitimately needs:
13+//
14+// NET_ADMIN configure interfaces, TAP devices, nftables
15+// inside the container's own network namespace
16+// IPC_LOCK lock guest memory (mlock)
17+// SYS_NICE, SYS_RESOURCE scheduling priority and rlimit overrides
18+//
19+// Everything else is refused. In particular ALL, SYS_ADMIN, SYS_PTRACE,
20+// SYS_MODULE, SYS_RAWIO, SYS_BOOT, SYS_TIME, DAC_READ_SEARCH, BPF,
21+// PERFMON, MAC_ADMIN, MAC_OVERRIDE, AUDIT_CONTROL, and SYSLOG can reach
22+// the host or defeat container confinement and have no policy form: a
23+// workload that truly needs one is not an unprivileged service.
24+var allowedAppCapabilities = []string{
25+ "AUDIT_WRITE",
26+ "CHOWN",
27+ "DAC_OVERRIDE",
28+ "FOWNER",
29+ "FSETID",
30+ "IPC_LOCK",
31+ "KILL",
32+ "MKNOD",
33+ "NET_ADMIN",
34+ "NET_BIND_SERVICE",
35+ "NET_RAW",
36+ "SETFCAP",
37+ "SETGID",
38+ "SETPCAP",
39+ "SETUID",
40+ "SYS_CHROOT",
41+ "SYS_NICE",
42+ "SYS_RESOURCE",
43+}
44+
45+// defaultAppCapAdd is Gordon's base capability add set for app containers
46+// (on top of cap_drop = ALL). Granted capabilities are added to it.
47+var defaultAppCapAdd = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"}
48+
49+// DefaultAppCapAdd returns Gordon's base capability add set. The slice is a
50+// copy.
51+func DefaultAppCapAdd() []string {
52+ return slices.Clone(defaultAppCapAdd)
53+}
54+
55+// NormalizeCapability returns the canonical form of a capability name as
56+// reported by an engine: upper case without the CAP_ prefix.
57+func NormalizeCapability(name string) string {
58+ return strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(name)), "CAP_")
59+}
60+
61+// ExcessCapabilities returns the sorted, normalized capabilities in actual
62+// that are not in authorized. Both sides are normalized first, so engines
63+// that report CAP_ prefixed names compare equal. A container holding fewer
64+// capabilities than authorized is not excess.
65+func ExcessCapabilities(actual, authorized []string) []string {
66+ allowed := make(map[string]struct{}, len(authorized))
67+ for _, name := range authorized {
68+ allowed[NormalizeCapability(name)] = struct{}{}
69+ }
70+ var excess []string
71+ for _, name := range actual {
72+ n := NormalizeCapability(name)
73+ if _, ok := allowed[n]; !ok {
74+ excess = append(excess, n)
75+ }
76+ }
77+ slices.Sort(excess)
78+ return slices.Compact(excess)
79+}
80+
81+// AllowedAppCapabilities returns the sorted capability names a policy may
82+// add. The slice is a copy.
83+func AllowedAppCapabilities() []string {
84+ return slices.Clone(allowedAppCapabilities)
85+}
86+
87+// AppCapabilityPolicy is one named administrative capability grant
88+// declared under [app_capabilities.<name>]. The installation maps a
89+// logical name to explicit Linux capabilities and exact app/service
90+// allowlists. App manifests request capabilities by logical name; Gordon
91+// resolves them at activation time and adds them to the container on top
92+// of its default capability set. Resolution is never persisted: revisions
93+// keep the logical names.
94+type AppCapabilityPolicy struct {
95+ Name string
96+ // Add holds the explicit capability names granted (for example
97+ // "NET_ADMIN"), restricted to the allowed set.
98+ Add []string
99+ // AllowedApps and AllowedServices are exact, non-empty allowlists.
100+ AllowedApps []string
101+ AllowedServices []string
102+}
103+
104+// ValidateCapabilityPolicyRef checks a logical capability-policy name used
105+// from a manifest (service charset plus -- ban).
106+func ValidateCapabilityPolicyRef(name string) error {
107+ if !serviceNamePattern.MatchString(name) {
108+ return fmt.Errorf("%w: capability name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
109+ }
110+ if strings.Contains(name, "--") {
111+ return fmt.Errorf("%w: capability name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
112+ }
113+ return nil
114+}
115+
116+// ValidateCapabilityPolicyName checks a stable policy name (service
117+// charset plus -- ban).
118+func ValidateCapabilityPolicyName(name string) error {
119+ if !serviceNamePattern.MatchString(name) {
120+ return fmt.Errorf("%w: capability policy name %q must match [a-z0-9_.-], max 63", ErrCapabilityPolicy, name)
121+ }
122+ if strings.Contains(name, "--") {
123+ return fmt.Errorf("%w: capability policy name %q must not contain -- (reserved separator)", ErrCapabilityPolicy, name)
124+ }
125+ return nil
126+}
127+
128+// Validate checks the policy shape without touching the runtime.
129+func (p AppCapabilityPolicy) Validate() error {
130+ if err := ValidateCapabilityPolicyName(p.Name); err != nil {
131+ return err
132+ }
133+ if err := validateCapabilityNames(p.Add); err != nil {
134+ return fmt.Errorf("%w: capability policy %q: %v", ErrCapabilityPolicy, p.Name, err)
135+ }
136+ if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
137+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
138+ }
139+ if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
140+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
141+ }
142+ return nil
143+}
144+
145+// validateCapabilityNames requires a non-empty list of unique names from
146+// the allowed set.
147+func validateCapabilityNames(names []string) error {
148+ if len(names) == 0 {
149+ return fmt.Errorf("add must not be empty")
150+ }
151+ return ValidateAddableCapabilities(names)
152+}
153+
154+// ValidateAddableCapabilities checks that every name is in the allowed set
155+// and unique. An empty list is valid. It is also the runtime guard's check
156+// on resolved capabilities, whatever produced them.
157+func ValidateAddableCapabilities(names []string) error {
158+ seen := map[string]struct{}{}
159+ for _, name := range names {
160+ if name == "" {
161+ return fmt.Errorf("capability must not be empty")
162+ }
163+ if !slices.Contains(allowedAppCapabilities, name) {
164+ return fmt.Errorf("capability %q is not allowed (allowed: %s)", name, strings.Join(allowedAppCapabilities, ", "))
165+ }
166+ if _, ok := seen[name]; ok {
167+ return fmt.Errorf("duplicate capability %q", name)
168+ }
169+ seen[name] = struct{}{}
170+ }
171+ return nil
172+}
173+
174+// ResolveAppCapabilities resolves the logical grant under this policy for
175+// the given app and service. It validates the policy shape, enforces the
176+// exact app/service allowlists, and returns the explicit capabilities.
177+func (p AppCapabilityPolicy) ResolveAppCapabilities(app, service string) ([]string, error) {
178+ if err := p.Validate(); err != nil {
179+ return nil, err
180+ }
181+ if !allowlistContains(p.AllowedApps, app) {
182+ return nil, fmt.Errorf("%w: capability policy %q does not allow app %q", ErrCapabilityPolicy, p.Name, app)
183+ }
184+ if !allowlistContains(p.AllowedServices, service) {
185+ return nil, fmt.Errorf("%w: capability policy %q does not allow service %q", ErrCapabilityPolicy, p.Name, service)
186+ }
187+ return slices.Clone(p.Add), nil
188+}
189+
190+// ResolveAppCapabilities resolves every logical capability policy in names
191+// for one app service. It returns the deterministic, de-duplicated union
192+// (sorted): two policies granting the same capability is harmless. Errors
193+// name only the app, service, and logical policy.
194+func ResolveAppCapabilities(app, service string, names []string, policies map[string]AppCapabilityPolicy) ([]string, error) {
195+ if len(names) == 0 {
196+ return nil, nil
197+ }
198+ var resolved []string
199+ for _, name := range names {
200+ policy, ok := policies[name]
201+ if !ok {
202+ return nil, fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured", ErrCapabilityPolicy, app, service, name)
203+ }
204+ caps, err := policy.ResolveAppCapabilities(app, service)
205+ if err != nil {
206+ return nil, fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", ErrCapabilityPolicy, app, service, name)
207+ }
208+ resolved = append(resolved, caps...)
209+ }
210+ slices.Sort(resolved)
211+ return slices.Compact(resolved), nil
212+}
A internal/domain/app_capability_policy_test.go +193 -0
@@ -0,0 +1,193 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func validCapabilityPolicy() AppCapabilityPolicy {
13+ return AppCapabilityPolicy{
14+ Name: "net-admin",
15+ Add: []string{"NET_ADMIN"},
16+ AllowedApps: []string{"demo"},
17+ AllowedServices: []string{"worker"},
18+ }
19+}
20+
21+func TestCapabilityPolicyValid(t *testing.T) {
22+ p := validCapabilityPolicy()
23+ require.NoError(t, p.Validate())
24+ p.Add = []string{"NET_ADMIN", "IPC_LOCK", "SYS_NICE"}
25+ require.NoError(t, p.Validate())
26+}
27+
28+func TestCapabilityPolicyNameRejected(t *testing.T) {
29+ for _, name := range []string{"", "Bad", "a--b", "has space"} {
30+ p := validCapabilityPolicy()
31+ p.Name = name
32+ err := p.Validate()
33+ require.Error(t, err, "name %q", name)
34+ assert.ErrorIs(t, err, ErrCapabilityPolicy)
35+ }
36+}
37+
38+func TestCapabilityPolicyAddRejected(t *testing.T) {
39+ cases := map[string][]string{
40+ "empty": {},
41+ "empty entry": {""},
42+ "ALL": {"ALL"},
43+ "SYS_ADMIN": {"SYS_ADMIN"},
44+ "SYS_PTRACE": {"SYS_PTRACE"},
45+ "SYS_MODULE": {"SYS_MODULE"},
46+ "BPF": {"BPF"},
47+ "lowercase": {"net_admin"},
48+ "CAP_ prefix": {"CAP_NET_ADMIN"},
49+ "unknown": {"NOT_A_CAP"},
50+ "duplicate": {"NET_ADMIN", "NET_ADMIN"},
51+ "mixed": {"NET_ADMIN", "SYS_ADMIN"},
52+ }
53+ for name, add := range cases {
54+ p := validCapabilityPolicy()
55+ p.Add = add
56+ err := p.Validate()
57+ require.Error(t, err, "case %s", name)
58+ assert.ErrorIs(t, err, ErrCapabilityPolicy, "case %s", name)
59+ }
60+}
61+
62+func TestCapabilityPolicyAllowlistRejected(t *testing.T) {
63+ p := validCapabilityPolicy()
64+ p.AllowedApps = nil
65+ require.ErrorIs(t, p.Validate(), ErrCapabilityPolicy)
66+
67+ p = validCapabilityPolicy()
68+ p.AllowedServices = nil
69+ require.ErrorIs(t, p.Validate(), ErrCapabilityPolicy)
70+
71+ p = validCapabilityPolicy()
72+ p.AllowedApps = []string{"demo", "demo"}
73+ require.Error(t, p.Validate())
74+
75+ p = validCapabilityPolicy()
76+ p.AllowedServices = []string{"*"}
77+ require.Error(t, p.Validate())
78+}
79+
80+func TestAllowedAppCapabilitiesExcludesDangerous(t *testing.T) {
81+ allowed := AllowedAppCapabilities()
82+ for _, bad := range []string{"ALL", "SYS_ADMIN", "SYS_PTRACE", "SYS_MODULE", "SYS_RAWIO", "SYS_BOOT", "SYS_TIME", "DAC_READ_SEARCH", "BPF", "PERFMON", "MAC_ADMIN", "MAC_OVERRIDE", "AUDIT_CONTROL", "SYSLOG"} {
83+ assert.NotContains(t, allowed, bad)
84+ }
85+ assert.Contains(t, allowed, "NET_ADMIN")
86+ // The returned slice is a copy.
87+ allowed[0] = "mutated"
88+ assert.NotEqual(t, "mutated", AllowedAppCapabilities()[0])
89+}
90+
91+func TestResolveAppCapabilitiesDenyByDefault(t *testing.T) {
92+ p := validCapabilityPolicy()
93+ _, err := p.ResolveAppCapabilities("other", "worker")
94+ require.ErrorIs(t, err, ErrCapabilityPolicy)
95+ _, err = p.ResolveAppCapabilities("demo", "helper")
96+ require.ErrorIs(t, err, ErrCapabilityPolicy)
97+}
98+
99+func TestResolveAppCapabilitiesExactGrantIsCopy(t *testing.T) {
100+ p := validCapabilityPolicy()
101+ caps, err := p.ResolveAppCapabilities("demo", "worker")
102+ require.NoError(t, err)
103+ assert.Equal(t, []string{"NET_ADMIN"}, caps)
104+ caps[0] = "mutated"
105+ again, err := p.ResolveAppCapabilities("demo", "worker")
106+ require.NoError(t, err)
107+ assert.Equal(t, []string{"NET_ADMIN"}, again)
108+}
109+
110+func TestResolveAppCapabilitiesUnionSortedDeduped(t *testing.T) {
111+ policies := map[string]AppCapabilityPolicy{
112+ "a": {Name: "a", Add: []string{"NET_ADMIN", "IPC_LOCK"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
113+ "b": {Name: "b", Add: []string{"NET_ADMIN", "SYS_NICE"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
114+ }
115+ caps, err := ResolveAppCapabilities("demo", "worker", []string{"b", "a"}, policies)
116+ require.NoError(t, err)
117+ assert.Equal(t, []string{"IPC_LOCK", "NET_ADMIN", "SYS_NICE"}, caps)
118+}
119+
120+func TestResolveAppCapabilitiesUnknownAndEmpty(t *testing.T) {
121+ _, err := ResolveAppCapabilities("demo", "worker", []string{"unknown"}, nil)
122+ require.ErrorIs(t, err, ErrCapabilityPolicy)
123+ caps, err := ResolveAppCapabilities("demo", "worker", nil, nil)
124+ require.NoError(t, err)
125+ assert.Nil(t, caps)
126+}
127+
128+func TestResolveAppCapabilitiesRefusedDoesNotEchoCaps(t *testing.T) {
129+ policies := map[string]AppCapabilityPolicy{"net-admin": validCapabilityPolicy()}
130+ _, err := ResolveAppCapabilities("other", "worker", []string{"net-admin"}, policies)
131+ require.ErrorIs(t, err, ErrCapabilityPolicy)
132+ assert.NotContains(t, err.Error(), "NET_ADMIN")
133+}
134+
135+func TestRuntimeGuardRefusesDisallowedAddedCapabilities(t *testing.T) {
136+ var g RuntimeGuard
137+ require.NoError(t, g.Check(&ContainerConfig{AddedCapabilities: []string{"NET_ADMIN"}}))
138+ for _, caps := range [][]string{{"ALL"}, {"SYS_ADMIN"}, {"NET_ADMIN", "NET_ADMIN"}} {
139+ err := g.Check(&ContainerConfig{AddedCapabilities: caps})
140+ require.ErrorIs(t, err, ErrRuntimeGuard, "caps %v", caps)
141+ }
142+}
143+
144+func TestAppServiceCapabilitiesValidation(t *testing.T) {
145+ mk := func(caps ...string) AppSpec {
146+ return AppSpec{Name: "demo", Services: []AppService{{Name: "worker", Image: "registry.example.com/demo/worker:1", StopGrace: 10 * time.Second, Readiness: AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}, Capabilities: caps}}}
147+ }
148+ require.NoError(t, mk("net-admin").Validate())
149+
150+ err := mk("net-admin", "net-admin").Validate()
151+ require.ErrorIs(t, err, ErrInvalidAppSpec)
152+ assert.Contains(t, err.Error(), "duplicate capability")
153+
154+ for _, name := range []string{"Bad!", "NET_ADMIN", "a--b", ""} {
155+ err = mk(name).Validate()
156+ require.Error(t, err, "name %q", name)
157+ assert.True(t, errors.Is(err, ErrInvalidAppSpec))
158+ }
159+}
160+
161+func TestDiffAppSpecCapabilities(t *testing.T) {
162+ svc := func(caps ...string) AppService {
163+ return AppService{Name: "worker", Image: "registry.example.com/demo/worker:1", StopGrace: 10 * time.Second, Readiness: AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}, Capabilities: caps}
164+ }
165+ mk := func(s AppService) AppSpec { return AppSpec{Name: "demo", Services: []AppService{s}} }
166+
167+ diff := DiffAppSpec(mk(svc()), mk(svc("net-admin")))
168+ assert.Contains(t, diff.Changed, "service/worker/capabilities")
169+
170+ diff = DiffAppSpec(mk(svc("net-admin")), mk(svc()))
171+ assert.Contains(t, diff.Changed, "service/worker/capabilities")
172+
173+ diff = DiffAppSpec(mk(svc("a", "b")), mk(svc("b", "a")))
174+ assert.NotContains(t, diff.Changed, "service/worker/capabilities", "reorder is a no-op")
175+
176+ diff = DiffAppSpec(mk(svc()), mk(svc([]string{}...)))
177+ assert.NotContains(t, diff.Changed, "service/worker/capabilities", "nil vs empty is a no-op")
178+ assert.NotContains(t, diff.Changed, "service/worker/devices")
179+}
180+
181+func TestExcessCapabilities(t *testing.T) {
182+ authorized := append(DefaultAppCapAdd(), "IPC_LOCK")
183+ assert.Empty(t, ExcessCapabilities([]string{"CAP_CHOWN", "ipc_lock"}, authorized))
184+ assert.Empty(t, ExcessCapabilities(nil, authorized))
185+ assert.Equal(t, []string{"NET_ADMIN"}, ExcessCapabilities([]string{"CHOWN", "CAP_NET_ADMIN", "NET_ADMIN"}, authorized))
186+ assert.Equal(t, "NET_ADMIN", NormalizeCapability(" cap_net_admin "))
187+}
188+
189+func TestDefaultAppCapAddIsCopy(t *testing.T) {
190+ d := DefaultAppCapAdd()
191+ d[0] = "mutated"
192+ assert.NotEqual(t, "mutated", DefaultAppCapAdd()[0])
193+}
@@ -0,0 +1,193 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func validCapabilityPolicy() AppCapabilityPolicy {
13+ return AppCapabilityPolicy{
14+ Name: "net-admin",
15+ Add: []string{"NET_ADMIN"},
16+ AllowedApps: []string{"demo"},
17+ AllowedServices: []string{"worker"},
18+ }
19+}
20+
21+func TestCapabilityPolicyValid(t *testing.T) {
22+ p := validCapabilityPolicy()
23+ require.NoError(t, p.Validate())
24+ p.Add = []string{"NET_ADMIN", "IPC_LOCK", "SYS_NICE"}
25+ require.NoError(t, p.Validate())
26+}
27+
28+func TestCapabilityPolicyNameRejected(t *testing.T) {
29+ for _, name := range []string{"", "Bad", "a--b", "has space"} {
30+ p := validCapabilityPolicy()
31+ p.Name = name
32+ err := p.Validate()
33+ require.Error(t, err, "name %q", name)
34+ assert.ErrorIs(t, err, ErrCapabilityPolicy)
35+ }
36+}
37+
38+func TestCapabilityPolicyAddRejected(t *testing.T) {
39+ cases := map[string][]string{
40+ "empty": {},
41+ "empty entry": {""},
42+ "ALL": {"ALL"},
43+ "SYS_ADMIN": {"SYS_ADMIN"},
44+ "SYS_PTRACE": {"SYS_PTRACE"},
45+ "SYS_MODULE": {"SYS_MODULE"},
46+ "BPF": {"BPF"},
47+ "lowercase": {"net_admin"},
48+ "CAP_ prefix": {"CAP_NET_ADMIN"},
49+ "unknown": {"NOT_A_CAP"},
50+ "duplicate": {"NET_ADMIN", "NET_ADMIN"},
51+ "mixed": {"NET_ADMIN", "SYS_ADMIN"},
52+ }
53+ for name, add := range cases {
54+ p := validCapabilityPolicy()
55+ p.Add = add
56+ err := p.Validate()
57+ require.Error(t, err, "case %s", name)
58+ assert.ErrorIs(t, err, ErrCapabilityPolicy, "case %s", name)
59+ }
60+}
61+
62+func TestCapabilityPolicyAllowlistRejected(t *testing.T) {
63+ p := validCapabilityPolicy()
64+ p.AllowedApps = nil
65+ require.ErrorIs(t, p.Validate(), ErrCapabilityPolicy)
66+
67+ p = validCapabilityPolicy()
68+ p.AllowedServices = nil
69+ require.ErrorIs(t, p.Validate(), ErrCapabilityPolicy)
70+
71+ p = validCapabilityPolicy()
72+ p.AllowedApps = []string{"demo", "demo"}
73+ require.Error(t, p.Validate())
74+
75+ p = validCapabilityPolicy()
76+ p.AllowedServices = []string{"*"}
77+ require.Error(t, p.Validate())
78+}
79+
80+func TestAllowedAppCapabilitiesExcludesDangerous(t *testing.T) {
81+ allowed := AllowedAppCapabilities()
82+ for _, bad := range []string{"ALL", "SYS_ADMIN", "SYS_PTRACE", "SYS_MODULE", "SYS_RAWIO", "SYS_BOOT", "SYS_TIME", "DAC_READ_SEARCH", "BPF", "PERFMON", "MAC_ADMIN", "MAC_OVERRIDE", "AUDIT_CONTROL", "SYSLOG"} {
83+ assert.NotContains(t, allowed, bad)
84+ }
85+ assert.Contains(t, allowed, "NET_ADMIN")
86+ // The returned slice is a copy.
87+ allowed[0] = "mutated"
88+ assert.NotEqual(t, "mutated", AllowedAppCapabilities()[0])
89+}
90+
91+func TestResolveAppCapabilitiesDenyByDefault(t *testing.T) {
92+ p := validCapabilityPolicy()
93+ _, err := p.ResolveAppCapabilities("other", "worker")
94+ require.ErrorIs(t, err, ErrCapabilityPolicy)
95+ _, err = p.ResolveAppCapabilities("demo", "helper")
96+ require.ErrorIs(t, err, ErrCapabilityPolicy)
97+}
98+
99+func TestResolveAppCapabilitiesExactGrantIsCopy(t *testing.T) {
100+ p := validCapabilityPolicy()
101+ caps, err := p.ResolveAppCapabilities("demo", "worker")
102+ require.NoError(t, err)
103+ assert.Equal(t, []string{"NET_ADMIN"}, caps)
104+ caps[0] = "mutated"
105+ again, err := p.ResolveAppCapabilities("demo", "worker")
106+ require.NoError(t, err)
107+ assert.Equal(t, []string{"NET_ADMIN"}, again)
108+}
109+
110+func TestResolveAppCapabilitiesUnionSortedDeduped(t *testing.T) {
111+ policies := map[string]AppCapabilityPolicy{
112+ "a": {Name: "a", Add: []string{"NET_ADMIN", "IPC_LOCK"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
113+ "b": {Name: "b", Add: []string{"NET_ADMIN", "SYS_NICE"}, AllowedApps: []string{"demo"}, AllowedServices: []string{"worker"}},
114+ }
115+ caps, err := ResolveAppCapabilities("demo", "worker", []string{"b", "a"}, policies)
116+ require.NoError(t, err)
117+ assert.Equal(t, []string{"IPC_LOCK", "NET_ADMIN", "SYS_NICE"}, caps)
118+}
119+
120+func TestResolveAppCapabilitiesUnknownAndEmpty(t *testing.T) {
121+ _, err := ResolveAppCapabilities("demo", "worker", []string{"unknown"}, nil)
122+ require.ErrorIs(t, err, ErrCapabilityPolicy)
123+ caps, err := ResolveAppCapabilities("demo", "worker", nil, nil)
124+ require.NoError(t, err)
125+ assert.Nil(t, caps)
126+}
127+
128+func TestResolveAppCapabilitiesRefusedDoesNotEchoCaps(t *testing.T) {
129+ policies := map[string]AppCapabilityPolicy{"net-admin": validCapabilityPolicy()}
130+ _, err := ResolveAppCapabilities("other", "worker", []string{"net-admin"}, policies)
131+ require.ErrorIs(t, err, ErrCapabilityPolicy)
132+ assert.NotContains(t, err.Error(), "NET_ADMIN")
133+}
134+
135+func TestRuntimeGuardRefusesDisallowedAddedCapabilities(t *testing.T) {
136+ var g RuntimeGuard
137+ require.NoError(t, g.Check(&ContainerConfig{AddedCapabilities: []string{"NET_ADMIN"}}))
138+ for _, caps := range [][]string{{"ALL"}, {"SYS_ADMIN"}, {"NET_ADMIN", "NET_ADMIN"}} {
139+ err := g.Check(&ContainerConfig{AddedCapabilities: caps})
140+ require.ErrorIs(t, err, ErrRuntimeGuard, "caps %v", caps)
141+ }
142+}
143+
144+func TestAppServiceCapabilitiesValidation(t *testing.T) {
145+ mk := func(caps ...string) AppSpec {
146+ return AppSpec{Name: "demo", Services: []AppService{{Name: "worker", Image: "registry.example.com/demo/worker:1", StopGrace: 10 * time.Second, Readiness: AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}, Capabilities: caps}}}
147+ }
148+ require.NoError(t, mk("net-admin").Validate())
149+
150+ err := mk("net-admin", "net-admin").Validate()
151+ require.ErrorIs(t, err, ErrInvalidAppSpec)
152+ assert.Contains(t, err.Error(), "duplicate capability")
153+
154+ for _, name := range []string{"Bad!", "NET_ADMIN", "a--b", ""} {
155+ err = mk(name).Validate()
156+ require.Error(t, err, "name %q", name)
157+ assert.True(t, errors.Is(err, ErrInvalidAppSpec))
158+ }
159+}
160+
161+func TestDiffAppSpecCapabilities(t *testing.T) {
162+ svc := func(caps ...string) AppService {
163+ return AppService{Name: "worker", Image: "registry.example.com/demo/worker:1", StopGrace: 10 * time.Second, Readiness: AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second}, Capabilities: caps}
164+ }
165+ mk := func(s AppService) AppSpec { return AppSpec{Name: "demo", Services: []AppService{s}} }
166+
167+ diff := DiffAppSpec(mk(svc()), mk(svc("net-admin")))
168+ assert.Contains(t, diff.Changed, "service/worker/capabilities")
169+
170+ diff = DiffAppSpec(mk(svc("net-admin")), mk(svc()))
171+ assert.Contains(t, diff.Changed, "service/worker/capabilities")
172+
173+ diff = DiffAppSpec(mk(svc("a", "b")), mk(svc("b", "a")))
174+ assert.NotContains(t, diff.Changed, "service/worker/capabilities", "reorder is a no-op")
175+
176+ diff = DiffAppSpec(mk(svc()), mk(svc([]string{}...)))
177+ assert.NotContains(t, diff.Changed, "service/worker/capabilities", "nil vs empty is a no-op")
178+ assert.NotContains(t, diff.Changed, "service/worker/devices")
179+}
180+
181+func TestExcessCapabilities(t *testing.T) {
182+ authorized := append(DefaultAppCapAdd(), "IPC_LOCK")
183+ assert.Empty(t, ExcessCapabilities([]string{"CAP_CHOWN", "ipc_lock"}, authorized))
184+ assert.Empty(t, ExcessCapabilities(nil, authorized))
185+ assert.Equal(t, []string{"NET_ADMIN"}, ExcessCapabilities([]string{"CHOWN", "CAP_NET_ADMIN", "NET_ADMIN"}, authorized))
186+ assert.Equal(t, "NET_ADMIN", NormalizeCapability(" cap_net_admin "))
187+}
188+
189+func TestDefaultAppCapAddIsCopy(t *testing.T) {
190+ d := DefaultAppCapAdd()
191+ d[0] = "mutated"
192+ assert.NotEqual(t, "mutated", DefaultAppCapAdd()[0])
193+}
M internal/domain/app_spec.go +39 -3
@@ -116,9 +116,14 @@ type AppService struct {
116116 // Devices lists logical device names granted by administrative
117117 // [app_devices] policy. Names resolve to CDI device IDs at activation
118118 // time; the logical names (not host resolution) persist in revisions.
119- Devices []string
120- Databases []AppDatabase
121- Backup AppBackup
119+ Devices []string
120+ // Capabilities lists logical capability-policy names granted by
121+ // administrative [app_capabilities] policy. Names resolve to Linux
122+ // capabilities added on top of Gordon's defaults at activation time;
123+ // the logical names (not the resolution) persist in revisions.
124+ Capabilities []string
125+ Databases []AppDatabase
126+ Backup AppBackup
122127 // LogExportDisabled opts the service out of OTLP log export. The
123128 // manifest resolves it from [services.<name>.telemetry] logs, then
124129 // the app-level [telemetry] logs, then the default (export on). The
@@ -571,6 +576,9 @@ func (s *AppService) validate() error {
571576 if err := s.validateDevices(); err != nil {
572577 return err
573578 }
579+ if err := s.validateCapabilities(); err != nil {
580+ return err
581+ }
574582 if err := s.validateDatabases(volumes); err != nil {
575583 return err
576584 }
@@ -708,6 +716,23 @@ func (s *AppService) validateDevices() error {
708716 return nil
709717 }
710718
719+// validateCapabilities checks logical capability-policy names and
720+// duplicates. Authorization against administrative policy happens at
721+// apply/deploy time, not here.
722+func (s *AppService) validateCapabilities() error {
723+ seen := map[string]struct{}{}
724+ for _, name := range s.Capabilities {
725+ if err := ValidateCapabilityPolicyRef(name); err != nil {
726+ return fmt.Errorf("%w: service %q: %v", ErrInvalidAppSpec, s.Name, err)
727+ }
728+ if _, ok := seen[name]; ok {
729+ return fmt.Errorf("%w: service %q duplicate capability %q", ErrInvalidAppSpec, s.Name, name)
730+ }
731+ seen[name] = struct{}{}
732+ }
733+ return nil
734+}
735+
711736 // validateDatabases checks database declarations and backup references.
712737 func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {
713738 seenDBs := map[string]struct{}{}
@@ -1114,6 +1139,7 @@ func diffService(name string, desired, effective AppService) []string {
11141139 changed = append(changed, "service/"+name+"/binds")
11151140 }
11161141 changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices)
1142+ changed = appendCapabilityChanges(changed, name, desired.Capabilities, effective.Capabilities)
11171143 return appendOperationChanges(changed, name, desired, effective)
11181144 }
11191145
@@ -1314,3 +1340,13 @@ func appendDeviceChanges(changed []string, name string, desired, effective []str
13141340 }
13151341 return changed
13161342 }
1343+
1344+// appendCapabilityChanges appends the service capabilities diff entry when
1345+// the capability-policy name sets differ. Like devices, the slices compare
1346+// as sets: reorder-only input is a no-op.
1347+func appendCapabilityChanges(changed []string, name string, desired, effective []string) []string {
1348+ if !equalDevices(desired, effective) {
1349+ changed = append(changed, "service/"+name+"/capabilities")
1350+ }
1351+ return changed
1352+}
@@ -116,9 +116,14 @@ type AppService struct {
116 // Devices lists logical device names granted by administrative116 // Devices lists logical device names granted by administrative
117 // [app_devices] policy. Names resolve to CDI device IDs at activation117 // [app_devices] policy. Names resolve to CDI device IDs at activation
118 // time; the logical names (not host resolution) persist in revisions.118 // time; the logical names (not host resolution) persist in revisions.
119- Devices []string119+ Devices []string
120- Databases []AppDatabase120+ // Capabilities lists logical capability-policy names granted by
121- Backup AppBackup121+ // administrative [app_capabilities] policy. Names resolve to Linux
122+ // capabilities added on top of Gordon's defaults at activation time;
123+ // the logical names (not the resolution) persist in revisions.
124+ Capabilities []string
125+ Databases []AppDatabase
126+ Backup AppBackup
122 // LogExportDisabled opts the service out of OTLP log export. The127 // LogExportDisabled opts the service out of OTLP log export. The
123 // manifest resolves it from [services.<name>.telemetry] logs, then128 // manifest resolves it from [services.<name>.telemetry] logs, then
124 // the app-level [telemetry] logs, then the default (export on). The129 // the app-level [telemetry] logs, then the default (export on). The
@@ -571,6 +576,9 @@ func (s *AppService) validate() error {
571 if err := s.validateDevices(); err != nil {576 if err := s.validateDevices(); err != nil {
572 return err577 return err
573 }578 }
579+ if err := s.validateCapabilities(); err != nil {
580+ return err
581+ }
574 if err := s.validateDatabases(volumes); err != nil {582 if err := s.validateDatabases(volumes); err != nil {
575 return err583 return err
576 }584 }
@@ -708,6 +716,23 @@ func (s *AppService) validateDevices() error {
708 return nil716 return nil
709 }717 }
710 718
719+// validateCapabilities checks logical capability-policy names and
720+// duplicates. Authorization against administrative policy happens at
721+// apply/deploy time, not here.
722+func (s *AppService) validateCapabilities() error {
723+ seen := map[string]struct{}{}
724+ for _, name := range s.Capabilities {
725+ if err := ValidateCapabilityPolicyRef(name); err != nil {
726+ return fmt.Errorf("%w: service %q: %v", ErrInvalidAppSpec, s.Name, err)
727+ }
728+ if _, ok := seen[name]; ok {
729+ return fmt.Errorf("%w: service %q duplicate capability %q", ErrInvalidAppSpec, s.Name, name)
730+ }
731+ seen[name] = struct{}{}
732+ }
733+ return nil
734+}
735+
711 // validateDatabases checks database declarations and backup references.736 // validateDatabases checks database declarations and backup references.
712 func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {737 func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {
713 seenDBs := map[string]struct{}{}738 seenDBs := map[string]struct{}{}
@@ -1114,6 +1139,7 @@ func diffService(name string, desired, effective AppService) []string {
1114 changed = append(changed, "service/"+name+"/binds")1139 changed = append(changed, "service/"+name+"/binds")
1115 }1140 }
1116 changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices)1141 changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices)
1142+ changed = appendCapabilityChanges(changed, name, desired.Capabilities, effective.Capabilities)
1117 return appendOperationChanges(changed, name, desired, effective)1143 return appendOperationChanges(changed, name, desired, effective)
1118 }1144 }
1119 1145
@@ -1314,3 +1340,13 @@ func appendDeviceChanges(changed []string, name string, desired, effective []str
1314 }1340 }
1315 return changed1341 return changed
1316 }1342 }
1343+
1344+// appendCapabilityChanges appends the service capabilities diff entry when
1345+// the capability-policy name sets differ. Like devices, the slices compare
1346+// as sets: reorder-only input is a no-op.
1347+func appendCapabilityChanges(changed []string, name string, desired, effective []string) []string {
1348+ if !equalDevices(desired, effective) {
1349+ changed = append(changed, "service/"+name+"/capabilities")
1350+ }
1351+ return changed
1352+}
M internal/domain/container.go +20 -11
@@ -24,6 +24,10 @@ type Container struct {
2424 // Env is the container's KEY=value environment as created. It may hold
2525 // secret values: compare it in memory only, never persist or log it.
2626 Env []string
27+ // CapAdd is the container's configured capability add set as reported
28+ // by the engine (names may carry a CAP_ prefix). Nil when the engine
29+ // reports none. Reuse paths compare it with the current authorization.
30+ CapAdd []string
2731 }
2832
2933 // ContainerVolumeMount describes a mounted volume-like resource on a container.
@@ -122,17 +126,22 @@ type ContainerConfig struct {
122126 // CDIDevices holds explicit CDI device IDs resolved from administrative
123127 // device policy at activation time. It is ephemeral like Binds: never
124128 // persisted, never logged by the runtime adapter.
125- CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest
126- NetworkMode string // Network to join
127- Hostname string // Container hostname for DNS
128- Aliases []string // Additional network aliases
129- MemoryLimit int64 // Memory limit in bytes (0 = no limit)
130- NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
131- PidsLimit int64 // Max number of PIDs (0 = no limit)
132- ReadOnlyRootFS bool // Mount container root filesystem read-only
133- User string // User to run as
134- CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
135- CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
129+ CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest
130+ // AddedCapabilities holds Linux capabilities resolved from
131+ // administrative capability policy at activation time. It is ephemeral
132+ // like CDIDevices: never persisted. The runtime adds them on top of the
133+ // base capability set (default or CapAdd) and never alters CapDrop.
134+ AddedCapabilities []string
135+ NetworkMode string // Network to join
136+ Hostname string // Container hostname for DNS
137+ Aliases []string // Additional network aliases
138+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
139+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
140+ PidsLimit int64 // Max number of PIDs (0 = no limit)
141+ ReadOnlyRootFS bool // Mount container root filesystem read-only
142+ User string // User to run as
143+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
144+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
136145 }
137146
138147 // ContainerStatus represents the current state of a container.
@@ -24,6 +24,10 @@ type Container struct {
24 // Env is the container's KEY=value environment as created. It may hold24 // Env is the container's KEY=value environment as created. It may hold
25 // secret values: compare it in memory only, never persist or log it.25 // secret values: compare it in memory only, never persist or log it.
26 Env []string26 Env []string
27+ // CapAdd is the container's configured capability add set as reported
28+ // by the engine (names may carry a CAP_ prefix). Nil when the engine
29+ // reports none. Reuse paths compare it with the current authorization.
30+ CapAdd []string
27 }31 }
28 32
29 // ContainerVolumeMount describes a mounted volume-like resource on a container.33 // ContainerVolumeMount describes a mounted volume-like resource on a container.
@@ -122,17 +126,22 @@ type ContainerConfig struct {
122 // CDIDevices holds explicit CDI device IDs resolved from administrative126 // CDIDevices holds explicit CDI device IDs resolved from administrative
123 // device policy at activation time. It is ephemeral like Binds: never127 // device policy at activation time. It is ephemeral like Binds: never
124 // persisted, never logged by the runtime adapter.128 // persisted, never logged by the runtime adapter.
125- CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest129+ CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest
126- NetworkMode string // Network to join130+ // AddedCapabilities holds Linux capabilities resolved from
127- Hostname string // Container hostname for DNS131+ // administrative capability policy at activation time. It is ephemeral
128- Aliases []string // Additional network aliases132+ // like CDIDevices: never persisted. The runtime adds them on top of the
129- MemoryLimit int64 // Memory limit in bytes (0 = no limit)133+ // base capability set (default or CapAdd) and never alters CapDrop.
130- NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)134+ AddedCapabilities []string
131- PidsLimit int64 // Max number of PIDs (0 = no limit)135+ NetworkMode string // Network to join
132- ReadOnlyRootFS bool // Mount container root filesystem read-only136+ Hostname string // Container hostname for DNS
133- User string // User to run as137+ Aliases []string // Additional network aliases
134- CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults138+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
135- CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults139+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
140+ PidsLimit int64 // Max number of PIDs (0 = no limit)
141+ ReadOnlyRootFS bool // Mount container root filesystem read-only
142+ User string // User to run as
143+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
144+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
136 }145 }
137 146
138 // ContainerStatus represents the current state of a container.147 // ContainerStatus represents the current state of a container.
M internal/domain/errors.go +4 -0
@@ -83,6 +83,10 @@ var (
8383 // distinct from ErrInvalidAppSpec: the manifest may be valid while the
8484 // installation policy refuses to serve it.
8585 ErrDevicePolicy = errors.New("device policy violation")
86+ // ErrCapabilityPolicy wraps administrative Linux capability policy
87+ // violations. It is distinct from ErrInvalidAppSpec: the manifest may
88+ // be valid while the installation policy refuses to serve it.
89+ ErrCapabilityPolicy = errors.New("capability policy violation")
8690 // ErrRuntimeGuard wraps app container configs refused by the runtime
8791 // guard: host or shared namespaces, host paths passed as volume
8892 // names, or binds overlapping Gordon's own sensitive paths.
@@ -83,6 +83,10 @@ var (
83 // distinct from ErrInvalidAppSpec: the manifest may be valid while the83 // distinct from ErrInvalidAppSpec: the manifest may be valid while the
84 // installation policy refuses to serve it.84 // installation policy refuses to serve it.
85 ErrDevicePolicy = errors.New("device policy violation")85 ErrDevicePolicy = errors.New("device policy violation")
86+ // ErrCapabilityPolicy wraps administrative Linux capability policy
87+ // violations. It is distinct from ErrInvalidAppSpec: the manifest may
88+ // be valid while the installation policy refuses to serve it.
89+ ErrCapabilityPolicy = errors.New("capability policy violation")
86 // ErrRuntimeGuard wraps app container configs refused by the runtime90 // ErrRuntimeGuard wraps app container configs refused by the runtime
87 // guard: host or shared namespaces, host paths passed as volume91 // guard: host or shared namespaces, host paths passed as volume
88 // names, or binds overlapping Gordon's own sensitive paths.92 // names, or binds overlapping Gordon's own sensitive paths.
M internal/domain/runtime_guard.go +3 -0
@@ -41,6 +41,9 @@ func (g RuntimeGuard) Check(config *ContainerConfig) error {
4141 if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
4242 return err
4343 }
44+ if err := ValidateAddableCapabilities(config.AddedCapabilities); err != nil {
45+ return fmt.Errorf("%w: %v", ErrRuntimeGuard, err)
46+ }
4447 for _, bind := range config.Binds {
4548 if g.isOwnSecretBind(config, bind) {
4649 continue
@@ -41,6 +41,9 @@ func (g RuntimeGuard) Check(config *ContainerConfig) error {
41 if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {41 if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
42 return err42 return err
43 }43 }
44+ if err := ValidateAddableCapabilities(config.AddedCapabilities); err != nil {
45+ return fmt.Errorf("%w: %v", ErrRuntimeGuard, err)
46+ }
44 for _, bind := range config.Binds {47 for _, bind := range config.Binds {
45 if g.isOwnSecretBind(config, bind) {48 if g.isOwnSecretBind(config, bind) {
46 continue49 continue
M internal/usecase/apps/app_service.go +12 -0
@@ -208,6 +208,18 @@ func (s *AppServiceImpl) SetDevicePolicies(policies map[string]domain.AppDeviceP
208208 s.core.SetDevicePolicies(policies)
209209 }
210210
211+// WithCapabilityPolicies supplies the administrative capability policies used at apply time.
212+func (s *AppServiceImpl) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *AppServiceImpl {
213+ s.core.SetCapabilityPolicies(policies)
214+ return s
215+}
216+
217+// SetCapabilityPolicies atomically replaces the administrative capability
218+// policies. It is safe to call from a config reload while applies are in flight.
219+func (s *AppServiceImpl) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
220+ s.core.SetCapabilityPolicies(policies)
221+}
222+
211223 var _ in.AppService = (*AppServiceImpl)(nil)
212224
213225 // Apply implements in.AppService.
@@ -208,6 +208,18 @@ func (s *AppServiceImpl) SetDevicePolicies(policies map[string]domain.AppDeviceP
208 s.core.SetDevicePolicies(policies)208 s.core.SetDevicePolicies(policies)
209 }209 }
210 210
211+// WithCapabilityPolicies supplies the administrative capability policies used at apply time.
212+func (s *AppServiceImpl) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *AppServiceImpl {
213+ s.core.SetCapabilityPolicies(policies)
214+ return s
215+}
216+
217+// SetCapabilityPolicies atomically replaces the administrative capability
218+// policies. It is safe to call from a config reload while applies are in flight.
219+func (s *AppServiceImpl) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
220+ s.core.SetCapabilityPolicies(policies)
221+}
222+
211 var _ in.AppService = (*AppServiceImpl)(nil)223 var _ in.AppService = (*AppServiceImpl)(nil)
212 224
213 // Apply implements in.AppService.225 // Apply implements in.AppService.
A internal/usecase/apps/capability_policy_test.go +97 -0
@@ -0,0 +1,97 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+ "github.com/bnema/gordon/internal/domain"
13+ "github.com/bnema/gordon/internal/usecase/apps"
14+)
15+
16+func capabilityPolicy(appNames, services []string) domain.AppCapabilityPolicy {
17+ return domain.AppCapabilityPolicy{
18+ Name: "net-admin",
19+ Add: []string{"NET_ADMIN"},
20+ AllowedApps: appNames,
21+ AllowedServices: services,
22+ }
23+}
24+
25+func TestApply_AcceptsCapabilitiesUnderAdministrativePolicy(t *testing.T) {
26+ store := outmocks.NewMockAppState(t)
27+ spec := testSpec("blog")
28+ spec.Services[0].Capabilities = []string{"net-admin"}
29+ applySuccess(store, spec)
30+
31+ svc := apps.NewService(store, zerowrap.Default()).
32+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
33+ "net-admin": capabilityPolicy([]string{"blog"}, []string{"web"}),
34+ })
35+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
36+ require.NoError(t, err)
37+}
38+
39+func TestApply_RejectsCapabilityWithoutPolicyBeforeStoreAccess(t *testing.T) {
40+ store := outmocks.NewMockAppState(t)
41+ spec := testSpec("blog")
42+ spec.Services[0].Capabilities = []string{"net-admin"}
43+ svc := apps.NewService(store, zerowrap.Default())
44+
45+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
46+
47+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
48+ assert.Contains(t, err.Error(), "blog")
49+ assert.Contains(t, err.Error(), "web")
50+ assert.Contains(t, err.Error(), "net-admin")
51+}
52+
53+func TestApply_RejectsCapabilityOutsideAllowlists(t *testing.T) {
54+ spec := testSpec("blog")
55+ spec.Services[0].Capabilities = []string{"net-admin"}
56+ for name, policy := range map[string]domain.AppCapabilityPolicy{
57+ "app not allowed": capabilityPolicy([]string{"other"}, []string{"web"}),
58+ "service not allowed": capabilityPolicy([]string{"blog"}, []string{"other"}),
59+ } {
60+ t.Run(name, func(t *testing.T) {
61+ store := outmocks.NewMockAppState(t)
62+ svc := apps.NewService(store, zerowrap.Default()).
63+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{"net-admin": policy})
64+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
65+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
66+ assert.NotContains(t, err.Error(), "NET_ADMIN")
67+ })
68+ }
69+}
70+
71+func TestApply_CapabilityPoliciesReplaceOnReload(t *testing.T) {
72+ spec := testSpec("blog")
73+ spec.Services[0].Capabilities = []string{"net-admin"}
74+
75+ store := outmocks.NewMockAppState(t)
76+ applySuccess(store, spec)
77+ svc := apps.NewService(store, zerowrap.Default()).
78+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
79+ "net-admin": capabilityPolicy([]string{"blog"}, []string{"web"}),
80+ })
81+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
82+ require.NoError(t, err)
83+
84+ svc.SetCapabilityPolicies(nil)
85+ _, _, err = svc.Apply(context.Background(), spec, []byte("manifest"), false)
86+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
87+}
88+
89+func TestApply_CapabilityDryRunRefusedWithoutMutation(t *testing.T) {
90+ store := outmocks.NewMockAppState(t)
91+ spec := testSpec("blog")
92+ spec.Services[0].Capabilities = []string{"net-admin"}
93+ svc := apps.NewService(store, zerowrap.Default())
94+
95+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), true)
96+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
97+}
@@ -0,0 +1,97 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+ "github.com/bnema/gordon/internal/domain"
13+ "github.com/bnema/gordon/internal/usecase/apps"
14+)
15+
16+func capabilityPolicy(appNames, services []string) domain.AppCapabilityPolicy {
17+ return domain.AppCapabilityPolicy{
18+ Name: "net-admin",
19+ Add: []string{"NET_ADMIN"},
20+ AllowedApps: appNames,
21+ AllowedServices: services,
22+ }
23+}
24+
25+func TestApply_AcceptsCapabilitiesUnderAdministrativePolicy(t *testing.T) {
26+ store := outmocks.NewMockAppState(t)
27+ spec := testSpec("blog")
28+ spec.Services[0].Capabilities = []string{"net-admin"}
29+ applySuccess(store, spec)
30+
31+ svc := apps.NewService(store, zerowrap.Default()).
32+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
33+ "net-admin": capabilityPolicy([]string{"blog"}, []string{"web"}),
34+ })
35+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
36+ require.NoError(t, err)
37+}
38+
39+func TestApply_RejectsCapabilityWithoutPolicyBeforeStoreAccess(t *testing.T) {
40+ store := outmocks.NewMockAppState(t)
41+ spec := testSpec("blog")
42+ spec.Services[0].Capabilities = []string{"net-admin"}
43+ svc := apps.NewService(store, zerowrap.Default())
44+
45+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
46+
47+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
48+ assert.Contains(t, err.Error(), "blog")
49+ assert.Contains(t, err.Error(), "web")
50+ assert.Contains(t, err.Error(), "net-admin")
51+}
52+
53+func TestApply_RejectsCapabilityOutsideAllowlists(t *testing.T) {
54+ spec := testSpec("blog")
55+ spec.Services[0].Capabilities = []string{"net-admin"}
56+ for name, policy := range map[string]domain.AppCapabilityPolicy{
57+ "app not allowed": capabilityPolicy([]string{"other"}, []string{"web"}),
58+ "service not allowed": capabilityPolicy([]string{"blog"}, []string{"other"}),
59+ } {
60+ t.Run(name, func(t *testing.T) {
61+ store := outmocks.NewMockAppState(t)
62+ svc := apps.NewService(store, zerowrap.Default()).
63+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{"net-admin": policy})
64+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
65+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
66+ assert.NotContains(t, err.Error(), "NET_ADMIN")
67+ })
68+ }
69+}
70+
71+func TestApply_CapabilityPoliciesReplaceOnReload(t *testing.T) {
72+ spec := testSpec("blog")
73+ spec.Services[0].Capabilities = []string{"net-admin"}
74+
75+ store := outmocks.NewMockAppState(t)
76+ applySuccess(store, spec)
77+ svc := apps.NewService(store, zerowrap.Default()).
78+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
79+ "net-admin": capabilityPolicy([]string{"blog"}, []string{"web"}),
80+ })
81+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
82+ require.NoError(t, err)
83+
84+ svc.SetCapabilityPolicies(nil)
85+ _, _, err = svc.Apply(context.Background(), spec, []byte("manifest"), false)
86+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
87+}
88+
89+func TestApply_CapabilityDryRunRefusedWithoutMutation(t *testing.T) {
90+ store := outmocks.NewMockAppState(t)
91+ spec := testSpec("blog")
92+ spec.Services[0].Capabilities = []string{"net-admin"}
93+ svc := apps.NewService(store, zerowrap.Default())
94+
95+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), true)
96+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
97+}
M internal/usecase/apps/service.go +56 -0
@@ -38,6 +38,9 @@ type Service struct {
3838 // atomically; applies read the current map without holding the lock.
3939 deviceMu sync.RWMutex
4040 devicePolicies map[string]domain.AppDevicePolicy
41+ // capabilityMu guards capabilityPolicies, replaced atomically on reload.
42+ capabilityMu sync.RWMutex
43+ capabilityPolicies map[string]domain.AppCapabilityPolicy
4144 }
4245
4346 // NewService creates the apps use case over an AppState store.
@@ -117,6 +120,36 @@ func (s *Service) snapshotDevicePolicies() map[string]domain.AppDevicePolicy {
117120 return s.devicePolicies
118121 }
119122
123+// WithCapabilityPolicies supplies the initial administrative capability policies.
124+func (s *Service) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *Service {
125+ s.SetCapabilityPolicies(policies)
126+ return s
127+}
128+
129+// SetCapabilityPolicies atomically replaces the administrative capability
130+// policies used to authorize manifest capabilities. The map and its slices
131+// are copied so later caller mutation cannot race an in-flight apply.
132+func (s *Service) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
133+ copied := make(map[string]domain.AppCapabilityPolicy, len(policies))
134+ for name, policy := range policies {
135+ policy.Add = append([]string(nil), policy.Add...)
136+ policy.AllowedApps = append([]string(nil), policy.AllowedApps...)
137+ policy.AllowedServices = append([]string(nil), policy.AllowedServices...)
138+ copied[name] = policy
139+ }
140+ s.capabilityMu.Lock()
141+ s.capabilityPolicies = copied
142+ s.capabilityMu.Unlock()
143+}
144+
145+// snapshotCapabilityPolicies returns the current policy map.
146+// SetCapabilityPolicies never mutates a published map.
147+func (s *Service) snapshotCapabilityPolicies() map[string]domain.AppCapabilityPolicy {
148+ s.capabilityMu.RLock()
149+ defer s.capabilityMu.RUnlock()
150+ return s.capabilityPolicies
151+}
152+
120153 // noopLease is a GC lease for an unwired barrier.
121154 type noopLease struct{}
122155
@@ -213,6 +246,9 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte,
213246 if err := s.validateDevicePolicies(spec); err != nil {
214247 return nil, nil, err
215248 }
249+ if err := s.validateCapabilityPolicies(spec); err != nil {
250+ return nil, nil, err
251+ }
216252 if err := s.validateEntrypointCompatibility(spec); err != nil {
217253 return nil, nil, err
218254 }
@@ -295,6 +331,26 @@ func (s *Service) validateDevicePolicies(spec domain.AppSpec) error {
295331 return nil
296332 }
297333
334+// validateCapabilityPolicies resolves every declared capability policy
335+// against the administrative capability policies before any desired state
336+// is persisted. Errors name only the policy, app, and service with an
337+// actionable hint.
338+func (s *Service) validateCapabilityPolicies(spec domain.AppSpec) error {
339+ policies := s.snapshotCapabilityPolicies()
340+ for _, svc := range spec.Services {
341+ for _, name := range svc.Capabilities {
342+ policy, ok := policies[name]
343+ if !ok {
344+ return fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured (declare [app_capabilities.%s] with add/allowed_apps/allowed_services)", domain.ErrCapabilityPolicy, spec.Name, svc.Name, name, name)
345+ }
346+ if _, err := policy.ResolveAppCapabilities(spec.Name, svc.Name); err != nil {
347+ return fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy (check allowed_apps/allowed_services)", domain.ErrCapabilityPolicy, spec.Name, svc.Name, name)
348+ }
349+ }
350+ }
351+ return nil
352+}
353+
298354 // validateEntrypointCompatibility checks every declared TCP/UDP interface
299355 // against the installation entrypoint listeners before any persistence or
300356 // workload effect. The runtime binds the entrypoint address, so a publish
@@ -38,6 +38,9 @@ type Service struct {
38 // atomically; applies read the current map without holding the lock.38 // atomically; applies read the current map without holding the lock.
39 deviceMu sync.RWMutex39 deviceMu sync.RWMutex
40 devicePolicies map[string]domain.AppDevicePolicy40 devicePolicies map[string]domain.AppDevicePolicy
41+ // capabilityMu guards capabilityPolicies, replaced atomically on reload.
42+ capabilityMu sync.RWMutex
43+ capabilityPolicies map[string]domain.AppCapabilityPolicy
41 }44 }
42 45
43 // NewService creates the apps use case over an AppState store.46 // NewService creates the apps use case over an AppState store.
@@ -117,6 +120,36 @@ func (s *Service) snapshotDevicePolicies() map[string]domain.AppDevicePolicy {
117 return s.devicePolicies120 return s.devicePolicies
118 }121 }
119 122
123+// WithCapabilityPolicies supplies the initial administrative capability policies.
124+func (s *Service) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *Service {
125+ s.SetCapabilityPolicies(policies)
126+ return s
127+}
128+
129+// SetCapabilityPolicies atomically replaces the administrative capability
130+// policies used to authorize manifest capabilities. The map and its slices
131+// are copied so later caller mutation cannot race an in-flight apply.
132+func (s *Service) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
133+ copied := make(map[string]domain.AppCapabilityPolicy, len(policies))
134+ for name, policy := range policies {
135+ policy.Add = append([]string(nil), policy.Add...)
136+ policy.AllowedApps = append([]string(nil), policy.AllowedApps...)
137+ policy.AllowedServices = append([]string(nil), policy.AllowedServices...)
138+ copied[name] = policy
139+ }
140+ s.capabilityMu.Lock()
141+ s.capabilityPolicies = copied
142+ s.capabilityMu.Unlock()
143+}
144+
145+// snapshotCapabilityPolicies returns the current policy map.
146+// SetCapabilityPolicies never mutates a published map.
147+func (s *Service) snapshotCapabilityPolicies() map[string]domain.AppCapabilityPolicy {
148+ s.capabilityMu.RLock()
149+ defer s.capabilityMu.RUnlock()
150+ return s.capabilityPolicies
151+}
152+
120 // noopLease is a GC lease for an unwired barrier.153 // noopLease is a GC lease for an unwired barrier.
121 type noopLease struct{}154 type noopLease struct{}
122 155
@@ -213,6 +246,9 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte,
213 if err := s.validateDevicePolicies(spec); err != nil {246 if err := s.validateDevicePolicies(spec); err != nil {
214 return nil, nil, err247 return nil, nil, err
215 }248 }
249+ if err := s.validateCapabilityPolicies(spec); err != nil {
250+ return nil, nil, err
251+ }
216 if err := s.validateEntrypointCompatibility(spec); err != nil {252 if err := s.validateEntrypointCompatibility(spec); err != nil {
217 return nil, nil, err253 return nil, nil, err
218 }254 }
@@ -295,6 +331,26 @@ func (s *Service) validateDevicePolicies(spec domain.AppSpec) error {
295 return nil331 return nil
296 }332 }
297 333
334+// validateCapabilityPolicies resolves every declared capability policy
335+// against the administrative capability policies before any desired state
336+// is persisted. Errors name only the policy, app, and service with an
337+// actionable hint.
338+func (s *Service) validateCapabilityPolicies(spec domain.AppSpec) error {
339+ policies := s.snapshotCapabilityPolicies()
340+ for _, svc := range spec.Services {
341+ for _, name := range svc.Capabilities {
342+ policy, ok := policies[name]
343+ if !ok {
344+ return fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured (declare [app_capabilities.%s] with add/allowed_apps/allowed_services)", domain.ErrCapabilityPolicy, spec.Name, svc.Name, name, name)
345+ }
346+ if _, err := policy.ResolveAppCapabilities(spec.Name, svc.Name); err != nil {
347+ return fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy (check allowed_apps/allowed_services)", domain.ErrCapabilityPolicy, spec.Name, svc.Name, name)
348+ }
349+ }
350+ }
351+ return nil
352+}
353+
298 // validateEntrypointCompatibility checks every declared TCP/UDP interface354 // validateEntrypointCompatibility checks every declared TCP/UDP interface
299 // against the installation entrypoint listeners before any persistence or355 // against the installation entrypoint listeners before any persistence or
300 // workload effect. The runtime binds the entrypoint address, so a publish356 // workload effect. The runtime binds the entrypoint address, so a publish
M internal/usecase/deployment/candidate.go +22 -21
@@ -42,9 +42,9 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
4242 if err != nil {
4343 return startedCandidate{}, err
4444 }
45- // Re-resolve devices from the current policy under the same
46- // fail-closed rule: revoked grants never reach the runtime.
47- resolvedDevices, err := s.resolveServiceDevices(app, p.spec)
45+ // Re-resolve devices and capabilities from the current policy under
46+ // the same fail-closed rule: revoked grants never reach the runtime.
47+ resolvedDevices, resolvedCaps, err := s.resolveServiceGrants(app, p.spec)
4848 if err != nil {
4949 return startedCandidate{}, err
5050 }
@@ -90,24 +90,25 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
9090 volumes[vol.Path] = runtimeName
9191 }
9292 config := &domain.ContainerConfig{
93- Image: image,
94- Name: domain.LogicalServiceIdentity(app, p.spec.Name) + "--" + shortOp(opID),
95- Env: env,
96- Entrypoint: append([]string(nil), p.spec.Command...),
97- Volumes: volumes,
98- ReadOnlyVolumes: readOnlyVolumes,
99- Binds: resolvedBinds,
100- CDIDevices: resolvedDevices,
101- Labels: appLabels(app, p.spec.Name, revision),
102- AutoRemove: false,
103- RestartPolicy: domain.RestartPolicyAlways,
104- PortPublishes: backendPublishes(p.spec),
105- NetworkMode: nets.private,
106- Hostname: p.spec.Name,
107- Aliases: []string{p.spec.Name},
108- MemoryLimit: s.deps.Limits.MemoryBytes,
109- NanoCPUs: s.deps.Limits.NanoCPUs,
110- PidsLimit: s.deps.Limits.PidsLimit,
93+ Image: image,
94+ Name: domain.LogicalServiceIdentity(app, p.spec.Name) + "--" + shortOp(opID),
95+ Env: env,
96+ Entrypoint: append([]string(nil), p.spec.Command...),
97+ Volumes: volumes,
98+ ReadOnlyVolumes: readOnlyVolumes,
99+ Binds: resolvedBinds,
100+ CDIDevices: resolvedDevices,
101+ AddedCapabilities: resolvedCaps,
102+ Labels: appLabels(app, p.spec.Name, revision),
103+ AutoRemove: false,
104+ RestartPolicy: domain.RestartPolicyAlways,
105+ PortPublishes: backendPublishes(p.spec),
106+ NetworkMode: nets.private,
107+ Hostname: p.spec.Name,
108+ Aliases: []string{p.spec.Name},
109+ MemoryLimit: s.deps.Limits.MemoryBytes,
110+ NanoCPUs: s.deps.Limits.NanoCPUs,
111+ PidsLimit: s.deps.Limits.PidsLimit,
111112 }
112113 created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)
113114 if err != nil {
@@ -42,9 +42,9 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
42 if err != nil {42 if err != nil {
43 return startedCandidate{}, err43 return startedCandidate{}, err
44 }44 }
45- // Re-resolve devices from the current policy under the same45+ // Re-resolve devices and capabilities from the current policy under
46- // fail-closed rule: revoked grants never reach the runtime.46+ // the same fail-closed rule: revoked grants never reach the runtime.
47- resolvedDevices, err := s.resolveServiceDevices(app, p.spec)47+ resolvedDevices, resolvedCaps, err := s.resolveServiceGrants(app, p.spec)
48 if err != nil {48 if err != nil {
49 return startedCandidate{}, err49 return startedCandidate{}, err
50 }50 }
@@ -90,24 +90,25 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
90 volumes[vol.Path] = runtimeName90 volumes[vol.Path] = runtimeName
91 }91 }
92 config := &domain.ContainerConfig{92 config := &domain.ContainerConfig{
93- Image: image,93+ Image: image,
94- Name: domain.LogicalServiceIdentity(app, p.spec.Name) + "--" + shortOp(opID),94+ Name: domain.LogicalServiceIdentity(app, p.spec.Name) + "--" + shortOp(opID),
95- Env: env,95+ Env: env,
96- Entrypoint: append([]string(nil), p.spec.Command...),96+ Entrypoint: append([]string(nil), p.spec.Command...),
97- Volumes: volumes,97+ Volumes: volumes,
98- ReadOnlyVolumes: readOnlyVolumes,98+ ReadOnlyVolumes: readOnlyVolumes,
99- Binds: resolvedBinds,99+ Binds: resolvedBinds,
100- CDIDevices: resolvedDevices,100+ CDIDevices: resolvedDevices,
101- Labels: appLabels(app, p.spec.Name, revision),101+ AddedCapabilities: resolvedCaps,
102- AutoRemove: false,102+ Labels: appLabels(app, p.spec.Name, revision),
103- RestartPolicy: domain.RestartPolicyAlways,103+ AutoRemove: false,
104- PortPublishes: backendPublishes(p.spec),104+ RestartPolicy: domain.RestartPolicyAlways,
105- NetworkMode: nets.private,105+ PortPublishes: backendPublishes(p.spec),
106- Hostname: p.spec.Name,106+ NetworkMode: nets.private,
107- Aliases: []string{p.spec.Name},107+ Hostname: p.spec.Name,
108- MemoryLimit: s.deps.Limits.MemoryBytes,108+ Aliases: []string{p.spec.Name},
109- NanoCPUs: s.deps.Limits.NanoCPUs,109+ MemoryLimit: s.deps.Limits.MemoryBytes,
110- PidsLimit: s.deps.Limits.PidsLimit,110+ NanoCPUs: s.deps.Limits.NanoCPUs,
111+ PidsLimit: s.deps.Limits.PidsLimit,
111 }112 }
112 created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)113 created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)
113 if err != nil {114 if err != nil {
A internal/usecase/deployment/capability_policy_internal_test.go +304 -0
@@ -0,0 +1,304 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func capabilityPolicyFor(apps, services []string) domain.AppCapabilityPolicy {
18+ return domain.AppCapabilityPolicy{
19+ Name: "net-admin",
20+ Add: []string{"NET_ADMIN"},
21+ AllowedApps: apps,
22+ AllowedServices: services,
23+ }
24+}
25+
26+func TestResolveServiceCapabilities(t *testing.T) {
27+ spec := domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}
28+
29+ t.Run("exact resolution", func(t *testing.T) {
30+ svc := NewService(Deps{}, zerowrap.Default()).
31+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
32+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
33+ })
34+ got, err := svc.resolveServiceCapabilities("blog", spec)
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{"NET_ADMIN"}, got)
37+ })
38+
39+ t.Run("empty is nil", func(t *testing.T) {
40+ svc := NewService(Deps{}, zerowrap.Default())
41+ got, err := svc.resolveServiceCapabilities("blog", domain.AppService{Name: "web"})
42+ require.NoError(t, err)
43+ assert.Nil(t, got)
44+ })
45+
46+ t.Run("unknown policy", func(t *testing.T) {
47+ svc := NewService(Deps{}, zerowrap.Default())
48+ _, err := svc.resolveServiceCapabilities("blog", spec)
49+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
50+ assert.Contains(t, err.Error(), "net-admin")
51+ })
52+
53+ t.Run("revoked policy", func(t *testing.T) {
54+ svc := NewService(Deps{}, zerowrap.Default()).
55+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
56+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
57+ })
58+ _, err := svc.resolveServiceCapabilities("blog", spec)
59+ require.NoError(t, err)
60+ svc.SetCapabilityPolicies(nil)
61+ _, err = svc.resolveServiceCapabilities("blog", spec)
62+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
63+ })
64+
65+ t.Run("refused omits capability names", func(t *testing.T) {
66+ svc := NewService(Deps{}, zerowrap.Default()).
67+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
68+ "net-admin": capabilityPolicyFor([]string{"other"}, []string{"web"}),
69+ })
70+ _, err := svc.resolveServiceCapabilities("blog", spec)
71+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
72+ assert.NotContains(t, err.Error(), "NET_ADMIN")
73+ })
74+}
75+
76+// TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation proves
77+// the pre-create re-resolution fails closed before any runtime call.
78+func TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation(t *testing.T) {
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
81+ p := pinnedService{
82+ name: "web",
83+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
84+ }
85+
86+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
87+
88+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
89+ runtime.AssertNotCalled(t, "CreateContainer")
90+ runtime.AssertNotCalled(t, "CreateVolume")
91+ runtime.AssertNotCalled(t, "StartContainer")
92+ runtime.AssertExpectations(t)
93+}
94+
95+// TestCreateAndStart_PassesResolvedCapabilitiesToRuntime proves resolved
96+// capabilities reach ContainerConfig.AddedCapabilities and never CapAdd /
97+// CapDrop, which stay with the runtime defaults.
98+func TestCreateAndStart_PassesResolvedCapabilitiesToRuntime(t *testing.T) {
99+ ctx := context.Background()
100+ state := outmocks.NewMockAppState(t)
101+ runtime := outmocks.NewMockContainerRuntime(t)
102+
103+ private := domain.AppPrivateNetworkName("gordon", "app-1")
104+ state.EXPECT().LoadOwnership(mock.Anything, "blog").
105+ Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
106+ runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
107+ runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
108+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
109+ return assert.Equal(t, []string{"NET_ADMIN"}, cfg.AddedCapabilities) &&
110+ assert.Nil(t, cfg.CapAdd, "base capability set stays with the runtime defaults") &&
111+ assert.Nil(t, cfg.CapDrop, "the default drop is never overridden")
112+ })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
113+ runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
114+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
115+
116+ svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
117+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
118+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
119+ })
120+ candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
121+ name: "web",
122+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
123+ }, "op-1", nil)
124+ require.NoError(t, err)
125+ require.NotNil(t, candidate.Container)
126+ runtime.AssertExpectations(t)
127+}
128+
129+func TestRestartOneService_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
130+ runtime := outmocks.NewMockContainerRuntime(t)
131+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
132+ eff := domain.AppEffectiveService{
133+ Container: "c1",
134+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
135+ }
136+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
137+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
138+ }}}
139+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
140+
141+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
142+ assert.Contains(t, op.Steps[0].Error, "net-admin")
143+ assert.Equal(t, "failed", result.Result)
144+ runtime.AssertNotCalled(t, "RestartContainer")
145+ runtime.AssertExpectations(t)
146+}
147+
148+func TestEnsureServiceRunning_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
149+ runtime := outmocks.NewMockContainerRuntime(t)
150+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
151+ eff := domain.AppEffectiveService{
152+ Container: "c1",
153+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
154+ }
155+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
156+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
157+ }}}
158+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
159+
160+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
161+
162+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
163+ assert.Contains(t, op.Steps[0].Error, "net-admin")
164+ runtime.AssertNotCalled(t, "StartContainer")
165+ runtime.AssertExpectations(t)
166+}
167+
168+// TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer proves a
169+// service with capability grants is always replaced on deploy: the resolved
170+// capabilities depend on host policy that ACTIVE does not record, so a
171+// changed policy must reach the container on the next deploy.
172+func TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer(t *testing.T) {
173+ svc := NewService(Deps{}, zerowrap.Default())
174+ p := pinnedService{name: "web", spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
175+ reason := svc.unchangedRefusal(context.Background(), "blog", p, domain.AppEffectiveService{Spec: p.spec})
176+ assert.Equal(t, "administrative capabilities", reason)
177+}
178+
179+// narrowedFixture models a policy narrowed from {NET_ADMIN, IPC_LOCK} to
180+// {IPC_LOCK} after a container was created with both: policy resolution
181+// still succeeds, but the existing container holds the revoked NET_ADMIN.
182+func narrowedFixture(t *testing.T, containerCaps []string) (*Service, *outmocks.MockContainerRuntime, domain.AppEffectiveService) {
183+ t.Helper()
184+ runtime := outmocks.NewMockContainerRuntime(t)
185+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
186+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
187+ "net-admin": {Name: "net-admin", Add: []string{"IPC_LOCK"}, AllowedApps: []string{"blog"}, AllowedServices: []string{"web"}},
188+ })
189+ eff := domain.AppEffectiveService{
190+ Container: "c1",
191+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
192+ }
193+ runtime.EXPECT().InspectContainer(mock.Anything, "c1").
194+ Return(&domain.Container{ID: "c1", CapAdd: containerCaps}, nil).Maybe()
195+ return svc, runtime, eff
196+}
197+
198+var staleCaps = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE", "NET_ADMIN", "IPC_LOCK"}
199+
200+func TestVerifyReusableCapabilities(t *testing.T) {
201+ t.Run("narrowed policy refuses a container holding a revoked capability", func(t *testing.T) {
202+ svc, _, eff := narrowedFixture(t, staleCaps)
203+ err := svc.verifyReusableCapabilities(context.Background(), "blog", eff)
204+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
205+ assert.Contains(t, err.Error(), "NET_ADMIN")
206+ assert.NotContains(t, err.Error(), "IPC_LOCK")
207+ })
208+ t.Run("CAP_ prefixed engine names compare equal", func(t *testing.T) {
209+ svc, _, eff := narrowedFixture(t, []string{"CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_IPC_LOCK"})
210+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
211+ })
212+ t.Run("fewer capabilities than authorized is allowed", func(t *testing.T) {
213+ svc, _, eff := narrowedFixture(t, []string{"CHOWN"})
214+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
215+ })
216+ t.Run("prefixed revoked capability is still caught", func(t *testing.T) {
217+ svc, _, eff := narrowedFixture(t, []string{"CAP_NET_ADMIN"})
218+ require.ErrorIs(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff), domain.ErrCapabilityPolicy)
219+ })
220+ t.Run("uninspectable container fails closed", func(t *testing.T) {
221+ runtime := outmocks.NewMockContainerRuntime(t)
222+ runtime.EXPECT().InspectContainer(mock.Anything, "c1").Return(nil, fmt.Errorf("daemon down at /run/podman.sock")).Once()
223+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
224+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
225+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
226+ })
227+ eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
228+ err := svc.verifyReusableCapabilities(context.Background(), "blog", eff)
229+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
230+ assert.NotContains(t, err.Error(), "podman.sock")
231+ })
232+ t.Run("missing container is left to the rebuild path", func(t *testing.T) {
233+ runtime := outmocks.NewMockContainerRuntime(t)
234+ runtime.EXPECT().InspectContainer(mock.Anything, "c1").Return(nil, domain.ErrContainerNotFound).Once()
235+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
236+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
237+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
238+ })
239+ eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
240+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
241+ })
242+ t.Run("service without capabilities never inspects", func(t *testing.T) {
243+ runtime := outmocks.NewMockContainerRuntime(t)
244+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
245+ eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web"}}
246+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
247+ })
248+}
249+
250+func TestRestartOneService_NarrowedCapabilityPolicyRefusesInPlaceRestart(t *testing.T) {
251+ svc, runtime, eff := narrowedFixture(t, staleCaps)
252+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
253+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
254+ }}}
255+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
256+
257+ assert.Equal(t, "failed", result.Result)
258+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
259+ assert.Contains(t, op.Steps[0].Error, "NET_ADMIN")
260+ runtime.AssertNotCalled(t, "RestartContainer")
261+ runtime.AssertNotCalled(t, "StartContainer")
262+}
263+
264+func TestEnsureServiceRunning_NarrowedCapabilityPolicyRefusesStart(t *testing.T) {
265+ svc, runtime, eff := narrowedFixture(t, staleCaps)
266+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
267+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
268+ }}}
269+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
270+
271+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
272+
273+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
274+ assert.Contains(t, op.Steps[0].Error, "NET_ADMIN")
275+ runtime.AssertNotCalled(t, "StartContainer")
276+ runtime.AssertNotCalled(t, "IsContainerRunning")
277+}
278+
279+func TestStartRecovered_NarrowedCapabilityPolicyRefusesStart(t *testing.T) {
280+ svc, runtime, eff := narrowedFixture(t, staleCaps)
281+ started, err := svc.startRecovered(context.Background(), "blog", "web", eff, recoveryKey{app: "blog", service: "web", container: "c1"})
282+
283+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
284+ assert.False(t, started)
285+ runtime.AssertNotCalled(t, "StartContainer")
286+}
287+
288+func TestRecoverHealth_NarrowedCapabilityPolicyRefusesRestart(t *testing.T) {
289+ svc, runtime, eff := narrowedFixture(t, staleCaps)
290+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c1").Return("unhealthy", true, nil).Once()
291+ key := recoveryKey{app: "blog", service: "web", container: "c1"}
292+ for i := 0; i < unhealthyThreshold; i++ {
293+ _, _, err := svc.recoverHealth(context.Background(), "blog", "web", eff, key)
294+ if err != nil {
295+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
296+ runtime.AssertNotCalled(t, "RestartContainer")
297+ return
298+ }
299+ if i < unhealthyThreshold-1 {
300+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c1").Return("unhealthy", true, nil).Once()
301+ }
302+ }
303+ t.Fatal("expected recoverHealth to refuse the restart")
304+}
@@ -0,0 +1,304 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func capabilityPolicyFor(apps, services []string) domain.AppCapabilityPolicy {
18+ return domain.AppCapabilityPolicy{
19+ Name: "net-admin",
20+ Add: []string{"NET_ADMIN"},
21+ AllowedApps: apps,
22+ AllowedServices: services,
23+ }
24+}
25+
26+func TestResolveServiceCapabilities(t *testing.T) {
27+ spec := domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}
28+
29+ t.Run("exact resolution", func(t *testing.T) {
30+ svc := NewService(Deps{}, zerowrap.Default()).
31+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
32+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
33+ })
34+ got, err := svc.resolveServiceCapabilities("blog", spec)
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{"NET_ADMIN"}, got)
37+ })
38+
39+ t.Run("empty is nil", func(t *testing.T) {
40+ svc := NewService(Deps{}, zerowrap.Default())
41+ got, err := svc.resolveServiceCapabilities("blog", domain.AppService{Name: "web"})
42+ require.NoError(t, err)
43+ assert.Nil(t, got)
44+ })
45+
46+ t.Run("unknown policy", func(t *testing.T) {
47+ svc := NewService(Deps{}, zerowrap.Default())
48+ _, err := svc.resolveServiceCapabilities("blog", spec)
49+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
50+ assert.Contains(t, err.Error(), "net-admin")
51+ })
52+
53+ t.Run("revoked policy", func(t *testing.T) {
54+ svc := NewService(Deps{}, zerowrap.Default()).
55+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
56+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
57+ })
58+ _, err := svc.resolveServiceCapabilities("blog", spec)
59+ require.NoError(t, err)
60+ svc.SetCapabilityPolicies(nil)
61+ _, err = svc.resolveServiceCapabilities("blog", spec)
62+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
63+ })
64+
65+ t.Run("refused omits capability names", func(t *testing.T) {
66+ svc := NewService(Deps{}, zerowrap.Default()).
67+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
68+ "net-admin": capabilityPolicyFor([]string{"other"}, []string{"web"}),
69+ })
70+ _, err := svc.resolveServiceCapabilities("blog", spec)
71+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
72+ assert.NotContains(t, err.Error(), "NET_ADMIN")
73+ })
74+}
75+
76+// TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation proves
77+// the pre-create re-resolution fails closed before any runtime call.
78+func TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation(t *testing.T) {
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
81+ p := pinnedService{
82+ name: "web",
83+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
84+ }
85+
86+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
87+
88+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
89+ runtime.AssertNotCalled(t, "CreateContainer")
90+ runtime.AssertNotCalled(t, "CreateVolume")
91+ runtime.AssertNotCalled(t, "StartContainer")
92+ runtime.AssertExpectations(t)
93+}
94+
95+// TestCreateAndStart_PassesResolvedCapabilitiesToRuntime proves resolved
96+// capabilities reach ContainerConfig.AddedCapabilities and never CapAdd /
97+// CapDrop, which stay with the runtime defaults.
98+func TestCreateAndStart_PassesResolvedCapabilitiesToRuntime(t *testing.T) {
99+ ctx := context.Background()
100+ state := outmocks.NewMockAppState(t)
101+ runtime := outmocks.NewMockContainerRuntime(t)
102+
103+ private := domain.AppPrivateNetworkName("gordon", "app-1")
104+ state.EXPECT().LoadOwnership(mock.Anything, "blog").
105+ Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
106+ runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
107+ runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
108+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
109+ return assert.Equal(t, []string{"NET_ADMIN"}, cfg.AddedCapabilities) &&
110+ assert.Nil(t, cfg.CapAdd, "base capability set stays with the runtime defaults") &&
111+ assert.Nil(t, cfg.CapDrop, "the default drop is never overridden")
112+ })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
113+ runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
114+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
115+
116+ svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
117+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
118+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
119+ })
120+ candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
121+ name: "web",
122+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
123+ }, "op-1", nil)
124+ require.NoError(t, err)
125+ require.NotNil(t, candidate.Container)
126+ runtime.AssertExpectations(t)
127+}
128+
129+func TestRestartOneService_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
130+ runtime := outmocks.NewMockContainerRuntime(t)
131+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
132+ eff := domain.AppEffectiveService{
133+ Container: "c1",
134+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
135+ }
136+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
137+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
138+ }}}
139+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
140+
141+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
142+ assert.Contains(t, op.Steps[0].Error, "net-admin")
143+ assert.Equal(t, "failed", result.Result)
144+ runtime.AssertNotCalled(t, "RestartContainer")
145+ runtime.AssertExpectations(t)
146+}
147+
148+func TestEnsureServiceRunning_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
149+ runtime := outmocks.NewMockContainerRuntime(t)
150+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
151+ eff := domain.AppEffectiveService{
152+ Container: "c1",
153+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
154+ }
155+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
156+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
157+ }}}
158+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
159+
160+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
161+
162+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
163+ assert.Contains(t, op.Steps[0].Error, "net-admin")
164+ runtime.AssertNotCalled(t, "StartContainer")
165+ runtime.AssertExpectations(t)
166+}
167+
168+// TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer proves a
169+// service with capability grants is always replaced on deploy: the resolved
170+// capabilities depend on host policy that ACTIVE does not record, so a
171+// changed policy must reach the container on the next deploy.
172+func TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer(t *testing.T) {
173+ svc := NewService(Deps{}, zerowrap.Default())
174+ p := pinnedService{name: "web", spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
175+ reason := svc.unchangedRefusal(context.Background(), "blog", p, domain.AppEffectiveService{Spec: p.spec})
176+ assert.Equal(t, "administrative capabilities", reason)
177+}
178+
179+// narrowedFixture models a policy narrowed from {NET_ADMIN, IPC_LOCK} to
180+// {IPC_LOCK} after a container was created with both: policy resolution
181+// still succeeds, but the existing container holds the revoked NET_ADMIN.
182+func narrowedFixture(t *testing.T, containerCaps []string) (*Service, *outmocks.MockContainerRuntime, domain.AppEffectiveService) {
183+ t.Helper()
184+ runtime := outmocks.NewMockContainerRuntime(t)
185+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
186+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
187+ "net-admin": {Name: "net-admin", Add: []string{"IPC_LOCK"}, AllowedApps: []string{"blog"}, AllowedServices: []string{"web"}},
188+ })
189+ eff := domain.AppEffectiveService{
190+ Container: "c1",
191+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
192+ }
193+ runtime.EXPECT().InspectContainer(mock.Anything, "c1").
194+ Return(&domain.Container{ID: "c1", CapAdd: containerCaps}, nil).Maybe()
195+ return svc, runtime, eff
196+}
197+
198+var staleCaps = []string{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE", "NET_ADMIN", "IPC_LOCK"}
199+
200+func TestVerifyReusableCapabilities(t *testing.T) {
201+ t.Run("narrowed policy refuses a container holding a revoked capability", func(t *testing.T) {
202+ svc, _, eff := narrowedFixture(t, staleCaps)
203+ err := svc.verifyReusableCapabilities(context.Background(), "blog", eff)
204+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
205+ assert.Contains(t, err.Error(), "NET_ADMIN")
206+ assert.NotContains(t, err.Error(), "IPC_LOCK")
207+ })
208+ t.Run("CAP_ prefixed engine names compare equal", func(t *testing.T) {
209+ svc, _, eff := narrowedFixture(t, []string{"CAP_CHOWN", "CAP_NET_BIND_SERVICE", "CAP_IPC_LOCK"})
210+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
211+ })
212+ t.Run("fewer capabilities than authorized is allowed", func(t *testing.T) {
213+ svc, _, eff := narrowedFixture(t, []string{"CHOWN"})
214+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
215+ })
216+ t.Run("prefixed revoked capability is still caught", func(t *testing.T) {
217+ svc, _, eff := narrowedFixture(t, []string{"CAP_NET_ADMIN"})
218+ require.ErrorIs(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff), domain.ErrCapabilityPolicy)
219+ })
220+ t.Run("uninspectable container fails closed", func(t *testing.T) {
221+ runtime := outmocks.NewMockContainerRuntime(t)
222+ runtime.EXPECT().InspectContainer(mock.Anything, "c1").Return(nil, fmt.Errorf("daemon down at /run/podman.sock")).Once()
223+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
224+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
225+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
226+ })
227+ eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
228+ err := svc.verifyReusableCapabilities(context.Background(), "blog", eff)
229+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
230+ assert.NotContains(t, err.Error(), "podman.sock")
231+ })
232+ t.Run("missing container is left to the rebuild path", func(t *testing.T) {
233+ runtime := outmocks.NewMockContainerRuntime(t)
234+ runtime.EXPECT().InspectContainer(mock.Anything, "c1").Return(nil, domain.ErrContainerNotFound).Once()
235+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()).
236+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
237+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
238+ })
239+ eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
240+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
241+ })
242+ t.Run("service without capabilities never inspects", func(t *testing.T) {
243+ runtime := outmocks.NewMockContainerRuntime(t)
244+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
245+ eff := domain.AppEffectiveService{Container: "c1", Spec: domain.AppService{Name: "web"}}
246+ require.NoError(t, svc.verifyReusableCapabilities(context.Background(), "blog", eff))
247+ })
248+}
249+
250+func TestRestartOneService_NarrowedCapabilityPolicyRefusesInPlaceRestart(t *testing.T) {
251+ svc, runtime, eff := narrowedFixture(t, staleCaps)
252+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
253+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
254+ }}}
255+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
256+
257+ assert.Equal(t, "failed", result.Result)
258+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
259+ assert.Contains(t, op.Steps[0].Error, "NET_ADMIN")
260+ runtime.AssertNotCalled(t, "RestartContainer")
261+ runtime.AssertNotCalled(t, "StartContainer")
262+}
263+
264+func TestEnsureServiceRunning_NarrowedCapabilityPolicyRefusesStart(t *testing.T) {
265+ svc, runtime, eff := narrowedFixture(t, staleCaps)
266+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
267+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
268+ }}}
269+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
270+
271+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
272+
273+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
274+ assert.Contains(t, op.Steps[0].Error, "NET_ADMIN")
275+ runtime.AssertNotCalled(t, "StartContainer")
276+ runtime.AssertNotCalled(t, "IsContainerRunning")
277+}
278+
279+func TestStartRecovered_NarrowedCapabilityPolicyRefusesStart(t *testing.T) {
280+ svc, runtime, eff := narrowedFixture(t, staleCaps)
281+ started, err := svc.startRecovered(context.Background(), "blog", "web", eff, recoveryKey{app: "blog", service: "web", container: "c1"})
282+
283+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
284+ assert.False(t, started)
285+ runtime.AssertNotCalled(t, "StartContainer")
286+}
287+
288+func TestRecoverHealth_NarrowedCapabilityPolicyRefusesRestart(t *testing.T) {
289+ svc, runtime, eff := narrowedFixture(t, staleCaps)
290+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c1").Return("unhealthy", true, nil).Once()
291+ key := recoveryKey{app: "blog", service: "web", container: "c1"}
292+ for i := 0; i < unhealthyThreshold; i++ {
293+ _, _, err := svc.recoverHealth(context.Background(), "blog", "web", eff, key)
294+ if err != nil {
295+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
296+ runtime.AssertNotCalled(t, "RestartContainer")
297+ return
298+ }
299+ if i < unhealthyThreshold-1 {
300+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c1").Return("unhealthy", true, nil).Once()
301+ }
302+ }
303+ t.Fatal("expected recoverHealth to refuse the restart")
304+}
M internal/usecase/deployment/execute.go +6 -1
@@ -489,6 +489,8 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
489489 switch {
490490 case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0:
491491 return "host binds or devices"
492+ case len(p.spec.Capabilities) > 0:
493+ return "administrative capabilities"
492494 case !domain.SameAppServiceRuntime(p.spec, eff.Spec):
493495 // Backup declarations never reach the container, so a backup-only
494496 // change keeps it; publishService still records the new spec in ACTIVE.
@@ -745,7 +747,7 @@ func (s *Service) deployOverlap(ctx context.Context, app, revision string, p pin
745747 return result
746748 }
747749
748-// revalidateService re-resolves the binds and devices of a service against
750+// revalidateService re-resolves the binds, devices, and capabilities of a service against
749751 // the current policy before any workload mutation. It returns the failed
750752 // result and false when either was revoked since preflight.
751753 func (s *Service) revalidateService(app, revision string, p pinnedService, before string) (ServiceResult, bool) {
@@ -755,6 +757,9 @@ func (s *Service) revalidateService(app, revision string, p pinnedService, befor
755757 if _, err := s.resolveServiceDevices(app, p.spec); err != nil {
756758 return s.failResult(revision, before, "", err), false
757759 }
760+ if _, err := s.resolveServiceCapabilities(app, p.spec); err != nil {
761+ return s.failResult(revision, before, "", err), false
762+ }
758763 return ServiceResult{}, true
759764 }
760765
@@ -489,6 +489,8 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
489 switch {489 switch {
490 case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0:490 case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0:
491 return "host binds or devices"491 return "host binds or devices"
492+ case len(p.spec.Capabilities) > 0:
493+ return "administrative capabilities"
492 case !domain.SameAppServiceRuntime(p.spec, eff.Spec):494 case !domain.SameAppServiceRuntime(p.spec, eff.Spec):
493 // Backup declarations never reach the container, so a backup-only495 // Backup declarations never reach the container, so a backup-only
494 // change keeps it; publishService still records the new spec in ACTIVE.496 // change keeps it; publishService still records the new spec in ACTIVE.
@@ -745,7 +747,7 @@ func (s *Service) deployOverlap(ctx context.Context, app, revision string, p pin
745 return result747 return result
746 }748 }
747 749
748-// revalidateService re-resolves the binds and devices of a service against750+// revalidateService re-resolves the binds, devices, and capabilities of a service against
749 // the current policy before any workload mutation. It returns the failed751 // the current policy before any workload mutation. It returns the failed
750 // result and false when either was revoked since preflight.752 // result and false when either was revoked since preflight.
751 func (s *Service) revalidateService(app, revision string, p pinnedService, before string) (ServiceResult, bool) {753 func (s *Service) revalidateService(app, revision string, p pinnedService, before string) (ServiceResult, bool) {
@@ -755,6 +757,9 @@ func (s *Service) revalidateService(app, revision string, p pinnedService, befor
755 if _, err := s.resolveServiceDevices(app, p.spec); err != nil {757 if _, err := s.resolveServiceDevices(app, p.spec); err != nil {
756 return s.failResult(revision, before, "", err), false758 return s.failResult(revision, before, "", err), false
757 }759 }
760+ if _, err := s.resolveServiceCapabilities(app, p.spec); err != nil {
761+ return s.failResult(revision, before, "", err), false
762+ }
758 return ServiceResult{}, true763 return ServiceResult{}, true
759 }764 }
760 765
M internal/usecase/deployment/lifecycle.go +10 -0
@@ -333,6 +333,9 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string,
333333 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
334334 return fail(err.Error())
335335 }
336+ if err := s.verifyReusableCapabilities(ctx, app, eff); err != nil {
337+ return fail(err.Error())
338+ }
336339 if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {
337340 _ = s.withdrawForRecovery(ctx, app, name)
338341 return fail(err.Error())
@@ -708,6 +711,10 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri
708711 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
709712 return
710713 }
714+ if err := s.verifyReusableCapabilities(ctx, app, eff); err != nil {
715+ s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
716+ return
717+ }
711718 // Boot recovery refuses a generation whose recovery is durably
712719 // inhibited: a replacement may already have written to its
713720 // volume, so reviving this ID could corrupt the newer data.
@@ -766,6 +773,9 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef
766773 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
767774 return failedServiceResult(eff.Container, err), err
768775 }
776+ if _, err := s.resolveServiceCapabilities(app, eff.Spec); err != nil {
777+ return failedServiceResult(eff.Container, err), err
778+ }
769779 pinned := pinnedService{
770780 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),
771781 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
@@ -333,6 +333,9 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string,
333 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {333 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
334 return fail(err.Error())334 return fail(err.Error())
335 }335 }
336+ if err := s.verifyReusableCapabilities(ctx, app, eff); err != nil {
337+ return fail(err.Error())
338+ }
336 if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {339 if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {
337 _ = s.withdrawForRecovery(ctx, app, name)340 _ = s.withdrawForRecovery(ctx, app, name)
338 return fail(err.Error())341 return fail(err.Error())
@@ -708,6 +711,10 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri
708 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())711 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
709 return712 return
710 }713 }
714+ if err := s.verifyReusableCapabilities(ctx, app, eff); err != nil {
715+ s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
716+ return
717+ }
711 // Boot recovery refuses a generation whose recovery is durably718 // Boot recovery refuses a generation whose recovery is durably
712 // inhibited: a replacement may already have written to its719 // inhibited: a replacement may already have written to its
713 // volume, so reviving this ID could corrupt the newer data.720 // volume, so reviving this ID could corrupt the newer data.
@@ -766,6 +773,9 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef
766 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {773 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
767 return failedServiceResult(eff.Container, err), err774 return failedServiceResult(eff.Container, err), err
768 }775 }
776+ if _, err := s.resolveServiceCapabilities(app, eff.Spec); err != nil {
777+ return failedServiceResult(eff.Container, err), err
778+ }
769 pinned := pinnedService{779 pinned := pinnedService{
770 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),780 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),
771 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),781 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
M internal/usecase/deployment/monitor.go +6 -0
@@ -739,6 +739,9 @@ func (s *Service) startRecovered(serviceCtx context.Context, app, name string, e
739739 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
740740 return false, err
741741 }
742+ if err := s.verifyReusableCapabilities(serviceCtx, app, eff); err != nil {
743+ return false, err
744+ }
742745 if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil {
743746 if errors.Is(err, domain.ErrContainerNotFound) {
744747 s.backoff.forget(key)
@@ -795,6 +798,9 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef
795798 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
796799 return false, false, err
797800 }
801+ if err := s.verifyReusableCapabilities(serviceCtx, app, eff); err != nil {
802+ return false, false, err
803+ }
798804 if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil {
799805 s.backoff.recordFailure(key)
800806 return false, false, fmt.Errorf("deployment: app %q service %q restart unhealthy %s: %w", app, name, eff.Container, err)
@@ -739,6 +739,9 @@ func (s *Service) startRecovered(serviceCtx context.Context, app, name string, e
739 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {739 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
740 return false, err740 return false, err
741 }741 }
742+ if err := s.verifyReusableCapabilities(serviceCtx, app, eff); err != nil {
743+ return false, err
744+ }
742 if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil {745 if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil {
743 if errors.Is(err, domain.ErrContainerNotFound) {746 if errors.Is(err, domain.ErrContainerNotFound) {
744 s.backoff.forget(key)747 s.backoff.forget(key)
@@ -795,6 +798,9 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef
795 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {798 if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil {
796 return false, false, err799 return false, false, err
797 }800 }
801+ if err := s.verifyReusableCapabilities(serviceCtx, app, eff); err != nil {
802+ return false, false, err
803+ }
798 if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil {804 if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil {
799 s.backoff.recordFailure(key)805 s.backoff.recordFailure(key)
800 return false, false, fmt.Errorf("deployment: app %q service %q restart unhealthy %s: %w", app, name, eff.Container, err)806 return false, false, fmt.Errorf("deployment: app %q service %q restart unhealthy %s: %w", app, name, eff.Container, err)
M internal/usecase/deployment/service.go +95 -0
@@ -116,6 +116,9 @@ type Service struct {
116116 // the lock.
117117 deviceMu sync.RWMutex
118118 devicePolicies map[string]domain.AppDevicePolicy
119+ // capabilityMu guards capabilityPolicies, replaced atomically on reload.
120+ capabilityMu sync.RWMutex
121+ capabilityPolicies map[string]domain.AppCapabilityPolicy
119122 // liveMu guards liveOps. liveOps holds the operations this process
120123 // owns between the StartDeploy claim and the end of ExecuteDeploy. It
121124 // is in-memory only: a fresh Service (boot) starts empty and may
@@ -245,6 +248,95 @@ func (s *Service) snapshotDevicePolicies() map[string]domain.AppDevicePolicy {
245248 return s.devicePolicies
246249 }
247250
251+// WithCapabilityPolicies supplies the initial administrative capability policies.
252+func (s *Service) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *Service {
253+ s.SetCapabilityPolicies(policies)
254+ return s
255+}
256+
257+// SetCapabilityPolicies atomically replaces the administrative capability
258+// policies used to authorize service capabilities. The map and its slices
259+// are copied so later caller mutation cannot race an in-flight deploy.
260+func (s *Service) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
261+ copied := make(map[string]domain.AppCapabilityPolicy, len(policies))
262+ for name, policy := range policies {
263+ policy.Add = append([]string(nil), policy.Add...)
264+ policy.AllowedApps = append([]string(nil), policy.AllowedApps...)
265+ policy.AllowedServices = append([]string(nil), policy.AllowedServices...)
266+ copied[name] = policy
267+ }
268+ s.capabilityMu.Lock()
269+ s.capabilityPolicies = copied
270+ s.capabilityMu.Unlock()
271+}
272+
273+// snapshotCapabilityPolicies returns the current policy map.
274+// SetCapabilityPolicies never mutates a published map.
275+func (s *Service) snapshotCapabilityPolicies() map[string]domain.AppCapabilityPolicy {
276+ s.capabilityMu.RLock()
277+ defer s.capabilityMu.RUnlock()
278+ return s.capabilityPolicies
279+}
280+
281+// resolveServiceCapabilities resolves every declared capability policy
282+// against the current administrative policies into the Linux capabilities
283+// to add. Errors name only the app, service, and policy.
284+func (s *Service) resolveServiceCapabilities(app string, svc domain.AppService) ([]string, error) {
285+ if len(svc.Capabilities) == 0 {
286+ return nil, nil
287+ }
288+ caps, err := domain.ResolveAppCapabilities(app, svc.Name, svc.Capabilities, s.snapshotCapabilityPolicies())
289+ if err != nil {
290+ return nil, fmt.Errorf("deployment: %w", err)
291+ }
292+ return caps, nil
293+}
294+
295+// verifyReusableCapabilities gates every path that reuses an existing
296+// capability-bearing container (restart, start, health recovery). Policy
297+// resolution alone is not enough: the container keeps the capability set it
298+// was created with, so a policy narrowed after creation would still run with
299+// the revoked capability. The container's configured CapAdd must therefore
300+// not exceed Gordon's defaults plus the currently authorized grant;
301+// otherwise the reuse is refused and a deploy must recreate it. A container
302+// holding fewer capabilities than authorized is fine (least privilege).
303+// A container that no longer exists is left to the caller's existing
304+// rebuild path, which re-resolves the policy.
305+func (s *Service) verifyReusableCapabilities(ctx context.Context, app string, eff domain.AppEffectiveService) error {
306+ granted, err := s.resolveServiceCapabilities(app, eff.Spec)
307+ if err != nil {
308+ return err
309+ }
310+ if len(eff.Spec.Capabilities) == 0 || eff.Container == "" {
311+ return nil
312+ }
313+ ctr, err := s.deps.Runtime.InspectContainer(ctx, eff.Container)
314+ if err != nil {
315+ if errors.Is(err, domain.ErrContainerNotFound) {
316+ return nil
317+ }
318+ return fmt.Errorf("deployment: app %q service %q: cannot verify container capabilities: %w", app, eff.Spec.Name, domain.ErrCapabilityPolicy)
319+ }
320+ authorized := append(domain.DefaultAppCapAdd(), granted...)
321+ if excess := domain.ExcessCapabilities(ctr.CapAdd, authorized); len(excess) > 0 {
322+ return fmt.Errorf("deployment: app %q service %q: container holds capabilities beyond the current policy (%s); deploy to recreate it: %w",
323+ app, eff.Spec.Name, strings.Join(excess, ", "), domain.ErrCapabilityPolicy)
324+ }
325+ return nil
326+}
327+
328+// resolveServiceGrants resolves the device and capability policies of a
329+// service in one fail-closed step.
330+func (s *Service) resolveServiceGrants(app string, svc domain.AppService) (devices, caps []string, err error) {
331+ if devices, err = s.resolveServiceDevices(app, svc); err != nil {
332+ return nil, nil, err
333+ }
334+ if caps, err = s.resolveServiceCapabilities(app, svc); err != nil {
335+ return nil, nil, err
336+ }
337+ return devices, caps, nil
338+}
339+
248340 // resolveServiceDevices resolves every declared logical device against the
249341 // current administrative policies into runtime CDI IDs. Errors name only
250342 // the app, service, and device: host inventory never appears.
@@ -753,6 +845,9 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
753845 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {
754846 return nil, err
755847 }
848+ if _, err := s.resolveServiceCapabilities(app, svc); err != nil {
849+ return nil, err
850+ }
756851 pinned = append(pinned, pinnedService{
757852 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,
758853 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
@@ -116,6 +116,9 @@ type Service struct {
116 // the lock.116 // the lock.
117 deviceMu sync.RWMutex117 deviceMu sync.RWMutex
118 devicePolicies map[string]domain.AppDevicePolicy118 devicePolicies map[string]domain.AppDevicePolicy
119+ // capabilityMu guards capabilityPolicies, replaced atomically on reload.
120+ capabilityMu sync.RWMutex
121+ capabilityPolicies map[string]domain.AppCapabilityPolicy
119 // liveMu guards liveOps. liveOps holds the operations this process122 // liveMu guards liveOps. liveOps holds the operations this process
120 // owns between the StartDeploy claim and the end of ExecuteDeploy. It123 // owns between the StartDeploy claim and the end of ExecuteDeploy. It
121 // is in-memory only: a fresh Service (boot) starts empty and may124 // is in-memory only: a fresh Service (boot) starts empty and may
@@ -245,6 +248,95 @@ func (s *Service) snapshotDevicePolicies() map[string]domain.AppDevicePolicy {
245 return s.devicePolicies248 return s.devicePolicies
246 }249 }
247 250
251+// WithCapabilityPolicies supplies the initial administrative capability policies.
252+func (s *Service) WithCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) *Service {
253+ s.SetCapabilityPolicies(policies)
254+ return s
255+}
256+
257+// SetCapabilityPolicies atomically replaces the administrative capability
258+// policies used to authorize service capabilities. The map and its slices
259+// are copied so later caller mutation cannot race an in-flight deploy.
260+func (s *Service) SetCapabilityPolicies(policies map[string]domain.AppCapabilityPolicy) {
261+ copied := make(map[string]domain.AppCapabilityPolicy, len(policies))
262+ for name, policy := range policies {
263+ policy.Add = append([]string(nil), policy.Add...)
264+ policy.AllowedApps = append([]string(nil), policy.AllowedApps...)
265+ policy.AllowedServices = append([]string(nil), policy.AllowedServices...)
266+ copied[name] = policy
267+ }
268+ s.capabilityMu.Lock()
269+ s.capabilityPolicies = copied
270+ s.capabilityMu.Unlock()
271+}
272+
273+// snapshotCapabilityPolicies returns the current policy map.
274+// SetCapabilityPolicies never mutates a published map.
275+func (s *Service) snapshotCapabilityPolicies() map[string]domain.AppCapabilityPolicy {
276+ s.capabilityMu.RLock()
277+ defer s.capabilityMu.RUnlock()
278+ return s.capabilityPolicies
279+}
280+
281+// resolveServiceCapabilities resolves every declared capability policy
282+// against the current administrative policies into the Linux capabilities
283+// to add. Errors name only the app, service, and policy.
284+func (s *Service) resolveServiceCapabilities(app string, svc domain.AppService) ([]string, error) {
285+ if len(svc.Capabilities) == 0 {
286+ return nil, nil
287+ }
288+ caps, err := domain.ResolveAppCapabilities(app, svc.Name, svc.Capabilities, s.snapshotCapabilityPolicies())
289+ if err != nil {
290+ return nil, fmt.Errorf("deployment: %w", err)
291+ }
292+ return caps, nil
293+}
294+
295+// verifyReusableCapabilities gates every path that reuses an existing
296+// capability-bearing container (restart, start, health recovery). Policy
297+// resolution alone is not enough: the container keeps the capability set it
298+// was created with, so a policy narrowed after creation would still run with
299+// the revoked capability. The container's configured CapAdd must therefore
300+// not exceed Gordon's defaults plus the currently authorized grant;
301+// otherwise the reuse is refused and a deploy must recreate it. A container
302+// holding fewer capabilities than authorized is fine (least privilege).
303+// A container that no longer exists is left to the caller's existing
304+// rebuild path, which re-resolves the policy.
305+func (s *Service) verifyReusableCapabilities(ctx context.Context, app string, eff domain.AppEffectiveService) error {
306+ granted, err := s.resolveServiceCapabilities(app, eff.Spec)
307+ if err != nil {
308+ return err
309+ }
310+ if len(eff.Spec.Capabilities) == 0 || eff.Container == "" {
311+ return nil
312+ }
313+ ctr, err := s.deps.Runtime.InspectContainer(ctx, eff.Container)
314+ if err != nil {
315+ if errors.Is(err, domain.ErrContainerNotFound) {
316+ return nil
317+ }
318+ return fmt.Errorf("deployment: app %q service %q: cannot verify container capabilities: %w", app, eff.Spec.Name, domain.ErrCapabilityPolicy)
319+ }
320+ authorized := append(domain.DefaultAppCapAdd(), granted...)
321+ if excess := domain.ExcessCapabilities(ctr.CapAdd, authorized); len(excess) > 0 {
322+ return fmt.Errorf("deployment: app %q service %q: container holds capabilities beyond the current policy (%s); deploy to recreate it: %w",
323+ app, eff.Spec.Name, strings.Join(excess, ", "), domain.ErrCapabilityPolicy)
324+ }
325+ return nil
326+}
327+
328+// resolveServiceGrants resolves the device and capability policies of a
329+// service in one fail-closed step.
330+func (s *Service) resolveServiceGrants(app string, svc domain.AppService) (devices, caps []string, err error) {
331+ if devices, err = s.resolveServiceDevices(app, svc); err != nil {
332+ return nil, nil, err
333+ }
334+ if caps, err = s.resolveServiceCapabilities(app, svc); err != nil {
335+ return nil, nil, err
336+ }
337+ return devices, caps, nil
338+}
339+
248 // resolveServiceDevices resolves every declared logical device against the340 // resolveServiceDevices resolves every declared logical device against the
249 // current administrative policies into runtime CDI IDs. Errors name only341 // current administrative policies into runtime CDI IDs. Errors name only
250 // the app, service, and device: host inventory never appears.342 // the app, service, and device: host inventory never appears.
@@ -753,6 +845,9 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
753 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {845 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {
754 return nil, err846 return nil, err
755 }847 }
848+ if _, err := s.resolveServiceCapabilities(app, svc); err != nil {
849+ return nil, err
850+ }
756 pinned = append(pinned, pinnedService{851 pinned = append(pinned, pinnedService{
757 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,852 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,
758 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),853 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close