diff --git a/spinnaker/api/application.go b/spinnaker/api/application.go index 0231d58..3244ef3 100644 --- a/spinnaker/api/application.go +++ b/spinnaker/api/application.go @@ -27,6 +27,11 @@ var ( "tencentcloud": {50, `^[a-zA-Z_0-9.\u4e00-\u9fa5-]*$`}, "titus": {250, `^[a-zA-Z_0-9.]*$`}, } + + // SupportedAccesses is a list for Spinnaker application level + // See details here + // ref: https://spinnaker.io/setup/security/authorization/ + SupportedAccesses = []string{"READ", "WRITE", "EXECUTE"} ) // applicationNameConstraint ... @@ -61,6 +66,39 @@ func NewCreateApplicationTask(d *schema.ResourceData) (CreateApplicationTask, er app["cloudProviders"] = strings.Join(cloudProviders, ",") } + if v, ok := d.GetOkExists("permission"); ok { + var permissions = map[string][]string{} + + inputs := v.([]interface{}) + for _, input := range inputs { + input := input.(map[string]interface{}) + accesses := convToStringArray(input["accesses"].([]interface{})) + if err := validateSpinnakerApplicationAccess(accesses); err != nil { + return nil, err + } + for _, access := range accesses { + if user := input["user"].(string); user != "" { + if len(permissions[access]) == 0 { + permissions[access] = []string{user} + continue + } + + if users := permissions[access]; len(users) > 0{ + for _, v := range users { + if user == v { + return nil, fmt.Errorf("user %s permission's declare duplicated", user) + } + } + } + + permissions[access] = append(permissions[access], user) + } + } + } + + app["permissions"] = permissions + } + createAppTask := map[string]interface{}{ "job": []interface{}{map[string]interface{}{"type": "createApplication", "application": app}}, "application": app["name"], @@ -202,3 +240,31 @@ func validateSpinnakerApplicationNameByCloudProvider(appName, provider string) e return fmt.Errorf("cloud provider %s is not supported", provider) } + +func validateSpinnakerApplicationAccess(accesses []string) error { + for _, access := range accesses { + var validAccess bool + for _, v := range SupportedAccesses { + if access == v { + validAccess = true + } + } + + if !validAccess { + return fmt.Errorf("access %s is not supported", access) + } + } + + return nil +} + +func convToStringArray(in []interface{}) []string { + out := make([]string, len(in)) + for i, v := range in { + if str, ok := v.(string); ok { + out[i] = str + } + } + + return out +} diff --git a/spinnaker/api/application_test.go b/spinnaker/api/application_test.go index d595c23..1c55422 100644 --- a/spinnaker/api/application_test.go +++ b/spinnaker/api/application_test.go @@ -18,7 +18,9 @@ func TestValidateApplicationCloudProviders(t *testing.T) { } for n, tc := range tcs { + tc := tc t.Run(n, func(t *testing.T) { + t.Parallel() for _, p := range tc.cloudProviders { err := validateSpinnakerApplicationNameByCloudProvider(tc.appName, p) if err != nil && tc.shouldPass { @@ -28,3 +30,25 @@ func TestValidateApplicationCloudProviders(t *testing.T) { }) } } + +func TestValidateSpinnakerApplicationAccess(t *testing.T) { + tcs := map[string]struct { + accesses []string + shouldPass bool + }{ + "pass": {[]string{"WRITE"}, true}, + "pass with multiple access": {SupportedAccesses, true}, + "not supported access": {[]string{"MERCARI", "KEKE"}, false}, + "mixture of supported access and not supported access": {[]string{"WRITE", "KEKE", "BLOG"}, false}, + } + + for n, tc := range tcs { + tc := tc + t.Run(n, func(t *testing.T) { + t.Parallel() + if err := validateSpinnakerApplicationAccess(tc.accesses); err != nil && tc.shouldPass { + t.Fatalf("failed: %v", err) + } + }) + } +} diff --git a/spinnaker/data_source_spinnaker_application.go b/spinnaker/data_source_spinnaker_application.go index 2d5c131..8605555 100644 --- a/spinnaker/data_source_spinnaker_application.go +++ b/spinnaker/data_source_spinnaker_application.go @@ -30,6 +30,10 @@ func datasourceApplication() *schema.Resource { Type: schema.TypeString, Computed: true, }, + "permission": { + Type: schema.TypeMap, + Computed: true, + }, }, Read: resourceSpinnakerApplicationRead, } diff --git a/spinnaker/data_source_spinnaker_application_test.go b/spinnaker/data_source_spinnaker_application_test.go index 6163e0e..24f11a6 100644 --- a/spinnaker/data_source_spinnaker_application_test.go +++ b/spinnaker/data_source_spinnaker_application_test.go @@ -70,6 +70,7 @@ func TestAccDataSourceSpinnakerApplication_cloudProviders(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "name", rName), resource.TestCheckResourceAttr(resourceName, "email", "acceptance@test.com"), resource.TestCheckResourceAttr(resourceName, "instance_port", strconv.Itoa(defaultInstancePort)), + resource.TestCheckResourceAttr(resourceName, "cloud_providers", cloudProvider), ), }, }, diff --git a/spinnaker/resource_application.go b/spinnaker/resource_application.go index ca6fdd6..8321ecc 100644 --- a/spinnaker/resource_application.go +++ b/spinnaker/resource_application.go @@ -1,6 +1,7 @@ package spinnaker import ( + "fmt" "strings" "github.com/hashicorp/terraform/helper/schema" @@ -39,6 +40,14 @@ func resourceSpinnakerApplication() *schema.Resource { Optional: true, Default: defaultInstancePort, }, + "permission": { + Description: "Application level permissions", + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: getPermissionSchema(), + }, + }, }, Create: resourceSpinnakerApplicationCreate, Read: resourceSpinnakerApplicationRead, @@ -57,10 +66,17 @@ type applicationRead struct { } type applicationAttributes struct { - Accounts string `json:"accounts"` - CloudProviders string `json:"cloudproviders"` - Email string `json:"email"` - InstancePort int `json:"instancePort"` + Accounts string `json:"accounts"` + CloudProviders string `json:"cloudproviders"` + Email string `json:"email"` + InstancePort int `json:"instancePort"` + Permissions *Permissions `json:"permissions"` +} + +type Permissions struct { + Read []string `json:"READ"` + Execute []string `json:"EXECUTE"` + Write []string `json:"WRITE"` } func resourceSpinnakerApplicationCreate(d *schema.ResourceData, meta interface{}) error { @@ -108,6 +124,14 @@ func resourceSpinnakerApplicationRead(d *schema.ResourceData, meta interface{}) if v := app.Attributes.InstancePort; v != 0 { d.Set("instance_port", v) } + if v := app.Attributes.Permissions; v != nil { + terraformPermissions, err := buildTerraformPermissions(v) + if err != nil { + return err + } + + d.Set("permissions", terraformPermissions) + } return nil } @@ -167,6 +191,42 @@ func resourceSpinnakerApplicationImport(d *schema.ResourceData, meta interface{} return []*schema.ResourceData{d}, nil } -func flattenCloudProviders(input string) []string { - return strings.Split(input, ",") +func getPermissionSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "user": { + Type: schema.TypeString, + Description: "User ID", + Required: true, + }, + "accesses": { + Type: schema.TypeList, + Description: "List of access", + Elem: &schema.Schema{Type: schema.TypeString}, + Required: true, + }, + } +} + +func buildTerraformPermissions(permissions *Permissions) (*map[string][]string, error) { + users := map[string][]string{} + for _, rUser := range permissions.Read { + users[rUser] = append(users[rUser], "READ") + } + + for _, xUser := range permissions.Execute { + users[xUser] = append(users[xUser], "EXECUTE") + } + + for _, wUser := range permissions.Read { + users[wUser] = append(users[wUser], "WRITE") + } + + for user, accesses := range users { + if len(accesses) > 3 { + return nil, fmt.Errorf("more than 3 access granted for %s", user) + } + } + + return &users, nil } + diff --git a/spinnaker/validators_test.go b/spinnaker/validators_test.go index a0b3499..46f3899 100644 --- a/spinnaker/validators_test.go +++ b/spinnaker/validators_test.go @@ -23,6 +23,7 @@ func TestValidateApplicationName(t *testing.T) { "invalid_name", "", } + for _, v := range invalidNames { _, errors := validateSpinnakerApplicationName(v, "application") if len(errors) == 0 { diff --git a/website/docs/d/application.html.md b/website/docs/d/application.html.md index ae25407..24d3845 100644 --- a/website/docs/d/application.html.md +++ b/website/docs/d/application.html.md @@ -26,3 +26,7 @@ data "spinnaker_application" "my_app" {} * `last_modified_by` - ID of the user last modified * `name` - Name of the user * `user` - User associated to application + * `permissions` - List of application level permissions + * `read` - List of `READ` permission's users, teams + * `execute` - List of `EXECUTE` permission's user, teams + * `write` - List of `WRITE` permission's users, teams diff --git a/website/docs/r/application.html.md b/website/docs/r/application.html.md index 317db44..e8519ba 100644 --- a/website/docs/r/application.html.md +++ b/website/docs/r/application.html.md @@ -22,13 +22,23 @@ resource "spinnaker_application" "my_app" { ## Argument Reference -The following arguments are supported: +~> **Be careful!** You can accidentally lock yourself out of your Spinnaker application using `permission` attribute. One user or team should obtain `write` permission to edit the application after creation. + +The following arguments are supported. * `application` - (Required) The Name of the application. * `email` - (Required) Email of the owner. * `cloud_providers` - (Optional) List of the cloud providers. * `instance_port` - (Optional) Port of the Spinnaker generated links. Default to `80`. +* `permission` - (Optional) Nested block describing a application permission configuration. You have to enable [Authorization(RBAC)](https://spinnaker.io/setup/security/authorization/) for your Spinnaker to use this feature. + +### Nested `permission` block + +Nested `permission` block will have the following structure: +* `user` - (Required) ID of the user. The ID type depends on the authorization methods. For example, the ID will be the email address if you use G Suite. Also, if you use GitHub Teams the ID will be the team name. +* `accesses` - (Required) List of the access permission. The options are `READ`, `EXECUTE` and `WRITE`. + ## Import Applications can be imported using their Spinnaker application name, e.g.