detached 2950329

feat(apps): add operator-gated Linux capabilities for app services

commit
2950329f2ec4fa2e923aed4b6ef29510e6133ead
author
brice · · 2026-10-18 19:36 UTC
committer
brice · · 2026-10-18 20:33 UTC
parent
714888a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +1232 -45
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 +19 -0
@@ -19,6 +19,7 @@ import (
1919 "net/url"
2020 "os"
2121 "path/filepath"
22+ "slices"
2223 "sort"
2324 "strconv"
2425 "strings"
@@ -204,6 +205,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
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
@@ -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"
@@ -204,6 +205,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
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
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+}
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 +176 -0
@@ -0,0 +1,176 @@
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+// AllowedAppCapabilities returns the sorted capability names a policy may
46+// add. The slice is a copy.
47+func AllowedAppCapabilities() []string {
48+ return slices.Clone(allowedAppCapabilities)
49+}
50+
51+// AppCapabilityPolicy is one named administrative capability grant
52+// declared under [app_capabilities.<name>]. The installation maps a
53+// logical name to explicit Linux capabilities and exact app/service
54+// allowlists. App manifests request capabilities by logical name; Gordon
55+// resolves them at activation time and adds them to the container on top
56+// of its default capability set. Resolution is never persisted: revisions
57+// keep the logical names.
58+type AppCapabilityPolicy struct {
59+ Name string
60+ // Add holds the explicit capability names granted (for example
61+ // "NET_ADMIN"), restricted to the allowed set.
62+ Add []string
63+ // AllowedApps and AllowedServices are exact, non-empty allowlists.
64+ AllowedApps []string
65+ AllowedServices []string
66+}
67+
68+// ValidateCapabilityPolicyRef checks a logical capability-policy name used
69+// from a manifest (service charset plus -- ban).
70+func ValidateCapabilityPolicyRef(name string) error {
71+ if !serviceNamePattern.MatchString(name) {
72+ return fmt.Errorf("%w: capability name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
73+ }
74+ if strings.Contains(name, "--") {
75+ return fmt.Errorf("%w: capability name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
76+ }
77+ return nil
78+}
79+
80+// ValidateCapabilityPolicyName checks a stable policy name (service
81+// charset plus -- ban).
82+func ValidateCapabilityPolicyName(name string) error {
83+ if !serviceNamePattern.MatchString(name) {
84+ return fmt.Errorf("%w: capability policy name %q must match [a-z0-9_.-], max 63", ErrCapabilityPolicy, name)
85+ }
86+ if strings.Contains(name, "--") {
87+ return fmt.Errorf("%w: capability policy name %q must not contain -- (reserved separator)", ErrCapabilityPolicy, name)
88+ }
89+ return nil
90+}
91+
92+// Validate checks the policy shape without touching the runtime.
93+func (p AppCapabilityPolicy) Validate() error {
94+ if err := ValidateCapabilityPolicyName(p.Name); err != nil {
95+ return err
96+ }
97+ if err := validateCapabilityNames(p.Add); err != nil {
98+ return fmt.Errorf("%w: capability policy %q: %v", ErrCapabilityPolicy, p.Name, err)
99+ }
100+ if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
101+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
102+ }
103+ if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
104+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
105+ }
106+ return nil
107+}
108+
109+// validateCapabilityNames requires a non-empty list of unique names from
110+// the allowed set.
111+func validateCapabilityNames(names []string) error {
112+ if len(names) == 0 {
113+ return fmt.Errorf("add must not be empty")
114+ }
115+ return ValidateAddableCapabilities(names)
116+}
117+
118+// ValidateAddableCapabilities checks that every name is in the allowed set
119+// and unique. An empty list is valid. It is also the runtime guard's check
120+// on resolved capabilities, whatever produced them.
121+func ValidateAddableCapabilities(names []string) error {
122+ seen := map[string]struct{}{}
123+ for _, name := range names {
124+ if name == "" {
125+ return fmt.Errorf("capability must not be empty")
126+ }
127+ if !slices.Contains(allowedAppCapabilities, name) {
128+ return fmt.Errorf("capability %q is not allowed (allowed: %s)", name, strings.Join(allowedAppCapabilities, ", "))
129+ }
130+ if _, ok := seen[name]; ok {
131+ return fmt.Errorf("duplicate capability %q", name)
132+ }
133+ seen[name] = struct{}{}
134+ }
135+ return nil
136+}
137+
138+// ResolveAppCapabilities resolves the logical grant under this policy for
139+// the given app and service. It validates the policy shape, enforces the
140+// exact app/service allowlists, and returns the explicit capabilities.
141+func (p AppCapabilityPolicy) ResolveAppCapabilities(app, service string) ([]string, error) {
142+ if err := p.Validate(); err != nil {
143+ return nil, err
144+ }
145+ if !allowlistContains(p.AllowedApps, app) {
146+ return nil, fmt.Errorf("%w: capability policy %q does not allow app %q", ErrCapabilityPolicy, p.Name, app)
147+ }
148+ if !allowlistContains(p.AllowedServices, service) {
149+ return nil, fmt.Errorf("%w: capability policy %q does not allow service %q", ErrCapabilityPolicy, p.Name, service)
150+ }
151+ return slices.Clone(p.Add), nil
152+}
153+
154+// ResolveAppCapabilities resolves every logical capability policy in names
155+// for one app service. It returns the deterministic, de-duplicated union
156+// (sorted): two policies granting the same capability is harmless. Errors
157+// name only the app, service, and logical policy.
158+func ResolveAppCapabilities(app, service string, names []string, policies map[string]AppCapabilityPolicy) ([]string, error) {
159+ if len(names) == 0 {
160+ return nil, nil
161+ }
162+ var resolved []string
163+ for _, name := range names {
164+ policy, ok := policies[name]
165+ if !ok {
166+ return nil, fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured", ErrCapabilityPolicy, app, service, name)
167+ }
168+ caps, err := policy.ResolveAppCapabilities(app, service)
169+ if err != nil {
170+ return nil, fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", ErrCapabilityPolicy, app, service, name)
171+ }
172+ resolved = append(resolved, caps...)
173+ }
174+ slices.Sort(resolved)
175+ return slices.Compact(resolved), nil
176+}
@@ -0,0 +1,176 @@
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+// AllowedAppCapabilities returns the sorted capability names a policy may
46+// add. The slice is a copy.
47+func AllowedAppCapabilities() []string {
48+ return slices.Clone(allowedAppCapabilities)
49+}
50+
51+// AppCapabilityPolicy is one named administrative capability grant
52+// declared under [app_capabilities.<name>]. The installation maps a
53+// logical name to explicit Linux capabilities and exact app/service
54+// allowlists. App manifests request capabilities by logical name; Gordon
55+// resolves them at activation time and adds them to the container on top
56+// of its default capability set. Resolution is never persisted: revisions
57+// keep the logical names.
58+type AppCapabilityPolicy struct {
59+ Name string
60+ // Add holds the explicit capability names granted (for example
61+ // "NET_ADMIN"), restricted to the allowed set.
62+ Add []string
63+ // AllowedApps and AllowedServices are exact, non-empty allowlists.
64+ AllowedApps []string
65+ AllowedServices []string
66+}
67+
68+// ValidateCapabilityPolicyRef checks a logical capability-policy name used
69+// from a manifest (service charset plus -- ban).
70+func ValidateCapabilityPolicyRef(name string) error {
71+ if !serviceNamePattern.MatchString(name) {
72+ return fmt.Errorf("%w: capability name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
73+ }
74+ if strings.Contains(name, "--") {
75+ return fmt.Errorf("%w: capability name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
76+ }
77+ return nil
78+}
79+
80+// ValidateCapabilityPolicyName checks a stable policy name (service
81+// charset plus -- ban).
82+func ValidateCapabilityPolicyName(name string) error {
83+ if !serviceNamePattern.MatchString(name) {
84+ return fmt.Errorf("%w: capability policy name %q must match [a-z0-9_.-], max 63", ErrCapabilityPolicy, name)
85+ }
86+ if strings.Contains(name, "--") {
87+ return fmt.Errorf("%w: capability policy name %q must not contain -- (reserved separator)", ErrCapabilityPolicy, name)
88+ }
89+ return nil
90+}
91+
92+// Validate checks the policy shape without touching the runtime.
93+func (p AppCapabilityPolicy) Validate() error {
94+ if err := ValidateCapabilityPolicyName(p.Name); err != nil {
95+ return err
96+ }
97+ if err := validateCapabilityNames(p.Add); err != nil {
98+ return fmt.Errorf("%w: capability policy %q: %v", ErrCapabilityPolicy, p.Name, err)
99+ }
100+ if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
101+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
102+ }
103+ if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
104+ return fmt.Errorf("%w: capability policy %q: %w", ErrCapabilityPolicy, p.Name, err)
105+ }
106+ return nil
107+}
108+
109+// validateCapabilityNames requires a non-empty list of unique names from
110+// the allowed set.
111+func validateCapabilityNames(names []string) error {
112+ if len(names) == 0 {
113+ return fmt.Errorf("add must not be empty")
114+ }
115+ return ValidateAddableCapabilities(names)
116+}
117+
118+// ValidateAddableCapabilities checks that every name is in the allowed set
119+// and unique. An empty list is valid. It is also the runtime guard's check
120+// on resolved capabilities, whatever produced them.
121+func ValidateAddableCapabilities(names []string) error {
122+ seen := map[string]struct{}{}
123+ for _, name := range names {
124+ if name == "" {
125+ return fmt.Errorf("capability must not be empty")
126+ }
127+ if !slices.Contains(allowedAppCapabilities, name) {
128+ return fmt.Errorf("capability %q is not allowed (allowed: %s)", name, strings.Join(allowedAppCapabilities, ", "))
129+ }
130+ if _, ok := seen[name]; ok {
131+ return fmt.Errorf("duplicate capability %q", name)
132+ }
133+ seen[name] = struct{}{}
134+ }
135+ return nil
136+}
137+
138+// ResolveAppCapabilities resolves the logical grant under this policy for
139+// the given app and service. It validates the policy shape, enforces the
140+// exact app/service allowlists, and returns the explicit capabilities.
141+func (p AppCapabilityPolicy) ResolveAppCapabilities(app, service string) ([]string, error) {
142+ if err := p.Validate(); err != nil {
143+ return nil, err
144+ }
145+ if !allowlistContains(p.AllowedApps, app) {
146+ return nil, fmt.Errorf("%w: capability policy %q does not allow app %q", ErrCapabilityPolicy, p.Name, app)
147+ }
148+ if !allowlistContains(p.AllowedServices, service) {
149+ return nil, fmt.Errorf("%w: capability policy %q does not allow service %q", ErrCapabilityPolicy, p.Name, service)
150+ }
151+ return slices.Clone(p.Add), nil
152+}
153+
154+// ResolveAppCapabilities resolves every logical capability policy in names
155+// for one app service. It returns the deterministic, de-duplicated union
156+// (sorted): two policies granting the same capability is harmless. Errors
157+// name only the app, service, and logical policy.
158+func ResolveAppCapabilities(app, service string, names []string, policies map[string]AppCapabilityPolicy) ([]string, error) {
159+ if len(names) == 0 {
160+ return nil, nil
161+ }
162+ var resolved []string
163+ for _, name := range names {
164+ policy, ok := policies[name]
165+ if !ok {
166+ return nil, fmt.Errorf("%w: app %q service %q capability %q: no administrative capability policy configured", ErrCapabilityPolicy, app, service, name)
167+ }
168+ caps, err := policy.ResolveAppCapabilities(app, service)
169+ if err != nil {
170+ return nil, fmt.Errorf("%w: app %q service %q capability %q: refused by administrative capability policy", ErrCapabilityPolicy, app, service, name)
171+ }
172+ resolved = append(resolved, caps...)
173+ }
174+ slices.Sort(resolved)
175+ return slices.Compact(resolved), nil
176+}
A internal/domain/app_capability_policy_test.go +179 -0
@@ -0,0 +1,179 @@
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+}
@@ -0,0 +1,179 @@
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+}
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 +16 -11
@@ -122,17 +122,22 @@ type ContainerConfig struct {
122122 // CDIDevices holds explicit CDI device IDs resolved from administrative
123123 // device policy at activation time. It is ephemeral like Binds: never
124124 // 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
125+ CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest
126+ // AddedCapabilities holds Linux capabilities resolved from
127+ // administrative capability policy at activation time. It is ephemeral
128+ // like CDIDevices: never persisted. The runtime adds them on top of the
129+ // base capability set (default or CapAdd) and never alters CapDrop.
130+ AddedCapabilities []string
131+ NetworkMode string // Network to join
132+ Hostname string // Container hostname for DNS
133+ Aliases []string // Additional network aliases
134+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
135+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
136+ PidsLimit int64 // Max number of PIDs (0 = no limit)
137+ ReadOnlyRootFS bool // Mount container root filesystem read-only
138+ User string // User to run as
139+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
140+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
136141 }
137142
138143 // ContainerStatus represents the current state of a container.
@@ -122,17 +122,22 @@ type ContainerConfig struct {
122 // CDIDevices holds explicit CDI device IDs resolved from administrative122 // CDIDevices holds explicit CDI device IDs resolved from administrative
123 // device policy at activation time. It is ephemeral like Binds: never123 // device policy at activation time. It is ephemeral like Binds: never
124 // persisted, never logged by the runtime adapter.124 // persisted, never logged by the runtime adapter.
125- CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest125+ CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest
126- NetworkMode string // Network to join126+ // AddedCapabilities holds Linux capabilities resolved from
127- Hostname string // Container hostname for DNS127+ // administrative capability policy at activation time. It is ephemeral
128- Aliases []string // Additional network aliases128+ // like CDIDevices: never persisted. The runtime adds them on top of the
129- MemoryLimit int64 // Memory limit in bytes (0 = no limit)129+ // base capability set (default or CapAdd) and never alters CapDrop.
130- NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)130+ AddedCapabilities []string
131- PidsLimit int64 // Max number of PIDs (0 = no limit)131+ NetworkMode string // Network to join
132- ReadOnlyRootFS bool // Mount container root filesystem read-only132+ Hostname string // Container hostname for DNS
133- User string // User to run as133+ Aliases []string // Additional network aliases
134- CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults134+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
135- CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults135+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
136+ PidsLimit int64 // Max number of PIDs (0 = no limit)
137+ ReadOnlyRootFS bool // Mount container root filesystem read-only
138+ User string // User to run as
139+ CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults
140+ CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults
136 }141 }
137 142
138 // ContainerStatus represents the current state of a container.143 // 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 +176 -0
@@ -0,0 +1,176 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func capabilityPolicyFor(apps, services []string) domain.AppCapabilityPolicy {
17+ return domain.AppCapabilityPolicy{
18+ Name: "net-admin",
19+ Add: []string{"NET_ADMIN"},
20+ AllowedApps: apps,
21+ AllowedServices: services,
22+ }
23+}
24+
25+func TestResolveServiceCapabilities(t *testing.T) {
26+ spec := domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}
27+
28+ t.Run("exact resolution", func(t *testing.T) {
29+ svc := NewService(Deps{}, zerowrap.Default()).
30+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
31+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
32+ })
33+ got, err := svc.resolveServiceCapabilities("blog", spec)
34+ require.NoError(t, err)
35+ assert.Equal(t, []string{"NET_ADMIN"}, got)
36+ })
37+
38+ t.Run("empty is nil", func(t *testing.T) {
39+ svc := NewService(Deps{}, zerowrap.Default())
40+ got, err := svc.resolveServiceCapabilities("blog", domain.AppService{Name: "web"})
41+ require.NoError(t, err)
42+ assert.Nil(t, got)
43+ })
44+
45+ t.Run("unknown policy", func(t *testing.T) {
46+ svc := NewService(Deps{}, zerowrap.Default())
47+ _, err := svc.resolveServiceCapabilities("blog", spec)
48+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
49+ assert.Contains(t, err.Error(), "net-admin")
50+ })
51+
52+ t.Run("revoked policy", func(t *testing.T) {
53+ svc := NewService(Deps{}, zerowrap.Default()).
54+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
55+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
56+ })
57+ _, err := svc.resolveServiceCapabilities("blog", spec)
58+ require.NoError(t, err)
59+ svc.SetCapabilityPolicies(nil)
60+ _, err = svc.resolveServiceCapabilities("blog", spec)
61+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
62+ })
63+
64+ t.Run("refused omits capability names", func(t *testing.T) {
65+ svc := NewService(Deps{}, zerowrap.Default()).
66+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
67+ "net-admin": capabilityPolicyFor([]string{"other"}, []string{"web"}),
68+ })
69+ _, err := svc.resolveServiceCapabilities("blog", spec)
70+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
71+ assert.NotContains(t, err.Error(), "NET_ADMIN")
72+ })
73+}
74+
75+// TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation proves
76+// the pre-create re-resolution fails closed before any runtime call.
77+func TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation(t *testing.T) {
78+ runtime := outmocks.NewMockContainerRuntime(t)
79+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
80+ p := pinnedService{
81+ name: "web",
82+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
83+ }
84+
85+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
86+
87+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
88+ runtime.AssertNotCalled(t, "CreateContainer")
89+ runtime.AssertNotCalled(t, "CreateVolume")
90+ runtime.AssertNotCalled(t, "StartContainer")
91+ runtime.AssertExpectations(t)
92+}
93+
94+// TestCreateAndStart_PassesResolvedCapabilitiesToRuntime proves resolved
95+// capabilities reach ContainerConfig.AddedCapabilities and never CapAdd /
96+// CapDrop, which stay with the runtime defaults.
97+func TestCreateAndStart_PassesResolvedCapabilitiesToRuntime(t *testing.T) {
98+ ctx := context.Background()
99+ state := outmocks.NewMockAppState(t)
100+ runtime := outmocks.NewMockContainerRuntime(t)
101+
102+ private := domain.AppPrivateNetworkName("gordon", "app-1")
103+ state.EXPECT().LoadOwnership(mock.Anything, "blog").
104+ Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
105+ runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
106+ runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
107+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
108+ return assert.Equal(t, []string{"NET_ADMIN"}, cfg.AddedCapabilities) &&
109+ assert.Nil(t, cfg.CapAdd, "base capability set stays with the runtime defaults") &&
110+ assert.Nil(t, cfg.CapDrop, "the default drop is never overridden")
111+ })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
112+ runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
113+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
114+
115+ svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
116+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
117+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
118+ })
119+ candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
120+ name: "web",
121+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
122+ }, "op-1", nil)
123+ require.NoError(t, err)
124+ require.NotNil(t, candidate.Container)
125+ runtime.AssertExpectations(t)
126+}
127+
128+func TestRestartOneService_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
129+ runtime := outmocks.NewMockContainerRuntime(t)
130+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
131+ eff := domain.AppEffectiveService{
132+ Container: "c1",
133+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
134+ }
135+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
136+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
137+ }}}
138+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
139+
140+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
141+ assert.Contains(t, op.Steps[0].Error, "net-admin")
142+ assert.Equal(t, "failed", result.Result)
143+ runtime.AssertNotCalled(t, "RestartContainer")
144+ runtime.AssertExpectations(t)
145+}
146+
147+func TestEnsureServiceRunning_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
148+ runtime := outmocks.NewMockContainerRuntime(t)
149+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
150+ eff := domain.AppEffectiveService{
151+ Container: "c1",
152+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
153+ }
154+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
155+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
156+ }}}
157+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
158+
159+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
160+
161+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
162+ assert.Contains(t, op.Steps[0].Error, "net-admin")
163+ runtime.AssertNotCalled(t, "StartContainer")
164+ runtime.AssertExpectations(t)
165+}
166+
167+// TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer proves a
168+// service with capability grants is always replaced on deploy: the resolved
169+// capabilities depend on host policy that ACTIVE does not record, so a
170+// changed policy must reach the container on the next deploy.
171+func TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer(t *testing.T) {
172+ svc := NewService(Deps{}, zerowrap.Default())
173+ p := pinnedService{name: "web", spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
174+ reason := svc.unchangedRefusal(context.Background(), "blog", p, domain.AppEffectiveService{Spec: p.spec})
175+ assert.Equal(t, "administrative capabilities", reason)
176+}
@@ -0,0 +1,176 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func capabilityPolicyFor(apps, services []string) domain.AppCapabilityPolicy {
17+ return domain.AppCapabilityPolicy{
18+ Name: "net-admin",
19+ Add: []string{"NET_ADMIN"},
20+ AllowedApps: apps,
21+ AllowedServices: services,
22+ }
23+}
24+
25+func TestResolveServiceCapabilities(t *testing.T) {
26+ spec := domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}
27+
28+ t.Run("exact resolution", func(t *testing.T) {
29+ svc := NewService(Deps{}, zerowrap.Default()).
30+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
31+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
32+ })
33+ got, err := svc.resolveServiceCapabilities("blog", spec)
34+ require.NoError(t, err)
35+ assert.Equal(t, []string{"NET_ADMIN"}, got)
36+ })
37+
38+ t.Run("empty is nil", func(t *testing.T) {
39+ svc := NewService(Deps{}, zerowrap.Default())
40+ got, err := svc.resolveServiceCapabilities("blog", domain.AppService{Name: "web"})
41+ require.NoError(t, err)
42+ assert.Nil(t, got)
43+ })
44+
45+ t.Run("unknown policy", func(t *testing.T) {
46+ svc := NewService(Deps{}, zerowrap.Default())
47+ _, err := svc.resolveServiceCapabilities("blog", spec)
48+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
49+ assert.Contains(t, err.Error(), "net-admin")
50+ })
51+
52+ t.Run("revoked policy", func(t *testing.T) {
53+ svc := NewService(Deps{}, zerowrap.Default()).
54+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
55+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
56+ })
57+ _, err := svc.resolveServiceCapabilities("blog", spec)
58+ require.NoError(t, err)
59+ svc.SetCapabilityPolicies(nil)
60+ _, err = svc.resolveServiceCapabilities("blog", spec)
61+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
62+ })
63+
64+ t.Run("refused omits capability names", func(t *testing.T) {
65+ svc := NewService(Deps{}, zerowrap.Default()).
66+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
67+ "net-admin": capabilityPolicyFor([]string{"other"}, []string{"web"}),
68+ })
69+ _, err := svc.resolveServiceCapabilities("blog", spec)
70+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
71+ assert.NotContains(t, err.Error(), "NET_ADMIN")
72+ })
73+}
74+
75+// TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation proves
76+// the pre-create re-resolution fails closed before any runtime call.
77+func TestCreateAndStart_RefusesRevokedCapabilityBeforeRuntimeMutation(t *testing.T) {
78+ runtime := outmocks.NewMockContainerRuntime(t)
79+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
80+ p := pinnedService{
81+ name: "web",
82+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
83+ }
84+
85+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
86+
87+ require.ErrorIs(t, err, domain.ErrCapabilityPolicy)
88+ runtime.AssertNotCalled(t, "CreateContainer")
89+ runtime.AssertNotCalled(t, "CreateVolume")
90+ runtime.AssertNotCalled(t, "StartContainer")
91+ runtime.AssertExpectations(t)
92+}
93+
94+// TestCreateAndStart_PassesResolvedCapabilitiesToRuntime proves resolved
95+// capabilities reach ContainerConfig.AddedCapabilities and never CapAdd /
96+// CapDrop, which stay with the runtime defaults.
97+func TestCreateAndStart_PassesResolvedCapabilitiesToRuntime(t *testing.T) {
98+ ctx := context.Background()
99+ state := outmocks.NewMockAppState(t)
100+ runtime := outmocks.NewMockContainerRuntime(t)
101+
102+ private := domain.AppPrivateNetworkName("gordon", "app-1")
103+ state.EXPECT().LoadOwnership(mock.Anything, "blog").
104+ Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Once()
105+ runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Once()
106+ runtime.EXPECT().CreateNetwork(mock.Anything, private, mock.Anything).Return(nil).Once()
107+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
108+ return assert.Equal(t, []string{"NET_ADMIN"}, cfg.AddedCapabilities) &&
109+ assert.Nil(t, cfg.CapAdd, "base capability set stays with the runtime defaults") &&
110+ assert.Nil(t, cfg.CapDrop, "the default drop is never overridden")
111+ })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
112+ runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
113+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
114+
115+ svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
116+ WithCapabilityPolicies(map[string]domain.AppCapabilityPolicy{
117+ "net-admin": capabilityPolicyFor([]string{"blog"}, []string{"web"}),
118+ })
119+ candidate, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
120+ name: "web",
121+ spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
122+ }, "op-1", nil)
123+ require.NoError(t, err)
124+ require.NotNil(t, candidate.Container)
125+ runtime.AssertExpectations(t)
126+}
127+
128+func TestRestartOneService_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
129+ runtime := outmocks.NewMockContainerRuntime(t)
130+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
131+ eff := domain.AppEffectiveService{
132+ Container: "c1",
133+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
134+ }
135+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
136+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
137+ }}}
138+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
139+
140+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
141+ assert.Contains(t, op.Steps[0].Error, "net-admin")
142+ assert.Equal(t, "failed", result.Result)
143+ runtime.AssertNotCalled(t, "RestartContainer")
144+ runtime.AssertExpectations(t)
145+}
146+
147+func TestEnsureServiceRunning_RevokedCapabilityFailsWithoutRuntimeMutation(t *testing.T) {
148+ runtime := outmocks.NewMockContainerRuntime(t)
149+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
150+ eff := domain.AppEffectiveService{
151+ Container: "c1",
152+ Spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}},
153+ }
154+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
155+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
156+ }}}
157+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
158+
159+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
160+
161+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
162+ assert.Contains(t, op.Steps[0].Error, "net-admin")
163+ runtime.AssertNotCalled(t, "StartContainer")
164+ runtime.AssertExpectations(t)
165+}
166+
167+// TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer proves a
168+// service with capability grants is always replaced on deploy: the resolved
169+// capabilities depend on host policy that ACTIVE does not record, so a
170+// changed policy must reach the container on the next deploy.
171+func TestUnchangedRefusal_CapabilitiesNeverKeepRunningContainer(t *testing.T) {
172+ svc := NewService(Deps{}, zerowrap.Default())
173+ p := pinnedService{name: "web", spec: domain.AppService{Name: "web", Capabilities: []string{"net-admin"}}}
174+ reason := svc.unchangedRefusal(context.Background(), "blog", p, domain.AppEffectiveService{Spec: p.spec})
175+ assert.Equal(t, "administrative capabilities", reason)
176+}
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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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.resolveServiceCapabilities(app, eff.Spec); 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 +62 -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,62 @@ 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+// resolveServiceGrants resolves the device and capability policies of a
296+// service in one fail-closed step.
297+func (s *Service) resolveServiceGrants(app string, svc domain.AppService) (devices, caps []string, err error) {
298+ if devices, err = s.resolveServiceDevices(app, svc); err != nil {
299+ return nil, nil, err
300+ }
301+ if caps, err = s.resolveServiceCapabilities(app, svc); err != nil {
302+ return nil, nil, err
303+ }
304+ return devices, caps, nil
305+}
306+
248307 // resolveServiceDevices resolves every declared logical device against the
249308 // current administrative policies into runtime CDI IDs. Errors name only
250309 // the app, service, and device: host inventory never appears.
@@ -753,6 +812,9 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
753812 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {
754813 return nil, err
755814 }
815+ if _, err := s.resolveServiceCapabilities(app, svc); err != nil {
816+ return nil, err
817+ }
756818 pinned = append(pinned, pinnedService{
757819 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,
758820 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,62 @@ 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+// resolveServiceGrants resolves the device and capability policies of a
296+// service in one fail-closed step.
297+func (s *Service) resolveServiceGrants(app string, svc domain.AppService) (devices, caps []string, err error) {
298+ if devices, err = s.resolveServiceDevices(app, svc); err != nil {
299+ return nil, nil, err
300+ }
301+ if caps, err = s.resolveServiceCapabilities(app, svc); err != nil {
302+ return nil, nil, err
303+ }
304+ return devices, caps, nil
305+}
306+
248 // resolveServiceDevices resolves every declared logical device against the307 // resolveServiceDevices resolves every declared logical device against the
249 // current administrative policies into runtime CDI IDs. Errors name only308 // current administrative policies into runtime CDI IDs. Errors name only
250 // the app, service, and device: host inventory never appears.309 // the app, service, and device: host inventory never appears.
@@ -753,6 +812,9 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
753 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {812 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {
754 return nil, err813 return nil, err
755 }814 }
815+ if _, err := s.resolveServiceCapabilities(app, svc); err != nil {
816+ return nil, err
817+ }
756 pinned = append(pinned, pinnedService{818 pinned = append(pinned, pinnedService{
757 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,819 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,
758 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),820 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