diff --git a/castai/provider.go b/castai/provider.go index 30c86bc2..4958aa45 100644 --- a/castai/provider.go +++ b/castai/provider.go @@ -54,6 +54,7 @@ func Provider(version string) *schema.Provider { "castai_sso_connection": resourceSSOConnection(), "castai_workload_scaling_policy": resourceWorkloadScalingPolicy(), "castai_organization_group": resourceOrganizationGroup(), + "castai_role_bindings": resourceRoleBindings(), }, DataSourcesMap: map[string]*schema.Resource{ diff --git a/castai/resource_role_bindings.go b/castai/resource_role_bindings.go new file mode 100644 index 00000000..7f4ea89d --- /dev/null +++ b/castai/resource_role_bindings.go @@ -0,0 +1,448 @@ +package castai + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/samber/lo" + + "github.com/castai/terraform-provider-castai/castai/sdk" +) + +const ( + FieldRoleBindingsOrganizationID = "organization_id" + FieldRoleBindingsName = "name" + FieldRoleBindingsDescription = "description" + FieldRoleBindingsRoleID = "role_id" + FieldRoleBindingsScope = "scope" + FieldRoleBindingsScopeKind = "kind" + FieldRoleBindingsScopeResourceID = "resource_id" + FieldRoleBindingsSubjects = "subjects" + FieldRoleBindingsSubject = "subject" + FieldRoleBindingsSubjectKind = "kind" + FieldRoleBindingsSubjectUserID = "user_id" + FieldRoleBindingsSubjectServiceAccountID = "service_account_id" + FieldRoleBindingsSubjectGroupID = "group_id" + + RoleBindingScopeKindOrganization = "organization" + RoleBindingScopeKindCluster = "cluster" + + RoleBindingSubjectKindUser = "user" + RoleBindingSubjectKindServiceAccount = "service_account" + RoleBindingSubjectKindGroup = "group" +) + +var ( + supportedScopeKinds = []string{RoleBindingScopeKindOrganization, RoleBindingScopeKindCluster} + supportedSubjectKinds = []string{RoleBindingSubjectKindUser, RoleBindingSubjectKindServiceAccount, RoleBindingSubjectKindGroup} +) + +func resourceRoleBindings() *schema.Resource { + return &schema.Resource{ + ReadContext: resourceRoleBindingsRead, + CreateContext: resourceRoleBindingsCreate, + UpdateContext: resourceRoleBindingsUpdate, + DeleteContext: resourceRoleBindingsDelete, + Description: "CAST AI organization group resource to manage organization groups", + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(2 * time.Minute), + Update: schema.DefaultTimeout(2 * time.Minute), + Delete: schema.DefaultTimeout(2 * time.Minute), + }, + Schema: map[string]*schema.Schema{ + FieldRoleBindingsOrganizationID: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "CAST AI organization ID.", + }, + FieldRoleBindingsName: { + Type: schema.TypeString, + Required: true, + Description: "Name of role binding.", + }, + FieldRoleBindingsDescription: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Description of the role binding.", + }, + FieldRoleBindingsRoleID: { + Type: schema.TypeString, + Required: true, + Description: "ID of role from role binding.", + }, + FieldRoleBindingsScope: { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Description: "Scope of the role binding.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + FieldRoleBindingsScopeKind: { + Type: schema.TypeString, + Required: true, + Description: fmt.Sprintf("Scope of the role binding Supported values include: %s.", strings.Join(supportedScopeKinds, ", ")), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(supportedScopeKinds, true)), + DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { + return strings.EqualFold(oldValue, newValue) + }, + }, + FieldRoleBindingsScopeResourceID: { + Type: schema.TypeString, + Required: true, + Description: "ID of the scope resource.", + }, + }, + }, + }, + FieldRoleBindingsSubjects: { + Type: schema.TypeList, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + FieldRoleBindingsSubject: { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + FieldRoleBindingsSubjectKind: { + Type: schema.TypeString, + Required: true, + Description: fmt.Sprintf("Kind of the subject. Supported values include: %s.", strings.Join(supportedSubjectKinds, ", ")), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(supportedSubjectKinds, true)), + DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { + return strings.EqualFold(oldValue, newValue) + }, + }, + FieldRoleBindingsSubjectUserID: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: fmt.Sprintf("Optional, required only if `%s` is `%s`.", FieldRoleBindingsSubjectKind, RoleBindingSubjectKindUser), + }, + FieldRoleBindingsSubjectServiceAccountID: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: fmt.Sprintf("Optional, required only if `%s` is `%s`.", FieldRoleBindingsSubjectKind, RoleBindingSubjectKindServiceAccount), + }, + FieldRoleBindingsSubjectGroupID: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: fmt.Sprintf("Optional, required only if `%s` is `%s`.", FieldRoleBindingsSubjectKind, RoleBindingSubjectKindGroup), + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func resourceRoleBindingsRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + roleBindingID := data.Id() + if roleBindingID == "" { + return diag.Errorf("role binding ID is not set") + } + + organizationID := data.Get(FieldRoleBindingsOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + roleBinding, err := getRoleBinding(client, ctx, organizationID, roleBindingID) + if err != nil { + return diag.FromErr(fmt.Errorf("getting role binding for read: %w", err)) + } + + if err := assignRoleBindingData(roleBinding, data); err != nil { + return diag.FromErr(fmt.Errorf("assigning role binding data for read: %w", err)) + } + + return nil +} + +func resourceRoleBindingsCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + organizationID := data.Get(FieldRoleBindingsOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + subjects, err := convertSubjectsToSDK(data) + if err != nil { + return diag.FromErr(err) + } + scope := convertScopeToSDK(data) + + resp, err := client.RbacServiceAPICreateRoleBindingsWithResponse(ctx, organizationID, sdk.RbacServiceAPICreateRoleBindingsJSONRequestBody{ + { + Definition: sdk.CastaiRbacV1beta1RoleBindingDefinition{ + RoleId: data.Get(FieldRoleBindingsRoleID).(string), + Scope: scope, + Subjects: &subjects, + }, + Description: lo.ToPtr(data.Get(FieldRoleBindingsDescription).(string)), + Name: data.Get(FieldRoleBindingsName).(string), + }, + }) + + if err := sdk.CheckOKResponse(resp, err); err != nil { + return diag.FromErr(fmt.Errorf("create role binding: %w", err)) + } + + if len(*resp.JSON200) == 0 { + return diag.FromErr(errors.New("unknown error with creating role binding")) + } + + data.SetId(*(*resp.JSON200)[0].Id) + + return resourceRoleBindingsRead(ctx, data, meta) +} + +func resourceRoleBindingsUpdate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + roleBindingID := data.Id() + if roleBindingID == "" { + return diag.Errorf("role binding ID is not set") + } + + organizationID := data.Get(FieldRoleBindingsOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + subjects, err := convertSubjectsToSDK(data) + if err != nil { + return diag.FromErr(err) + } + scope := convertScopeToSDK(data) + + resp, err := client.RbacServiceAPIUpdateRoleBindingWithResponse(ctx, organizationID, roleBindingID, sdk.RbacServiceAPIUpdateRoleBindingJSONRequestBody{ + Definition: sdk.CastaiRbacV1beta1RoleBindingDefinition{ + RoleId: data.Get(FieldRoleBindingsRoleID).(string), + Scope: scope, + Subjects: &subjects, + }, + Description: lo.ToPtr(data.Get(FieldRoleBindingsDescription).(string)), + Name: data.Get(FieldRoleBindingsName).(string), + }) + + if err := sdk.CheckOKResponse(resp, err); err != nil { + return diag.FromErr(fmt.Errorf("update role binding: %w", err)) + } + + return resourceRoleBindingsRead(ctx, data, meta) +} + +func resourceRoleBindingsDelete(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics { + roleBindingID := data.Id() + if roleBindingID == "" { + return diag.Errorf("role binding ID is not set") + } + + organizationID := data.Get(FieldRoleBindingsOrganizationID).(string) + if organizationID == "" { + var err error + organizationID, err = getDefaultOrganizationId(ctx, meta) + if err != nil { + return diag.FromErr(fmt.Errorf("getting default organization: %w", err)) + } + } + + client := meta.(*ProviderConfig).api + + resp, err := client.RbacServiceAPIDeleteRoleBindingWithResponse(ctx, organizationID, roleBindingID) + if err := sdk.CheckOKResponse(resp, err); err != nil { + return diag.FromErr(fmt.Errorf("destroy role binding: %w", err)) + } + + return nil +} + +func getRoleBinding(client *sdk.ClientWithResponses, ctx context.Context, organizationID, roleBindingID string) (*sdk.CastaiRbacV1beta1RoleBinding, error) { + resp, err := client.RbacServiceAPIGetRoleBindingWithResponse(ctx, organizationID, roleBindingID) + if err != nil { + return nil, fmt.Errorf("fetching role binding: %w", err) + } + if resp.StatusCode() == http.StatusNotFound { + return nil, fmt.Errorf("role binding %s not found", roleBindingID) + } + if err := sdk.CheckOKResponse(resp, err); err != nil { + return nil, fmt.Errorf("retrieving role binding: %w", err) + } + if resp.JSON200 == nil { + return nil, fmt.Errorf("role binding %s not found", roleBindingID) + } + return resp.JSON200, nil +} + +func assignRoleBindingData(roleBinding *sdk.CastaiRbacV1beta1RoleBinding, data *schema.ResourceData) error { + if err := data.Set(FieldRoleBindingsOrganizationID, roleBinding.OrganizationId); err != nil { + return fmt.Errorf("setting organization_id: %w", err) + } + if err := data.Set(FieldRoleBindingsDescription, roleBinding.Description); err != nil { + return fmt.Errorf("setting description: %w", err) + } + if err := data.Set(FieldRoleBindingsName, roleBinding.Name); err != nil { + return fmt.Errorf("setting role binding name: %w", err) + } + if err := data.Set(FieldRoleBindingsRoleID, roleBinding.Definition.RoleId); err != nil { + return fmt.Errorf("setting role binding role id: %w", err) + } + + if roleBinding.Definition.Scope.Organization != nil { + err := data.Set(FieldRoleBindingsScope, []any{ + map[string]any{ + FieldRoleBindingsScopeKind: RoleBindingScopeKindOrganization, + FieldRoleBindingsScopeResourceID: roleBinding.Definition.Scope.Organization.Id, + }, + }) + if err != nil { + return fmt.Errorf("parsing scope organization: %w", err) + } + } else if roleBinding.Definition.Scope.Cluster != nil { + err := data.Set(FieldRoleBindingsScope, []any{ + map[string]any{ + FieldRoleBindingsScopeKind: RoleBindingScopeKindCluster, + FieldRoleBindingsScopeResourceID: roleBinding.Definition.Scope.Cluster.Id, + }, + }) + if err != nil { + return fmt.Errorf("parsing scope cluster: %w", err) + } + } + + if roleBinding.Definition.Subjects != nil { + var subjects []map[string]string + for _, subject := range *roleBinding.Definition.Subjects { + + if subject.User != nil { + subjects = append(subjects, map[string]string{ + FieldRoleBindingsSubjectKind: RoleBindingSubjectKindUser, + FieldRoleBindingsSubjectUserID: subject.User.Id, + }) + } else if subject.Group != nil { + subjects = append(subjects, map[string]string{ + FieldRoleBindingsSubjectKind: RoleBindingSubjectKindGroup, + FieldRoleBindingsSubjectGroupID: subject.Group.Id, + }) + } else if subject.ServiceAccount != nil { + subjects = append(subjects, map[string]string{ + FieldRoleBindingsSubjectKind: RoleBindingSubjectKindServiceAccount, + FieldRoleBindingsSubjectServiceAccountID: subject.ServiceAccount.Id, + }) + } + } + err := data.Set(FieldRoleBindingsSubjects, []any{ + map[string]any{ + FieldRoleBindingsSubject: subjects, + }, + }) + if err != nil { + return fmt.Errorf("parsing roleBinding subjects: %w", err) + } + } + + return nil +} + +func convertScopeToSDK(data *schema.ResourceData) sdk.CastaiRbacV1beta1Scope { + scopes := data.Get(FieldRoleBindingsScope).([]any) + if len(scopes) == 0 { + return sdk.CastaiRbacV1beta1Scope{} + } + + scope := scopes[0].(map[string]any) + + switch scope[FieldRoleBindingsScopeKind].(string) { + case RoleBindingScopeKindOrganization: + return sdk.CastaiRbacV1beta1Scope{ + Organization: &sdk.CastaiRbacV1beta1OrganizationScope{ + Id: scope[FieldRoleBindingsScopeResourceID].(string), + }, + } + case RoleBindingScopeKindCluster: + return sdk.CastaiRbacV1beta1Scope{ + Cluster: &sdk.CastaiRbacV1beta1ClusterScope{ + Id: scope[FieldRoleBindingsScopeResourceID].(string), + }, + } + default: + return sdk.CastaiRbacV1beta1Scope{} + } +} + +func convertSubjectsToSDK(data *schema.ResourceData) ([]sdk.CastaiRbacV1beta1Subject, error) { + var subjects []sdk.CastaiRbacV1beta1Subject + + for _, dataSubjectsDef := range data.Get(FieldRoleBindingsSubjects).([]any) { + for i, dataSubject := range dataSubjectsDef.(map[string]any)[FieldRoleBindingsSubject].([]any) { + + switch dataSubject.(map[string]any)[FieldRoleBindingsSubjectKind].(string) { + case RoleBindingSubjectKindUser: + if dataSubject.(map[string]any)[FieldRoleBindingsSubjectUserID].(string) == "" { + return nil, fmt.Errorf("missing `%s` value for subject no. %d", FieldRoleBindingsSubjectUserID, i) + } + + subjects = append(subjects, sdk.CastaiRbacV1beta1Subject{ + User: &sdk.CastaiRbacV1beta1UserSubject{ + Id: dataSubject.(map[string]any)[FieldRoleBindingsSubjectUserID].(string), + }, + }) + case RoleBindingSubjectKindServiceAccount: + if dataSubject.(map[string]any)[FieldRoleBindingsSubjectServiceAccountID].(string) == "" { + return nil, fmt.Errorf("missing `%s` value for subject no. %d", FieldRoleBindingsSubjectServiceAccountID, i) + } + + subjects = append(subjects, sdk.CastaiRbacV1beta1Subject{ + ServiceAccount: &sdk.CastaiRbacV1beta1ServiceAccountSubject{ + Id: dataSubject.(map[string]any)[FieldRoleBindingsSubjectServiceAccountID].(string), + }, + }) + case RoleBindingSubjectKindGroup: + if dataSubject.(map[string]any)[FieldRoleBindingsSubjectGroupID].(string) == "" { + return nil, fmt.Errorf("missing `%s` value for subject no. %d", FieldRoleBindingsSubjectGroupID, i) + } + + subjects = append(subjects, sdk.CastaiRbacV1beta1Subject{ + Group: &sdk.CastaiRbacV1beta1GroupSubject{ + Id: dataSubject.(map[string]any)[FieldRoleBindingsSubjectGroupID].(string), + }, + }) + } + } + } + + return subjects, nil +} diff --git a/castai/resource_role_bindings_test.go b/castai/resource_role_bindings_test.go new file mode 100644 index 00000000..e56c99bb --- /dev/null +++ b/castai/resource_role_bindings_test.go @@ -0,0 +1,614 @@ +package castai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + "github.com/castai/terraform-provider-castai/castai/sdk" + mock_sdk "github.com/castai/terraform-provider-castai/castai/sdk/mock" +) + +func TestRoleBindingsReadContext(t *testing.T) { + t.Parallel() + + t.Run("when state is missing role binding ID then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + ctx := context.Background() + provider := &ProviderConfig{} + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("role binding ID is not set", result[0].Summary) + }) + + t.Run("when RbacServiceAPI respond with 404 then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(""))) + + mockClient.EXPECT(). + RbacServiceAPIGetRoleBinding(gomock.Any(), organizationID, roleBindingID). + Return(&http.Response{StatusCode: http.StatusNotFound, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("getting role binding for read: role binding "+roleBindingID+" not found", result[0].Summary) + }) + + t.Run("when RbacServiceAPI respond with 500 then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte("internal error"))) + + mockClient.EXPECT(). + RbacServiceAPIGetRoleBinding(gomock.Any(), organizationID, roleBindingID). + Return(&http.Response{StatusCode: http.StatusInternalServerError, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("getting role binding for read: retrieving role binding: expected status code 200, received: status=500 body=internal error", result[0].Summary) + }) + + t.Run("when calling RbacServiceAPI throws error then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIGetRoleBinding(gomock.Any(), organizationID, roleBindingID). + Return(nil, errors.New("unexpected error")) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("getting role binding for read: fetching role binding: unexpected error", result[0].Summary) + }) + + t.Run("when RbacServiceAPI respond with 200 then populate the state", func(t *testing.T) { + t.Parallel() + + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := "4e4cd9eb-82eb-407e-a926-e5fef81cab50" + roleBindingID := "a83b7bf2-5a99-45d9-bcac-b969386e751f" + roleID := "4df39779-dfb2-48d3-91d8-7ee5bd2bca4b" + userID := "671b2ebb-f361-42f0-aa2f-3049de93f8c1" + serviceAccountID := "b11f5945-22ca-4101-a86e-d6e37f44a415" + groupID := "844d2bf2-870d-42da-a81c-4e19befc78fc" + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "` + roleBindingID + `", + "organizationId": "` + organizationID + `", + "name": "role-binding-name", + "description": "role-binding-description", + "definition": { + "roleId": "` + roleID + `", + "scope": { + "organization": { + "id": "` + organizationID + `" + } + }, + "subjects": [ + { + "user": { + "id": "` + userID + `" + } + }, + { + "serviceAccount": { + "id": "` + serviceAccountID + `" + } + }, + { + "group": { + "id": "` + groupID + `" + } + } + ] + } +}`))) + + mockClient.EXPECT(). + RbacServiceAPIGetRoleBinding(gomock.Any(), organizationID, roleBindingID). + Return(&http.Response{StatusCode: 200, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.ReadContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + r.Equal(`ID = `+roleBindingID+` +description = role-binding-description +name = role-binding-name +organization_id = `+organizationID+` +role_id = `+roleID+` +scope.# = 1 +scope.0.kind = organization +scope.0.resource_id = `+organizationID+` +subjects.# = 1 +subjects.0.subject.# = 3 +subjects.0.subject.0.group_id = +subjects.0.subject.0.kind = user +subjects.0.subject.0.service_account_id = +subjects.0.subject.0.user_id = `+userID+` +subjects.0.subject.1.group_id = +subjects.0.subject.1.kind = service_account +subjects.0.subject.1.service_account_id = `+serviceAccountID+` +subjects.0.subject.1.user_id = +subjects.0.subject.2.group_id = `+groupID+` +subjects.0.subject.2.kind = group +subjects.0.subject.2.service_account_id = +subjects.0.subject.2.user_id = +Tainted = false +`, data.State().String()) + }) +} + +func TestRoleBindingsUpdateContext(t *testing.T) { + t.Parallel() + + t.Run("when state is missing role binding ID then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + ctx := context.Background() + provider := &ProviderConfig{} + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.UpdateContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("role binding ID is not set", result[0].Summary) + }) + + t.Run("when RbacServiceAPI UpdateRoleBinding respond with 500 then throw error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIUpdateRoleBinding(gomock.Any(), organizationID, roleBindingID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqRoleBindingID string, req sdk.RbacServiceAPIUpdateRoleBindingJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(roleBindingID, reqRoleBindingID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1RoleBinding{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.UpdateContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Contains(result[0].Summary, "update role binding: expected status code 200, received: status=500") + }) + + t.Run("when RbacServiceAPI UpdateRoleBinding respond with 200 then no errors", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + firstUserID := uuid.NewString() + secondUserID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "` + roleBindingID + `", + "organizationId": "` + organizationID + `", + "name": "test group", + "description": "test role binding description changed", + "definition": { + "members": [ + { + "id": "` + firstUserID + `", + "email": "test-user-1@test.com" + }, + { + "id": "` + secondUserID + `", + "email": "test-user-2@test.com" + } + ] + } + }`))) + + mockClient.EXPECT(). + RbacServiceAPIGetRoleBinding(gomock.Any(), organizationID, roleBindingID). + Return(&http.Response{StatusCode: http.StatusOK, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + mockClient.EXPECT(). + RbacServiceAPIUpdateRoleBinding(gomock.Any(), organizationID, roleBindingID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqRoleBindingID string, req sdk.RbacServiceAPIUpdateRoleBindingJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(roleBindingID, reqRoleBindingID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1RoleBinding{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.UpdateContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + }) +} + +func TestRoleBindingsCreateContext(t *testing.T) { + t.Parallel() + + t.Run("when RbacServiceAPI CreateRoleBindings respond with 500 then throw error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPICreateRoleBindings(gomock.Any(), organizationID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, req sdk.RbacServiceAPICreateRoleBindingsJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1RoleBinding{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.CreateContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Contains(result[0].Summary, "create role binding: expected status code 200, received: status=500") + }) + + t.Run("when RbacServiceAPI CreateRoleBindings respond with 200 then assume role binding was created", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + firstUserID := uuid.NewString() + secondUserID := uuid.NewString() + + body := io.NopCloser(bytes.NewReader([]byte(`{ + "id": "` + roleBindingID + `", + "organizationId": "` + organizationID + `", + "name": "test role binding", + "description": "test role binding description changed", + "definition": { + "members": [ + { + "id": "` + firstUserID + `", + "email": "test-user-1@test.com" + }, + { + "id": "` + secondUserID + `", + "email": "test-user-2@test.com" + } + ] + } + }`))) + + mockClient.EXPECT(). + RbacServiceAPIGetRoleBinding(gomock.Any(), organizationID, roleBindingID). + Return(&http.Response{StatusCode: http.StatusOK, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + mockClient.EXPECT(). + RbacServiceAPICreateRoleBindings(gomock.Any(), organizationID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, req sdk.RbacServiceAPICreateRoleBindingsJSONRequestBody) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + + body := bytes.NewBuffer([]byte("")) + err := json.NewEncoder(body).Encode(&[]sdk.CastaiRbacV1beta1RoleBinding{ + { + Id: lo.ToPtr(roleBindingID), + }, + }) + r.NoError(err) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.CreateContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + }) +} + +func TestRoleBindingsDeleteContext(t *testing.T) { + t.Parallel() + + t.Run("when state is missing role binding ID then return error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + ctx := context.Background() + provider := &ProviderConfig{} + + stateValue := cty.ObjectVal(map[string]cty.Value{}) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.DeleteContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Equal("role binding ID is not set", result[0].Summary) + }) + + t.Run("when RbacServiceAPI DeleteRoleBinding respond with 500 then throw error", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIDeleteRoleBinding(gomock.Any(), organizationID, roleBindingID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqRoleBindingID string) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(roleBindingID, reqRoleBindingID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1RoleBinding{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.DeleteContext(ctx, data, provider) + + r.NotNil(result) + r.True(result.HasError()) + r.Len(result, 1) + r.Contains(result[0].Summary, "destroy role binding: expected status code 200, received: status=500") + }) + + t.Run("when RbacServiceAPI DeleteRoleBinding respond with 200 then no errors", func(t *testing.T) { + t.Parallel() + r := require.New(t) + mockClient := mock_sdk.NewMockClientInterface(gomock.NewController(t)) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + organizationID := uuid.NewString() + roleBindingID := uuid.NewString() + + mockClient.EXPECT(). + RbacServiceAPIDeleteRoleBinding(gomock.Any(), organizationID, roleBindingID, gomock.Any()). + DoAndReturn(func(ctx context.Context, reqOrgID string, reqRoleBindingID string) (*http.Response, error) { + r.Equal(organizationID, reqOrgID) + r.Equal(roleBindingID, reqRoleBindingID) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&sdk.CastaiRbacV1beta1RoleBinding{}) + r.NoError(err) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil + }) + + stateValue := cty.ObjectVal(map[string]cty.Value{ + "organization_id": cty.StringVal(organizationID), + }) + state := terraform.NewInstanceStateShimmedFromValue(stateValue, 0) + state.ID = roleBindingID + + resource := resourceRoleBindings() + data := resource.Data(state) + + result := resource.DeleteContext(ctx, data, provider) + + r.Nil(result) + r.False(result.HasError()) + }) +} diff --git a/docs/resources/role_bindings.md b/docs/resources/role_bindings.md new file mode 100644 index 00000000..1c820e26 --- /dev/null +++ b/docs/resources/role_bindings.md @@ -0,0 +1,75 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "castai_role_bindings Resource - terraform-provider-castai" +subcategory: "" +description: |- + CAST AI organization group resource to manage organization groups +--- + +# castai_role_bindings (Resource) + +CAST AI organization group resource to manage organization groups + + + + +## Schema + +### Required + +- `name` (String) Name of role binding. +- `organization_id` (String) CAST AI organization ID. +- `role_id` (String) ID of role from role binding. +- `scope` (Block List, Min: 1, Max: 1) Scope of the role binding. (see [below for nested schema](#nestedblock--scope)) +- `subjects` (Block List, Min: 1) (see [below for nested schema](#nestedblock--subjects)) + +### Optional + +- `description` (String) Description of the role binding. +- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `scope` + +Required: + +- `kind` (String) Scope of the role binding Supported values include: organization, cluster. +- `resource_id` (String) ID of the scope resource. + + + +### Nested Schema for `subjects` + +Optional: + +- `subject` (Block List) (see [below for nested schema](#nestedblock--subjects--subject)) + + +### Nested Schema for `subjects.subject` + +Required: + +- `kind` (String) Kind of the subject. Supported values include: user, service_account, group. + +Optional: + +- `group_id` (String) Optional, required only if `kind` is `group`. +- `service_account_id` (String) Optional, required only if `kind` is `service_account`. +- `user_id` (String) Optional, required only if `kind` is `user`. + + + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) +- `delete` (String) +- `update` (String) + + diff --git a/examples/role_bindings/main.tf b/examples/role_bindings/main.tf new file mode 100644 index 00000000..672adac0 --- /dev/null +++ b/examples/role_bindings/main.tf @@ -0,0 +1,60 @@ +terraform { + required_providers { + castai = { + source = "castai/castai" + } + } + required_version = ">= 0.13" +} + +data "castai_organization" "test" { + name = "My test organization name" +} + +resource "castai_role_bindings" "owner_test" { + organization_id = data.castai_organization.test.id + name = "Role binding owner" + description = "Owner access for whole organization." + + role_id = "3e1050c7-6593-4298-94bb-154637911d78" # Role "Owner" + scope { + kind = "organization" + resource_id = data.castai_organization.test.id + } + subjects { + subject { + kind = "user" + user_id = "21c133e2-a899-4f51-b297-830bc62e51d6" # user x + } + subject { + kind = "user" + user_id = "0d1efe35-7ecb-4821-a52d-fd56c9710a64" # user y + } + subject { + kind = "group" + group_id = "651734a7-0d0c-49f3-9654-dd92175febaa" + } + subject { + kind = "service_account" + service_account_id = "3bf49513-3e9c-4a12-962c-af3bb1a85074" + } + } +} + +resource "castai_role_bindings" "viewer_test" { + organization_id = data.castai_organization.test.id + name = "Role binding viewer for cluster 7063d31c-897e-48ef-a322-bdfda6fdbcfb" + description = "Viewer access for on of the clusters." + + role_id = "6fc95bd7-6049-4735-80b0-ce5ccde71cb1" # Role "Viewer" + scope { + kind = "cluster" + resource_id = "7063d31c-897e-48ef-a322-bdfda6fdbcfb" + } + subjects { + subject { + kind = "user" + user_id = "21c133e2-a899-4f51-b297-830bc62e51d6" # user z + } + } +} \ No newline at end of file