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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 58 | 58 | Volumes []rawVolume `toml:"volume"` | |
| 59 | 59 | Binds []rawBind `toml:"bind"` | |
| 60 | 60 | // 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"` | |
| 65 | 68 | } | |
| 66 | 69 | ||
| 67 | 70 | // rawReadiness mirrors [services.<name>.readiness]. | |
| @@ -346,6 +349,8 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 346 | 349 | // Preserve manifest order; domain equalDevices treats the slice as a | |
| 347 | 350 | // set, so reorder-only input is a no-op in diffs. | |
| 348 | 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 | 354 | for _, db := range raw.Databases { | |
| 350 | 355 | svc.Databases = append(svc.Databases, domain.AppDatabase{ | |
| 351 | 356 | 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 a | 349 | // 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) { | |||
| 620 | 620 | require.NoError(t, err) | |
| 621 | 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 | + | } | |
| @@ -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 { | |||
| 532 | 532 | return errors.Is(err, domain.ErrInvalidAppSpec) || | |
| 533 | 533 | errors.Is(err, domain.ErrBindPolicy) || | |
| 534 | 534 | errors.Is(err, domain.ErrDevicePolicy) || | |
| 535 | + | errors.Is(err, domain.ErrCapabilityPolicy) || | |
| 535 | 536 | isPreflightErrorRest(err) | |
| 536 | 537 | } | |
| 537 | 538 | ||
| @@ -559,6 +560,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) { | |||
| 559 | 560 | h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization") | |
| 560 | 561 | case errors.Is(err, domain.ErrDevicePolicy): | |
| 561 | 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 | 565 | case errors.Is(err, domain.ErrRuntimeGuard): | |
| 563 | 566 | h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings") | |
| 564 | 567 | 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) { | |||
| 713 | 713 | require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) | |
| 714 | 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 | + | } | |
| @@ -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 = `{ | |||
| 615 | 615 | "secrets_env": {}, | |
| 616 | 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 | + | } | |
| @@ -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 ( | |||
| 19 | 19 | "net/url" | |
| 20 | 20 | "os" | |
| 21 | 21 | "path/filepath" | |
| 22 | + | "slices" | |
| 22 | 23 | "sort" | |
| 23 | 24 | "strconv" | |
| 24 | 25 | "strings" | |
| @@ -204,6 +205,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 204 | 205 | if config.CapAdd != nil { | |
| 205 | 206 | capAdd = config.CapAdd | |
| 206 | 207 | } | |
| 208 | + | capAdd = mergeAddedCapabilities(capAdd, config.AddedCapabilities) | |
| 207 | 209 | hostConfig := &container.HostConfig{ | |
| 208 | 210 | PortBindings: portBindings, | |
| 209 | 211 | AutoRemove: config.AutoRemove, | |
| @@ -496,6 +498,23 @@ func parseEngineVersionComponent(value string) (int, bool) { | |||
| 496 | 498 | 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 | 518 | // buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI | |
| 500 | 519 | // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the | |
| 501 | 520 | // 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.CapAdd | 206 | 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, true | 498 | 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 CDI | 518 | // buildDeviceRequests encodes ephemeral CDI device IDs as one native CDI | ||
| 500 | // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the | 519 | // DeviceRequest. Capabilities and Options stay empty; Count stays 0 so the | ||
| 501 | // explicit DeviceIDs are the only grant. CDI IDs are never logged or echoed | 520 | // 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) { | |||
| 255 | 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 | + | } | |
| @@ -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 { | |||
| 95 | 95 | return nil | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 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 | |
| 100 | 100 | // keeps the previous policies live. Split from applyLoadedConfig to keep | |
| 101 | 101 | // its complexity within budget. | |
| 102 | 102 | func validateAppPolicies(v *viper.Viper, cfg Config) error { | |
| 103 | 103 | if _, err := buildAppMountPolicies(cfg); err != nil { | |
| 104 | 104 | 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 | } | |
| @@ -95,13 +95,16 @@ func validateDeviceConfig(v *viper.Viper, cfg Config) error { | |||||
| 95 | return nil | 95 | return nil | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | - | // validateAppPolicies validates both administrative app policy subtrees | 98 | + | // validateAppPolicies validates all administrative app policy subtrees |
| 99 | - | // (bind mounts and device grants) before publication. Failed validation | 99 | + | // (bind mounts, device grants, and capability grants) before publication. Failed validation |
| 100 | // keeps the previous policies live. Split from applyLoadedConfig to keep | 100 | // 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 err | 104 | 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 { | |||
| 127 | 127 | if err != nil { | |
| 128 | 128 | return err | |
| 129 | 129 | } | |
| 130 | + | capabilityPolicies, err := buildAppCapabilityPolicies(si.cfg) | |
| 131 | + | if err != nil { | |
| 132 | + | return err | |
| 133 | + | } | |
| 130 | 134 | si.svc.appDeploySvc.SetBindPolicies(mountPolicies) | |
| 131 | 135 | si.svc.appDeploySvc.SetDevicePolicies(devicePolicies) | |
| 136 | + | si.svc.appDeploySvc.SetCapabilityPolicies(capabilityPolicies) | |
| 132 | 137 | appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log). | |
| 133 | 138 | WithEntrypoints(appEntrypointListeners(si.cfg)). | |
| 134 | 139 | WithGCBarrier(si.svc.gcBarrier). | |
| 135 | 140 | WithImagePolicy(imagePolicy). | |
| 136 | 141 | WithBindPolicies(mountPolicies). | |
| 137 | - | WithDevicePolicies(devicePolicies) | |
| 142 | + | WithDevicePolicies(devicePolicies). | |
| 143 | + | WithCapabilityPolicies(capabilityPolicies) | |
| 138 | 144 | si.svc.appSvcImpl = appSvcImpl | |
| 139 | 145 | si.svc.appSvc = appSvcImpl | |
| 140 | 146 | // 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 err | 128 | 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 = appSvcImpl | 144 | si.svc.appSvcImpl = appSvcImpl | ||
| 139 | si.svc.appSvc = appSvcImpl | 145 | 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 { | |||
| 63 | 63 | // `devices` list. | |
| 64 | 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 | 71 | Logging struct { | |
| 67 | 72 | Level string `mapstructure:"level"` | |
| 68 | 73 | Format string `mapstructure:"format"` | |
| @@ -246,6 +251,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||
| 246 | 251 | if err := validateDeviceConfig(v, cfg); err != nil { | |
| 247 | 252 | return nil, Config{}, err | |
| 248 | 253 | } | |
| 254 | + | if err := validateCapabilityConfig(v, cfg); err != nil { | |
| 255 | + | return nil, Config{}, err | |
| 256 | + | } | |
| 249 | 257 | ||
| 250 | 258 | return v, cfg, nil | |
| 251 | 259 | } | |
| @@ -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{}, err | 252 | 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, nil | 258 | return v, cfg, nil | ||
| 251 | } | 259 | } | ||
M internal/app/reload.go +7 -1
| @@ -261,6 +261,7 @@ type reloadPlan struct { | |||
| 261 | 261 | tlsConfig *tls.Config | |
| 262 | 262 | bindPolicies map[string]domain.AppBindPolicy | |
| 263 | 263 | devicePolicies map[string]domain.AppDevicePolicy | |
| 264 | + | capPolicies map[string]domain.AppCapabilityPolicy | |
| 264 | 265 | } | |
| 265 | 266 | ||
| 266 | 267 | // Apply is the reloadCoordinator's runtime step. | |
| @@ -301,6 +302,9 @@ func (r *reloadRuntime) prepare(ctx context.Context, cfg Config) (reloadPlan, er | |||
| 301 | 302 | if plan.devicePolicies, err = buildAppDevicePolicies(cfg); err != nil { | |
| 302 | 303 | return plan, err | |
| 303 | 304 | } | |
| 305 | + | if plan.capPolicies, err = buildAppCapabilityPolicies(cfg); err != nil { | |
| 306 | + | return plan, err | |
| 307 | + | } | |
| 304 | 308 | } | |
| 305 | 309 | return plan, nil | |
| 306 | 310 | } | |
| @@ -327,7 +331,7 @@ func (r *reloadRuntime) applyServing(ctx context.Context, cfg Config, plan reloa | |||
| 327 | 331 | return nil | |
| 328 | 332 | } | |
| 329 | 333 | ||
| 330 | - | // applyAppPolicies publishes the reloaded bind and device policies to both | |
| 334 | + | // applyAppPolicies publishes the reloaded bind, device, and capability policies to both | |
| 331 | 335 | // app services that enforce them. | |
| 332 | 336 | func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) { | |
| 333 | 337 | if r.svc.appSvcImpl == nil { | |
| @@ -335,9 +339,11 @@ func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) { | |||
| 335 | 339 | } | |
| 336 | 340 | r.svc.appSvcImpl.SetBindPolicies(plan.bindPolicies) | |
| 337 | 341 | r.svc.appSvcImpl.SetDevicePolicies(plan.devicePolicies) | |
| 342 | + | r.svc.appSvcImpl.SetCapabilityPolicies(plan.capPolicies) | |
| 338 | 343 | if r.svc.appDeploySvc != nil { | |
| 339 | 344 | r.svc.appDeploySvc.SetBindPolicies(plan.bindPolicies) | |
| 340 | 345 | r.svc.appDeploySvc.SetDevicePolicies(plan.devicePolicies) | |
| 346 | + | r.svc.appDeploySvc.SetCapabilityPolicies(plan.capPolicies) | |
| 341 | 347 | } | |
| 342 | 348 | } | |
| 343 | 349 | ||
| @@ -261,6 +261,7 @@ type reloadPlan struct { | |||||
| 261 | tlsConfig *tls.Config | 261 | tlsConfig *tls.Config | ||
| 262 | bindPolicies map[string]domain.AppBindPolicy | 262 | bindPolicies map[string]domain.AppBindPolicy | ||
| 263 | devicePolicies map[string]domain.AppDevicePolicy | 263 | 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, err | 303 | return plan, err | ||
| 303 | } | 304 | } | ||
| 305 | + | if plan.capPolicies, err = buildAppCapabilityPolicies(cfg); err != nil { | |||
| 306 | + | return plan, err | |||
| 307 | + | } | |||
| 304 | } | 308 | } | ||
| 305 | return plan, nil | 309 | return plan, nil | ||
| 306 | } | 310 | } | ||
| @@ -327,7 +331,7 @@ func (r *reloadRuntime) applyServing(ctx context.Context, cfg Config, plan reloa | |||||
| 327 | return nil | 331 | return nil | ||
| 328 | } | 332 | } | ||
| 329 | 333 | ||||
| 330 | - | // applyAppPolicies publishes the reloaded bind and device policies to both | 334 | + | // 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 { | |||
| 116 | 116 | // Devices lists logical device names granted by administrative | |
| 117 | 117 | // [app_devices] policy. Names resolve to CDI device IDs at activation | |
| 118 | 118 | // 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 | |
| 122 | 127 | // LogExportDisabled opts the service out of OTLP log export. The | |
| 123 | 128 | // manifest resolves it from [services.<name>.telemetry] logs, then | |
| 124 | 129 | // the app-level [telemetry] logs, then the default (export on). The | |
| @@ -571,6 +576,9 @@ func (s *AppService) validate() error { | |||
| 571 | 576 | if err := s.validateDevices(); err != nil { | |
| 572 | 577 | return err | |
| 573 | 578 | } | |
| 579 | + | if err := s.validateCapabilities(); err != nil { | |
| 580 | + | return err | |
| 581 | + | } | |
| 574 | 582 | if err := s.validateDatabases(volumes); err != nil { | |
| 575 | 583 | return err | |
| 576 | 584 | } | |
| @@ -708,6 +716,23 @@ func (s *AppService) validateDevices() error { | |||
| 708 | 716 | 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 | 736 | // validateDatabases checks database declarations and backup references. | |
| 712 | 737 | func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error { | |
| 713 | 738 | seenDBs := map[string]struct{}{} | |
| @@ -1114,6 +1139,7 @@ func diffService(name string, desired, effective AppService) []string { | |||
| 1114 | 1139 | changed = append(changed, "service/"+name+"/binds") | |
| 1115 | 1140 | } | |
| 1116 | 1141 | changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices) | |
| 1142 | + | changed = appendCapabilityChanges(changed, name, desired.Capabilities, effective.Capabilities) | |
| 1117 | 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 | 1341 | 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 | + | } | |
| @@ -116,9 +116,14 @@ type AppService struct { | |||||
| 116 | // Devices lists logical device names granted by administrative | 116 | // Devices lists logical device names granted by administrative | ||
| 117 | // [app_devices] policy. Names resolve to CDI device IDs at activation | 117 | // [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 []string | 119 | + | Devices []string |
| 120 | - | Databases []AppDatabase | 120 | + | // Capabilities lists logical capability-policy names granted by |
| 121 | - | Backup AppBackup | 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 | |||
| 122 | // LogExportDisabled opts the service out of OTLP log export. The | 127 | // LogExportDisabled opts the service out of OTLP log export. The | ||
| 123 | // manifest resolves it from [services.<name>.telemetry] logs, then | 128 | // manifest resolves it from [services.<name>.telemetry] logs, then | ||
| 124 | // the app-level [telemetry] logs, then the default (export on). The | 129 | // 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 err | 577 | 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 err | 583 | return err | ||
| 576 | } | 584 | } | ||
| @@ -708,6 +716,23 @@ func (s *AppService) validateDevices() error { | |||||
| 708 | return nil | 716 | 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 changed | 1341 | 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 { | |||
| 122 | 122 | // CDIDevices holds explicit CDI device IDs resolved from administrative | |
| 123 | 123 | // device policy at activation time. It is ephemeral like Binds: never | |
| 124 | 124 | // 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 | |
| 136 | 141 | } | |
| 137 | 142 | ||
| 138 | 143 | // ContainerStatus represents the current state of a container. | |
| @@ -122,17 +122,22 @@ type ContainerConfig struct { | |||||
| 122 | // CDIDevices holds explicit CDI device IDs resolved from administrative | 122 | // CDIDevices holds explicit CDI device IDs resolved from administrative | ||
| 123 | // device policy at activation time. It is ephemeral like Binds: never | 123 | // 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 DeviceRequest | 125 | + | CDIDevices []string // ephemeral resolved CDI IDs, encoded as one native CDI DeviceRequest |
| 126 | - | NetworkMode string // Network to join | 126 | + | // AddedCapabilities holds Linux capabilities resolved from |
| 127 | - | Hostname string // Container hostname for DNS | 127 | + | // administrative capability policy at activation time. It is ephemeral |
| 128 | - | Aliases []string // Additional network aliases | 128 | + | // 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-only | 132 | + | Hostname string // Container hostname for DNS |
| 133 | - | User string // User to run as | 133 | + | Aliases []string // Additional network aliases |
| 134 | - | CapDrop []string // Linux capabilities to drop; nil uses runtime compat defaults | 134 | + | MemoryLimit int64 // Memory limit in bytes (0 = no limit) |
| 135 | - | CapAdd []string // Linux capabilities to add; nil uses runtime compat defaults | 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 | |||
| 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 ( | |||
| 83 | 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | |
| 84 | 84 | // installation policy refuses to serve it. | |
| 85 | 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 | 90 | // ErrRuntimeGuard wraps app container configs refused by the runtime | |
| 87 | 91 | // guard: host or shared namespaces, host paths passed as volume | |
| 88 | 92 | // names, or binds overlapping Gordon's own sensitive paths. | |
| @@ -83,6 +83,10 @@ var ( | |||||
| 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | 83 | // 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 runtime | 90 | // ErrRuntimeGuard wraps app container configs refused by the runtime | ||
| 87 | // guard: host or shared namespaces, host paths passed as volume | 91 | // 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 { | |||
| 41 | 41 | if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil { | |
| 42 | 42 | return err | |
| 43 | 43 | } | |
| 44 | + | if err := ValidateAddableCapabilities(config.AddedCapabilities); err != nil { | |
| 45 | + | return fmt.Errorf("%w: %v", ErrRuntimeGuard, err) | |
| 46 | + | } | |
| 44 | 47 | for _, bind := range config.Binds { | |
| 45 | 48 | if g.isOwnSecretBind(config, bind) { | |
| 46 | 49 | 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 err | 42 | 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 | continue | 49 | continue | ||
M internal/usecase/apps/app_service.go +12 -0
| @@ -208,6 +208,18 @@ func (s *AppServiceImpl) SetDevicePolicies(policies map[string]domain.AppDeviceP | |||
| 208 | 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 | 223 | var _ in.AppService = (*AppServiceImpl)(nil) | |
| 212 | 224 | ||
| 213 | 225 | // 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 { | |||
| 38 | 38 | // atomically; applies read the current map without holding the lock. | |
| 39 | 39 | deviceMu sync.RWMutex | |
| 40 | 40 | 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 | 46 | // NewService creates the apps use case over an AppState store. | |
| @@ -117,6 +120,36 @@ func (s *Service) snapshotDevicePolicies() map[string]domain.AppDevicePolicy { | |||
| 117 | 120 | 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 | 153 | // noopLease is a GC lease for an unwired barrier. | |
| 121 | 154 | type noopLease struct{} | |
| 122 | 155 | ||
| @@ -213,6 +246,9 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte, | |||
| 213 | 246 | if err := s.validateDevicePolicies(spec); err != nil { | |
| 214 | 247 | return nil, nil, err | |
| 215 | 248 | } | |
| 249 | + | if err := s.validateCapabilityPolicies(spec); err != nil { | |
| 250 | + | return nil, nil, err | |
| 251 | + | } | |
| 216 | 252 | if err := s.validateEntrypointCompatibility(spec); err != nil { | |
| 217 | 253 | return nil, nil, err | |
| 218 | 254 | } | |
| @@ -295,6 +331,26 @@ func (s *Service) validateDevicePolicies(spec domain.AppSpec) error { | |||
| 295 | 331 | 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 | 354 | // validateEntrypointCompatibility checks every declared TCP/UDP interface | |
| 299 | 355 | // against the installation entrypoint listeners before any persistence or | |
| 300 | 356 | // 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.RWMutex | 39 | deviceMu sync.RWMutex | ||
| 40 | devicePolicies map[string]domain.AppDevicePolicy | 40 | 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.devicePolicies | 120 | 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, err | 247 | 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, err | 253 | return nil, nil, err | ||
| 218 | } | 254 | } | ||
| @@ -295,6 +331,26 @@ func (s *Service) validateDevicePolicies(spec domain.AppSpec) error { | |||||
| 295 | return nil | 331 | 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 interface | 354 | // validateEntrypointCompatibility checks every declared TCP/UDP interface | ||
| 299 | // against the installation entrypoint listeners before any persistence or | 355 | // against the installation entrypoint listeners before any persistence or | ||
| 300 | // workload effect. The runtime binds the entrypoint address, so a publish | 356 | // 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 | |||
| 42 | 42 | if err != nil { | |
| 43 | 43 | return startedCandidate{}, err | |
| 44 | 44 | } | |
| 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) | |
| 48 | 48 | if err != nil { | |
| 49 | 49 | return startedCandidate{}, err | |
| 50 | 50 | } | |
| @@ -90,24 +90,25 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi | |||
| 90 | 90 | volumes[vol.Path] = runtimeName | |
| 91 | 91 | } | |
| 92 | 92 | 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, | |
| 111 | 112 | } | |
| 112 | 113 | created, err := s.createWithSecretFiles(ctx, app, p.name, config, files) | |
| 113 | 114 | 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{}, err | 43 | return startedCandidate{}, err | ||
| 44 | } | 44 | } | ||
| 45 | - | // Re-resolve devices from the current policy under the same | 45 | + | // 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{}, err | 49 | 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] = runtimeName | 90 | 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 | |||
| 489 | 489 | switch { | |
| 490 | 490 | case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0: | |
| 491 | 491 | return "host binds or devices" | |
| 492 | + | case len(p.spec.Capabilities) > 0: | |
| 493 | + | return "administrative capabilities" | |
| 492 | 494 | case !domain.SameAppServiceRuntime(p.spec, eff.Spec): | |
| 493 | 495 | // Backup declarations never reach the container, so a backup-only | |
| 494 | 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 | 747 | return result | |
| 746 | 748 | } | |
| 747 | 749 | ||
| 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 | |
| 749 | 751 | // the current policy before any workload mutation. It returns the failed | |
| 750 | 752 | // result and false when either was revoked since preflight. | |
| 751 | 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 | 757 | if _, err := s.resolveServiceDevices(app, p.spec); err != nil { | |
| 756 | 758 | 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 | 763 | return ServiceResult{}, true | |
| 759 | 764 | } | |
| 760 | 765 | ||
| @@ -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-only | 495 | // 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 result | 747 | return result | ||
| 746 | } | 748 | } | ||
| 747 | 749 | ||||
| 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 |
| 749 | // the current policy before any workload mutation. It returns the failed | 751 | // 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), false | 758 | 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{}, true | 763 | 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, | |||
| 333 | 333 | if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil { | |
| 334 | 334 | return fail(err.Error()) | |
| 335 | 335 | } | |
| 336 | + | if _, err := s.resolveServiceCapabilities(app, eff.Spec); err != nil { | |
| 337 | + | return fail(err.Error()) | |
| 338 | + | } | |
| 336 | 339 | if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil { | |
| 337 | 340 | _ = s.withdrawForRecovery(ctx, app, name) | |
| 338 | 341 | return fail(err.Error()) | |
| @@ -708,6 +711,10 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri | |||
| 708 | 711 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | |
| 709 | 712 | 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 | 718 | // Boot recovery refuses a generation whose recovery is durably | |
| 712 | 719 | // inhibited: a replacement may already have written to its | |
| 713 | 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 | 773 | if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil { | |
| 767 | 774 | 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 | 779 | pinned := pinnedService{ | |
| 770 | 780 | name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env), | |
| 771 | 781 | 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 | return | 712 | 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 durably | 718 | // Boot recovery refuses a generation whose recovery is durably | ||
| 712 | // inhibited: a replacement may already have written to its | 719 | // 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), err | 774 | 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 | |||
| 739 | 739 | if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil { | |
| 740 | 740 | return false, err | |
| 741 | 741 | } | |
| 742 | + | if _, err := s.resolveServiceCapabilities(app, eff.Spec); err != nil { | |
| 743 | + | return false, err | |
| 744 | + | } | |
| 742 | 745 | if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil { | |
| 743 | 746 | if errors.Is(err, domain.ErrContainerNotFound) { | |
| 744 | 747 | s.backoff.forget(key) | |
| @@ -795,6 +798,9 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef | |||
| 795 | 798 | if _, err := s.resolveServiceDevices(app, eff.Spec); err != nil { | |
| 796 | 799 | return false, false, err | |
| 797 | 800 | } | |
| 801 | + | if _, err := s.resolveServiceCapabilities(app, eff.Spec); err != nil { | |
| 802 | + | return false, false, err | |
| 803 | + | } | |
| 798 | 804 | if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil { | |
| 799 | 805 | s.backoff.recordFailure(key) | |
| 800 | 806 | 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, err | 740 | 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, err | 799 | 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 { | |||
| 116 | 116 | // the lock. | |
| 117 | 117 | deviceMu sync.RWMutex | |
| 118 | 118 | devicePolicies map[string]domain.AppDevicePolicy | |
| 119 | + | // capabilityMu guards capabilityPolicies, replaced atomically on reload. | |
| 120 | + | capabilityMu sync.RWMutex | |
| 121 | + | capabilityPolicies map[string]domain.AppCapabilityPolicy | |
| 119 | 122 | // liveMu guards liveOps. liveOps holds the operations this process | |
| 120 | 123 | // owns between the StartDeploy claim and the end of ExecuteDeploy. It | |
| 121 | 124 | // 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 | 248 | 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 | 307 | // resolveServiceDevices resolves every declared logical device against the | |
| 249 | 308 | // current administrative policies into runtime CDI IDs. Errors name only | |
| 250 | 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 | 812 | if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil { | |
| 754 | 813 | return nil, err | |
| 755 | 814 | } | |
| 815 | + | if _, err := s.resolveServiceCapabilities(app, svc); err != nil { | |
| 816 | + | return nil, err | |
| 817 | + | } | |
| 756 | 818 | pinned = append(pinned, pinnedService{ | |
| 757 | 819 | name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv, | |
| 758 | 820 | appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...), | |
| @@ -116,6 +116,9 @@ type Service struct { | |||||
| 116 | // the lock. | 116 | // the lock. | ||
| 117 | deviceMu sync.RWMutex | 117 | deviceMu sync.RWMutex | ||
| 118 | devicePolicies map[string]domain.AppDevicePolicy | 118 | 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 process | 122 | // liveMu guards liveOps. liveOps holds the operations this process | ||
| 120 | // owns between the StartDeploy claim and the end of ExecuteDeploy. It | 123 | // owns between the StartDeploy claim and the end of ExecuteDeploy. It | ||
| 121 | // is in-memory only: a fresh Service (boot) starts empty and may | 124 | // 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.devicePolicies | 248 | 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 the | 307 | // resolveServiceDevices resolves every declared logical device against the | ||
| 249 | // current administrative policies into runtime CDI IDs. Errors name only | 308 | // 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, err | 813 | 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...), | ||