From c91d62fda0dc3e81eb935d0350481a7fa70209f8 Mon Sep 17 00:00:00 2001 From: James Nugent Date: Tue, 24 May 2016 19:50:35 -0600 Subject: [PATCH] provider/aws: aws_iam_policy_document data source This brings over the work done by @apparentlymart and @radeksimko in PR #3124, and converts it into a data source for the AWS provider: This commit adds a helper to construct IAM policy documents using familiar Terraform concepts. It makes Terraform-style interpolations easier and resolves the syntax conflict between Terraform interpolations and IAM policy variables by changing the latter to use &{...} for its interpolations. Its use is completely optional and users are free to go on using literal heredocs, file interpolations or whatever else; this just adds another option that fits more naturally into a Terraform config. --- .../data_source_aws_iam_policy_document.go | 210 ++++++++++++++++++ ...ata_source_aws_iam_policy_document_test.go | 172 ++++++++++++++ builtin/providers/aws/iam_policy_model.go | 74 ++++++ builtin/providers/aws/provider.go | 5 +- .../aws/d/iam_policy_document.html.markdown | 141 ++++++++++++ website/source/layouts/aws.erb | 5 + 6 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 builtin/providers/aws/data_source_aws_iam_policy_document.go create mode 100644 builtin/providers/aws/data_source_aws_iam_policy_document_test.go create mode 100644 builtin/providers/aws/iam_policy_model.go create mode 100644 website/source/docs/providers/aws/d/iam_policy_document.html.markdown diff --git a/builtin/providers/aws/data_source_aws_iam_policy_document.go b/builtin/providers/aws/data_source_aws_iam_policy_document.go new file mode 100644 index 000000000..ef045df80 --- /dev/null +++ b/builtin/providers/aws/data_source_aws_iam_policy_document.go @@ -0,0 +1,210 @@ +package aws + +import ( + "encoding/json" + "strings" + + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/schema" + "strconv" +) + +var dataSourceAwsIamPolicyDocumentVarReplacer = strings.NewReplacer("&{", "${") + +func dataSourceAwsIamPolicyDocument() *schema.Resource { + setOfString := &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + } + + return &schema.Resource{ + Read: dataSourceAwsIamPolicyDocumentRead, + + Schema: map[string]*schema.Schema{ + "id": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "statement": &schema.Schema{ + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "effect": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "Allow", + }, + "actions": setOfString, + "not_actions": setOfString, + "resources": setOfString, + "not_resources": setOfString, + "principals": dataSourceAwsIamPolicyPrincipalSchema(), + "not_principals": dataSourceAwsIamPolicyPrincipalSchema(), + "condition": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "test": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "variable": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "values": &schema.Schema{ + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + }, + }, + }, + "json": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceAwsIamPolicyDocumentRead(d *schema.ResourceData, meta interface{}) error { + doc := &IAMPolicyDoc{ + Version: "2012-10-17", + } + + if policyId, hasPolicyId := d.GetOk("id"); hasPolicyId { + doc.Id = policyId.(string) + } + + var cfgStmts = d.Get("statement").(*schema.Set).List() + stmts := make([]*IAMPolicyStatement, len(cfgStmts)) + doc.Statements = stmts + for i, stmtI := range cfgStmts { + cfgStmt := stmtI.(map[string]interface{}) + stmt := &IAMPolicyStatement{ + Effect: cfgStmt["effect"].(string), + } + + if actions := cfgStmt["actions"].(*schema.Set).List(); len(actions) > 0 { + stmt.Actions = iamPolicyDecodeConfigStringList(actions) + } + if actions := cfgStmt["not_actions"].(*schema.Set).List(); len(actions) > 0 { + stmt.NotActions = iamPolicyDecodeConfigStringList(actions) + } + + if resources := cfgStmt["resources"].(*schema.Set).List(); len(resources) > 0 { + stmt.Resources = dataSourceAwsIamPolicyDocumentReplaceVarsInList( + iamPolicyDecodeConfigStringList(resources), + ) + } + if resources := cfgStmt["not_resources"].(*schema.Set).List(); len(resources) > 0 { + stmt.NotResources = dataSourceAwsIamPolicyDocumentReplaceVarsInList( + iamPolicyDecodeConfigStringList(resources), + ) + } + + if principals := cfgStmt["principals"].(*schema.Set).List(); len(principals) > 0 { + stmt.Principals = dataSourceAwsIamPolicyDocumentMakePrincipals(principals) + } + + if principals := cfgStmt["not_principals"].(*schema.Set).List(); len(principals) > 0 { + stmt.NotPrincipals = dataSourceAwsIamPolicyDocumentMakePrincipals(principals) + } + + if conditions := cfgStmt["condition"].(*schema.Set).List(); len(conditions) > 0 { + stmt.Conditions = dataSourceAwsIamPolicyDocumentMakeConditions(conditions) + } + + stmts[i] = stmt + } + + jsonDoc, err := json.MarshalIndent(doc, "", " ") + if err != nil { + // should never happen if the above code is correct + return err + } + jsonString := string(jsonDoc) + + d.Set("json", jsonString) + d.SetId(strconv.Itoa(hashcode.String(jsonString))) + + return nil +} + +func dataSourceAwsIamPolicyDocumentReplaceVarsInList(in []string) []string { + out := make([]string, len(in)) + for i, item := range in { + out[i] = dataSourceAwsIamPolicyDocumentVarReplacer.Replace(item) + } + return out +} + +func dataSourceAwsIamPolicyDocumentMakeConditions(in []interface{}) IAMPolicyStatementConditionSet { + out := make([]IAMPolicyStatementCondition, len(in)) + for i, itemI := range in { + item := itemI.(map[string]interface{}) + out[i] = IAMPolicyStatementCondition{ + Test: item["test"].(string), + Variable: item["variable"].(string), + Values: dataSourceAwsIamPolicyDocumentReplaceVarsInList( + iamPolicyDecodeConfigStringList( + item["values"].(*schema.Set).List(), + ), + ), + } + } + return IAMPolicyStatementConditionSet(out) +} + +func dataSourceAwsIamPolicyDocumentMakePrincipals(in []interface{}) IAMPolicyStatementPrincipalSet { + out := make([]IAMPolicyStatementPrincipal, len(in)) + for i, itemI := range in { + item := itemI.(map[string]interface{}) + out[i] = IAMPolicyStatementPrincipal{ + Type: item["type"].(string), + Identifiers: dataSourceAwsIamPolicyDocumentReplaceVarsInList( + iamPolicyDecodeConfigStringList( + item["identifiers"].(*schema.Set).List(), + ), + ), + } + } + return IAMPolicyStatementPrincipalSet(out) +} + +func dataSourceAwsIamPolicyPrincipalSchema() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "identifiers": &schema.Schema{ + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + } +} diff --git a/builtin/providers/aws/data_source_aws_iam_policy_document_test.go b/builtin/providers/aws/data_source_aws_iam_policy_document_test.go new file mode 100644 index 000000000..edd0f8d40 --- /dev/null +++ b/builtin/providers/aws/data_source_aws_iam_policy_document_test.go @@ -0,0 +1,172 @@ +package aws + +import ( + "testing" + + "fmt" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccAWSIAMPolicyDocument(t *testing.T) { + // This really ought to be able to be a unit test rather than an + // acceptance test, but just instantiating the AWS provider requires + // some AWS API calls, and so this needs valid AWS credentials to work. + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSIAMPolicyDocumentConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckStateValue( + "data.aws_iam_policy_document.test", + "json", + testAccAWSIAMPolicyDocumentExpectedJSON, + ), + ), + }, + }, + }) +} + +func testAccCheckStateValue(id, name, value string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[id] + if !ok { + return fmt.Errorf("Not found: %s", id) + } + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + v := rs.Primary.Attributes[name] + if v != value { + return fmt.Errorf( + "Value for %s is %s, not %s", name, v, value) + } + + return nil + } +} + +var testAccAWSIAMPolicyDocumentConfig = ` +data "aws_iam_policy_document" "test" { + statement { + actions = [ + "s3:ListAllMyBuckets", + "s3:GetBucketLocation", + ] + resources = [ + "arn:aws:s3:::*", + ] + } + + statement { + actions = [ + "s3:ListBucket", + ] + resources = [ + "arn:aws:s3:::foo", + ] + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + "", + "home/", + "home/&{aws:username}/", + ] + } + + not_principals { + type = "AWS" + identifiers = ["arn:blahblah:example"] + } + } + + statement { + actions = [ + "s3:*", + ] + resources = [ + "arn:aws:s3:::foo/home/&{aws:username}", + "arn:aws:s3:::foo/home/&{aws:username}/*", + ] + principals { + type = "AWS" + identifiers = ["arn:blahblah:example"] + } + } + + statement { + effect = "Deny" + not_actions = ["s3:*"] + not_resources = ["arn:aws:s3:::*"] + } + +} +` + +var testAccAWSIAMPolicyDocumentExpectedJSON = `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetBucketLocation", + "s3:ListAllMyBuckets" + ], + "Resource": [ + "arn:aws:s3:::*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::foo" + ], + "NotPrincipal": { + "AWS": [ + "arn:blahblah:example" + ] + }, + "Condition": { + "StringLike": { + "s3:prefix": [ + "", + "home/", + "home/${aws:username}/" + ] + } + } + }, + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:aws:s3:::foo/home/${aws:username}/*", + "arn:aws:s3:::foo/home/${aws:username}" + ], + "Principal": { + "AWS": [ + "arn:blahblah:example" + ] + } + }, + { + "Effect": "Deny", + "NotAction": [ + "s3:*" + ], + "NotResource": [ + "arn:aws:s3:::*" + ] + } + ] +}` diff --git a/builtin/providers/aws/iam_policy_model.go b/builtin/providers/aws/iam_policy_model.go new file mode 100644 index 000000000..e90a08fe4 --- /dev/null +++ b/builtin/providers/aws/iam_policy_model.go @@ -0,0 +1,74 @@ +package aws + +import ( + "encoding/json" +) + +type IAMPolicyDoc struct { + Id string `json:",omitempty"` + Version string `json:",omitempty"` + Statements []*IAMPolicyStatement `json:"Statement"` +} + +type IAMPolicyStatement struct { + Sid string `json:",omitempty"` + Effect string `json:",omitempty"` + Actions []string `json:"Action,omitempty"` + NotActions []string `json:"NotAction,omitempty"` + Resources []string `json:"Resource,omitempty"` + NotResources []string `json:"NotResource,omitempty"` + Principals IAMPolicyStatementPrincipalSet `json:"Principal,omitempty"` + NotPrincipals IAMPolicyStatementPrincipalSet `json:"NotPrincipal,omitempty"` + Conditions IAMPolicyStatementConditionSet `json:"Condition,omitempty"` +} + +type IAMPolicyStatementPrincipal struct { + Type string + Identifiers []string +} + +type IAMPolicyStatementCondition struct { + Test string + Variable string + Values []string +} + +type IAMPolicyStatementPrincipalSet []IAMPolicyStatementPrincipal +type IAMPolicyStatementConditionSet []IAMPolicyStatementCondition + +func (ps IAMPolicyStatementPrincipalSet) MarshalJSON() ([]byte, error) { + raw := map[string][]string{} + + for _, p := range ps { + if _, ok := raw[p.Type]; !ok { + raw[p.Type] = make([]string, 0, len(p.Identifiers)) + } + raw[p.Type] = append(raw[p.Type], p.Identifiers...) + } + + return json.Marshal(&raw) +} + +func (cs IAMPolicyStatementConditionSet) MarshalJSON() ([]byte, error) { + raw := map[string]map[string][]string{} + + for _, c := range cs { + if _, ok := raw[c.Test]; !ok { + raw[c.Test] = map[string][]string{} + } + if _, ok := raw[c.Test][c.Variable]; !ok { + raw[c.Test][c.Variable] = make([]string, 0, len(c.Values)) + } + raw[c.Test][c.Variable] = append(raw[c.Test][c.Variable], c.Values...) + } + + return json.Marshal(&raw) +} + +func iamPolicyDecodeConfigStringList(lI []interface{}) []string { + ret := make([]string, len(lI)) + for i, vI := range lI { + ret[i] = vI.(string) + } + return ret +} diff --git a/builtin/providers/aws/provider.go b/builtin/providers/aws/provider.go index 9f7f55797..7ea7ef400 100644 --- a/builtin/providers/aws/provider.go +++ b/builtin/providers/aws/provider.go @@ -111,8 +111,9 @@ func Provider() terraform.ResourceProvider { }, DataSourcesMap: map[string]*schema.Resource{ - "aws_ami": dataSourceAwsAmi(), - "aws_availability_zones": dataSourceAwsAvailabilityZones(), + "aws_ami": dataSourceAwsAmi(), + "aws_availability_zones": dataSourceAwsAvailabilityZones(), + "aws_iam_policy_document": dataSourceAwsIamPolicyDocument(), }, ResourcesMap: map[string]*schema.Resource{ diff --git a/website/source/docs/providers/aws/d/iam_policy_document.html.markdown b/website/source/docs/providers/aws/d/iam_policy_document.html.markdown new file mode 100644 index 000000000..f055834ab --- /dev/null +++ b/website/source/docs/providers/aws/d/iam_policy_document.html.markdown @@ -0,0 +1,141 @@ +--- +layout: "aws" +page_title: "AWS: aws_iam_policy_document" +sidebar_current: "docs-aws-resource-iam-policy-document" +description: |- + Generates an IAM policy document in JSON format +--- + +# aws\_iam\_policy\_document + +Generates an IAM policy document in JSON format. + +This is a data source which can be used to construct a JSON representation of +an IAM policy document, for use with resources which expect policy documents, +such as the `aws_iam_policy` resource. + +``` +data "aws_iam_policy_document" "example" { + statement { + actions = [ + "s3:ListAllMyBuckets", + "s3:GetBucketLocation", + ] + resources = [ + "arn:aws:s3:::*", + ] + } + + statement { + actions = [ + "s3:ListBucket", + ] + resources = [ + "arn:aws:s3:::${var.s3_bucket_name}", + ] + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + "", + "home/", + "home/&{aws:username}/", + ] + } + } + + statement { + actions = [ + "s3:*", + ] + resources = [ + "arn:aws:s3:::${var.s3_bucket_name}/home/&{aws:username}", + "arn:aws:s3:::${var.s3_bucket_name}/home/&{aws:username}/*", + ] + } + +} + +resource "aws_iam_policy" "example" { + name = "example_policy" + path = "/" + policy = "${data.aws_iam_policy.example.json}" +} +``` + +Using this data source to generate policy documents is *optional*. It is also +valid to use literal JSON strings within your configuration, or to use the +`file` interpolation function to read a raw JSON policy document from a file. + +## Argument Reference + +The following arguments are supported: + +* `id` (Optional) - An ID for the policy document. +* `statement` (Required) - A nested configuration block (described below) + configuring one *statement* to be included in the policy document. + +Each document configuration must have one or more `statement` blocks, which +each accept the following arguments: + +* `id` (Optional) - An ID for the policy statement. +* `effect` (Optional) - Either "Allow" or "Deny", to specify whether this + statement allows or denies the given actions. The default is "Allow". +* `actions` (Optional) - A list of actions that this statement either allows + or denies. For example, ``["ec2:RunInstances", "s3:*"]``. +* `not_actions` (Optional) - A list of actions that this statement does *not* + apply to. Used to apply a policy statement to all actions *except* those + listed. +* `resources` (Optional) - A list of resource ARNs that this statement applies + to. +* `not_resources` (Optional) - A list of resource ARNs that this statement + does *not* apply to. Used to apply a policy statement to all resources + *except* those listed. +* `principals` (Optional) - A nested configuration block (described below) + specifying a resource (or resource pattern) to which this statement applies. +* `not_principals` (Optional) - Like `principals` except gives resources that + the statement does *not* apply to. +* `condition` (Optional) - A nested configuration block (described below) + that defines a further, possibly-service-specific condition that constrains + whether this statement applies. + +Each policy may have either zero or more `principals` blocks or zero or more +`not_principals` blocks, both of which each accept the following arguments: + +* `type` (Required) The type of principal. For AWS accounts this is "AWS". +* `identifiers` (Required) List of identifiers for principals. When `type` + is "AWS", these are IAM user or role ARNs. + +Each policy statement may have zero or more `condition` blocks, which each +accept the following arguments: + +* `test` (Required) The name of the + [IAM condition type](http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#AccessPolicyLanguage_ConditionType) + to evaluate. +* `variable` (Required) The name of a + [Context Variable](http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#AvailableKeys) + to apply the condition to. Context variables may either be standard AWS + variables starting with `aws:`, or service-specific variables prefixed with + the service name. +* `values` (Required) The values to evaluate the condition against. If multiple + values are provided, the condition matches if at least one of them applies. + (That is, the tests are combined with the "OR" boolean operation.) + +When multiple `condition` blocks are provided, they must *all* evaluate to true +for the policy statement to apply. (In other words, the conditions are combined +with the "AND" boolean operation.) + +## Context Variable Interpolation + +The IAM policy document format allows context variables to be interpolated +into various strings within a statement. The native IAM policy document format +uses `${...}`-style syntax that is in conflict with Terraform's interpolation +syntax, so this data source instead uses `&{...}` syntax for interpolations that +should be processed by AWS rather than by Terraform. + +## Attributes Reference + +The following attribute is exported: + +* `json` - The above arguments serialized as a standard JSON policy document. + diff --git a/website/source/layouts/aws.erb b/website/source/layouts/aws.erb index a4c248907..c49a6a4d6 100644 --- a/website/source/layouts/aws.erb +++ b/website/source/layouts/aws.erb @@ -19,6 +19,10 @@ > aws_availability_zones + + > + aws_iam_policy_document + @@ -402,6 +406,7 @@ > aws_iam_policy + > aws_iam_policy_attachment