From 4a6b40078eddec53ae32a71317102c8d0bfebe9c Mon Sep 17 00:00:00 2001 From: Jake Neyer Date: Thu, 9 Mar 2023 13:59:21 -0500 Subject: [PATCH] Add permission resources (#91) --- GNUmakefile | 3 +- cli/cmd/root.go | 2 + cli/cmd/run.go | 3 +- docs/resources/policy.md | 62 ++++ docs/resources/role.md | 36 ++ .../resources/polytomic_policy/resource.tf | 17 + examples/resources/polytomic_role/resource.tf | 3 + go.mod | 4 +- go.sum | 7 +- importer/formatter.go | 14 +- importer/import.go | 7 +- importer/policy.go | 99 ++++++ importer/role.go | 85 +++++ provider/provider.go | 2 + provider/provider_test.go | 26 +- provider/resource_policy.go | 336 ++++++++++++++++++ provider/resource_policy_test.go | 87 +++++ provider/resource_role.go | 189 ++++++++++ provider/resource_role_test.go | 66 ++++ provider/resource_s3_connection_test.go | 63 ---- provider/resource_user_test.go | 5 + 21 files changed, 1040 insertions(+), 76 deletions(-) create mode 100644 docs/resources/policy.md create mode 100644 docs/resources/role.md create mode 100644 examples/resources/polytomic_policy/resource.tf create mode 100644 examples/resources/polytomic_role/resource.tf create mode 100644 importer/policy.go create mode 100644 importer/role.go create mode 100644 provider/resource_policy.go create mode 100644 provider/resource_policy_test.go create mode 100644 provider/resource_role.go create mode 100644 provider/resource_role_test.go delete mode 100644 provider/resource_s3_connection_test.go diff --git a/GNUmakefile b/GNUmakefile index af75b87c..778ca948 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,12 +1,11 @@ POLYTOMIC_DEPLOYMENT_URL ?= app.polytomic-local.com:8443 -POLYTOMIC_DEPLOYMENT_KEY ?= secret-key default: testacc # Run acceptance tests .PHONY: testacc testacc: - POLYTOMIC_DEPLOYMENT_URL=$(POLYTOMIC_DEPLOYMENT_URL) POLYTOMIC_DEPLOYMENT_KEY=$(POLYTOMIC_DEPLOYMENT_KEY) TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m + POLYTOMIC_DEPLOYMENT_URL=$(POLYTOMIC_DEPLOYMENT_URL) TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m .PHONY: dev diff --git a/cli/cmd/root.go b/cli/cmd/root.go index f5caf605..91802466 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -26,8 +26,10 @@ func newRootCmd(version string) *cobra.Command { var output string runCmd.PersistentFlags().StringVar(&output, "output", ".", "Output directory for generated files (defaults to current directory)") runCmd.PersistentFlags().Bool("replace", false, "Replace existing files") + runCmd.PersistentFlags().Bool("include-permissions", false, "Include permission resources") viper.BindPFlag("output", runCmd.PersistentFlags().Lookup("output")) viper.BindPFlag("replace", runCmd.PersistentFlags().Lookup("replace")) + viper.BindPFlag("include-permissions", runCmd.PersistentFlags().Lookup("include-permissions")) // Register commands rootCmd.AddCommand(runCmd) diff --git a/cli/cmd/run.go b/cli/cmd/run.go index dea97a7d..e34b021c 100644 --- a/cli/cmd/run.go +++ b/cli/cmd/run.go @@ -15,7 +15,8 @@ var runCmd = &cobra.Command{ apiKey := viper.GetString("api-key") path := viper.GetString("output") replace := viper.GetBool("replace") + includePermissions := viper.GetBool("include-permissions") - importer.Init(url, apiKey, path, replace) + importer.Init(url, apiKey, path, replace, includePermissions) }, } diff --git a/docs/resources/policy.md b/docs/resources/policy.md new file mode 100644 index 00000000..b1e0269f --- /dev/null +++ b/docs/resources/policy.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/fbreckle/terraform-plugin-docs +page_title: "polytomic_policy Resource - terraform-provider-polytomic" +subcategory: "Organizations" +description: |- + A policy in a Polytomic organization +--- + +# polytomic_policy (Resource) + +A policy in a Polytomic organization + +## Example Usage + +```terraform +resource "polytomic_policy" "example" { + name = "Terraform role" + policy_actions = [ + { + action = "create" + role_ids = [ + polytomic_role.example.id + ] + }, + { + action = "delete" + role_ids = [ + polytomic_role.example.id + ] + }, + ] +} +``` + + +## Schema + +### Required + +- `name` (String) + +### Optional + +- `organization` (String) Organization ID +- `policy_actions` (Attributes Set) Policy actions (see [below for nested schema](#nestedatt--policy_actions)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `policy_actions` + +Required: + +- `action` (String) Action + +Optional: + +- `role_ids` (Set of String) Role IDs + + diff --git a/docs/resources/role.md b/docs/resources/role.md new file mode 100644 index 00000000..47f5770f --- /dev/null +++ b/docs/resources/role.md @@ -0,0 +1,36 @@ +--- +# generated by https://github.com/fbreckle/terraform-plugin-docs +page_title: "polytomic_role Resource - terraform-provider-polytomic" +subcategory: "Organizations" +description: |- + A role in a Polytomic organization +--- + +# polytomic_role (Resource) + +A role in a Polytomic organization + +## Example Usage + +```terraform +resource "polytomic_role" "example" { + name = "Terraform role" +} +``` + + +## Schema + +### Required + +- `name` (String) + +### Optional + +- `organization` (String) Organization ID + +### Read-Only + +- `id` (String) The ID of this resource. + + diff --git a/examples/resources/polytomic_policy/resource.tf b/examples/resources/polytomic_policy/resource.tf new file mode 100644 index 00000000..ae09aceb --- /dev/null +++ b/examples/resources/polytomic_policy/resource.tf @@ -0,0 +1,17 @@ +resource "polytomic_policy" "example" { + name = "Terraform role" + policy_actions = [ + { + action = "create" + role_ids = [ + polytomic_role.example.id + ] + }, + { + action = "delete" + role_ids = [ + polytomic_role.example.id + ] + }, + ] +} diff --git a/examples/resources/polytomic_role/resource.tf b/examples/resources/polytomic_role/resource.tf new file mode 100644 index 00000000..0ffe5e9c --- /dev/null +++ b/examples/resources/polytomic_role/resource.tf @@ -0,0 +1,3 @@ +resource "polytomic_role" "example" { + name = "Terraform role" +} diff --git a/go.mod b/go.mod index b15c8425..d9244c45 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/hashicorp/terraform-plugin-log v0.8.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.25.0 github.com/mitchellh/mapstructure v1.5.0 - github.com/polytomic/polytomic-go v0.0.0-20230224215215-d1563076f476 + github.com/polytomic/polytomic-go v0.0.0-20230308205457-3e0a5ade2f59 github.com/rs/zerolog v1.29.0 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.15.0 @@ -74,7 +74,7 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect - github.com/vmihailenco/tagparser v0.1.1 // indirect + github.com/vmihailenco/tagparser v0.1.2 // indirect golang.org/x/crypto v0.6.0 // indirect golang.org/x/mod v0.7.0 // indirect golang.org/x/net v0.6.0 // indirect diff --git a/go.sum b/go.sum index 8b982a3a..ea66f204 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/polytomic/polytomic-go v0.0.0-20230224215215-d1563076f476 h1:11enc89C8zq4mYoeDxUxoVY1jrhGcIrvbFYQhiUAwy0= -github.com/polytomic/polytomic-go v0.0.0-20230224215215-d1563076f476/go.mod h1:OYSOoZCtLFAW6hCf5YGWGXuoX6RgYAd49QEV7v7QLvo= +github.com/polytomic/polytomic-go v0.0.0-20230308205457-3e0a5ade2f59 h1:XJUlgDX6IN5eKYnDmd503lw+8tqYnbJMtqy4kMjJdw0= +github.com/polytomic/polytomic-go v0.0.0-20230308205457-3e0a5ade2f59/go.mod h1:OYSOoZCtLFAW6hCf5YGWGXuoX6RgYAd49QEV7v7QLvo= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= @@ -355,8 +355,9 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v4 v4.3.12 h1:07s4sz9IReOgdikxLTKNbBdqDMLsjPKXwvCazn8G65U= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= -github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/importer/formatter.go b/importer/formatter.go index 6ad30b79..cee7db9e 100644 --- a/importer/formatter.go +++ b/importer/formatter.go @@ -40,6 +40,18 @@ func typeConverter(value any) cty.Value { config[k] = typeConverter(v) case map[string]string: config[k] = typeConverter(v) + case []string: + if len(v) == 0 { + continue + } + vals := make([]cty.Value, 0) + for _, v := range v { + vals = append(vals, cty.StringVal(v)) + } + if len(vals) == 0 { + continue + } + config[k] = cty.ListVal(vals) case []any: if len(v) == 0 { continue @@ -49,7 +61,6 @@ func typeConverter(value any) cty.Value { switch v := v.(type) { case map[string]any: vals = append(vals, typeConverter(v)) - case string: vals = append(vals, cty.StringVal(v)) case int: @@ -100,7 +111,6 @@ func typeConverter(value any) cty.Value { switch v := v.(type) { case map[string]any: vals = append(vals, typeConverter(v)) - case string: vals = append(vals, cty.StringVal(v)) case int: diff --git a/importer/import.go b/importer/import.go index 0df33d23..ff88b2b9 100644 --- a/importer/import.go +++ b/importer/import.go @@ -19,7 +19,7 @@ type Importable interface { Filename() string } -func Init(url, key, path string, recreate bool) { +func Init(url, key, path string, recreate bool, includePermissions bool) { err := createDirectory(path) if err != nil { log.Fatal().AnErr("error", err).Msg("failed to create directory") @@ -36,6 +36,11 @@ func Init(url, key, path string, recreate bool) { NewSyncs(c), } + if includePermissions { + importables = append(importables, NewPolicies(c)) + importables = append(importables, NewRoles(c)) + } + // Create import.sh importFile, err := createFile(recreate, 0755, path, ImportFileName) if err != nil { diff --git a/importer/policy.go b/importer/policy.go new file mode 100644 index 00000000..868d36c7 --- /dev/null +++ b/importer/policy.go @@ -0,0 +1,99 @@ +package importer + +import ( + "context" + "fmt" + "io" + + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/polytomic/polytomic-go" + "github.com/polytomic/terraform-provider-polytomic/provider" + "github.com/zclconf/go-cty/cty" +) + +const ( + PoliciesResourceFileName = "policies.tf" + PolicyResource = "polytomic_policy" +) + +var ( + _ Importable = &Policies{} +) + +type Policies struct { + c *polytomic.Client + + Resources []*polytomic.Policy +} + +func NewPolicies(c *polytomic.Client) *Policies { + return &Policies{ + c: c, + } +} + +func (p *Policies) Init(ctx context.Context) error { + policies, err := p.c.Permissions().ListPolicies(ctx) + if err != nil { + return err + } + + for _, policy := range policies { + // Skip system policies, they are not editable + if policy.System { + continue + } + + hyrdatedPolicy, err := p.c.Permissions().GetPolicy(ctx, policy.ID) + if err != nil { + return err + } + + p.Resources = append(p.Resources, hyrdatedPolicy) + } + + return nil + +} + +func (p *Policies) GenerateTerraformFiles(ctx context.Context, writer io.Writer) error { + for _, policy := range p.Resources { + hclFile := hclwrite.NewEmptyFile() + body := hclFile.Body() + name := provider.ToSnakeCase(policy.Name) + + resourceBlock := body.AppendNewBlock("resource", []string{PolicyResource, name}) + + resourceBlock.Body().SetAttributeValue("name", cty.StringVal(policy.Name)) + if policy.OrganizationID != "" { + resourceBlock.Body().SetAttributeValue("organization", cty.StringVal(policy.OrganizationID)) + } + + var policyActions []map[string]interface{} + for _, action := range policy.PolicyActions { + policyActions = append(policyActions, map[string]interface{}{ + "action": action.Action, + "role_ids": action.RoleIDs, + }) + } + resourceBlock.Body().SetAttributeValue("policy_actions", typeConverter(policyActions)) + + writer.Write(hclFile.Bytes()) + } + return nil +} + +func (p *Policies) GenerateImports(ctx context.Context, writer io.Writer) error { + for _, policy := range p.Resources { + writer.Write([]byte(fmt.Sprintf("terraform import %s.%s %s", + PolicyResource, + provider.ToSnakeCase(policy.Name), + policy.ID))) + writer.Write([]byte(fmt.Sprintf(" # %s\n", policy.Name))) + } + return nil +} + +func (p *Policies) Filename() string { + return PoliciesResourceFileName +} diff --git a/importer/role.go b/importer/role.go new file mode 100644 index 00000000..f75e5be7 --- /dev/null +++ b/importer/role.go @@ -0,0 +1,85 @@ +package importer + +import ( + "context" + "fmt" + "io" + + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/polytomic/polytomic-go" + "github.com/polytomic/terraform-provider-polytomic/provider" + "github.com/zclconf/go-cty/cty" +) + +const ( + RolesResourceFileName = "roles.tf" + RoleResource = "polytomic_role" +) + +var ( + _ Importable = &Roles{} +) + +type Roles struct { + c *polytomic.Client + + Resources []polytomic.Role +} + +func NewRoles(c *polytomic.Client) *Roles { + return &Roles{ + c: c, + } +} + +func (p *Roles) Init(ctx context.Context) error { + roles, err := p.c.Permissions().ListRoles(ctx) + if err != nil { + return err + } + + for _, role := range roles { + // Skip system roles, they are not editable + if role.System { + continue + } + + p.Resources = append(p.Resources, role) + } + + return nil + +} + +func (p *Roles) GenerateTerraformFiles(ctx context.Context, writer io.Writer) error { + for _, role := range p.Resources { + hclFile := hclwrite.NewEmptyFile() + body := hclFile.Body() + name := provider.ToSnakeCase(role.Name) + + resourceBlock := body.AppendNewBlock("resource", []string{RoleResource, name}) + + resourceBlock.Body().SetAttributeValue("name", cty.StringVal(role.Name)) + if role.OrganizationID != "" { + resourceBlock.Body().SetAttributeValue("organization", cty.StringVal(role.OrganizationID)) + } + + writer.Write(hclFile.Bytes()) + } + return nil +} + +func (p *Roles) GenerateImports(ctx context.Context, writer io.Writer) error { + for _, role := range p.Resources { + writer.Write([]byte(fmt.Sprintf("terraform import %s.%s %s", + RoleResource, + provider.ToSnakeCase(role.Name), + role.ID))) + writer.Write([]byte(fmt.Sprintf(" # %s\n", role.Name))) + } + return nil +} + +func (p *Roles) Filename() string { + return RolesResourceFileName +} diff --git a/provider/provider.go b/provider/provider.go index cfd0eb3d..6d80133e 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -121,6 +121,8 @@ func (p *ptProvider) Resources(ctx context.Context) []func() resource.Resource { resourceList := []func() resource.Resource{ func() resource.Resource { return &organizationResource{} }, func() resource.Resource { return &userResource{} }, + func() resource.Resource { return &roleResource{} }, + func() resource.Resource { return &policyResource{} }, func() resource.Resource { return &modelResource{} }, func() resource.Resource { return &bulkSyncResource{} }, func() resource.Resource { return &bulkSyncSchemaResource{} }, diff --git a/provider/provider_test.go b/provider/provider_test.go index 0aac3e68..50bacf9f 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/polytomic/polytomic-go" ) // testAccProtoV6ProviderFactories are used to instantiate a provider during @@ -21,11 +22,32 @@ func testAccPreCheck(t *testing.T) { // about the appropriate environment variables being set are common to see in a pre-check // function. - if os.Getenv(PolytomicDeploymentKey) == "" { - t.Fatalf("%s must be set for acceptance testing", PolytomicDeploymentKey) + if os.Getenv(PolytomicAPIKey) == "" && os.Getenv(PolytomicDeploymentKey) == "" { + t.Fatalf("%s or %s must be set for acceptance testing", PolytomicAPIKey, PolytomicDeploymentKey) } if os.Getenv(PolytomicDeploymentURL) == "" { t.Fatalf("%s must be set for acceptance testing", PolytomicDeploymentURL) } } + +func testClient() *polytomic.Client { + deployURL := os.Getenv(PolytomicDeploymentURL) + deployKey := os.Getenv(PolytomicDeploymentKey) + apiKey := os.Getenv(PolytomicAPIKey) + + var client *polytomic.Client + if deployKey != "" { + client = polytomic.NewClient( + deployURL, + polytomic.DeploymentKey(deployKey), + ) + } else { + client = polytomic.NewClient( + deployURL, + polytomic.APIKey(apiKey), + ) + } + + return client +} diff --git a/provider/resource_policy.go b/provider/resource_policy.go new file mode 100644 index 00000000..8bc42747 --- /dev/null +++ b/provider/resource_policy.go @@ -0,0 +1,336 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/polytomic/polytomic-go" +) + +var _ resource.Resource = &policyResource{} +var _ resource.ResourceWithImportState = &policyResource{} + +func (r *policyResource) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + MarkdownDescription: ":meta:subcategory:Organizations: A policy in a Polytomic organization", + Attributes: map[string]tfsdk.Attribute{ + "organization": { + MarkdownDescription: "Organization ID", + Optional: true, + Computed: true, + Type: types.StringType, + }, + "name": { + Type: types.StringType, + Required: true, + }, + "policy_actions": { + MarkdownDescription: "Policy actions", + Optional: true, + Computed: true, + Attributes: tfsdk.SetNestedAttributes(map[string]tfsdk.Attribute{ + "action": { + MarkdownDescription: "Action", + Required: true, + Type: types.StringType, + }, + "role_ids": { + MarkdownDescription: "Role IDs", + Optional: true, + Computed: true, + Type: types.SetType{ElemType: types.StringType}, + }}), + }, + "id": { + Computed: true, + MarkdownDescription: "", + PlanModifiers: tfsdk.AttributePlanModifiers{ + resource.UseStateForUnknown(), + }, + Type: types.StringType, + }, + }, + }, nil +} + +func (r policyResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_policy" +} + +type policyResourceData struct { + Organization types.String `tfsdk:"organization"` + Name types.String `tfsdk:"name"` + Id types.String `tfsdk:"id"` + PolicyActions types.Set `tfsdk:"policy_actions"` +} + +type policyResource struct { + client *polytomic.Client +} + +func (r *policyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data policyResourceData + + diags := req.Config.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + var policyActions []polytomic.PolicyAction + diags = data.PolicyActions.ElementsAs(ctx, &policyActions, true) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + policy, err := r.client.Permissions().CreatePolicy( + ctx, + polytomic.PolicyRequest{ + Name: data.Name.ValueString(), + OrganizationID: data.Organization.ValueString(), + PolicyActions: policyActions, + }, + ) + if err != nil { + resp.Diagnostics.AddError(clientError, fmt.Sprintf("Error creating policy: %s", err)) + return + } + + tracked := make(map[string]bool) + for _, action := range policyActions { + tracked[action.Action] = true + } + // We only want to track the actions in the configuration + // additional actions may be returned by the API + // and we don't want to track them in the state + var prunedPolicyActions []polytomic.PolicyAction + for _, action := range policy.PolicyActions { + if tracked[action.Action] { + prunedPolicyActions = append(prunedPolicyActions, action) + continue + } + + if action.RoleIDs != nil && len(action.RoleIDs) > 0 { + resp.Diagnostics.AddWarning( + "Policy has actions not tracked by Terraform", + fmt.Sprintf("Policy action %s has roles set but is not tracked in the state. This may cause data to be overwritten", + strings.ToUpper(action.Action)), + ) + } + } + resultPolicies, diags := types.SetValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "action": types.StringType, + "role_ids": types.SetType{ElemType: types.StringType}, + }, + }, prunedPolicyActions) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + data.Id = types.StringValue(policy.ID) + data.Name = types.StringValue(policy.Name) + data.Organization = types.StringValue(policy.OrganizationID) + data.PolicyActions = resultPolicies + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *policyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data policyResourceData + + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + var policyActions []polytomic.PolicyAction + diags = data.PolicyActions.ElementsAs(ctx, &policyActions, true) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + policy, err := r.client.Permissions().GetPolicy(ctx, data.Id.ValueString()) + if err != nil { + pErr := polytomic.ApiError{} + if errors.As(err, &pErr) { + if pErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + resp.Diagnostics.AddError("Error reading policy", err.Error()) + return + } + + tracked := make(map[string]bool) + for _, action := range policyActions { + tracked[action.Action] = true + } + // We only want to track the actions in the configuration + // additional actions may be returned by the API + // and we don't want to track them in the state + var prunedPolicyActions []polytomic.PolicyAction + for _, action := range policy.PolicyActions { + if tracked[action.Action] { + prunedPolicyActions = append(prunedPolicyActions, action) + continue + } + + if action.RoleIDs != nil && len(action.RoleIDs) > 0 { + resp.Diagnostics.AddWarning( + "Policy has actions not tracked by Terraform", + fmt.Sprintf("Policy action %s has roles set but is not tracked in the state. This may cause data to be overwritten", + strings.ToUpper(action.Action)), + ) + } + } + + resultPolicies, diags := types.SetValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "action": types.StringType, + "role_ids": types.SetType{ElemType: types.StringType}, + }, + }, prunedPolicyActions) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + data.Id = types.StringValue(policy.ID) + data.Name = types.StringValue(policy.Name) + data.Organization = types.StringValue(policy.OrganizationID) + data.PolicyActions = resultPolicies + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *policyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data policyResourceData + + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + var policyActions []polytomic.PolicyAction + diags = data.PolicyActions.ElementsAs(ctx, &policyActions, true) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + policy, err := r.client.Permissions().UpdatePolicy( + ctx, + data.Id.ValueString(), + polytomic.PolicyRequest{ + Name: data.Name.ValueString(), + OrganizationID: data.Organization.ValueString(), + PolicyActions: policyActions, + }) + if err != nil { + resp.Diagnostics.AddError(clientError, fmt.Sprintf("Error updating policy: %s", err)) + return + } + + tracked := make(map[string]bool) + for _, action := range policyActions { + tracked[action.Action] = true + } + // We only want to track the actions in the configuration + // additional actions may be returned by the API + // and we don't want to track them in the state + var prunedPolicyActions []polytomic.PolicyAction + for _, action := range policy.PolicyActions { + if tracked[action.Action] { + prunedPolicyActions = append(prunedPolicyActions, action) + continue + } + + if action.RoleIDs != nil && len(action.RoleIDs) > 0 { + resp.Diagnostics.AddWarning( + "Policy has actions not tracked by Terraform", + fmt.Sprintf("Policy action %s has roles set but is not tracked in the state. This may cause data to be overwritten", + strings.ToUpper(action.Action)), + ) + } + } + + resultPolicies, diags := types.SetValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "action": types.StringType, + "role_ids": types.SetType{ElemType: types.StringType}, + }, + }, prunedPolicyActions) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + data.Id = types.StringValue(policy.ID) + data.Name = types.StringValue(policy.Name) + data.Organization = types.StringValue(policy.OrganizationID) + data.PolicyActions = resultPolicies + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *policyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data policyResourceData + + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.Permissions().DeletePolicy(ctx, data.Id.ValueString()) + if err != nil { + resp.Diagnostics.AddError(clientError, fmt.Sprintf("Error deleting policy: %s", err)) + return + } +} + +func (r *policyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *policyResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*polytomic.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} diff --git a/provider/resource_policy_test.go b/provider/resource_policy_test.go new file mode 100644 index 00000000..9bb66376 --- /dev/null +++ b/provider/resource_policy_test.go @@ -0,0 +1,87 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccPolicy(t *testing.T) { + name := "TestAccPolicy" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccPolicyResource(name), + Check: resource.ComposeTestCheckFunc( + // Check if the resource exists + testAccPolicyExists(name), + // Check the name + resource.TestCheckResourceAttr("polytomic_policy.test", "name", name), + // Number of policy actions + resource.TestCheckResourceAttr("polytomic_policy.test", "policy_actions.#", "2"), + // Check the first policy action + resource.TestCheckResourceAttr("polytomic_policy.test", "policy_actions.0.action", "apply_policy"), + resource.TestCheckResourceAttr("polytomic_policy.test", "policy_actions.0.role_ids.#", "1")), + }, + }, + }) +} + +func testAccPolicyExists(name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + _, ok := s.RootModule().Resources["polytomic_policy.test"] + if !ok { + return fmt.Errorf("not found: %s", "polytomic_policy.test") + } + + client := testClient() + policies, err := client.Permissions().ListPolicies(context.TODO()) + if err != nil { + return err + } + var found bool + for _, policy := range policies { + if policy.Name == name { + found = true + break + } + } + + if !found { + return fmt.Errorf("policy %s not found", name) + } + + return nil + + } +} + +func testAccPolicyResource(name string) string { + return fmt.Sprintf(` +resource "polytomic_policy" "test" { + name = "%s" + policy_actions = [ + { + action = "apply_policy" + role_ids = [ + polytomic_role.test.id + ] + }, + { + action = "delete" + role_ids = [ + polytomic_role.test.id + ] + } + ] +} +resource "polytomic_role" "test" { + name = "%s" +} +`, name, name) +} diff --git a/provider/resource_role.go b/provider/resource_role.go new file mode 100644 index 00000000..ab3f29ad --- /dev/null +++ b/provider/resource_role.go @@ -0,0 +1,189 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/polytomic/polytomic-go" +) + +var _ resource.Resource = &roleResource{} +var _ resource.ResourceWithImportState = &roleResource{} + +func (r *roleResource) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) { + return tfsdk.Schema{ + MarkdownDescription: ":meta:subcategory:Organizations: A role in a Polytomic organization", + Attributes: map[string]tfsdk.Attribute{ + "organization": { + MarkdownDescription: "Organization ID", + Optional: true, + Computed: true, + Type: types.StringType, + }, + "name": { + Type: types.StringType, + Required: true, + }, + "id": { + Computed: true, + MarkdownDescription: "", + PlanModifiers: tfsdk.AttributePlanModifiers{ + resource.UseStateForUnknown(), + }, + Type: types.StringType, + }, + }, + }, nil +} + +func (r roleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_role" +} + +type roleResourceData struct { + Organization types.String `tfsdk:"organization"` + Name types.String `tfsdk:"name"` + Id types.String `tfsdk:"id"` +} + +type roleResource struct { + client *polytomic.Client +} + +func (r *roleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data roleResourceData + + diags := req.Config.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + role, err := r.client.Permissions().CreateRole( + ctx, + polytomic.RoleRequest{ + Name: data.Name.ValueString(), + OrganizationID: data.Organization.ValueString(), + }, + ) + if err != nil { + resp.Diagnostics.AddError(clientError, fmt.Sprintf("Error creating role: %s", err)) + return + } + data.Id = types.StringValue(role.ID) + data.Name = types.StringValue(role.Name) + data.Organization = types.StringValue(role.OrganizationID) + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *roleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data roleResourceData + + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + role, err := r.client.Permissions().GetRole(ctx, data.Id.ValueString()) + if err != nil { + pErr := polytomic.ApiError{} + if errors.As(err, &pErr) { + if pErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + resp.Diagnostics.AddError("Error reading role", err.Error()) + return + } + + data.Id = types.StringValue(role.ID) + data.Name = types.StringValue(role.Name) + data.Organization = types.StringValue(role.OrganizationID) + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *roleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data roleResourceData + + diags := req.Plan.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + role, err := r.client.Permissions().UpdateRole( + ctx, + data.Id.ValueString(), + polytomic.RoleRequest{ + Name: data.Name.ValueString(), + OrganizationID: data.Organization.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError(clientError, fmt.Sprintf("Error updating role: %s", err)) + return + } + + data.Id = types.StringValue(role.ID) + data.Name = types.StringValue(role.Name) + data.Organization = types.StringValue(role.OrganizationID) + + diags = resp.State.Set(ctx, &data) + resp.Diagnostics.Append(diags...) +} + +func (r *roleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data roleResourceData + + diags := req.State.Get(ctx, &data) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.Permissions().DeleteRole(ctx, data.Id.ValueString()) + if err != nil { + resp.Diagnostics.AddError(clientError, fmt.Sprintf("Error deleting role: %s", err)) + return + } +} + +func (r *roleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *roleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*polytomic.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} diff --git a/provider/resource_role_test.go b/provider/resource_role_test.go new file mode 100644 index 00000000..29a1f3ce --- /dev/null +++ b/provider/resource_role_test.go @@ -0,0 +1,66 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccRole(t *testing.T) { + name := "TestAccRole" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccRoleResource(name), + Check: resource.ComposeTestCheckFunc( + // Check if the resource exists + testAccRoleExists(name), + // Check the name + resource.TestCheckResourceAttr("polytomic_role.test", "name", name), + ), + }, + }, + }) +} + +func testAccRoleExists(name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + _, ok := s.RootModule().Resources["polytomic_role.test"] + if !ok { + return fmt.Errorf("not found: %s", "polytomic_role.test") + } + + client := testClient() + roles, err := client.Permissions().ListRoles(context.TODO()) + if err != nil { + return err + } + var found bool + for _, role := range roles { + if role.Name == name { + found = true + break + } + } + + if !found { + return fmt.Errorf("role %s not found", name) + } + + return nil + + } +} + +func testAccRoleResource(name string) string { + return fmt.Sprintf(` +resource "polytomic_role" "test" { + name = "%s" +} +`, name) +} diff --git a/provider/resource_s3_connection_test.go b/provider/resource_s3_connection_test.go deleted file mode 100644 index c92a47cd..00000000 --- a/provider/resource_s3_connection_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package provider - -import ( - "fmt" - "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" -) - -func TestAccS3(t *testing.T) { - key := "test" - secret := "test" - region := "us-east-1" - bucket := "test-bucket" - org := "terraform-test-org" - - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: testAccS3Resource(key, secret, region, bucket, org), - Check: resource.ComposeTestCheckFunc( - testAccS3Exists(bucket), - ), - }, - }, - }) -} - -func testAccS3Exists(bucket string) resource.TestCheckFunc { - return func(s *terraform.State) error { - rs, ok := s.RootModule().Resources["polytomic_s3_connection.test"] - if !ok { - return fmt.Errorf("not found: %s", "polytomic_s3_connection.test") - } - if rs.Primary.Attributes["configuration.bucket"] != bucket { - return fmt.Errorf("bucket is %s; want %s", rs.Primary.Attributes["configuration.bucket"], bucket) - } - return nil - - } -} - -func testAccS3Resource(accessKey, secretKey, region, bucket, organization string) string { - return fmt.Sprintf(` -resource "polytomic_s3_connection" "test" { - organization = polytomic_organization.acme.id - name = "Acc Test Bucket" - configuration = { - access_key_id = "%s" - access_key_secret = "%s" - region = "%s" - bucket = "%s" - } -} -resource "polytomic_organization" "acme" { - name = "%s" - sso_domain = "acmeinc.com" -} -`, accessKey, secretKey, region, bucket, organization) -} diff --git a/provider/resource_user_test.go b/provider/resource_user_test.go index fcef66d8..f62bea79 100644 --- a/provider/resource_user_test.go +++ b/provider/resource_user_test.go @@ -2,6 +2,7 @@ package provider import ( "fmt" + "os" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -9,6 +10,10 @@ import ( ) func TestAccUser_basic(t *testing.T) { + if os.Getenv("TEST_ORG_RESOURCES") != "true" { + t.Skip("Skipping test that creates resources in the Terraform test organization. To run, set TEST_ORG_RESOURCES=true") + } + email := "test@example.com" email2 := "mIxEdCase@example.com"