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:
Benjamin Ritter 2025-03-14 10:31:05 +01:00 committed by GitHub
parent 69b117f4e7
commit dadea7a904
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 853 additions and 1 deletions

View file

@ -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,

View file

@ -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

View 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]".

View 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]".

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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

View 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.",
)
}

View 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)
}
})
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,6 @@
resource "stackit_authorization_project_role_assignment" "serviceaccount_duplicate" {
resource_id = var.project_id
role = "reader"
subject = var.test_service_account
}

View file

@ -0,0 +1,6 @@
resource "stackit_authorization_project_role_assignment" "invalid_role" {
resource_id = var.project_id
role = "thisrolesdoesnotexist"
subject = var.test_service_account
}

View file

@ -0,0 +1,6 @@
resource "stackit_authorization_organization_role_assignment" "serviceaccount" {
resource_id = var.organization_id
role = "organization.member"
subject = var.test_service_account
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}