Skip to content

Commit

Permalink
Merge pull request #738 from CircleCI-Public/SECENG-600-remote-policy…
Browse files Browse the repository at this point in the history
…-decision

added make decision policy command
  • Loading branch information
davidmdm authored Jun 28, 2022
2 parents a65cbb5 + fe60bd2 commit cc194b5
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 20 deletions.
45 changes: 45 additions & 0 deletions api/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,51 @@ func (c Client) GetDecisionLogs(ownerID string, request DecisionQueryRequest) ([
return body, nil
}

// DecisionRequest represents a request to Policy-Service to evaluate a given input against an organization's policies.
// The context determines which policies to apply.
type DecisionRequest struct {
Input string `json:"input"`
Context string `json:"context"`
}

// MakeDecision sends a requests to Policy-Service public decision endpoint and returns the decision response
func (c Client) MakeDecision(ownerID string, req DecisionRequest) (interface{}, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}

endpoint := fmt.Sprintf("%s/api/v1/owner/%s/decision", c.serverUrl, ownerID)

request, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("failed to construct request: %w", err)
}

request.Header.Set("Content-Length", strconv.Itoa(len(payload)))

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

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

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

return body, nil
}

// NewClient returns a new policy client that will use the provided settings.Config to automatically inject appropriate
// Circle-Token authentication and other relevant CLI headers.
func NewClient(baseURL string, config *settings.Config) *Client {
Expand Down
86 changes: 86 additions & 0 deletions api/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package policy

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -731,3 +733,87 @@ func TestClientGetDecisionLogs(t *testing.T) {
assert.NilError(t, err)
})
}

func TestMakeDecision(t *testing.T) {
testcases := []struct {
Name string
OwnerID string
Request DecisionRequest
Handler http.HandlerFunc
ExpectedError error
ExpectedDecision interface{}
}{
{
Name: "sends expexted request",
OwnerID: "test-owner",
Request: DecisionRequest{
Input: "test-input",
Context: "test-context",
},
Handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/decision")
assert.Equal(t, r.Method, "POST")
assert.Equal(t, r.Header.Get("Circle-Token"), "test-token")

var payload map[string]interface{}
assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))

assert.DeepEqual(t, payload, map[string]interface{}{
"context": "test-context",
"input": "test-input",
})

_ = json.NewEncoder(w).Encode(map[string]string{"status": "PASS"})
},
ExpectedDecision: map[string]interface{}{"status": "PASS"},
},
{
Name: "unexpected statuscode",
OwnerID: "test-owner",
Request: DecisionRequest{},
Handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
_, _ = io.WriteString(w, `{"error":"that was a bad request!"}`)
},
ExpectedError: errors.New("unexpected status-code: 400 - that was a bad request!"),
},

{
Name: "unexpected statuscode no body",
OwnerID: "test-owner",
Request: DecisionRequest{},
Handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
},
ExpectedError: errors.New("unexpected status-code: 204"),
},
{
Name: "bad decoding",
OwnerID: "test-owner",
Request: DecisionRequest{},
Handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "not a json response")
},
ExpectedError: errors.New("failed to decode response body: invalid character 'o' in literal null (expecting 'u')"),
},
}

for _, tc := range testcases {
t.Run(tc.Name, func(t *testing.T) {
svr := httptest.NewServer(tc.Handler)
defer svr.Close()

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

decision, err := client.MakeDecision(tc.OwnerID, tc.Request)
if tc.ExpectedError == nil {
assert.NilError(t, err)
} else {
assert.Error(t, err, tc.ExpectedError.Error())
return
}

assert.DeepEqual(t, decision, tc.ExpectedDecision)
})
}
}
71 changes: 51 additions & 20 deletions cmd/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {

policyBaseURL := cmd.PersistentFlags().String("policy-base-url", "https://internal.circleci.com", "base url for policy api")
ownerID := cmd.PersistentFlags().String("owner-id", "", "the id of the owner of a policy")

if err := cmd.MarkPersistentFlagRequired("owner-id"); err != nil {
panic(err)
}
Expand All @@ -56,10 +57,7 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
return fmt.Errorf("failed to list policies: %v", err)
}

enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")

if err := enc.Encode(policies); err != nil {
if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(policies); err != nil {
return fmt.Errorf("failed to output policies in json format: %v", err)
}

Expand Down Expand Up @@ -96,10 +94,7 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
return fmt.Errorf("failed to create policy: %w", err)
}

enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")

if err := enc.Encode(result); err != nil {
if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(result); err != nil {
return fmt.Errorf("failed to encode result to stdout: %w", err)
}

Expand Down Expand Up @@ -133,10 +128,7 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
return fmt.Errorf("failed to get policy: %v", err)
}

enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")

if err := enc.Encode(p); err != nil {
if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(p); err != nil {
return fmt.Errorf("failed to output policy in json format: %v", err)
}

Expand Down Expand Up @@ -215,10 +207,7 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
return fmt.Errorf("failed to update policy: %w", err)
}

enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")

if err := enc.Encode(result); err != nil {
if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(result); err != nil {
return fmt.Errorf("failed to encode result to stdout: %w", err)
}

Expand Down Expand Up @@ -302,10 +291,7 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
request.Offset = len(allLogs)
}

enc := json.NewEncoder(dst)
enc.SetIndent("", " ")

if err := enc.Encode(allLogs); err != nil {
if err := prettyJSONEncoder(dst).Encode(allLogs); err != nil {
return fmt.Errorf("failed to output policy decision logs in json format: %v", err)
}

Expand All @@ -324,12 +310,57 @@ func NewCommand(config *settings.Config, preRunE validator) *cobra.Command {
return cmd
}()

decide := func() *cobra.Command {
var inputPath string
var request policy.DecisionRequest

cmd := &cobra.Command{
Short: "make a decision",
Use: "decide",
RunE: func(cmd *cobra.Command, args []string) error {
input, err := ioutil.ReadFile(inputPath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}

request.Input = string(input)

decision, err := policy.NewClient(*policyBaseURL, config).MakeDecision(*ownerID, request)
if err != nil {
return fmt.Errorf("failed to make decision: %w", err)
}

if err := prettyJSONEncoder(cmd.OutOrStdout()).Encode(decision); err != nil {
return fmt.Errorf("failed to encode decision: %w", err)
}

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

cmd.Flags().StringVar(&request.Context, "context", "config", "policy context for decision")
cmd.Flags().StringVar(&inputPath, "input", "", "path to input file")

_ = cmd.MarkFlagRequired("input")

return cmd
}()

cmd.AddCommand(list)
cmd.AddCommand(create)
cmd.AddCommand(get)
cmd.AddCommand(delete)
cmd.AddCommand(update)
cmd.AddCommand(logs)
cmd.AddCommand(decide)

return cmd
}

// prettyJSONEncoder takes a writer and returns a new json encoder with indent set to two space characters
func prettyJSONEncoder(dst io.Writer) *json.Encoder {
enc := json.NewEncoder(dst)
enc.SetIndent("", " ")
return enc
}
89 changes: 89 additions & 0 deletions cmd/policy/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -649,6 +650,94 @@ func TestGetDecisionLogs(t *testing.T) {
}
}

func TestMakeDecisionCommand(t *testing.T) {
testcases := []struct {
Name string
Args []string
ServerHandler http.HandlerFunc
ExpectedOutput string
ExpectedErr string
}{
{
Name: "requires flags",
Args: []string{"decide"},
ExpectedErr: `required flag(s) "input", "owner-id" not set`,
},
{
Name: "sends expected request",
Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test.yaml"},
ServerHandler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Method, "POST")
assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/decision")

var payload map[string]interface{}
assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))

assert.DeepEqual(t, payload, map[string]interface{}{
"context": "config",
"input": "test: config\n",
})

_, _ = io.WriteString(w, `{"status":"PASS"}`)
},
ExpectedOutput: "{\n \"status\": \"PASS\"\n}\n",
},
{
Name: "sends expected request with context",
Args: []string{"decide", "--owner-id", "test-owner", "--input", "./testdata/test.yaml", "--context", "custom"},
ServerHandler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Method, "POST")
assert.Equal(t, r.URL.Path, "/api/v1/owner/test-owner/decision")

var payload map[string]interface{}
assert.NilError(t, json.NewDecoder(r.Body).Decode(&payload))

assert.DeepEqual(t, payload, map[string]interface{}{
"context": "custom",
"input": "test: config\n",
})

_, _ = io.WriteString(w, `{"status":"PASS"}`)
},
ExpectedOutput: "{\n \"status\": \"PASS\"\n}\n",
},
{
Name: "fails on unexpected status code",
Args: []string{"decide", "--input", "./testdata/test.yaml", "--owner-id", "test-owner"},
ServerHandler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
_, _ = io.WriteString(w, `{"error":"oopsie!"}`)
},

ExpectedErr: "failed to make decision: unexpected status-code: 500 - oopsie!",
},
}

for _, tc := range testcases {
t.Run(tc.Name, func(t *testing.T) {
if tc.ServerHandler == nil {
tc.ServerHandler = func(w http.ResponseWriter, r *http.Request) {}
}

svr := httptest.NewServer(tc.ServerHandler)
defer svr.Close()

cmd, stdout, _ := makeCMD()

cmd.SetArgs(append(tc.Args, "--policy-base-url", svr.URL))

err := cmd.Execute()
if tc.ExpectedErr == "" {
assert.NilError(t, err)
} else {
assert.Error(t, err, tc.ExpectedErr)
return
}
assert.Equal(t, stdout.String(), tc.ExpectedOutput)
})
}
}

func makeCMD() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
config := &settings.Config{Token: "testtoken", HTTPClient: http.DefaultClient}
cmd := NewCommand(config, nil)
Expand Down
1 change: 1 addition & 0 deletions cmd/policy/testdata/policy-expected-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Usage:

Available Commands:
create create policy
decide make a decision
delete Delete a policy
get Get a policy
list List all policies
Expand Down
10 changes: 10 additions & 0 deletions cmd/policy/testdata/policy/decide-expected-usage.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Usage:
policy decide [flags]

Flags:
--context string policy context for decision (default "config")
--input string path to input file

Global Flags:
--owner-id string the id of the owner of a policy
--policy-base-url string base url for policy api (default "https://internal.circleci.com")
1 change: 1 addition & 0 deletions cmd/policy/testdata/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test: config

0 comments on commit cc194b5

Please sign in to comment.