IAM Role Assignment (#665)
* Initial PoC for a Project Role Assignment resource Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * fix: move project_role_assignment into new "authorization" resource group Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * feat: add authorization_project_role_assignment acceptance test Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * docs: add authorization_project_role_assignment docs and examples Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * fix: linting Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * feat: add generic role_assignment resources Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * feat: add infrastructure for experimental features Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * feat: Make IAM resources part of the iam experiment Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * fix: Log an error if an experiment does not exist Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> * fix: Do not cache the experiment check Caching the experiment check causes problems when running the provider in debug mode, since configure in the provider can be called multiple times there with different configurations, with different experiments enabled. Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> --------- Signed-off-by: Benjamin Ritter <benjamin.ritter@stackit.cloud> Co-authored-by: Benjamin Ritter <benjamin.ritter@stackit.cloud>
This commit is contained in:
parent
69b117f4e7
commit
dadea7a904
18 changed files with 853 additions and 1 deletions
19
README.md
19
README.md
|
|
@ -172,6 +172,25 @@ To use beta resources in the STACKIT Terraform provider, follow these steps:
|
|||
|
||||
For more details, please refer to the [beta resources configuration guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources).
|
||||
|
||||
## Opting into Experiments
|
||||
|
||||
Experiments are features that are even less mature and stable than Beta Resources. While there is some assumed stability in beta resources, will have to expect breaking changes while using experimental resources. Experimental Resources do not come with any support or warranty.
|
||||
|
||||
To enable experiments set the experiments field in the provider definition:
|
||||
|
||||
```hcl
|
||||
provider "stackit" {
|
||||
region = "eu01"
|
||||
experiments = ["iam"]
|
||||
}
|
||||
```
|
||||
|
||||
### Available Experiments
|
||||
|
||||
#### `iam`
|
||||
|
||||
Enables IAM management features in the Terraform provider. The underlying IAM API is expected to undergo a redesign in the future, which leads to it being considered experimental.
|
||||
|
||||
## Acceptance Tests
|
||||
|
||||
Terraform acceptance tests are run using the command `make test-acceptance-tf`. For all services,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de
|
|||
- `default_region` (String) Region will be used as the default location for regional services. Not all services require a region, some are global
|
||||
- `dns_custom_endpoint` (String) Custom endpoint for the DNS service
|
||||
- `enable_beta_resources` (Boolean) Enable beta resources. Default is false.
|
||||
- `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: [iam]
|
||||
- `iaas_custom_endpoint` (String) Custom endpoint for the IaaS service
|
||||
- `loadbalancer_custom_endpoint` (String) Custom endpoint for the Load Balancer service
|
||||
- `logme_custom_endpoint` (String) Custom endpoint for the LogMe service
|
||||
|
|
|
|||
37
docs/resources/authorization_organization_role_assignment.md
Normal file
37
docs/resources/authorization_organization_role_assignment.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
# generated by https://github.com/hashicorp/terraform-plugin-docs
|
||||
page_title: "stackit_authorization_organization_role_assignment Resource - stackit"
|
||||
subcategory: ""
|
||||
description: |-
|
||||
organization Role Assignment resource schema.
|
||||
~> This resource is part of the iam experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
|
||||
---
|
||||
|
||||
# stackit_authorization_organization_role_assignment (Resource)
|
||||
|
||||
organization Role Assignment resource schema.
|
||||
|
||||
~> This resource is part of the iam experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```terraform
|
||||
resource "stackit_authorization_organization_role_assignment" "example" {
|
||||
resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
role = "owner"
|
||||
subject = "john.doe@stackit.cloud"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- schema generated by tfplugindocs -->
|
||||
## Schema
|
||||
|
||||
### Required
|
||||
|
||||
- `resource_id` (String) organization Resource to assign the role to.
|
||||
- `role` (String) Role to be assigned
|
||||
- `subject` (String) Identifier of user, service account or client. Usually email address or name in case of clients
|
||||
|
||||
### Read-Only
|
||||
|
||||
- `id` (String) Terraform's internal resource identifier. It is structured as "[resource_id],[role],[subject]".
|
||||
37
docs/resources/authorization_project_role_assignment.md
Normal file
37
docs/resources/authorization_project_role_assignment.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
# generated by https://github.com/hashicorp/terraform-plugin-docs
|
||||
page_title: "stackit_authorization_project_role_assignment Resource - stackit"
|
||||
subcategory: ""
|
||||
description: |-
|
||||
project Role Assignment resource schema.
|
||||
~> This resource is part of the iam experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
|
||||
---
|
||||
|
||||
# stackit_authorization_project_role_assignment (Resource)
|
||||
|
||||
project Role Assignment resource schema.
|
||||
|
||||
~> This resource is part of the iam experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
|
||||
|
||||
## Example Usage
|
||||
|
||||
```terraform
|
||||
resource "stackit_authorization_project_role_assignment" "example" {
|
||||
resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
role = "owner"
|
||||
subject = "john.doe@stackit.cloud"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- schema generated by tfplugindocs -->
|
||||
## Schema
|
||||
|
||||
### Required
|
||||
|
||||
- `resource_id` (String) project Resource to assign the role to.
|
||||
- `role` (String) Role to be assigned
|
||||
- `subject` (String) Identifier of user, service account or client. Usually email address or name in case of clients
|
||||
|
||||
### Read-Only
|
||||
|
||||
- `id` (String) Terraform's internal resource identifier. It is structured as "[resource_id],[role],[subject]".
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
resource "stackit_authorization_organization_role_assignment" "example" {
|
||||
resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
role = "owner"
|
||||
subject = "john.doe@stackit.cloud"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
resource "stackit_authorization_project_role_assignment" "example" {
|
||||
resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
role = "owner"
|
||||
subject = "john.doe@stackit.cloud"
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ type ProviderData struct {
|
|||
SKECustomEndpoint string
|
||||
ServiceEnablementCustomEndpoint string
|
||||
EnableBetaResources bool
|
||||
Experiments []string
|
||||
}
|
||||
|
||||
// GetRegion returns the effective region for the provider, falling back to the deprecated _region_ attribute
|
||||
|
|
|
|||
61
stackit/internal/features/experiments.go
Normal file
61
stackit/internal/features/experiments.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package features
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/diag"
|
||||
"github.com/hashicorp/terraform-plugin-log/tflog"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
|
||||
)
|
||||
|
||||
var AvailableExperiments []string = []string{"iam"}
|
||||
|
||||
// Check if an experiment is valid.
|
||||
func ValidExperiment(experiment string, diags *diag.Diagnostics) bool {
|
||||
validExperiment := slices.ContainsFunc(AvailableExperiments, func(e string) bool {
|
||||
return strings.EqualFold(e, experiment)
|
||||
})
|
||||
if !validExperiment {
|
||||
diags.AddError("Invalid Experiment", fmt.Sprintf("The Experiment %s is invalid. This is most likely a bug in the STACKIT Provider. Please open an issue. Available Experiments: %v", experiment, AvailableExperiments))
|
||||
}
|
||||
|
||||
return validExperiment
|
||||
}
|
||||
|
||||
// Check if an experiment is enabled.
|
||||
func CheckExperimentEnabled(ctx context.Context, data *core.ProviderData, experiment, resourceType string, diags *diag.Diagnostics) {
|
||||
if !ValidExperiment(experiment, diags) {
|
||||
errTitle := fmt.Sprintf("The experiment %s does not exist.", experiment)
|
||||
errContent := "This is a bug in the STACKIT Terraform Provider. Please open an issue here: https://github.com/stackitcloud/terraform-provider-stackit/issues"
|
||||
diags.AddError(errTitle, errContent)
|
||||
return
|
||||
}
|
||||
experimentActive := slices.ContainsFunc(data.Experiments, func(e string) bool {
|
||||
return strings.EqualFold(e, experiment)
|
||||
})
|
||||
|
||||
if experimentActive {
|
||||
warnTitle := fmt.Sprintf("%s is part of the %s experiment.", resourceType, experiment)
|
||||
warnContent := fmt.Sprintf("This resource is part of the %s experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.", experiment)
|
||||
tflog.Warn(ctx, fmt.Sprintf("%s | %s", warnTitle, warnContent))
|
||||
diags.AddWarning(warnTitle, warnContent)
|
||||
return
|
||||
}
|
||||
errTitle := fmt.Sprintf("%s is part of the %s experiment, which is currently disabled by default", resourceType, experiment)
|
||||
errContent := fmt.Sprintf(`Enable the %s experiment by adding it into your provider block.`, experiment)
|
||||
tflog.Error(ctx, fmt.Sprintf("%s | %s", errTitle, errContent))
|
||||
diags.AddError(errTitle, errContent)
|
||||
}
|
||||
|
||||
func AddExperimentDescription(description, experiment string) string {
|
||||
// Callout block: https://developer.hashicorp.com/terraform/registry/providers/docs#callouts
|
||||
return fmt.Sprintf("%s\n\n~> %s%s%s",
|
||||
description,
|
||||
"This resource is part of the ",
|
||||
experiment,
|
||||
" experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.",
|
||||
)
|
||||
}
|
||||
113
stackit/internal/features/experiments_test.go
Normal file
113
stackit/internal/features/experiments_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package features
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/diag"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
|
||||
)
|
||||
|
||||
func TestValidExperiment(t *testing.T) {
|
||||
type args struct {
|
||||
experiment string
|
||||
diags *diag.Diagnostics
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
args: args{
|
||||
experiment: "iam",
|
||||
diags: &diag.Diagnostics{},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
args: args{
|
||||
experiment: "foo",
|
||||
diags: &diag.Diagnostics{},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ValidExperiment(tt.args.experiment, tt.args.diags); got != tt.want {
|
||||
t.Errorf("ValidExperiment() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExperimentEnabled(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
data *core.ProviderData
|
||||
experiment string
|
||||
resourceType string
|
||||
diags *diag.Diagnostics
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantDiagsErr bool
|
||||
wantDiagsWarning bool
|
||||
}{
|
||||
{
|
||||
name: "enabled",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: &core.ProviderData{
|
||||
Experiments: []string{"iam"},
|
||||
},
|
||||
experiment: "iam",
|
||||
diags: &diag.Diagnostics{},
|
||||
},
|
||||
wantDiagsErr: false,
|
||||
wantDiagsWarning: true,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: &core.ProviderData{
|
||||
Experiments: []string{},
|
||||
},
|
||||
experiment: "iam",
|
||||
diags: &diag.Diagnostics{},
|
||||
},
|
||||
wantDiagsErr: true,
|
||||
wantDiagsWarning: false,
|
||||
},
|
||||
{
|
||||
name: "invalid experiment",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: &core.ProviderData{
|
||||
Experiments: []string{"iam"},
|
||||
},
|
||||
experiment: "foobar",
|
||||
resourceType: "provider",
|
||||
diags: &diag.Diagnostics{},
|
||||
},
|
||||
wantDiagsErr: true,
|
||||
wantDiagsWarning: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
CheckExperimentEnabled(tt.args.ctx, tt.args.data, tt.args.experiment, tt.args.resourceType, tt.args.diags)
|
||||
if got := tt.args.diags.HasError(); got != tt.wantDiagsErr {
|
||||
t.Errorf("CheckExperimentEnabled() diags.HasError() = %v, want %v", got, tt.wantDiagsErr)
|
||||
}
|
||||
if got := tt.args.diags.WarningsCount() > 0; got != tt.wantDiagsWarning {
|
||||
t.Errorf("CheckExperimentEnabled() diags.WarningsCount() > 0 = %v, want %v", got, tt.wantDiagsErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package authorization_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-testing/config"
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
"github.com/hashicorp/terraform-plugin-testing/terraform"
|
||||
stackitSdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
|
||||
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
|
||||
)
|
||||
|
||||
//go:embed testfiles/prerequisites.tf
|
||||
var prerequisites string
|
||||
|
||||
//go:embed testfiles/double-definition.tf
|
||||
var doubleDefinition string
|
||||
|
||||
//go:embed testfiles/project-owner.tf
|
||||
var projectOwner string
|
||||
|
||||
//go:embed testfiles/invalid-role.tf
|
||||
var invalidRole string
|
||||
|
||||
//go:embed testfiles/organization-role.tf
|
||||
var organizationRole string
|
||||
|
||||
var testConfigVars = config.Variables{
|
||||
"project_id": config.StringVariable(testutil.ProjectId),
|
||||
"test_service_account": config.StringVariable(testutil.TestProjectServiceAccountEmail),
|
||||
"organization_id": config.StringVariable(testutil.OrganizationId),
|
||||
}
|
||||
|
||||
func TestAccProjectRoleAssignmentResource(t *testing.T) {
|
||||
t.Log(testutil.AuthorizationProviderConfig())
|
||||
resource.Test(t, resource.TestCase{
|
||||
ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
ConfigVariables: testConfigVars,
|
||||
Config: testutil.AuthorizationProviderConfig() + prerequisites,
|
||||
Check: func(_ *terraform.State) error {
|
||||
client, err := authApiClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
members, err := client.ListMembers(context.TODO(), "project", testutil.ProjectId).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(*members.Members, func(m authorization.Member) bool {
|
||||
return *m.Role == "reader" && *m.Subject == testutil.TestProjectServiceAccountEmail
|
||||
}) {
|
||||
t.Log(members.Members)
|
||||
return errors.New("Membership not found")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// Assign a resource to an organization
|
||||
ConfigVariables: testConfigVars,
|
||||
Config: testutil.AuthorizationProviderConfig() + prerequisites + organizationRole,
|
||||
},
|
||||
{
|
||||
// The Service Account inherits owner permissions for the project from the organization. Check if you can still assign owner permissions on the project explicitly
|
||||
ConfigVariables: testConfigVars,
|
||||
Config: testutil.AuthorizationProviderConfig() + prerequisites + organizationRole + projectOwner,
|
||||
},
|
||||
{
|
||||
// Expect failure on creating an already existing role_assignment
|
||||
// Would be bad, since two resources could be created and deletion of one would lead to state drift for the second TF resource
|
||||
ConfigVariables: testConfigVars,
|
||||
Config: testutil.AuthorizationProviderConfig() + prerequisites + doubleDefinition,
|
||||
ExpectError: regexp.MustCompile(".+"),
|
||||
},
|
||||
{
|
||||
// Assign a non-existent role. Expect failure
|
||||
ConfigVariables: testConfigVars,
|
||||
Config: testutil.AuthorizationProviderConfig() + prerequisites + invalidRole,
|
||||
ExpectError: regexp.MustCompile(".+"),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func authApiClient() (*authorization.APIClient, error) {
|
||||
var client *authorization.APIClient
|
||||
var err error
|
||||
if testutil.AuthorizationCustomEndpoint == "" {
|
||||
client, err = authorization.NewAPIClient(
|
||||
stackitSdkConfig.WithRegion("eu01"),
|
||||
)
|
||||
} else {
|
||||
client, err = authorization.NewAPIClient(
|
||||
stackitSdkConfig.WithEndpoint(testutil.AuthorizationCustomEndpoint),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
package roleassignments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/hashicorp/terraform-plugin-log/tflog"
|
||||
"github.com/stackitcloud/stackit-sdk-go/core/config"
|
||||
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
|
||||
)
|
||||
|
||||
// List of permission assignments targets in form [TF resource name]:[api name]
|
||||
var roleTargets = []string{
|
||||
"project",
|
||||
"organization",
|
||||
}
|
||||
|
||||
// This resource is part of the "iam" experiment
|
||||
var experiment = "iam"
|
||||
|
||||
// Ensure the implementation satisfies the expected interfaces.
|
||||
var (
|
||||
_ resource.Resource = &roleAssignmentResource{}
|
||||
_ resource.ResourceWithConfigure = &roleAssignmentResource{}
|
||||
_ resource.ResourceWithImportState = &roleAssignmentResource{}
|
||||
|
||||
errRoleAssignmentNotFound = errors.New("response members did not contain expected role assignment")
|
||||
errRoleAssignmentDuplicateFound = errors.New("found a duplicate role assignment.")
|
||||
)
|
||||
|
||||
// Provider's internal model
|
||||
type Model struct {
|
||||
Id types.String `tfsdk:"id"` // needed by TF
|
||||
ResourceId types.String `tfsdk:"resource_id"`
|
||||
Role types.String `tfsdk:"role"`
|
||||
Subject types.String `tfsdk:"subject"`
|
||||
}
|
||||
|
||||
// NewProjectRoleAssignmentResource is a helper function to simplify the provider implementation.
|
||||
func NewRoleAssignmentResources() []func() resource.Resource {
|
||||
resources := make([]func() resource.Resource, 0)
|
||||
for _, v := range roleTargets {
|
||||
resources = append(resources, func() resource.Resource {
|
||||
return &roleAssignmentResource{
|
||||
apiName: v,
|
||||
}
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
// roleAssignmentResource is the resource implementation.
|
||||
type roleAssignmentResource struct {
|
||||
authorizationClient *authorization.APIClient
|
||||
apiName string
|
||||
}
|
||||
|
||||
// Metadata returns the resource type name.
|
||||
func (r *roleAssignmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = fmt.Sprintf("%s_authorization_%s_role_assignment", req.ProviderTypeName, r.apiName)
|
||||
}
|
||||
|
||||
// Configure adds the provider configured client to the resource.
|
||||
func (r *roleAssignmentResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
// Prevent panic if the provider has not been configured.
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
|
||||
providerData, ok := req.ProviderData.(core.ProviderData)
|
||||
if !ok {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading providerData", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
|
||||
return
|
||||
}
|
||||
|
||||
features.CheckExperimentEnabled(ctx, &providerData, experiment, fmt.Sprintf("stackit_authorization_%s_role_assignment", r.apiName), &resp.Diagnostics)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
var aClient *authorization.APIClient
|
||||
if providerData.AuthorizationCustomEndpoint != "" {
|
||||
ctx = tflog.SetField(ctx, "authorization_custom_endpoint", providerData.AuthorizationCustomEndpoint)
|
||||
aClient, err = authorization.NewAPIClient(
|
||||
config.WithCustomAuth(providerData.RoundTripper),
|
||||
config.WithEndpoint(providerData.AuthorizationCustomEndpoint),
|
||||
)
|
||||
} else {
|
||||
aClient, err = authorization.NewAPIClient(
|
||||
config.WithCustomAuth(providerData.RoundTripper),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring Authorization API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
|
||||
return
|
||||
}
|
||||
|
||||
r.authorizationClient = aClient
|
||||
tflog.Info(ctx, fmt.Sprintf("Resource Manager %s Role Assignment client configured", r.apiName))
|
||||
}
|
||||
|
||||
// Schema defines the schema for the resource.
|
||||
func (r *roleAssignmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
descriptions := map[string]string{
|
||||
"main": features.AddExperimentDescription(fmt.Sprintf("%s Role Assignment resource schema.", r.apiName), experiment),
|
||||
"id": "Terraform's internal resource identifier. It is structured as \"[resource_id],[role],[subject]\".",
|
||||
"resource_id": fmt.Sprintf("%s Resource to assign the role to.", r.apiName),
|
||||
"role": "Role to be assigned",
|
||||
"subject": "Identifier of user, service account or client. Usually email address or name in case of clients",
|
||||
}
|
||||
|
||||
resp.Schema = schema.Schema{
|
||||
Description: descriptions["main"],
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Description: descriptions["id"],
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.UseStateForUnknown(),
|
||||
},
|
||||
},
|
||||
"resource_id": schema.StringAttribute{
|
||||
Description: descriptions["resource_id"],
|
||||
Required: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
Validators: []validator.String{
|
||||
validate.UUID(),
|
||||
validate.NoSeparator(),
|
||||
},
|
||||
},
|
||||
"role": schema.StringAttribute{
|
||||
Description: descriptions["role"],
|
||||
Required: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
"subject": schema.StringAttribute{
|
||||
Description: descriptions["subject"],
|
||||
Required: true,
|
||||
PlanModifiers: []planmodifier.String{
|
||||
stringplanmodifier.RequiresReplace(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates the resource and sets the initial Terraform state.
|
||||
func (r *roleAssignmentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
var model Model
|
||||
diags := req.Plan.Get(ctx, &model)
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx = r.annotateLogger(ctx, &model)
|
||||
|
||||
if err := r.checkDuplicate(ctx, model); err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error while checking for duplicate role assignments", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Create new project role assignment
|
||||
payload, err := r.toCreatePayload(&model)
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Creating API payload: %v", err))
|
||||
return
|
||||
}
|
||||
createResp, err := r.authorizationClient.AddMembers(ctx, model.ResourceId.ValueString()).AddMembersPayload(*payload).Execute()
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, fmt.Sprintf("Error creating %s role assignment", r.apiName), fmt.Sprintf("Calling API: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Map response body to schema
|
||||
err = mapMembersResponse(createResp, &model)
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, fmt.Sprintf("Error creating %s role assignment", r.apiName), fmt.Sprintf("Processing API payload: %v", err))
|
||||
return
|
||||
}
|
||||
diags = resp.State.Set(ctx, model)
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
tflog.Info(ctx, fmt.Sprintf("%s role assignment created", r.apiName))
|
||||
}
|
||||
|
||||
// Read refreshes the Terraform state with the latest data.
|
||||
func (r *roleAssignmentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
var model Model
|
||||
diags := req.State.Get(ctx, &model)
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx = r.annotateLogger(ctx, &model)
|
||||
|
||||
listResp, err := r.authorizationClient.ListMembers(ctx, r.apiName, model.ResourceId.ValueString()).Subject(model.Subject.ValueString()).Execute()
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading authorizations", fmt.Sprintf("Calling API: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Map response body to schema
|
||||
err = mapListMembersResponse(listResp, &model)
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading authorization", fmt.Sprintf("Processing API payload: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Set refreshed state
|
||||
diags = resp.State.Set(ctx, model)
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
tflog.Info(ctx, fmt.Sprintf("%s role assignment read successful", r.apiName))
|
||||
}
|
||||
|
||||
// Update updates the resource and sets the updated Terraform state on success.
|
||||
func (r *roleAssignmentResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
// does nothing since resource updates should always trigger resource replacement
|
||||
}
|
||||
|
||||
// Delete deletes the resource and removes the Terraform state on success.
|
||||
func (r *roleAssignmentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
|
||||
var model Model
|
||||
diags := req.State.Get(ctx, &model)
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx = r.annotateLogger(ctx, &model)
|
||||
|
||||
payload := authorization.RemoveMembersPayload{
|
||||
ResourceType: &r.apiName,
|
||||
Members: &[]authorization.Member{
|
||||
*authorization.NewMember(model.Role.ValueStringPointer(), model.Subject.ValueStringPointer()),
|
||||
},
|
||||
}
|
||||
|
||||
// Delete existing project role assignment
|
||||
_, err := r.authorizationClient.RemoveMembers(ctx, model.ResourceId.ValueString()).RemoveMembersPayload(payload).Execute()
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, fmt.Sprintf("Error deleting %s role assignment", r.apiName), fmt.Sprintf("Calling API: %v", err))
|
||||
}
|
||||
|
||||
tflog.Info(ctx, fmt.Sprintf("%s role assignment deleted", r.apiName))
|
||||
}
|
||||
|
||||
// ImportState imports a resource into the Terraform state on success.
|
||||
// The expected format of the project role assignment resource import identifier is: resource_id,role,subject
|
||||
func (r *roleAssignmentResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
idParts := strings.Split(req.ID, core.Separator)
|
||||
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics,
|
||||
fmt.Sprintf("Error importing %s role assignment", r.apiName),
|
||||
fmt.Sprintf("Expected import identifier with format [resource_id],[role],[subject], got %q", req.ID),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("resource_id"), idParts[0])...)
|
||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("role"), idParts[1])...)
|
||||
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("subject"), idParts[2])...)
|
||||
tflog.Info(ctx, fmt.Sprintf("%s role assignment state imported", r.apiName))
|
||||
}
|
||||
|
||||
// Maps project role assignment fields to the provider's internal model.
|
||||
func mapListMembersResponse(resp *authorization.ListMembersResponse, model *Model) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("response input is nil")
|
||||
}
|
||||
if resp.Members == nil {
|
||||
return fmt.Errorf("response members are nil")
|
||||
}
|
||||
if model == nil {
|
||||
return fmt.Errorf("model input is nil")
|
||||
}
|
||||
|
||||
idParts := []string{
|
||||
model.ResourceId.ValueString(),
|
||||
model.Role.ValueString(),
|
||||
model.Subject.ValueString(),
|
||||
}
|
||||
model.Id = types.StringValue(
|
||||
strings.Join(idParts, core.Separator),
|
||||
)
|
||||
|
||||
model.ResourceId = types.StringPointerValue(resp.ResourceId)
|
||||
|
||||
for _, m := range *resp.Members {
|
||||
if *m.Role == model.Role.ValueString() && *m.Subject == model.Subject.ValueString() {
|
||||
model.Role = types.StringPointerValue(m.Role)
|
||||
model.Subject = types.StringPointerValue(m.Subject)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errRoleAssignmentNotFound
|
||||
}
|
||||
|
||||
func mapMembersResponse(resp *authorization.MembersResponse, model *Model) error {
|
||||
listMembersResponse, err := typeConverter[authorization.ListMembersResponse](resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mapListMembersResponse(listMembersResponse, model)
|
||||
}
|
||||
|
||||
// Helper to convert objects with equal JSON tags
|
||||
func typeConverter[R any](data any) (*R, error) {
|
||||
var result R
|
||||
b, err := json.Marshal(&data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, err
|
||||
}
|
||||
|
||||
// Build Createproject role assignmentPayload from provider's model
|
||||
func (r *roleAssignmentResource) toCreatePayload(model *Model) (*authorization.AddMembersPayload, error) {
|
||||
if model == nil {
|
||||
return nil, fmt.Errorf("nil model")
|
||||
}
|
||||
|
||||
return &authorization.AddMembersPayload{
|
||||
ResourceType: &r.apiName,
|
||||
Members: &[]authorization.Member{
|
||||
*authorization.NewMember(model.Role.ValueStringPointer(), model.Subject.ValueStringPointer()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *roleAssignmentResource) annotateLogger(ctx context.Context, model *Model) context.Context {
|
||||
resourceId := model.ResourceId.ValueString()
|
||||
ctx = tflog.SetField(ctx, "resource_id", resourceId)
|
||||
ctx = tflog.SetField(ctx, "subject", model.Subject.ValueString())
|
||||
ctx = tflog.SetField(ctx, "role", model.Role.ValueString())
|
||||
ctx = tflog.SetField(ctx, "resource_type", r.apiName)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// returns an error if duplicate role assignment exists
|
||||
func (r *roleAssignmentResource) checkDuplicate(ctx context.Context, model Model) error { //nolint:gocritic // A read only copy is required since an api response is parsed into the model and this check should not affect the model parameter
|
||||
listResp, err := r.authorizationClient.ListMembers(ctx, r.apiName, model.ResourceId.ValueString()).Subject(model.Subject.ValueString()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Map response body to schema
|
||||
err = mapListMembersResponse(listResp, &model)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errRoleAssignmentNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errRoleAssignmentDuplicateFound
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
resource "stackit_authorization_project_role_assignment" "serviceaccount_duplicate" {
|
||||
resource_id = var.project_id
|
||||
role = "reader"
|
||||
subject = var.test_service_account
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
resource "stackit_authorization_project_role_assignment" "invalid_role" {
|
||||
resource_id = var.project_id
|
||||
role = "thisrolesdoesnotexist"
|
||||
subject = var.test_service_account
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
resource "stackit_authorization_organization_role_assignment" "serviceaccount" {
|
||||
resource_id = var.organization_id
|
||||
role = "organization.member"
|
||||
subject = var.test_service_account
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
variable "project_id" {}
|
||||
variable "test_service_account" {}
|
||||
variable "organization_id" {}
|
||||
|
||||
resource "stackit_authorization_project_role_assignment" "serviceaccount" {
|
||||
resource_id = var.project_id
|
||||
role = "reader"
|
||||
subject = var.test_service_account
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
resource "stackit_authorization_project_role_assignment" "serviceaccount_project_owner" {
|
||||
resource_id = var.project_id
|
||||
role = "owner"
|
||||
subject = var.test_service_account
|
||||
}
|
||||
|
|
@ -376,6 +376,23 @@ func SKEProviderConfig() string {
|
|||
)
|
||||
}
|
||||
|
||||
func AuthorizationProviderConfig() string {
|
||||
if AuthorizationCustomEndpoint == "" {
|
||||
return `
|
||||
provider "stackit" {
|
||||
region = "eu01"
|
||||
experiments = ["iam"]
|
||||
}`
|
||||
}
|
||||
return fmt.Sprintf(`
|
||||
provider "stackit" {
|
||||
authorization_custom_endpoint = "%s"
|
||||
experiments = ["iam"]
|
||||
}`,
|
||||
AuthorizationCustomEndpoint,
|
||||
)
|
||||
}
|
||||
|
||||
func ResourceNameWithDateTime(name string) string {
|
||||
dateTime := time.Now().Format(time.RFC3339)
|
||||
// Remove timezone to have a smaller datetime
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import (
|
|||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
|
||||
argusCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/argus/credential"
|
||||
argusInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/argus/instance"
|
||||
argusScrapeConfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/argus/scrapeconfig"
|
||||
roleassignments "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments"
|
||||
dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset"
|
||||
dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone"
|
||||
iaasAffinityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/affinitygroup"
|
||||
|
|
@ -133,6 +135,7 @@ type providerModel struct {
|
|||
TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"`
|
||||
EnableBetaResources types.Bool `tfsdk:"enable_beta_resources"`
|
||||
ServiceEnablementCustomEndpoint types.String `tfsdk:"service_enablement_custom_endpoint"`
|
||||
Experiments types.List `tfsdk:"experiments"`
|
||||
}
|
||||
|
||||
// Schema defines the provider-level schema for configuration data.
|
||||
|
|
@ -170,6 +173,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
|
|||
"service_enablement_custom_endpoint": "Custom endpoint for the Service Enablement API",
|
||||
"token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow",
|
||||
"enable_beta_resources": "Enable beta resources. Default is false.",
|
||||
"experiments": fmt.Sprintf("Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v", features.AvailableExperiments),
|
||||
}
|
||||
|
||||
resp.Schema = schema.Schema{
|
||||
|
|
@ -311,6 +315,11 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
|
|||
Optional: true,
|
||||
Description: descriptions["enable_beta_resources"],
|
||||
},
|
||||
"experiments": schema.ListAttribute{
|
||||
ElementType: types.StringType,
|
||||
Optional: true,
|
||||
Description: descriptions["experiments"],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -411,6 +420,15 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
|
|||
if !(providerConfig.EnableBetaResources.IsUnknown() || providerConfig.EnableBetaResources.IsNull()) {
|
||||
providerData.EnableBetaResources = providerConfig.EnableBetaResources.ValueBool()
|
||||
}
|
||||
if !(providerConfig.Experiments.IsUnknown() || providerConfig.Experiments.IsNull()) {
|
||||
var experimentValues []string
|
||||
diags := providerConfig.Experiments.ElementsAs(ctx, &experimentValues, false)
|
||||
if diags.HasError() {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring provider", fmt.Sprintf("Setting up experiments: %v", diags.Errors()))
|
||||
}
|
||||
providerData.Experiments = experimentValues
|
||||
}
|
||||
|
||||
roundTripper, err := sdkauth.SetupAuth(sdkConfig)
|
||||
if err != nil {
|
||||
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring provider", fmt.Sprintf("Setting up authentication: %v", err))
|
||||
|
|
@ -481,7 +499,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource
|
|||
|
||||
// Resources defines the resources implemented in the provider.
|
||||
func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
resources := []func() resource.Resource{
|
||||
argusCredential.NewCredentialResource,
|
||||
argusInstance.NewInstanceResource,
|
||||
argusScrapeConfig.NewScrapeConfigResource,
|
||||
|
|
@ -538,4 +556,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
|
|||
skeCluster.NewClusterResource,
|
||||
skeKubeconfig.NewKubeconfigResource,
|
||||
}
|
||||
resources = append(resources, roleassignments.NewRoleAssignmentResources()...)
|
||||
|
||||
return resources
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue