From e16a3339ad1fe797b4c94ed293c8b0c9a90ba818 Mon Sep 17 00:00:00 2001 From: Allison Larson Date: Thu, 7 Aug 2025 12:03:18 -0700 Subject: [PATCH] Add CSI Volume Sentinel Policy scaffolding (#26438) * Add ent policy enforcement stubs to CSI Volume create/register * Wire policy override/warnings through CSI volume register/create * Add new scope to sentinel apply * Sanitize CSISecrets & CSIMountOptions * Add sentinel policy scope to ui * Update docs for new sentinel scope/policy * Create new api funcs for CSI endpoints * fix sentinel csi ui test * Update sentinel-policy docs * Add changelog * Update docs from feedback --- .changelog/26438.txt | 3 + api/csi.go | 48 ++++++++++++-- api/sentinel.go | 1 + command/sentinel_apply.go | 4 +- command/volume_create.go | 5 +- command/volume_create_csi.go | 16 ++++- command/volume_create_csi_test.go | 62 +++++++++++++++++++ command/volume_register.go | 5 +- command/volume_register_csi.go | 14 ++++- command/volume_register_csi_test.go | 51 +++++++++++++++ nomad/csi_endpoint.go | 18 ++++++ nomad/csi_endpoint_ce.go | 16 +++++ nomad/csi_endpoint_test.go | 23 ++----- nomad/structs/csi.go | 34 ++++++++++ nomad/structs/csi_test.go | 31 ++++++++++ ui/app/components/sentinel-policy-editor.hbs | 3 + ui/mirage/factories/sentinel-policy.js | 2 +- ui/mirage/scenarios/default.js | 17 +++++ ui/tests/acceptance/sentinel-policies-test.js | 22 +++++++ website/content/api-docs/volumes.mdx | 30 ++++++--- website/content/commands/sentinel/apply.mdx | 1 + website/content/commands/volume/create.mdx | 3 +- website/content/commands/volume/register.mdx | 5 +- .../docs/reference/sentinel-policy.mdx | 30 +++++++++ 24 files changed, 396 insertions(+), 48 deletions(-) create mode 100644 .changelog/26438.txt create mode 100644 command/volume_create_csi_test.go create mode 100644 nomad/csi_endpoint_ce.go diff --git a/.changelog/26438.txt b/.changelog/26438.txt new file mode 100644 index 000000000..b692c6e70 --- /dev/null +++ b/.changelog/26438.txt @@ -0,0 +1,3 @@ +```release-note:improvement +sentinel (Enterprise): Added policy scope for csi-volumes +``` diff --git a/api/csi.go b/api/csi.go index 517aac3bb..47f48f478 100644 --- a/api/csi.go +++ b/api/csi.go @@ -76,13 +76,27 @@ func (v *CSIVolumes) Info(id string, q *QueryOptions) (*CSIVolume, *QueryMeta, e // Register registers a single CSIVolume with Nomad. The volume must already // exist in the external storage provider. func (v *CSIVolumes) Register(vol *CSIVolume, w *WriteOptions) (*WriteMeta, error) { - req := CSIVolumeRegisterRequest{ + req := &CSIVolumeRegisterRequest{ Volumes: []*CSIVolume{vol}, } - meta, err := v.client.put("/v1/volume/csi/"+vol.ID, req, nil, w) + _, meta, err := v.RegisterOpts(req, w) return meta, err } +// RegisterOpts registers a single CSIVolume with Nomad. The volume must already +// exist in the external storage provider. It expects a single volume in the +// request. +func (v *CSIVolumes) RegisterOpts(req *CSIVolumeRegisterRequest, w *WriteOptions) (*CSIVolumeRegisterResponse, *WriteMeta, error) { + if w == nil { + w = &WriteOptions{} + } + vol := req.Volumes[0] + resp := &CSIVolumeRegisterResponse{} + meta, err := v.client.put("/v1/volume/csi/"+vol.ID, req, resp, w) + + return resp, meta, err +} + // Deregister deregisters a single CSIVolume from Nomad. The volume will not be deleted from the external storage provider. func (v *CSIVolumes) Deregister(id string, force bool, w *WriteOptions) error { _, err := v.client.delete(fmt.Sprintf("/v1/volume/csi/%v?force=%t", url.PathEscape(id), force), nil, nil, w) @@ -97,9 +111,21 @@ func (v *CSIVolumes) Create(vol *CSIVolume, w *WriteOptions) ([]*CSIVolume, *Wri Volumes: []*CSIVolume{vol}, } + resp, meta, err := v.CreateOpts(&req, w) + return resp.Volumes, meta, err +} + +// CreateOpts creates a single CSIVolume in an external storage provider and +// registers it with Nomad. You do not need to call Register if this call is +// successful. It expects a single volume in the request. +func (v *CSIVolumes) CreateOpts(req *CSIVolumeCreateRequest, w *WriteOptions) (*CSIVolumeCreateResponse, *WriteMeta, error) { + if w == nil { + w = &WriteOptions{} + } + vol := req.Volumes[0] resp := &CSIVolumeCreateResponse{} meta, err := v.client.put(fmt.Sprintf("/v1/volume/csi/%v/create", vol.ID), req, resp, w) - return resp.Volumes, meta, err + return resp, meta, err } // Delete deletes a CSI volume from an external storage provider. The ID @@ -452,19 +478,33 @@ func (v CSIVolumeExternalStubSort) Swap(i, j int) { type CSIVolumeCreateRequest struct { Volumes []*CSIVolume + + // PolicyOverride overrides Sentinel soft-mandatory policy enforcement + PolicyOverride bool + WriteRequest } type CSIVolumeCreateResponse struct { - Volumes []*CSIVolume + Volumes []*CSIVolume + Warnings string QueryMeta } type CSIVolumeRegisterRequest struct { Volumes []*CSIVolume + + // PolicyOverride overrides Sentinel soft-mandatory policy enforcement + PolicyOverride bool + WriteRequest } +type CSIVolumeRegisterResponse struct { + Volumes []*CSIVolume + Warnings string +} + type CSIVolumeDeregisterRequest struct { VolumeIDs []string WriteRequest diff --git a/api/sentinel.go b/api/sentinel.go index 1e9330884..e32b6fc53 100644 --- a/api/sentinel.go +++ b/api/sentinel.go @@ -87,4 +87,5 @@ type SentinelPolicyListStub struct { const ( SentinelScopeSubmitJob = "submit-job" SentinelScopeSubmitHostVolume = "submit-host-volume" + SentinelScopeSubmitCSIVolume = "submit-csi-volume" ) diff --git a/command/sentinel_apply.go b/command/sentinel_apply.go index 7db40022b..e19a08e45 100644 --- a/command/sentinel_apply.go +++ b/command/sentinel_apply.go @@ -39,7 +39,7 @@ Apply Options: -scope Sets the scope of the policy and when it should be enforced. One of - "submit-job" or "submit-host-volume". + "submit-job", "submit-host-volume" or "submit-csi-volume". -level (default: advisory) Sets the enforcement level of the policy. Must be one of advisory, @@ -109,7 +109,7 @@ func (c *SentinelApplyCommand) Run(args []string) int { } switch scope { - case api.SentinelScopeSubmitJob, api.SentinelScopeSubmitHostVolume: + case api.SentinelScopeSubmitJob, api.SentinelScopeSubmitHostVolume, api.SentinelScopeSubmitCSIVolume: case "": c.Ui.Error("-scope flag is required") return 1 diff --git a/command/volume_create.go b/command/volume_create.go index cca272e72..4d89a9e3f 100644 --- a/command/volume_create.go +++ b/command/volume_create.go @@ -51,8 +51,7 @@ Create Options: volumes only. -policy-override - Sets the flag to force override any soft mandatory Sentinel policies. Used - for dynamic host volumes only. + Sets the flag to force override any soft mandatory Sentinel policies. ` return strings.TrimSpace(helpText) @@ -134,7 +133,7 @@ func (c *VolumeCreateCommand) Run(args []string) int { switch strings.ToLower(volType) { case "csi": - return c.csiCreate(client, ast) + return c.csiCreate(client, ast, override) case "host": return c.hostVolumeCreate(client, ast, detach, verbose, override, volID) default: diff --git a/command/volume_create_csi.go b/command/volume_create_csi.go index 1d3ac47b9..d9006aaae 100644 --- a/command/volume_create_csi.go +++ b/command/volume_create_csi.go @@ -10,19 +10,29 @@ import ( "github.com/hashicorp/nomad/api" ) -func (c *VolumeCreateCommand) csiCreate(client *api.Client, ast *ast.File) int { +func (c *VolumeCreateCommand) csiCreate(client *api.Client, ast *ast.File, override bool) int { vol, err := csiDecodeVolume(ast) if err != nil { c.Ui.Error(fmt.Sprintf("Error decoding the volume definition: %s", err)) return 1 } - vols, _, err := client.CSIVolumes().Create(vol, nil) + resp, _, err := client.CSIVolumes().CreateOpts(&api.CSIVolumeCreateRequest{ + Volumes: []*api.CSIVolume{vol}, + PolicyOverride: override, + }, nil) if err != nil { c.Ui.Error(fmt.Sprintf("Error creating volume: %s", err)) return 1 } - for _, vol := range vols { + + if resp.Warnings != "" { + c.Ui.Output( + c.Colorize().Color( + fmt.Sprintf("[bold][yellow]Volume Warnings:\n%s[reset]\n", resp.Warnings))) + } + + for _, vol := range resp.Volumes { // note: the command only ever returns 1 volume from the API c.Ui.Output(fmt.Sprintf( "Created external volume %s with ID %s", vol.ExternalID, vol.ID)) diff --git a/command/volume_create_csi_test.go b/command/volume_create_csi_test.go new file mode 100644 index 000000000..880cb40bd --- /dev/null +++ b/command/volume_create_csi_test.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "os" + "testing" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" +) + +func TestVolumeCreateCommand_Run(t *testing.T) { + ci.Parallel(t) + + srv, client, url := testServer(t, true, nil) + t.Cleanup(srv.Shutdown) + waitForNodes(t, client) + + _, err := client.Namespaces().Register(&api.Namespace{Name: "prod"}, nil) + must.NoError(t, err) + + ui := cli.NewMockUi() + cmd := &VolumeCreateCommand{ + Meta: Meta{Ui: ui}, + } + + volumeHCL := ` +type = "csi" +id = "test-volume" +name = "test-volume" +external_id = "external-test-volume" +plugin_id = "test-plugin" +capacity_min = "1GiB" +capacity_max = "10GiB" + +capability { + access_mode = "single-node-reader-only" + attachment_mode = "block-device" +} +` + + file, err := os.CreateTemp(t.TempDir(), "csi-volume-test-*.hcl") + must.NoError(t, err) + _, err = file.WriteString(volumeHCL) + must.NoError(t, err) + + // Since we can't easily mock the API client to fake a CSI plugin running, + // we'll expect this to fail with a plugin-related error. The flow and + // parsing can still be tested. + args := []string{"-address", url, file.Name()} + code := cmd.Run(args) + must.Eq(t, 1, code) + + // Verify error output contains expected message about volume creation + output := ui.ErrorWriter.String() + must.StrContains(t, output, "Error creating volume") + must.StrContains(t, output, "no CSI plugin named: test-plugin could be found") +} diff --git a/command/volume_register.go b/command/volume_register.go index ec510a4e5..4bb993d87 100644 --- a/command/volume_register.go +++ b/command/volume_register.go @@ -43,8 +43,7 @@ Register Options: host volumes only. -policy-override - Sets the flag to force override any soft mandatory Sentinel policies. Used - for dynamic host volumes only. + Sets the flag to force override any soft mandatory Sentinel policies. ` return strings.TrimSpace(helpText) @@ -123,7 +122,7 @@ func (c *VolumeRegisterCommand) Run(args []string) int { switch volType { case "csi": - return c.csiRegister(client, ast) + return c.csiRegister(client, ast, override) case "host": return c.hostVolumeRegister(client, ast, override, volID) default: diff --git a/command/volume_register_csi.go b/command/volume_register_csi.go index 923764ef4..aa5e60551 100644 --- a/command/volume_register_csi.go +++ b/command/volume_register_csi.go @@ -15,17 +15,27 @@ import ( "github.com/mitchellh/mapstructure" ) -func (c *VolumeRegisterCommand) csiRegister(client *api.Client, ast *ast.File) int { +func (c *VolumeRegisterCommand) csiRegister(client *api.Client, ast *ast.File, override bool) int { vol, err := csiDecodeVolume(ast) if err != nil { c.Ui.Error(fmt.Sprintf("Error decoding the volume definition: %s", err)) return 1 } - _, err = client.CSIVolumes().Register(vol, nil) + resp, _, err := client.CSIVolumes().RegisterOpts(&api.CSIVolumeRegisterRequest{ + Volumes: []*api.CSIVolume{vol}, + PolicyOverride: override, + }, nil) if err != nil { c.Ui.Error(fmt.Sprintf("Error registering volume: %s", err)) return 1 } + if resp.Warnings != "" { + c.Ui.Output( + c.Colorize().Color( + fmt.Sprintf("[bold][yellow]Volume Warnings:\n%s[reset]\n", resp.Warnings))) + } + + vol = resp.Volumes[0] // note: the command only ever returns 1 volume from the API c.Ui.Output(fmt.Sprintf("Volume %q registered", vol.ID)) return 0 diff --git a/command/volume_register_csi_test.go b/command/volume_register_csi_test.go index 5a757674b..bc4310433 100644 --- a/command/volume_register_csi_test.go +++ b/command/volume_register_csi_test.go @@ -4,8 +4,10 @@ package command import ( + "os" "testing" + "github.com/hashicorp/cli" "github.com/hashicorp/hcl" "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" @@ -203,3 +205,52 @@ topology_request { }) } } + +func TestVolumeRegisterCommand_Run(t *testing.T) { + ci.Parallel(t) + + srv, client, url := testServer(t, true, nil) + t.Cleanup(srv.Shutdown) + waitForNodes(t, client) + + _, err := client.Namespaces().Register(&api.Namespace{Name: "prod"}, nil) + must.NoError(t, err) + + ui := cli.NewMockUi() + cmd := &VolumeRegisterCommand{ + Meta: Meta{Ui: ui}, + } + + volumeHCL := ` +type = "csi" +id = "test-volume" +name = "test-volume" +external_id = "external-test-volume" +plugin_id = "test-plugin" +capacity_min = "1GiB" +capacity_max = "10GiB" + +capability { + access_mode = "single-node-reader-only" + attachment_mode = "block-device" +} +` + + file, err := os.CreateTemp(t.TempDir(), "csi-volume-test-*.hcl") + must.NoError(t, err) + _, err = file.WriteString(volumeHCL) + must.NoError(t, err) + + // Since we can't easily mock the API client to fake a CSI plugin running, + // we'll expect this to fail with a plugin-related error. The flow and + // parsing can still be tested. + args := []string{"-address", url, file.Name()} + + code := cmd.Run(args) + must.Eq(t, 1, code) + + // Verify error output contains expected message about volume creation + output := ui.ErrorWriter.String() + must.StrContains(t, output, "Error registering volume") + must.StrContains(t, output, "no CSI plugin named: test-plugin could be found") +} diff --git a/nomad/csi_endpoint.go b/nomad/csi_endpoint.go index 4258a532b..f2c02b79e 100644 --- a/nomad/csi_endpoint.go +++ b/nomad/csi_endpoint.go @@ -354,6 +354,15 @@ func (v *CSIVolume) Register(args *structs.CSIVolumeRegisterRequest, reply *stru if err := v.controllerValidateVolume(args, vol, plugin); err != nil { return err } + + warn, err := v.enforceEnterprisePolicy(snap, vol, existingVol, args.GetIdentity().GetACLToken(), args.PolicyOverride) + if warn != nil { + reply.Warnings = warn.Error() + } + + if err != nil { + return err + } } _, index, err := v.srv.raftApply(structs.CSIVolumeRegisterRequestType, args) @@ -1093,6 +1102,15 @@ func (v *CSIVolume) Create(args *structs.CSIVolumeCreateRequest, reply *structs. validatedVols = append(validatedVols, validated{vol, plugin, current}) + + warn, err := v.enforceEnterprisePolicy(snap, vol, current, args.GetIdentity().GetACLToken(), args.PolicyOverride) + if warn != nil { + reply.Warnings = warn.Error() + } + + if err != nil { + return err + } } // Attempt to create all the validated volumes and write only successfully diff --git a/nomad/csi_endpoint_ce.go b/nomad/csi_endpoint_ce.go new file mode 100644 index 000000000..6a6423a54 --- /dev/null +++ b/nomad/csi_endpoint_ce.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build !ent +// +build !ent + +package nomad + +import ( + "github.com/hashicorp/nomad/nomad/state" + "github.com/hashicorp/nomad/nomad/structs" +) + +func (v *CSIVolume) enforceEnterprisePolicy(_ *state.StateSnapshot, _ *structs.CSIVolume, _ *structs.CSIVolume, _ *structs.ACLToken, _ bool) (error, error) { + return nil, nil +} diff --git a/nomad/csi_endpoint_test.go b/nomad/csi_endpoint_test.go index 744dd38c2..d392b98d0 100644 --- a/nomad/csi_endpoint_test.go +++ b/nomad/csi_endpoint_test.go @@ -285,6 +285,7 @@ func TestCSIVolumeEndpoint_Register(t *testing.T) { err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Register", req1, resp1) must.NoError(t, err) must.NotEq(t, uint64(0), resp1.Index) + must.Eq(t, "", resp1.Warnings) // Get the volume back out req2 := &structs.CSIVolumeGetRequest{ @@ -1282,25 +1283,11 @@ func TestCSIVolumeEndpoint_Create(t *testing.T) { must.NoError(t, err) must.NotEq(t, uint64(0), resp1.Index) - // Get the volume back out - req2 := &structs.CSIVolumeGetRequest{ - ID: volID, - QueryOptions: structs.QueryOptions{ - Region: "global", - Namespace: ns, - AuthToken: validToken, - }, - } - resp2 := &structs.CSIVolumeGetResponse{} - err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", req2, resp2) - must.NoError(t, err) - must.Eq(t, resp1.Index, resp2.Index) + // Check the new volume in the response + must.Eq(t, 1, len(resp1.Volumes)) + must.Eq(t, "", resp1.Warnings) + vol := resp1.Volumes[0] - vol := resp2.Volume - must.NotNil(t, vol) - must.Eq(t, volID, vol.ID) - - // these fields are set from the args must.Eq(t, "csi.CSISecrets(map[mysecret:[REDACTED]])", vol.Secrets.String()) must.Eq(t, "csi.CSIOptions(FSType: ext4, MountFlags: [REDACTED])", diff --git a/nomad/structs/csi.go b/nomad/structs/csi.go index 70efacfe9..7aee0a04d 100644 --- a/nomad/structs/csi.go +++ b/nomad/structs/csi.go @@ -204,6 +204,15 @@ func (o *CSIMountOptions) GoString() string { return o.String() } +// Sanitize returns a copy of the CSIMountOptions with sensitive data redacted +func (o *CSIMountOptions) Sanitize() *CSIMountOptions { + redacted := *o + if len(o.MountFlags) != 0 { + redacted.MountFlags = []string{"[REDACTED]"} + } + return &redacted +} + // CSISecrets contain optional additional configuration that can be used // when specifying that a Volume should be used with VolumeAccessTypeMount. type CSISecrets map[string]string @@ -225,6 +234,15 @@ func (s *CSISecrets) GoString() string { return s.String() } +// Sanitize returns a copy of the CSISecrets with sensitive data redacted +func (s *CSISecrets) Sanitize() *CSISecrets { + redacted := CSISecrets{} + for k := range *s { + redacted[k] = "[REDACTED]" + } + return &redacted +} + type CSIVolumeClaim struct { AllocationID string NodeID string @@ -852,10 +870,18 @@ func (v *CSIVolume) Merge(other *CSIVolume) error { type CSIVolumeRegisterRequest struct { Volumes []*CSIVolume Timestamp int64 // UnixNano + + // PolicyOverride is set when the user is attempting to override any + // Enterprise policy enforcement + PolicyOverride bool + WriteRequest } type CSIVolumeRegisterResponse struct { + // Warnings are non-fatal messages from Enterprise policy enforcement + Warnings string + QueryMeta } @@ -872,11 +898,19 @@ type CSIVolumeDeregisterResponse struct { type CSIVolumeCreateRequest struct { Volumes []*CSIVolume Timestamp int64 // UnixNano + + // PolicyOverride is set when the user is attempting to override any + // Enterprise policy enforcement + PolicyOverride bool + WriteRequest } type CSIVolumeCreateResponse struct { Volumes []*CSIVolume + + // Warnings are non-fatal messages from Enterprise policy enforcement + Warnings string QueryMeta } diff --git a/nomad/structs/csi_test.go b/nomad/structs/csi_test.go index a2a087edd..e3189f3ad 100644 --- a/nomad/structs/csi_test.go +++ b/nomad/structs/csi_test.go @@ -1092,3 +1092,34 @@ func TestTaskCSIPluginConfig_Equal(t *testing.T) { Apply: func(c *TaskCSIPluginConfig) { c.HealthTimeout = 1 * time.Second }, }}) } + +func TestCSISecretsSanitize(t *testing.T) { + ci.Parallel(t) + + orig := &CSISecrets{ + "foo": "bar", + "baz": "qux", + } + + sanitized := orig.Sanitize() + must.NotEq(t, orig, sanitized) + + for _, v := range *sanitized { + must.Eq(t, v, "[REDACTED]") + } +} + +func TestCSIMountOptionsSanitize(t *testing.T) { + ci.Parallel(t) + + orig := &CSIMountOptions{ + FSType: "ext4", + MountFlags: []string{"ro", "noatime"}, + } + + sanitized := orig.Sanitize() + must.NotEq(t, orig, sanitized) + + must.Eq(t, sanitized.FSType, orig.FSType) + must.Eq(t, sanitized.MountFlags, []string{"[REDACTED]"}) +} diff --git a/ui/app/components/sentinel-policy-editor.hbs b/ui/app/components/sentinel-policy-editor.hbs index 99931e7fd..a08aa781a 100644 --- a/ui/app/components/sentinel-policy-editor.hbs +++ b/ui/app/components/sentinel-policy-editor.hbs @@ -85,6 +85,9 @@ Submit Host Volume + + + Submit CSI Volume diff --git a/ui/mirage/factories/sentinel-policy.js b/ui/mirage/factories/sentinel-policy.js index 44e1ab71b..51144ebac 100644 --- a/ui/mirage/factories/sentinel-policy.js +++ b/ui/mirage/factories/sentinel-policy.js @@ -22,6 +22,6 @@ export default Factory.extend({ main = rule { false }`, - scope: pickOne(['submit-job', 'submit-host-volume']), + scope: pickOne(['submit-job', 'submit-host-volume', 'submit-csi-volume']), enforcementLevel: pickOne(['advisory', 'soft-mandatory', 'hard-mandatory']), }); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 2ead57feb..5708131fc 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -718,6 +718,23 @@ main = rule { has_tag() } scope: 'submit-host-volume', }); server.createList('sentinel-policy', 5); + + server.create('sentinel-policy', { + id: 'csi-volume-policy', + name: 'csi-volume-policy', + description: 'A sentinel policy generated by Mirage', + enforcementLevel: 'soft-mandatory', + policy: ` +has_tag = func() { +print("volume is missing tag") +tag = volume.parameters["tag"] else 0 +return tag is not 0 +} +main = rule { has_tag() } + `, + scope: 'submit-csi-volume', + }); + server.createList('sentinel-policy', 5); } faker.seed(1); diff --git a/ui/tests/acceptance/sentinel-policies-test.js b/ui/tests/acceptance/sentinel-policies-test.js index 1d606ec44..1d8cfdab7 100644 --- a/ui/tests/acceptance/sentinel-policies-test.js +++ b/ui/tests/acceptance/sentinel-policies-test.js @@ -128,6 +128,28 @@ module('Acceptance | sentinel policies', function (hooks) { assert .dom(policyRow.querySelector('[data-test-sentinel-policy-scope]')) .hasText('submit-host-volume'); + + const policyCsi = server.db.sentinelPolicies.findBy( + (sp) => sp.name === 'csi-volume-policy' + ); + await click('[data-test-sentinel-policy-name="csi-volume-policy"]'); + assert.equal( + currentURL(), + `/administration/sentinel-policies/${policyCsi.id}` + ); + + await click('[data-test-scope="submit-csi-volume"]'); + await click('button[data-test-save-policy]'); + assert.dom('.flash-message.alert-success').exists(); + + await Administration.visitSentinelPolicies(); + const policyRowCsi = find( + '[data-test-sentinel-policy-name="csi-volume-policy"]' + ).closest('[data-test-sentinel-policy-row]'); + assert.dom(policyRowCsi).exists(); + assert + .dom(policyRowCsi.querySelector('[data-test-sentinel-policy-scope]')) + .hasText('submit-csi-volume'); }); test('New Sentinel Policy from Scratch', async function (assert) { diff --git a/website/content/api-docs/volumes.mdx b/website/content/api-docs/volumes.mdx index afee25f47..735cb1f09 100644 --- a/website/content/api-docs/volumes.mdx +++ b/website/content/api-docs/volumes.mdx @@ -313,7 +313,7 @@ The following table shows this endpoint's support for [blocking queries][] and | Blocking Queries | ACL Required | | ---------------- | ---------------------------- | -| `NO` | `namespace:csi-write-volume` | +| `NO` | `namespace:csi-write-volume`.
`namespace:sentinel-override` if `PolicyOverride` set | ### Parameters @@ -321,13 +321,20 @@ The following table shows this endpoint's support for [blocking queries][] and volume. This must be the full ID. Specify this as part of the path. -### Sample Payload +- `Volumes` ([Volumes][csi-volume-spec]: ``) - Specifies + the JSON definition of the CSI volume. You should include the ID field if you + are updating an existing volume. Note that you must include the `NodeID` field + for the register API. -The payload must include a JSON document that describes the volume's -parameters. Note that the `NodeID` field is required for the register API. +- `PolicyOverride` `(bool: false)` - If set, Nomad overrides any soft mandatory + Sentinel policies. This field allows creating a volume when it would be denied + by policy. + +### Sample Payload ```json { + "PolicyOverride": false, "Volumes": [ { "ExternalID": "vol-abcdef", @@ -382,7 +389,7 @@ The following table shows this endpoint's support for [blocking queries][] and | Blocking Queries | ACL Required | | ---------------- | ---------------------------- | -| `NO` | `namespace:csi-write-volume` | +| `NO` | `namespace:csi-write-volume`.
`namespace:sentinel-override` if `PolicyOverride` set | ### Parameters @@ -390,13 +397,19 @@ The following table shows this endpoint's support for [blocking queries][] and volume. This must be the full ID. Specify this as part of the path. -### Sample Payload +- `Volumes` ([Volumes][csi-volume-spec]: ``) - Specifies + the JSON definition of the CSI volume. You should include the ID field if you + are updating an existing volume. -The payload must include a JSON document that describes the volume's -parameters. +- `PolicyOverride` `(bool: false)` - If set, Nomad overrides any soft mandatory + Sentinel policies. This field allows creating a volume when it would be denied + by policy. + +### Sample Payload ```json { + "PolicyOverride": false, "Volumes": [ { "ID": "volume-id1", @@ -1226,3 +1239,4 @@ $ curl \ [csi_plugins_internals]: /nomad/docs/architecture/storage/csi [Create CSI Volume]: #create-csi-volume [Volume Expansion]: /nomad/docs/other-specifications/volume/csi#volume-expansion +[csi-volume-spec]: /nomad/docs/other-specifications/volume/csi diff --git a/website/content/commands/sentinel/apply.mdx b/website/content/commands/sentinel/apply.mdx index f763ee8a7..c5a354d0c 100644 --- a/website/content/commands/sentinel/apply.mdx +++ b/website/content/commands/sentinel/apply.mdx @@ -38,6 +38,7 @@ requires a management token. - The `submit-job` scope for registering jobs - The `submit-host-volume` scope for creating or updating dynamic host volumes. + - The `submit-csi-volume` scope for creating or updating CSI volumes. Refer to the [Sentinel guide](/nomad/docs/reference/sentinel-policy) for scope details. diff --git a/website/content/commands/volume/create.mdx b/website/content/commands/volume/create.mdx index f99fb2aa8..33a2992e1 100644 --- a/website/content/commands/volume/create.mdx +++ b/website/content/commands/volume/create.mdx @@ -45,8 +45,7 @@ volumes or `host-volume-create` for dynamic host volumes. dynamic host volumes only. Not valid for CSI volumes. - `-policy-override`: Sets the flag to force override any soft mandatory - Sentinel policies. Used for dynamic host volumes only. Not valid for CSI - volumes. + Sentinel policies. ## Volume specification diff --git a/website/content/commands/volume/register.mdx b/website/content/commands/volume/register.mdx index 1c33a3a4e..a68ff9a3c 100644 --- a/website/content/commands/volume/register.mdx +++ b/website/content/commands/volume/register.mdx @@ -35,8 +35,7 @@ volumes or `host-volume-register` for dynamic host volumes. ## Options - `-policy-override`: Sets the flag to force override any soft mandatory - Sentinel policies. Used for dynamic host volumes only. Not valid for CSI - volumes. + Sentinel policies. ## Volume specification @@ -113,3 +112,5 @@ the exact section. [csi_plugins_internals]: /nomad/docs/architecture/storage/csi [volume_specification]: /nomad/docs/other-specifications/volume [`volume create`]: /nomad/commands/volume/create +[csi_vol_spec]: /nomad/docs/other-specifications/volume/csi +[host_vol_spec]: /nomad/docs/other-specifications/volume/host diff --git a/website/content/docs/reference/sentinel-policy.mdx b/website/content/docs/reference/sentinel-policy.mdx index 05983e874..e55a44605 100644 --- a/website/content/docs/reference/sentinel-policy.mdx +++ b/website/content/docs/reference/sentinel-policy.mdx @@ -74,6 +74,23 @@ The following top-level objects are available to policies in the * `node_pool`: the node pool of the node the volume has been placed on. This is a [Sentinel Node Pool Object](#sentinel-node-pool-objects). +### submit-csi-volume scope + +The following top-level objects are available to policies in the +`submit-csi-volume` scope automatically, without an explicit import. + +- `volume`: the submitted volume. This is a [Sentinel CSI Volume + Object](#sentinel-csi-volume-objects). +- `existing_volume`: the previous version of the volume. If `volume_exists` is + true, this is always non-nil. This is also a [Sentinel CSI Volume + Object](#sentinel-csi-volume-objects). +- `volume_exists`: a boolean field that indicates that a previous version of the + volume exists. +- `nomad_acl_token`: the ACL token the job was submitted with. This is a + [Sentinel Nomad ACL Token Object](#sentinel-acl-token-objects). +- `namespace`: the namespace the job is in. This is a [Sentinel Nomad Namespace + Object](#sentinel-namespace-objects). + Sentinel convention for identifiers is lower case and separated by underscores. All fields on an object are accessed by the same name, converted to lower case and separating camel case to underscores. @@ -143,6 +160,18 @@ converted to the Sentinel convention. Here are some examples: | `volume.Name` | `volume.name` | | `volume.RequestedCapabilities[0].AccessMode` | `volume.requested_capabilities[0].access_mode` | +## Sentinel CSI Volume Objects + +The `volume` object maps to the [CSI Volume][], with the fields +converted to the Sentinel convention. Here are some examples: + +| CSI Volume Field | Sentinel Accessor | +|----------------------------------------------|------------------------------------------------| +| `volume.Name` | `volume.name` | +| `volume.RequestedCapabilities[0].AccessMode` | `volume.requested_capabilities[0].access_mode` | + +Note that the `volume.Secrets` values and `volume.MountOptions.MountFlags` are always redacted to prevent credential leaks. + [Nomad Sentinel Tutorial]: /nomad/docs/govern/sentinel [`nomad sentinel`]: /nomad/commands/sentinel @@ -153,3 +182,4 @@ converted to the Sentinel convention. Here are some examples: [Node]: https://github.com/hashicorp/nomad/blob/v1.9.4/nomad/structs/structs.go#L2086-L2210 [Node Pool]: https://github.com/hashicorp/nomad/blob/v1.9.4/nomad/structs/node_pool.go#L46-L68 [Dynamic Host Volume]: https://github.com/hashicorp/nomad/blob/main/nomad/structs/host_volumes.go#L18-L87 +[CSI Volume]: https://github.com/hashicorp/nomad/blob/main/nomad/structs/csi.go#L249-L312