Skip to content

Commit

Permalink
added dry mode to policy creation (#767)
Browse files Browse the repository at this point in the history
* Add prompt during policy push
* Add policy diff subcommand
* made policy path positional argument in policy decide and eval commands
Co-authored-by: Sagar Gupta <[email protected]>
  • Loading branch information
davidmdm authored Aug 18, 2022
1 parent e042377 commit 5ef6776
Show file tree
Hide file tree
Showing 11 changed files with 327 additions and 86 deletions.
31 changes: 22 additions & 9 deletions api/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,37 +35,50 @@ type httpError struct {
// CreatePolicyBundleRequest defines the fields for the Create-Policy-Bundle endpoint as defined in Policy Service
type CreatePolicyBundleRequest struct {
Policies map[string]string `json:"policies"`
DryRun bool `json:"-"`
}

// CreatePolicyBundle calls the Create Policy Bundle API in the Policy-Service.
// It creates a policy bundle for the specified owner+context and returns the http status code as response
func (c Client) CreatePolicyBundle(ownerID string, context string, policy CreatePolicyBundleRequest) error {
data, err := json.Marshal(policy)
func (c Client) CreatePolicyBundle(ownerID string, context string, request CreatePolicyBundleRequest) (interface{}, error) {
data, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("failed to encode policy payload: %w", err)
return nil, fmt.Errorf("failed to encode policy payload: %w", err)
}

req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/owner/%s/context/%s/policy-bundle", c.serverUrl, ownerID, context), bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to construct request: %v", err)
return nil, fmt.Errorf("failed to construct request: %v", err)
}

req.Header.Set("Content-Length", strconv.Itoa(len(data)))

if request.DryRun {
q := req.URL.Query()
q.Set("dry", "true")
req.URL.RawQuery = q.Encode()
}

resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to get response from policy-service: %w", err)
return nil, fmt.Errorf("failed to get response from policy-service: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
var response httpError
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
return nil, fmt.Errorf("unexpected status-code: %d", resp.StatusCode)
}
return fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, response.Error)
return nil, fmt.Errorf("unexpected status-code: %d - %s", resp.StatusCode, response.Error)
}
return nil

var body interface{}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("failed to decode response body: %v", err)
}

return body, nil
}

// FetchPolicyBundle calls the GET policy-bundle API in the policy-service
Expand Down
38 changes: 36 additions & 2 deletions api/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,47 @@ func TestClientCreatePolicy(t *testing.T) {
assert.DeepEqual(t, actual, req)

w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("{}"))
}))
defer svr.Close()

config := &settings.Config{Token: "testtoken", HTTPClient: http.DefaultClient}
client := NewClient(svr.URL, config)

err := client.CreatePolicyBundle("ownerId", "config", req)
_, err := client.CreatePolicyBundle("ownerId", "config", req)
assert.NilError(t, err)
})

t.Run("expected dry request", func(t *testing.T) {
req := CreatePolicyBundleRequest{
Policies: map[string]string{"policy_a": "package org"},
DryRun: true,
}

svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Header.Get("circle-token"), "testtoken")
assert.Equal(t, r.Header.Get("accept"), "application/json")
assert.Equal(t, r.Header.Get("content-type"), "application/json")
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
assert.Equal(t, r.Header.Get("circle-token"), "testtoken")

assert.Equal(t, r.Method, "POST")
assert.Equal(t, r.URL.Path, "/api/v1/owner/ownerId/context/config/policy-bundle")
assert.Equal(t, r.URL.RawQuery, "dry=true")

var actual CreatePolicyBundleRequest
assert.NilError(t, json.NewDecoder(r.Body).Decode(&actual))
assert.DeepEqual(t, actual, CreatePolicyBundleRequest{Policies: req.Policies})

w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("{}"))
}))
defer svr.Close()

config := &settings.Config{Token: "testtoken", HTTPClient: http.DefaultClient}
client := NewClient(svr.URL, config)

_, err := client.CreatePolicyBundle("ownerId", "config", req)
assert.NilError(t, err)
})

Expand All @@ -175,7 +209,7 @@ func TestClientCreatePolicy(t *testing.T) {
config := &settings.Config{Token: "testtoken", HTTPClient: &http.Client{}}
client := NewClient(svr.URL, config)

err := client.CreatePolicyBundle("ownerId", "config", CreatePolicyBundleRequest{})
_, err := client.CreatePolicyBundle("ownerId", "config", CreatePolicyBundleRequest{})
assert.Error(t, err, "unexpected status-code: 403 - Forbidden")
})
}
Expand Down
146 changes: 118 additions & 28 deletions cmd/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"time"

"github.com/CircleCI-Public/circle-policy-agent/cpa"
Expand Down Expand Up @@ -35,44 +36,89 @@ func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Com

push := func() *cobra.Command {
var ownerID, context string
var creationRequest policy.CreatePolicyBundleRequest
var noPrompt bool
var request policy.CreatePolicyBundleRequest

cmd := &cobra.Command{
Short: "push policy bundle",
Use: "push",
RunE: func(cmd *cobra.Command, args []string) error {
policyPath := args[0]
creationRequest.Policies = make(map[string]string)
bundle, err := loadBundleFromFS(args[0])
if err != nil {
return fmt.Errorf("failed to walk policy directory path: %w", err)
}

err := filepath.WalkDir(policyPath, func(path string, f fs.DirEntry, err error) error {
request.Policies = bundle

client := policy.NewClient(*policyBaseURL, config)

if !noPrompt {
request.DryRun = true
diff, err := client.CreatePolicyBundle(ownerID, context, request)
if err != nil {
return err
return fmt.Errorf("failed to get bundle diff: %v", err)
}
if !f.IsDir() && (filepath.Ext(f.Name()) == ".rego") {
fileContent, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
creationRequest.Policies[f.Name()] = string(fileContent)

_, _ = io.WriteString(cmd.ErrOrStderr(), "The following changes are going to be made: ")
_ = prettyJSONEncoder(cmd.ErrOrStderr()).Encode(diff)
_, _ = io.WriteString(cmd.ErrOrStderr(), "\n")

if !Confirm(cmd.ErrOrStderr(), cmd.InOrStdin(), "Do you wish to continue? (y/N)") {
return nil
}
return nil
})
if err != nil {
return fmt.Errorf("failed to walk policy directory path: %w", err)
_, _ = io.WriteString(cmd.ErrOrStderr(), "\n")
}

err = policy.NewClient(*policyBaseURL, config).CreatePolicyBundle(ownerID, context, creationRequest)
request.DryRun = false

diff, err := client.CreatePolicyBundle(ownerID, context, request)
if err != nil {
return fmt.Errorf("failed to push policy bundle: %w", err)
}

_, _ = io.WriteString(cmd.ErrOrStderr(), "Policy Bundle Pushed Successfully\n")
_, _ = io.WriteString(cmd.ErrOrStderr(), "\ndiff: ")
_ = prettyJSONEncoder(cmd.OutOrStdout()).Encode(diff)

return nil
},
Args: cobra.ExactArgs(1),
Example: `policy push ./policy_bundle_dir_path --owner-id 462d67f8-b232-4da4-a7de-0c86dd667d3f --context config`,
}

cmd.Flags().StringVar(&context, "context", "config", "policy context")
cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
cmd.Flags().BoolVar(&noPrompt, "no-prompt", false, "removes the prompt")
if err := cmd.MarkFlagRequired("owner-id"); err != nil {
panic(err)
}

return cmd
}()

diff := func() *cobra.Command {
var ownerID, context string
cmd := &cobra.Command{
Short: "Get diff between local and remote policy bundles",
Use: "diff",
RunE: func(cmd *cobra.Command, args []string) error {
bundle, err := loadBundleFromFS(args[0])
if err != nil {
return fmt.Errorf("failed to walk policy directory path: %w", err)
}

diff, err := policy.NewClient(*policyBaseURL, config).CreatePolicyBundle(ownerID, context, policy.CreatePolicyBundleRequest{
Policies: bundle,
DryRun: true,
})
if err != nil {
return fmt.Errorf("failed to get diff: %w", err)
}

return prettyJSONEncoder(cmd.OutOrStdout()).Encode(diff)
},
Args: cobra.ExactArgs(1),
}
cmd.Flags().StringVar(&context, "context", "config", "policy context")
cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
if err := cmd.MarkFlagRequired("owner-id"); err != nil {
Expand Down Expand Up @@ -219,9 +265,12 @@ func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Com
cmd := &cobra.Command{
Short: "make a decision",
Use: "decide",
RunE: func(cmd *cobra.Command, _ []string) error {
if policyPath == "" && ownerID == "" {
return fmt.Errorf("--owner-id or --policy is required")
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 {
policyPath = args[0]
}
if (policyPath == "" && ownerID == "") || (policyPath != "" && ownerID != "") {
return fmt.Errorf("either policy-path or --owner-id is required")
}

input, err := os.ReadFile(inputPath)
Expand Down Expand Up @@ -258,13 +307,12 @@ func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Com

return nil
},
Args: cobra.ExactArgs(0),
Args: cobra.MaximumNArgs(1),
}

cmd.Flags().StringVar(&ownerID, "owner-id", "", "the id of the policy's owner")
cmd.Flags().StringVar(&context, "context", "config", "policy context for decision")
cmd.Flags().StringVar(&inputPath, "input", "", "path to input file")
cmd.Flags().StringVar(&policyPath, "policy", "", "path to rego policy file or directory containing policy files")
cmd.Flags().StringVar(&metaFile, "metafile", "", "decision metadata file")

if err := cmd.MarkFlagRequired("input"); err != nil {
Expand All @@ -275,11 +323,12 @@ func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Com
}()

eval := func() *cobra.Command {
var inputPath, policyPath, metaFile, query string
var inputPath, metaFile, query string
cmd := &cobra.Command{
Short: "perform raw opa evaluation locally",
Use: "eval",
RunE: func(cmd *cobra.Command, _ []string) error {
RunE: func(cmd *cobra.Command, args []string) error {
policyPath := args[0]
input, err := os.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("failed to read input file: %w", err)
Expand Down Expand Up @@ -307,25 +356,22 @@ func NewCommand(config *settings.Config, preRunE validator.Validator) *cobra.Com

return nil
},
Args: cobra.ExactArgs(0),
Args: cobra.ExactArgs(1),
}

cmd.Flags().StringVar(&inputPath, "input", "", "path to input file")
cmd.Flags().StringVar(&policyPath, "policy", "", "path to rego policy file or directory containing policy files")
cmd.Flags().StringVar(&metaFile, "metafile", "", "decision metadata file")
cmd.Flags().StringVar(&query, "query", "data", "policy decision query")

if err := cmd.MarkFlagRequired("input"); err != nil {
panic(err)
}
if err := cmd.MarkFlagRequired("policy"); err != nil {
panic(err)
}

return cmd
}()

cmd.AddCommand(push)
cmd.AddCommand(diff)
cmd.AddCommand(fetch)
cmd.AddCommand(logs)
cmd.AddCommand(decide)
Expand Down Expand Up @@ -380,3 +426,47 @@ func getPolicyEvaluationLocally(policyPath string, rawInput []byte, meta map[str

return decision, nil
}

func loadBundleFromFS(root string) (map[string]string, error) {
root = filepath.Clean(root)

rootInfo, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("failed to get path info: %w", err)
}
if !rootInfo.IsDir() {
return nil, fmt.Errorf("policy path is not a directory")
}

bundle := make(map[string]string)

err = filepath.WalkDir(root, func(path string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
if f.IsDir() || filepath.Ext(path) != ".rego" {
return nil
}

fileContent, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}

bundle[path] = string(fileContent)

return nil
})

return bundle, err
}

func Confirm(w io.Writer, r io.Reader, question string) bool {
fmt.Fprint(w, question+" ")
var answer string

_, _ = fmt.Fscanln(r, &answer)

answer = strings.ToLower(answer)
return answer == "y" || answer == "yes"
}
Loading

0 comments on commit 5ef6776

Please sign in to comment.