diff --git a/CHANGELOG.md b/CHANGELOG.md index 830859de2..9b3a46a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +## HEAD (Unreleased) + +- Improvements to `pulumi policy` functionality. Add ability to remove & disable Policy Packs. + +- Breaking change for Policy which is in Public Preview: Change `pulumi policy apply` to `pulumi policy enable`, and allow users to specify the Policy Group. + ## 1.8.1 (2019-12-20) - Fix a panic in `pulumi stack select`. [#3687](https://github.com/pulumi/pulumi/pull/3687) @@ -34,6 +40,10 @@ CHANGELOG - Fix bug in determining PRNumber and BuildURL for an Azure Pipelines CI environment. [#3677](https://github.com/pulumi/pulumi/pull/3677) +- Improvements to `pulumi policy` functionality. Add ability to remove & disable Policy Packs. + +- Breaking change for Policy which is in Public Preview: Change `pulumi policy apply` to `pulumi policy enable`, and allow users to specify the Policy Group. + ## 1.7.1 (2019-12-13) - Fix [SxS issue](https://github.com/pulumi/pulumi/issues/3652) introduced in 1.7.0 when assigning diff --git a/cmd/policy.go b/cmd/policy.go index 5a001871a..85263a5f0 100644 --- a/cmd/policy.go +++ b/cmd/policy.go @@ -26,9 +26,11 @@ func newPolicyCmd() *cobra.Command { Args: cmdutil.NoArgs, } + cmd.AddCommand(newPolicyDisableCmd()) + cmd.AddCommand(newPolicyEnableCmd()) cmd.AddCommand(newPolicyNewCmd()) cmd.AddCommand(newPolicyPublishCmd()) - cmd.AddCommand(newPolicyApplyCmd()) + cmd.AddCommand(newPolicyRmCmd()) return cmd } diff --git a/cmd/policy_disable.go b/cmd/policy_disable.go new file mode 100644 index 000000000..bddfd3eaa --- /dev/null +++ b/cmd/policy_disable.go @@ -0,0 +1,61 @@ +// Copyright 2016-2020, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "strconv" + + "github.com/pkg/errors" + "github.com/pulumi/pulumi/pkg/backend" + "github.com/pulumi/pulumi/pkg/util/cmdutil" + "github.com/spf13/cobra" +) + +type policyDisableArgs struct { + policyGroup string +} + +func newPolicyDisableCmd() *cobra.Command { + args := policyDisableArgs{} + + var cmd = &cobra.Command{ + Use: "disable / ", + Args: cmdutil.ExactArgs(2), + Short: "Disable a Policy Pack for a Pulumi organization", + Long: "Disable a Policy Pack for a Pulumi organization", + Run: cmdutil.RunFunc(func(cmd *cobra.Command, cliArgs []string) error { + // Obtain current PolicyPack, tied to the Pulumi service backend. + policyPack, err := requirePolicyPack(cliArgs[0]) + if err != nil { + return err + } + + version, err := strconv.Atoi(cliArgs[1]) + if err != nil { + return errors.Wrapf(err, "Could not parse version (should be an integer)") + } + + // Attempt to disable the Policy Pack. + return policyPack.Disable(commandContext(), args.policyGroup, backend.PolicyPackOperation{ + Version: version, Scopes: cancellationScopes}) + }), + } + + cmd.PersistentFlags().StringVar( + &args.policyGroup, "policy-group", "", + "The Policy Group for which the Policy Pack will be disabled; if not specified, the default Policy Group is used") + + return cmd +} diff --git a/cmd/policy_enable.go b/cmd/policy_enable.go new file mode 100644 index 000000000..0d78cbf30 --- /dev/null +++ b/cmd/policy_enable.go @@ -0,0 +1,61 @@ +// Copyright 2016-2020, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "strconv" + + "github.com/pkg/errors" + "github.com/pulumi/pulumi/pkg/backend" + "github.com/pulumi/pulumi/pkg/util/cmdutil" + "github.com/spf13/cobra" +) + +type policyEnableArgs struct { + policyGroup string +} + +func newPolicyEnableCmd() *cobra.Command { + args := policyEnableArgs{} + + var cmd = &cobra.Command{ + Use: "enable / ", + Args: cmdutil.ExactArgs(2), + Short: "Enable a Policy Pack for a Pulumi organization", + Long: "Enable a Policy Pack for a Pulumi organization", + Run: cmdutil.RunFunc(func(cmd *cobra.Command, cliArgs []string) error { + // Obtain current PolicyPack, tied to the Pulumi service backend. + policyPack, err := requirePolicyPack(cliArgs[0]) + if err != nil { + return err + } + + version, err := strconv.Atoi(cliArgs[1]) + if err != nil { + return errors.Wrapf(err, "Could not parse version (should be an integer)") + } + + // Attempt to enable the PolicyPack. + return policyPack.Apply(commandContext(), args.policyGroup, backend.PolicyPackOperation{ + Version: version, Scopes: cancellationScopes}) + }), + } + + cmd.PersistentFlags().StringVar( + &args.policyGroup, "policy-group", "", + "The Policy Group for which the Policy Pack will be enabled; if not specified, the default Policy Group is used") + + return cmd +} diff --git a/cmd/policy_apply.go b/cmd/policy_rm.go similarity index 65% rename from cmd/policy_apply.go rename to cmd/policy_rm.go index 0ecfb354e..f1ba2efdb 100644 --- a/cmd/policy_apply.go +++ b/cmd/policy_rm.go @@ -23,32 +23,27 @@ import ( "github.com/spf13/cobra" ) -func newPolicyApplyCmd() *cobra.Command { +func newPolicyRmCmd() *cobra.Command { var cmd = &cobra.Command{ - Use: "apply / ", + Use: "rm / ", Args: cmdutil.ExactArgs(2), - Short: "Apply a Policy Pack to a Pulumi organization", - Long: "Apply a Policy Pack to a Pulumi organization", - Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error { - // + Short: "Removes a Policy Pack from a Pulumi organization", + Long: "Removes a Policy Pack from a Pulumi organization. " + + "The Policy Pack must be disabled from all Policy Groups before it can be removed.", + Run: cmdutil.RunFunc(func(cmd *cobra.Command, cliArgs []string) error { // Obtain current PolicyPack, tied to the Pulumi service backend. - // - - policyPack, err := requirePolicyPack(args[0]) + policyPack, err := requirePolicyPack(cliArgs[0]) if err != nil { return err } - version, err := strconv.Atoi(args[1]) + version, err := strconv.Atoi(cliArgs[1]) if err != nil { return errors.Wrapf(err, "Could not parse version (should be an integer)") } - // - // Attempt to publish the PolicyPack. - // - - return policyPack.Apply(commandContext(), backend.ApplyOperation{ + // Attempt to remove the Policy Pack. + return policyPack.Remove(commandContext(), backend.PolicyPackOperation{ Version: version, Scopes: cancellationScopes}) }), } diff --git a/pkg/apitype/policy.go b/pkg/apitype/policy.go index d27cfcc8a..14cded517 100644 --- a/pkg/apitype/policy.go +++ b/pkg/apitype/policy.go @@ -14,6 +14,9 @@ package apitype +// DefaultPolicyGroup is the name of the default Policy Group for organizations. +const DefaultPolicyGroup = "default-policy-group" + // CreatePolicyPackRequest defines the request body for creating a new Policy // Pack for an organization. The request contains the metadata related to the // Policy Pack. @@ -107,3 +110,27 @@ type GetStackPolicyPacksResponse struct { // RequiredPolicies is a list of required Policy Packs to run during the update. RequiredPolicies []RequiredPolicy `json:"requiredPolicies,omitempty"` } + +// UpdatePolicyGroupRequest modifies a Policy Group. +type UpdatePolicyGroupRequest struct { + NewName *string `json:"newName,omitempty"` + + AddStack *PulumiStackReference `json:"addStack,omitempty"` + RemoveStack *PulumiStackReference `json:"removeStack,omitempty"` + + AddPolicyPack *PolicyPackMetadata `json:"addPolicyPack,omitempty"` + RemovePolicyPack *PolicyPackMetadata `json:"removePolicyPack,omitempty"` +} + +// PulumiStackReference contains the StackName and ProjectName of the stack. +type PulumiStackReference struct { + Name string `json:"name"` + RoutingProject string `json:"routingProject"` +} + +// PolicyPackMetadata is the metadata of a Policy Pack. +type PolicyPackMetadata struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version int `json:"version"` +} diff --git a/pkg/backend/httpstate/client/client.go b/pkg/backend/httpstate/client/client.go index 9abe978bc..4fd2365fb 100644 --- a/pkg/backend/httpstate/client/client.go +++ b/pkg/backend/httpstate/client/client.go @@ -103,13 +103,27 @@ func publishPolicyPackPath(orgName string) string { return fmt.Sprintf("/api/orgs/%s/policypacks", orgName) } -// appyPolicyPackPath returns the path for an API call to the Pulumi service to apply a PolicyPack +// applyPolicyPackPath returns the path for an API call to the Pulumi service to apply a PolicyPack // to a Pulumi organization. func applyPolicyPackPath(orgName, policyPackName string, version int) string { return fmt.Sprintf( "/api/orgs/%s/policypacks/%s/versions/%d/apply", orgName, policyPackName, version) } +// updatePolicyGroupPath returns the path for an API call to the Pulumi service to update a PolicyGroup +// for a Pulumi organization. +func updatePolicyGroupPath(orgName, policyGroup string) string { + return fmt.Sprintf( + "/api/orgs/%s/policygroups/%s", orgName, policyGroup) +} + +// deletePolicyPackVersionPath returns the path for an API call to the Pulumi service to delete +// a Policy Pack from a Pulumi organization. +func deletePolicyPackVersionPath(orgName, policyPackName string, version int) string { + return fmt.Sprintf( + "/api/orgs/%s/policypacks/%s/versions/%d", orgName, policyPackName, version) +} + // publishPolicyPackPublishComplete returns the path for an API call to signal to the Pulumi service // that a PolicyPack to a Pulumi organization. func publishPolicyPackPublishComplete(orgName, policyPackName string, version int) string { @@ -503,7 +517,7 @@ func (pc *Client) PublishPolicyPack(ctx context.Context, orgName string, var resp apitype.CreatePolicyPackResponse err := pc.restCall(ctx, "POST", publishPolicyPackPath(orgName), nil, req, &resp) if err != nil { - return errors.Wrapf(err, "HTTP POST to publish policy pack failed") + return errors.Wrapf(err, "Publish policy pack failed") } fmt.Printf("Published as version %d\n", resp.Version) @@ -530,24 +544,76 @@ func (pc *Client) PublishPolicyPack(ctx context.Context, orgName string, err = pc.restCall(ctx, "POST", publishPolicyPackPublishComplete(orgName, analyzerInfo.Name, resp.Version), nil, nil, nil) if err != nil { - return errors.Wrapf(err, "HTTP POST to signal completion of the publish operation failed") + return errors.Wrapf(err, "Request to signal completion of the publish operation failed") } return nil } -// ApplyPolicyPack applies a `PolicyPack` to the Pulumi organization. -func (pc *Client) ApplyPolicyPack(ctx context.Context, orgName string, policyPackName string, - version int) error { +// ApplyPolicyPack enables a `PolicyPack` to the Pulumi organization. If policyGroup is not empty, +// it will enable the PolicyPack on the default PolicyGroup. +func (pc *Client) ApplyPolicyPack(ctx context.Context, orgName string, policyGroup string, + policyPackName string, version int) error { - req := apitype.ApplyPolicyPackRequest{Name: policyPackName, Version: version} + if policyGroup == "" { + req := apitype.ApplyPolicyPackRequest{Name: policyPackName, Version: version} - err := pc.restCall( - ctx, "POST", applyPolicyPackPath(orgName, policyPackName, version), nil, req, nil) - if err != nil { - return errors.Wrapf(err, "HTTP POST to apply policy pack failed") + err := pc.restCall( + ctx, "POST", applyPolicyPackPath(orgName, policyPackName, version), nil, req, nil) + if err != nil { + return errors.Wrapf(err, "Enable policy pack failed") + } + return nil } + // If a Policy Group was specified, enable it for the specific group only. + req := apitype.UpdatePolicyGroupRequest{ + AddPolicyPack: &apitype.PolicyPackMetadata{ + Name: policyPackName, + Version: version, + }, + } + + err := pc.restCall(ctx, http.MethodPatch, updatePolicyGroupPath(orgName, policyGroup), nil, req, nil) + if err != nil { + return errors.Wrapf(err, "Enable policy pack failed") + } + return nil +} + +// DisablePolicyPack disables a `PolicyPack` to the Pulumi organization. If policyGroup is not empty, +// it will disable the PolicyPack on the default PolicyGroup. +func (pc *Client) DisablePolicyPack(ctx context.Context, orgName string, policyGroup string, + policyPackName string, version int) error { + + // If Policy Group was not specified, use the default Policy Group. + if policyGroup == "" { + policyGroup = apitype.DefaultPolicyGroup + } + + req := apitype.UpdatePolicyGroupRequest{ + RemovePolicyPack: &apitype.PolicyPackMetadata{ + Name: policyPackName, + Version: version, + }, + } + + err := pc.restCall(ctx, http.MethodPatch, updatePolicyGroupPath(orgName, policyGroup), nil, req, nil) + if err != nil { + return errors.Wrapf(err, "Request to disable policy pack failed") + } + return nil +} + +// RemovePolicyPack removes a `PolicyPack` from the Pulumi organization. +func (pc *Client) RemovePolicyPack(ctx context.Context, orgName string, + policyPackName string, version int) error { + + path := deletePolicyPackVersionPath(orgName, policyPackName, version) + err := pc.restCall(ctx, http.MethodDelete, path, nil, nil, nil) + if err != nil { + return errors.Wrapf(err, "Request to remove policy pack failed") + } return nil } diff --git a/pkg/backend/httpstate/policypack.go b/pkg/backend/httpstate/policypack.go index 00bdbd5ee..d0b3be8c3 100644 --- a/pkg/backend/httpstate/policypack.go +++ b/pkg/backend/httpstate/policypack.go @@ -166,8 +166,16 @@ func (pack *cloudPolicyPack) Publish( return nil } -func (pack *cloudPolicyPack) Apply(ctx context.Context, op backend.ApplyOperation) error { - return pack.cl.ApplyPolicyPack(ctx, pack.ref.orgName, string(pack.ref.name), op.Version) +func (pack *cloudPolicyPack) Apply(ctx context.Context, policyGroup string, op backend.PolicyPackOperation) error { + return pack.cl.ApplyPolicyPack(ctx, pack.ref.orgName, policyGroup, string(pack.ref.name), op.Version) +} + +func (pack *cloudPolicyPack) Disable(ctx context.Context, policyGroup string, op backend.PolicyPackOperation) error { + return pack.cl.DisablePolicyPack(ctx, pack.ref.orgName, policyGroup, string(pack.ref.name), op.Version) +} + +func (pack *cloudPolicyPack) Remove(ctx context.Context, op backend.PolicyPackOperation) error { + return pack.cl.RemovePolicyPack(ctx, pack.ref.orgName, string(pack.ref.name), op.Version) } const npmPackageDir = "package" diff --git a/pkg/backend/policypack.go b/pkg/backend/policypack.go index 9225af773..9722402f7 100644 --- a/pkg/backend/policypack.go +++ b/pkg/backend/policypack.go @@ -31,8 +31,8 @@ type PublishOperation struct { Scopes CancellationScopeSource } -// ApplyOperation publishes a PolicyPack to the backend. -type ApplyOperation struct { +// PolicyPackOperation is used to make various operations against a Policy Pack. +type PolicyPackOperation struct { Version int Scopes CancellationScopeSource } @@ -45,6 +45,15 @@ type PolicyPack interface { Backend() Backend // Publish the PolicyPack to the service. Publish(ctx context.Context, op PublishOperation) result.Result - // Apply the PolicyPack to an organization. - Apply(ctx context.Context, op ApplyOperation) error + // Apply the PolicyPack to a Policy Group in an organization. If Policy Group is + // empty, it enables it for the default Policy Group. + Apply(ctx context.Context, policyGroup string, op PolicyPackOperation) error + + // Disable the PolicyPack for a Policy Group in an organization. If Policy Group is + // empty, it disables it for the default Policy Group. + Disable(ctx context.Context, policyGroup string, op PolicyPackOperation) error + + // Remove the PolicyPack from an organization. The Policy Pack must be removed from + // all Policy Groups before it can be removed. + Remove(ctx context.Context, op PolicyPackOperation) error } diff --git a/tests/integration/policy/policy_test.go b/tests/integration/policy/policy_test.go new file mode 100644 index 000000000..302552574 --- /dev/null +++ b/tests/integration/policy/policy_test.go @@ -0,0 +1,43 @@ +// Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +package ints + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + ptesting "github.com/pulumi/pulumi/pkg/testing" +) + +// TestPolicy tests policy related commands work. +func TestPolicy(t *testing.T) { + e := ptesting.NewEnvironment(t) + defer func() { + if !t.Failed() { + e.DeleteEnvironment() + } + }() + + // Confirm we have credentials. + if os.Getenv("PULUMI_ACCESS_TOKEN") == "" { + t.Fatal("PULUMI_ACCESS_TOKEN not found, aborting tests.") + } + + name, _ := e.RunCommand("pulumi", "whoami") + orgName := strings.TrimSpace(name) + + // Pack and push a Policy Pack for the organization. + policyPackName := fmt.Sprintf("%s-%x", "test-policy-pack", time.Now().UnixNano()) + e.ImportDirectory("test_policy_pack") + e.RunCommand("yarn", "install") + os.Setenv("TEST_POLICY_PACK", policyPackName) + e.RunCommand("pulumi", "policy", "publish", orgName) + + // Enable, Disable and then Delete the Policy Pack. + e.RunCommand("pulumi", "policy", "enable", fmt.Sprintf("%s/%s", orgName, policyPackName), "1") + e.RunCommand("pulumi", "policy", "disable", fmt.Sprintf("%s/%s", orgName, policyPackName), "1") + e.RunCommand("pulumi", "policy", "rm", fmt.Sprintf("%s/%s", orgName, policyPackName), "1") +} diff --git a/tests/integration/policy/test_policy_pack/PulumiPolicy.yaml b/tests/integration/policy/test_policy_pack/PulumiPolicy.yaml new file mode 100644 index 000000000..5539c8ef0 --- /dev/null +++ b/tests/integration/policy/test_policy_pack/PulumiPolicy.yaml @@ -0,0 +1,3 @@ +name: aws-policy +description: Example policies for AWS +runtime: nodejs diff --git a/tests/integration/policy/test_policy_pack/index.ts b/tests/integration/policy/test_policy_pack/index.ts new file mode 100644 index 000000000..20e346b35 --- /dev/null +++ b/tests/integration/policy/test_policy_pack/index.ts @@ -0,0 +1,30 @@ +// Copyright 2016-2020, Pulumi Corporation. All rights reserved. + +import * as aws from "@pulumi/aws"; +import * as policy from "@pulumi/policy"; + +const packName = process.env.TEST_POLICY_PACK; + +if (!packName) { + console.log("no policy name provided"); + process.exit(-1); + +} else { + const policies = new policy.PolicyPack(packName, { + policies: [ + { + name: "s3-no-public-read", + description: "Prohibits setting the publicRead or publicReadWrite permission on AWS S3 buckets.", + enforcementLevel: "mandatory", + validateResource: policy.validateTypedResource(aws.s3.Bucket, (bucket, args, reportViolation) => { + if (bucket.acl === "public-read" || bucket.acl === "public-read-write") { + reportViolation( + "You cannot set public-read or public-read-write on an S3 bucket. " + + "Read more about ACLs here: " + + "https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html"); + } + }), + }, + ], + }); +} diff --git a/tests/integration/policy/test_policy_pack/package.json b/tests/integration/policy/test_policy_pack/package.json new file mode 100644 index 000000000..5593c2e97 --- /dev/null +++ b/tests/integration/policy/test_policy_pack/package.json @@ -0,0 +1,14 @@ +{ + "name": "aws-policy", + "version": "0.0.1", + "dependencies": { + "@pulumi/pulumi": "^0.17.28", + "@pulumi/aws": "0.18.16", + "@pulumi/policy": "^0.2.0" + }, + "devDependencies": { + "@types/mocha": "^2.2.42", + "@types/node": "^10.12.7", + "tslint": "^5.11.0" + } +} diff --git a/tests/integration/policy/test_policy_pack/tsconfig.json b/tests/integration/policy/test_policy_pack/tsconfig.json new file mode 100644 index 000000000..151c0c54a --- /dev/null +++ b/tests/integration/policy/test_policy_pack/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "outDir": "bin", + "target": "es6", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "sourceMap": false, + "stripInternal": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "strictNullChecks": true, + }, + "files": [ + "index.ts", + ] +} +