diff --git a/apiClient/guardrail.go b/apiClient/guardrail.go new file mode 100644 index 00000000..6f6d13f0 --- /dev/null +++ b/apiClient/guardrail.go @@ -0,0 +1,39 @@ +package apiClient + +func (client *Client) CreateGuardrail(input map[string]interface{}) (*Guardrail, error) { + query := createGuardrailMutation() + responseData := &GuardrailResponse{} + variables := map[string]interface{}{ + "input": input, + } + + // execute api call + if err := client.doRequest(query, variables, responseData); err != nil { + return nil, client.handleCreateError(err, input, "guardrail") + } + return &responseData.Guardrail, nil +} + +func (client *Client) ReadGuardrail(id string) (*Guardrail, error) { + query := readGuardrailQuery(id) + responseData := &GuardrailResponse{} + + // execute api call + if err := client.doRequest(query, nil, responseData); err != nil { + return nil, client.handleReadError(err, id, "guardrail") + } + return &responseData.Guardrail, nil +} + +func (client *Client) UpdateGuardrail(input map[string]interface{}) (*Guardrail, error) { + query := updateGuardrailMutation() + responseData := &GuardrailResponse{} + variables := map[string]interface{}{ + "input": input, + } + // execute api call + if err := client.doRequest(query, variables, responseData); err != nil { + return nil, client.handleUpdateError(err, input, "guardrail") + } + return &responseData.Guardrail, nil +} diff --git a/apiClient/guardrail_attachment.go b/apiClient/guardrail_attachment.go new file mode 100644 index 00000000..0b2ce702 --- /dev/null +++ b/apiClient/guardrail_attachment.go @@ -0,0 +1,34 @@ +package apiClient + +import ( + "fmt" +) + +func (client *Client) AttachGuardrail(input map[string]interface{}) (*TurbotResourceMetadata, error) { + query := attachGuardrailMutation() + responseData := &AttachGuardrailResponse{} + + variables := map[string]interface{}{ + "input": input, + } + + // execute api call + if err := client.doRequest(query, variables, responseData); err != nil { + return nil, client.handleCreateError(err, input, "guardrail attachment") + } + return &responseData.Turbot, nil +} + +func (client *Client) DetachGuardrail(input map[string]interface{}) error { + query := detachGuardrailMutation() + var responseData interface{} + + variables := map[string]interface{}{ + "input": input, + } + // execute api call + if err := client.doRequest(query, variables, responseData); err != nil { + return fmt.Errorf("error deleting guardrail attachment: %s", err.Error()) + } + return nil +} diff --git a/apiClient/queries.go b/apiClient/queries.go index 5164010a..078982b1 100644 --- a/apiClient/queries.go +++ b/apiClient/queries.go @@ -732,3 +732,88 @@ func (client *Client) GetTurbotWorkspaceVersion() (*semver.Version, error) { } return version, nil } + +// guardrail + +func createGuardrailMutation() string { + return `mutation CreateGuardrail($input: CreateGuardrailInput!) { + guardrail: createGuardrail(input: $input) { + title + description + tags + turbot { + id + akas + title + } + } + }` +} + +func readGuardrailQuery(id string) string { + return fmt.Sprintf(`{ + guardrail: guardrail(id: "%s") { + description + turbot { + id + akas + title + tags + } + accounts { + items { + turbot { + id + akas + } + } + } + targets { + items { + uri + } + } + controlTypes { + items { + uri + } + } + } + }`, id) +} + +func updateGuardrailMutation() string { + return `mutation UpdateGuardrail($input: UpdateGuardrailInput!) { + guardrail: updateGuardrail(input: $input) { + description + tags + turbot { + id + akas + title + } + } + }` +} + +func attachGuardrailMutation() string { + return `mutation AttachGuardrail($input: AttachGuardrailInput!) { + guardrail: attachGuardrails(input: $input) { + turbot { + id + akas + title + } + } + }` +} + +func detachGuardrailMutation() string { + return `mutation DetachGuardrail($input: DetachGuardrailInput!) { + guardrail: detachGuardrails(input: $input) { + turbot { + id + } + } + }` +} diff --git a/apiClient/types.go b/apiClient/types.go index 285ade65..9e3980bd 100644 --- a/apiClient/types.go +++ b/apiClient/types.go @@ -520,3 +520,39 @@ type TurbotWatchMetadata struct { ResourceId string FavoriteId string } + +// Guardrail + +type GuardrailResponse struct { + Guardrail Guardrail +} + +type Guardrail struct { + Description string + Turbot TurbotResourceMetadata + Accounts struct { + Items []Account + } + ControlTypes struct { + Items []ControlType + } + Targets struct { + Items []Target + } +} + +type AttachGuardrailResponse struct { + Turbot TurbotResourceMetadata +} + +type Account struct { + Turbot TurbotResourceMetadata +} + +type ControlType struct { + Uri string +} + +type Target struct { + Uri string +} diff --git a/errors/errors.go b/errors/errors.go index 2b630f7b..866b3f73 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -2,11 +2,12 @@ package errors import ( "fmt" - "github.com/pkg/errors" "net/http" "regexp" "strconv" "strings" + + "github.com/pkg/errors" ) func NotFoundError(err error) bool { @@ -21,6 +22,12 @@ func FailedValidationError(err error) bool { return expectedErr.Match([]byte(err.Error())) } +func ForbiddenError(err error) bool { + forbiddenErr := "(?i)graphql: Forbidden: Insufficient permissions for resource(?i)" + expectedErr := regexp.MustCompile(forbiddenErr) + return expectedErr.Match([]byte(err.Error())) +} + func ExtractErrorCode(err error) (int, error) { // error returned from machinebox/graphql is of graphql type // errorNon200Template = "graphql: server returned a non-200 status code: 503" diff --git a/turbot/provider.go b/turbot/provider.go index 9754e02b..8311803e 100755 --- a/turbot/provider.go +++ b/turbot/provider.go @@ -41,6 +41,8 @@ func Provider() terraform.ResourceProvider { "turbot_google_directory": resourceGoogleDirectory(), "turbot_grant": resourceTurbotGrant(), "turbot_grant_activation": resourceTurbotGrantActivation(), + "turbot_guardrail": resourceTurbotGuardrail(), + "turbot_guardrail_attachment": resourceTurbotGuardrailAttachment(), "turbot_ldap_directory": resourceTurbotLdapDirectory(), "turbot_local_directory": resourceTurbotLocalDirectory(), "turbot_local_directory_user": resourceTurbotLocalDirectoryUser(), diff --git a/turbot/resource_turbot_guardrail.go b/turbot/resource_turbot_guardrail.go new file mode 100755 index 00000000..5d829ddd --- /dev/null +++ b/turbot/resource_turbot_guardrail.go @@ -0,0 +1,181 @@ +package turbot + +import ( + "github.com/hashicorp/terraform/helper/schema" + "github.com/turbot/terraform-provider-turbot/apiClient" + "github.com/turbot/terraform-provider-turbot/errors" +) + +// properties which must be passed to a create/update call +var guardrailProperties = map[string]string{ + "title": "title", + "description": "description", + "targets": "targets", + "controls": "controlTypes", + "akas": "akas", + "tags": "tags", +} + +func getGuardrailUpdateProperties() map[string]string { + excludedProperties := []string{"controls"} + + // Remove the excluded properties from guardrailProperties + for _, key := range excludedProperties { + delete(guardrailProperties, key) + } + + return guardrailProperties +} + +func resourceTurbotGuardrail() *schema.Resource { + return &schema.Resource{ + Create: resourceTurbotGuardrailCreate, + Read: resourceTurbotGuardrailRead, + Update: resourceTurbotGuardrailUpdate, + Delete: resourceTurbotGuardrailDelete, + Exists: resourceTurbotGuardrailExists, + Importer: &schema.ResourceImporter{ + State: resourceTurbotGuardrailImport, + }, + Schema: map[string]*schema.Schema{ + "title": { + Description: "The title of the guardrail.", + Type: schema.TypeString, + Required: true, + }, + "description": { + Description: "The description of the guardrail.", + Type: schema.TypeString, + Optional: true, + }, + "targets": { + Description: "The targets where the guardrail will be applied.", + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "controls": { + Description: "The control types associated with the guardrail.", + Type: schema.TypeList, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "tags": { + Description: "The tags for the guardrail.", + Type: schema.TypeMap, + Optional: true, + }, + "akas": { + Description: "The akas of the guardrail.", + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + DiffSuppressFunc: suppressIfAkaRemoved(), + }, + }, + } +} + +func resourceTurbotGuardrailExists(d *schema.ResourceData, meta interface{}) (b bool, e error) { + client := meta.(*apiClient.Client) + id := d.Id() + return client.ResourceExists(id) +} + +func resourceTurbotGuardrailCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + // build map of folder properties + input := mapFromResourceDataWithPropertyMap(d, guardrailProperties) + + guardrail, err := client.CreateGuardrail(input) + if err != nil { + return err + } + + // assign the id + d.SetId(guardrail.Turbot.Id) + + // TODO Remove Read call once schema changes are In. + return resourceTurbotGuardrailRead(d, meta) +} + +func resourceTurbotGuardrailUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + id := d.Id() + + // build map of folder properties + input := mapFromResourceDataWithPropertyMap(d, getGuardrailUpdateProperties()) + input["id"] = id + + _, err := client.UpdateGuardrail(input) + if err != nil { + return err + } + // set 'Read' Properties + // TODO Remove Read call once schema changes are In. + return resourceTurbotGuardrailRead(d, meta) +} + +func resourceTurbotGuardrailRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + id := d.Id() + + guardrail, err := client.ReadGuardrail(id) + if err != nil { + if errors.NotFoundError(err) { + // folder was not found - clear id + d.SetId("") + } + return err + } + + d.Set("title", guardrail.Turbot.Title) + d.Set("description", guardrail.Description) + d.Set("akas", guardrail.Turbot.Akas) + d.Set("tags", guardrail.Turbot.Tags) + + if len(guardrail.Targets.Items) > 0 { + targets := []string{} + for _, target := range guardrail.Targets.Items { + targets = append(targets, target.Uri) + } + d.Set("targets", targets) + } + + if len(guardrail.ControlTypes.Items) > 0 { + controls := []string{} + for _, control := range guardrail.ControlTypes.Items { + controls = append(controls, control.Uri) + } + d.Set("controls", controls) + } + + return nil +} + +func resourceTurbotGuardrailDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + id := d.Id() + err := client.DeleteResource(id) + if err != nil { + return err + } + + // clear the id to show we have deleted + d.SetId("") + + return nil +} + +func resourceTurbotGuardrailImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + if err := resourceTurbotGuardrailRead(d, meta); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +} diff --git a/turbot/resource_turbot_guardrail_attachment.go b/turbot/resource_turbot_guardrail_attachment.go new file mode 100644 index 00000000..25637dd0 --- /dev/null +++ b/turbot/resource_turbot_guardrail_attachment.go @@ -0,0 +1,150 @@ +package turbot + +import ( + "fmt" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/turbot/terraform-provider-turbot/apiClient" +) + +func resourceTurbotGuardrailAttachment() *schema.Resource { + return &schema.Resource{ + Create: resourceTurbotGuardrailAttachmentCreate, + Read: resourceTurbotGuardrailAttachmentRead, + Delete: resourceTurbotGuardrailAttachmentDelete, + Exists: resourceTurbotGuardrailAttachmentExists, + Importer: &schema.ResourceImporter{ + State: resourceTurbotGuardrailAttachmentImport, + }, + Schema: map[string]*schema.Schema{ + "resource": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: suppressIfAkaMatches("resource_akas"), + }, + "guardrail": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "guardrail_phase": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "resource_akas": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func resourceTurbotGuardrailAttachmentExists(d *schema.ResourceData, meta interface{}) (b bool, e error) { + client := meta.(*apiClient.Client) + guardrailId, resource := parseSmartFolderId(d.Id()) + + // execute api call + guardrail, err := client.ReadGuardrail(guardrailId) + if err != nil { + return false, fmt.Errorf("error reading guardrail: %s", err.Error()) + } + + //find resource aka in list of attached resources + for _, attachedResource := range guardrail.Accounts.Items { + if resource == attachedResource.Turbot.Id { + return true, nil + } + + for _, aka := range attachedResource.Turbot.Akas { + if aka == resource { + return true, nil + } + } + } + return false, nil +} + +func resourceTurbotGuardrailAttachmentCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + resource := d.Get("resource").(string) + guardrail := d.Get("guardrail").(string) + guardrailPhase := d.Get("guardrail_phase").(string) + + input := map[string]interface{}{ + "resource": resource, + "guardrailsWithPhase": []map[string]interface{}{ + { + "guardrail": guardrail, + "phase": guardrailPhase, + }, + }, + } + + _, err := client.AttachGuardrail(input) + if err != nil { + return err + } + + // set resource_akas property by loading resource and fetching the akas + if err := storeAkas(resource, "resource_akas", d, meta); err != nil { + return err + } + // assign the id + var stateId = buildId(guardrail, resource) + d.SetId(stateId) + d.Set("resource", resource) + d.Set("guardrail", guardrail) + d.Set("guardrail_phase", guardrailPhase) + return nil +} + +func resourceTurbotGuardrailAttachmentRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + // NOTE: This will not be called if the attachment does not exist + guardrail, resource := parseSmartFolderId(d.Id()) + guardrailPhase := d.Get("guardrail_phase").(string) + + turbotResource, err := client.ReadResource(resource, nil) + if err != nil { + return err + } + // set resource_akas property by loading resource and fetching the akas + if err := storeAkas(turbotResource.Turbot.Id, "resource_akas", d, meta); err != nil { + return err + } + // assign results directly back into ResourceData + d.Set("resource", resource) + d.Set("guardrail", guardrail) + d.Set("guardrail_phase", guardrailPhase) + return nil +} + +func resourceTurbotGuardrailAttachmentDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + resource := d.Get("resource").(string) + guardrail := d.Get("guardrail").(string) + input := map[string]interface{}{ + "resource": resource, + "guardrails": []string{guardrail}, + } + err := client.DetachGuardrail(input) + if err != nil { + return err + } + + // clear the id to show we have deleted + d.SetId("") + return nil +} + +func resourceTurbotGuardrailAttachmentImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + if err := resourceTurbotGuardrailAttachmentRead(d, meta); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +} diff --git a/turbot/resource_turbot_guardrail_test.go b/turbot/resource_turbot_guardrail_test.go new file mode 100644 index 00000000..5fb3698b --- /dev/null +++ b/turbot/resource_turbot_guardrail_test.go @@ -0,0 +1,109 @@ +package turbot + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/turbot/terraform-provider-turbot/apiClient" + "github.com/turbot/terraform-provider-turbot/errors" +) + +// test suites +func TestAccGuardrail_Basic(t *testing.T) { + resourceName := "turbot_guardrail.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGuardrailDestroy, + Steps: []resource.TestStep{ + { + Config: testAccGuardrailConfig(), + Check: resource.ComposeTestCheckFunc( + testAccCheckGuardrailExists("turbot_guardrail.test"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "title", "terraform_guardrail_test"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "description", "Guardrail Testing"), + ), + }, + { + Config: testAccGuardrailUpdateDescConfig(), + Check: resource.ComposeTestCheckFunc( + testAccCheckGuardrailExists("turbot_guardrail.test"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "title", "terraform_guardrail_test"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "description", "Guardrail Testing updated"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "controls.#", "1"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "controls.0", "tmod:@turbot/aws-s3#/control/types/encryptionInTransit"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "targets.#", "1"), + resource.TestCheckResourceAttr("turbot_guardrail.test", "targets.0", "tmod:@turbot/aws#/resource/types/account"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: false, + }, + }, + }) +} + +// configs +func testAccGuardrailConfig() string { + return ` +resource "turbot_guardrail" "test" { + description = "Guardrail Testing" + title = "terraform_guardrail_test" + + targets = ["tmod:@turbot/aws#/resource/types/account"] + controls = ["tmod:@turbot/aws-s3#/control/types/encryptionInTransit"] +} +` +} + +func testAccGuardrailUpdateDescConfig() string { + return ` +resource "turbot_guardrail" "test" { + description = "Guardrail Testing updated" + title ="terraform_guardrail_test" + + targets = ["tmod:@turbot/aws#/resource/types/account"] + controls = ["tmod:@turbot/aws-s3#/control/types/encryptionInTransit"] +} +` +} + +// helper functions +func testAccCheckGuardrailExists(resource string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resource] + if !ok { + return fmt.Errorf("not found: %s", resource) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no Record ID is set") + } + client := testAccProvider.Meta().(*apiClient.Client) + _, err := client.ReadGuardrail(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error fetching item with resource %s. %s", resource, err) + } + return nil + } +} + +func testAccCheckGuardrailDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*apiClient.Client) + for _, rs := range s.RootModule().Resources { + if rs.Type == "turbot_guardrail" { + _, err := client.ReadGuardrail(rs.Primary.ID) + if err == nil { + return fmt.Errorf("alert still exists") + } + if !errors.ForbiddenError(err) { + return fmt.Errorf("expected 'not found' error, got %s", err) + } + } + } + + return nil +} diff --git a/website/docs/r/guardrail.html.markdown b/website/docs/r/guardrail.html.markdown new file mode 100644 index 00000000..00093eb6 --- /dev/null +++ b/website/docs/r/guardrail.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "turbot" +title: "turbot" +template: Documentation +page_title: "Turbot: turbot_guardrail" +nav: + title: turbot_guardrail +--- + +# turbot_guardrail + +`Turbot Guardrail` in Turbot Governance provide a structured way to enforce compliance by grouping a control with its associated policies. + +## Example Usage + +**Creating Your First Guardrail** + +```hcl +resource "turbot_guardrail" "aws_s3_encryption_in_transit" { + title = "AWS S3 S3 Bucket Encryption in Transit" + description = "Ensure that access to Amazon S3 objects is only permitted through HTTPS, not HTTP." + akas = [ "aws-s3-encryption-in-transit" ] + + targets = [ "tmod:@turbot/aws#/resource/types/account" ] + controls = [ "tmod:@turbot/aws-s3#/policy/types/encryptionInTransit" ] + + tags = { + baseline = "required" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +- `controls` - (Required) A list of control types associated with the guardrail. +- `title` - (Required) Short display name for the guardrail. +- `akas` - (Optional) Unique identifier of the resource. +- `description` - (Optional) Brief description of the purpose and details of the guardrail. +- `tags` - (Optional) User defined label for grouping guardrails. +- `targets` - (Optional) A list of targets where the guardrail will be applied. + +## Attributes Reference + +In addition to all the arguments above, the following attributes are exported: + +- `color` - The color of the guardrail to create, that will be used to highlight the Guardrail in the Turbot console. +- `id` - Unique identifier of the resource. + +## Import + +Guardrails can be imported using the `id`. For example, + +``` +terraform import turbot_guardrail.test 123456789012 +```