diff --git a/docs/resources/alb_waf_managed_rule_set.md b/docs/resources/alb_waf_managed_rule_set.md index ea1a29cf8..0d9873a6c 100644 --- a/docs/resources/alb_waf_managed_rule_set.md +++ b/docs/resources/alb_waf_managed_rule_set.md @@ -18,6 +18,19 @@ resource "stackit_alb_waf_managed_rule_set" "example" { name = "example-managed-rule-set" type = "TYPE_OWASP_CRS" } + +# Set individual rules to a different mode, keyed by rule ID. Only the +# listed rules are managed; a rule removed from the map goes back to +# MODE_ENABLED. +resource "stackit_alb_waf_managed_rule_set" "with_rule_modes" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-managed-rule-set-modes" + type = "TYPE_OWASP_CRS" + rule_modes = { + "911100" = "MODE_LOG_ONLY" # allow PUT and DELETE through method enforcement + "920450" = "MODE_DISABLED" + } +} ``` @@ -32,6 +45,7 @@ resource "stackit_alb_waf_managed_rule_set" "example" { ### Optional - `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. +- `rule_modes` (Map of String) Modes for individual rules of the rule set, keyed by rule ID (e.g. `911100`). Only the listed rules are managed; every other rule keeps the mode the API gives it. A rule removed from this map is set back to `MODE_ENABLED`. Possible values are: `MODE_ENABLED`, `MODE_DISABLED`, `MODE_LOG_ONLY`. ### Read-Only diff --git a/examples/resources/stackit_alb_waf_managed_rule_set/resource.tf b/examples/resources/stackit_alb_waf_managed_rule_set/resource.tf index f30363ed4..3bd6fe044 100644 --- a/examples/resources/stackit_alb_waf_managed_rule_set/resource.tf +++ b/examples/resources/stackit_alb_waf_managed_rule_set/resource.tf @@ -3,3 +3,16 @@ resource "stackit_alb_waf_managed_rule_set" "example" { name = "example-managed-rule-set" type = "TYPE_OWASP_CRS" } + +# Set individual rules to a different mode, keyed by rule ID. Only the +# listed rules are managed; a rule removed from the map goes back to +# MODE_ENABLED. +resource "stackit_alb_waf_managed_rule_set" "with_rule_modes" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-managed-rule-set-modes" + type = "TYPE_OWASP_CRS" + rule_modes = { + "911100" = "MODE_LOG_ONLY" # allow PUT and DELETE through method enforcement + "920450" = "MODE_DISABLED" + } +} diff --git a/stackit/internal/services/albwaf/albwaf_acc_test.go b/stackit/internal/services/albwaf/albwaf_acc_test.go index ce7989ec3..b41748bc6 100644 --- a/stackit/internal/services/albwaf/albwaf_acc_test.go +++ b/stackit/internal/services/albwaf/albwaf_acc_test.go @@ -31,6 +31,9 @@ var ( //go:embed testdata/managed-rule-set.tf managedRuleSetConfig string + //go:embed testdata/managed-rule-set-rule-modes.tf + managedRuleSetRuleModesConfig string + //go:embed testdata/resource-max.tf wafMaxConfig string @@ -467,6 +470,60 @@ func TestAccManagedRuleSet(t *testing.T) { }) } +func testManagedRuleSetRuleModes(modes map[string]string) config.Variables { + vars := config.Variables{} + maps.Copy(vars, testManagedRuleSet) + values := map[string]config.Variable{} + for rule, mode := range modes { + values[rule] = config.StringVariable(mode) + } + vars["rule_modes"] = config.MapVariable(values) + return vars +} + +// ruleModeInGroups checks the mode of a rule in the computed groups inventory. +func ruleModeInGroups(group, rule, mode string) resource.TestCheckFunc { + return resource.TestCheckResourceAttr("stackit_alb_waf_managed_rule_set.managed_rule_set", fmt.Sprintf("groups.%s.rules.%s.mode", group, rule), mode) +} + +func TestAccManagedRuleSetRuleModes(t *testing.T) { + cfg := fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().BuildProviderConfig(), managedRuleSetRuleModesConfig) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, + Steps: []resource.TestStep{ + // Creation with rule modes + { + ConfigVariables: testManagedRuleSetRuleModes(map[string]string{"911100": "MODE_LOG_ONLY", "920450": "MODE_LOG_ONLY"}), + Config: cfg, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_alb_waf_managed_rule_set.managed_rule_set", "rule_modes.911100", "MODE_LOG_ONLY"), + resource.TestCheckResourceAttr("stackit_alb_waf_managed_rule_set.managed_rule_set", "rule_modes.920450", "MODE_LOG_ONLY"), + ruleModeInGroups("911", "911100", "MODE_LOG_ONLY"), + ruleModeInGroups("920", "920450", "MODE_LOG_ONLY"), + ), + }, + // Change in place: one rule removed (back to enabled), one changed + { + ConfigVariables: testManagedRuleSetRuleModes(map[string]string{"920450": "MODE_DISABLED"}), + Config: cfg, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_alb_waf_managed_rule_set.managed_rule_set", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("stackit_alb_waf_managed_rule_set.managed_rule_set", "rule_modes.911100"), + resource.TestCheckResourceAttr("stackit_alb_waf_managed_rule_set.managed_rule_set", "rule_modes.920450", "MODE_DISABLED"), + ruleModeInGroups("911", "911100", "MODE_ENABLED"), + ruleModeInGroups("920", "920450", "MODE_DISABLED"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func TestAccWafConfigurationMin(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, diff --git a/stackit/internal/services/albwaf/managed_rule_set/resource.go b/stackit/internal/services/albwaf/managed_rule_set/resource.go index 46a81761f..b84317f77 100644 --- a/stackit/internal/services/albwaf/managed_rule_set/resource.go +++ b/stackit/internal/services/albwaf/managed_rule_set/resource.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -19,6 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" albWaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" @@ -44,6 +46,13 @@ type Model struct { Version types.String `tfsdk:"version"` } +// ResourceModel is the resource's model: the data source's, plus rule_modes, +// which only the resource can set. +type ResourceModel struct { + Model + RuleModes types.Map `tfsdk:"rule_modes"` +} + type RuleGroupModel struct { Description types.String `tfsdk:"description"` GroupName types.String `tfsdk:"group_name"` @@ -111,8 +120,14 @@ var descriptions = map[string]string{ "rule_description": "A description of what this rule does.", "rule_mode": "The current mode of the rule.", "rule_severity": "Impact level.", + "rule_modes": "Modes for individual rules of the rule set, keyed by rule ID (e.g. `911100`). Only the listed rules are managed; every other rule keeps the mode the API gives it. " + + "A rule removed from this map is set back to `MODE_ENABLED`. " + + "Possible values are: `MODE_ENABLED`, `MODE_DISABLED`, `MODE_LOG_ONLY`.", } +// ruleModeValues are the modes a rule can be set to through rule_modes. +var ruleModeValues = sdkUtils.EnumSliceToStringSlice(albWaf.AllowedModeEnumValues) + func (r *managedRuleSetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: fmt.Sprintf("ALB WAF Managed Rule Set resource schema. %s", core.ResourceRegionFallbackDocstring), @@ -167,6 +182,16 @@ func (r *managedRuleSetResource) Schema(_ context.Context, _ resource.SchemaRequ Description: descriptions["version"], Computed: true, }, + "rule_modes": schema.MapAttribute{ + Description: descriptions["rule_modes"], + ElementType: types.StringType, + Optional: true, + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + mapvalidator.KeysAre(stringvalidator.RegexMatches(regexp.MustCompile(`^\d+$`), "must be a numeric rule ID")), + mapvalidator.ValueStringsAre(stringvalidator.OneOf(ruleModeValues...)), + }, + }, "groups": schema.MapNestedAttribute{ Description: descriptions["groups"], Computed: true, @@ -208,7 +233,7 @@ func (r *managedRuleSetResource) Schema(_ context.Context, _ resource.SchemaRequ } func (r *managedRuleSetResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform - var configModel Model + var configModel ResourceModel if req.Config.Raw.IsNull() { return } @@ -217,7 +242,7 @@ func (r *managedRuleSetResource) ModifyPlan(ctx context.Context, req resource.Mo return } - var planModel Model + var planModel ResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) if resp.Diagnostics.HasError() { return @@ -229,10 +254,22 @@ func (r *managedRuleSetResource) ModifyPlan(ctx context.Context, req resource.Mo } if !req.State.Raw.IsNull() { - var stateModel Model + var stateModel ResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) if !resp.Diagnostics.HasError() { utils.WarnIfNameChanges(stateModel.Name, planModel.Name, "Managed Rule Set", &resp.Diagnostics) + // On an in-place update the only thing that can change in groups is + // the mode of rules in rule_modes. Plan exactly that instead of + // leaving the whole inventory unknown, which turns every rule-mode + // change into a plan hundreds of lines long. + if planModel.Groups.IsUnknown() && !tfutils.IsUndefined(stateModel.Groups) && !planModel.RuleModes.IsUnknown() { + groups, err := plannedGroups(ctx, stateModel.Groups, planModel.RuleModes, stateModel.RuleModes) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error planning ALB WAF Managed Rule Set", fmt.Sprintf("Planning rule modes: %v", err)) + return + } + planModel.Groups = groups + } } } @@ -262,7 +299,7 @@ func (r *managedRuleSetResource) ImportState(ctx context.Context, req resource.I } func (r *managedRuleSetResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform - var model Model + var model ResourceModel diags := req.Plan.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -277,7 +314,7 @@ func (r *managedRuleSetResource) Create(ctx context.Context, req resource.Create ctx = tflog.SetField(ctx, "region", region) ctx = tflog.SetField(ctx, "name", model.Name) - payload, err := toCreatePayload(ctx, &model) + payload, err := toCreatePayload(ctx, &model.Model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating ALB WAF Managed Rule Set", fmt.Sprintf("Creating API payload: %v", err)) return @@ -300,7 +337,21 @@ func (r *managedRuleSetResource) Create(ctx context.Context, req resource.Create return } - err = mapFields(ctx, createResp, &model, region) + ruleSet := createResp + if !model.RuleModes.IsNull() { + patchPayload, err := toPatchPayload(ctx, createResp, model.RuleModes, types.MapNull(types.StringType)) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating ALB WAF Managed Rule Set", fmt.Sprintf("Creating rule modes payload: %v", err)) + return + } + ruleSet, err = r.client.PatchManagedRuleSet(ctx, projectId, region, model.Name.ValueString()).PatchManagedRuleSetPayload(*patchPayload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating ALB WAF Managed Rule Set", fmt.Sprintf("Setting rule modes: %v", err)) + return + } + } + + err = mapResourceFields(ctx, ruleSet, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating ALB WAF Managed Rule Set", fmt.Sprintf("Processing API payload: %v", err)) return @@ -314,12 +365,67 @@ func (r *managedRuleSetResource) Create(ctx context.Context, req resource.Create tflog.Info(ctx, "ALB WAF Managed Rule Set created") } -func (r *managedRuleSetResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - core.LogAndAddError(ctx, &resp.Diagnostics, "Ressource not updatable", "alb Managed Rule Set is not updatable") +// Update applies rule_modes, the only attribute that changes without replacement. +func (r *managedRuleSetResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var model ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + var stateModel ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + name := model.Name.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "name", name) + + // The current rule set is needed to find the group each rule ID belongs to. + current, err := r.client.GetManagedRuleSet(ctx, projectId, region, name).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating ALB WAF Managed Rule Set", fmt.Sprintf("Calling API: %v", err)) + return + } + + patchPayload, err := toPatchPayload(ctx, current, model.RuleModes, stateModel.RuleModes) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating ALB WAF Managed Rule Set", fmt.Sprintf("Creating rule modes payload: %v", err)) + return + } + + ruleSet := current + if len(patchPayload.Groups) > 0 { + ruleSet, err = r.client.PatchManagedRuleSet(ctx, projectId, region, name).PatchManagedRuleSetPayload(*patchPayload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating ALB WAF Managed Rule Set", fmt.Sprintf("Setting rule modes: %v", err)) + return + } + } + + ctx = core.LogResponse(ctx) + + err = mapResourceFields(ctx, ruleSet, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating ALB WAF Managed Rule Set", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, model)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "ALB WAF Managed Rule Set updated") } func (r *managedRuleSetResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform - var model Model + var model ResourceModel diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -348,7 +454,7 @@ func (r *managedRuleSetResource) Read(ctx context.Context, req resource.ReadRequ ctx = core.LogResponse(ctx) - err = mapFields(ctx, managedRuleSetResp, &model, region) + err = mapResourceFields(ctx, managedRuleSetResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading ALB WAF Managed Rule Set", fmt.Sprintf("Processing API payload: %v", err)) return @@ -363,7 +469,7 @@ func (r *managedRuleSetResource) Read(ctx context.Context, req resource.ReadRequ } func (r *managedRuleSetResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform - var model Model + var model ResourceModel diags := req.State.Get(ctx, &model) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -403,6 +509,116 @@ func toCreatePayload(_ context.Context, model *Model) (*albWaf.CreateManagedRule return payload, nil } +// plannedGroups returns the groups inventory from state with the modes the +// update will set: wanted modes, and MODE_ENABLED for rules only in previous. +// Rules that do not exist are left for Update to report. +func plannedGroups(ctx context.Context, stateGroups, wanted, previous types.Map) (types.Map, error) { + modes := map[string]string{} + for ruleKey := range previous.Elements() { + modes[ruleKey] = string(albWaf.MODE_MODE_ENABLED) + } + for ruleKey, value := range wanted.Elements() { + mode, ok := value.(types.String) + if !ok || mode.IsUnknown() { + return types.MapUnknown(types.ObjectType{AttrTypes: ruleGroupType}), nil + } + modes[ruleKey] = mode.ValueString() + } + + var groups map[string]RuleGroupModel + if diags := stateGroups.ElementsAs(ctx, &groups, false); diags.HasError() { + return types.Map{}, fmt.Errorf("reading groups: %w", core.DiagsToError(diags)) + } + groupsMap := map[string]attr.Value{} + for groupKey, group := range groups { + var rules map[string]RuleModel + if diags := group.Rules.ElementsAs(ctx, &rules, false); diags.HasError() { + return types.Map{}, fmt.Errorf("reading rules: %w", core.DiagsToError(diags)) + } + for ruleKey, mode := range modes { + if rule, ok := rules[ruleKey]; ok { + rule.Mode = types.StringValue(mode) + rules[ruleKey] = rule + } + } + var diags diag.Diagnostics + group.Rules, diags = types.MapValueFrom(ctx, types.ObjectType{AttrTypes: ruleType}, rules) + if diags.HasError() { + return types.Map{}, fmt.Errorf("mapping rules: %w", core.DiagsToError(diags)) + } + groupsMap[groupKey], diags = types.ObjectValueFrom(ctx, ruleGroupType, group) + if diags.HasError() { + return types.Map{}, fmt.Errorf("mapping group: %w", core.DiagsToError(diags)) + } + } + result, diags := types.MapValue(types.ObjectType{AttrTypes: ruleGroupType}, groupsMap) + if diags.HasError() { + return types.Map{}, fmt.Errorf("mapping groups: %w", core.DiagsToError(diags)) + } + return result, nil +} + +// ruleGroupIndex maps each rule ID in the rule set to the group that holds it. +func ruleGroupIndex(managedRuleSet *albWaf.GetManagedRuleSetResponse) map[string]string { + index := map[string]string{} + if groups, ok := managedRuleSet.GetGroupsOk(); ok { + for groupKey, group := range *groups { + if rules, ok := group.GetRulesOk(); ok { + for ruleKey := range *rules { + index[ruleKey] = groupKey + } + } + } + } + return index +} + +// toPatchPayload builds the PATCH that brings the rule set from previous to +// wanted rule_modes: every wanted rule gets its mode, and every rule only in +// previous goes back to MODE_ENABLED. +func toPatchPayload(_ context.Context, managedRuleSet *albWaf.GetManagedRuleSetResponse, wanted, previous types.Map) (*albWaf.PatchManagedRuleSetPayload, error) { + if managedRuleSet == nil { + return nil, fmt.Errorf("rule set input is nil") + } + + modes := map[string]string{} + if !tfutils.IsUndefined(previous) { + for ruleKey := range previous.Elements() { + modes[ruleKey] = string(albWaf.MODE_MODE_ENABLED) + } + } + if !tfutils.IsUndefined(wanted) { + for ruleKey, value := range wanted.Elements() { + mode, ok := value.(types.String) + if !ok || tfutils.IsUndefined(mode) { + return nil, fmt.Errorf("rule %q: mode is not a known string", ruleKey) + } + modes[ruleKey] = mode.ValueString() + } + } + + index := ruleGroupIndex(managedRuleSet) + groups := map[string]albWaf.PatchMRSRuleGroup{} + for ruleKey, modeValue := range modes { + groupKey, ok := index[ruleKey] + if !ok { + return nil, fmt.Errorf("rule %q does not exist in managed rule set %q (version %s)", ruleKey, managedRuleSet.Name, managedRuleSet.Version) + } + mode, err := albWaf.NewModeFromValue(modeValue) + if err != nil { + return nil, fmt.Errorf("rule %q: %w", ruleKey, err) + } + group, ok := groups[groupKey] + if !ok { + group = albWaf.PatchMRSRuleGroup{Rules: map[string]albWaf.PatchMRSRule{}} + } + group.Rules[ruleKey] = albWaf.PatchMRSRule{Mode: mode} + groups[groupKey] = group + } + + return albWaf.NewPatchManagedRuleSetPayload(groups), nil +} + func mapFields(ctx context.Context, managedRuleSet *albWaf.GetManagedRuleSetResponse, model *Model, region string) error { if managedRuleSet == nil { return fmt.Errorf("response input is nil") @@ -464,3 +680,43 @@ func mapFields(ctx context.Context, managedRuleSet *albWaf.GetManagedRuleSetResp return nil } + +// mapResourceFields maps the response onto the resource model: everything the +// data source maps, then rule_modes. +func mapResourceFields(ctx context.Context, managedRuleSet *albWaf.GetManagedRuleSetResponse, model *ResourceModel, region string) error { + if model == nil { + return fmt.Errorf("model input is nil") + } + if err := mapFields(ctx, managedRuleSet, &model.Model, region); err != nil { + return err + } + + var diags diag.Diagnostics + // rule_modes holds only the rules the configuration manages. Refresh each + // of those from the live rule set so a mode changed outside Terraform + // shows as drift; a rule that no longer exists is dropped. + if !tfutils.IsUndefined(model.RuleModes) { + live := map[string]string{} + if groups, ok := managedRuleSet.GetGroupsOk(); ok { + for _, group := range *groups { + if rules, ok := group.GetRulesOk(); ok { + for ruleKey, rule := range *rules { + live[ruleKey] = string(rule.Mode) + } + } + } + } + modes := map[string]attr.Value{} + for ruleKey := range model.RuleModes.Elements() { + if mode, ok := live[ruleKey]; ok { + modes[ruleKey] = types.StringValue(mode) + } + } + model.RuleModes, diags = types.MapValue(types.StringType, modes) + if diags.HasError() { + return fmt.Errorf("mapping rule modes: %w", core.DiagsToError(diags)) + } + } + + return nil +} diff --git a/stackit/internal/services/albwaf/managed_rule_set/resource_test.go b/stackit/internal/services/albwaf/managed_rule_set/resource_test.go index 5271a5381..249f20d47 100644 --- a/stackit/internal/services/albwaf/managed_rule_set/resource_test.go +++ b/stackit/internal/services/albwaf/managed_rule_set/resource_test.go @@ -110,3 +110,145 @@ func TestMapFields(t *testing.T) { }) } } + +func TestMapResourceFields(t *testing.T) { + tests := []struct { + name string + state types.Map + input *albWaf.GetManagedRuleSetResponse + expected types.Map + }{ + { + name: "drift shows, a rule that no longer exists is dropped, unmanaged rules stay out", + state: modes(map[string]string{"911100": "MODE_LOG_ONLY", "999999": "MODE_LOG_ONLY"}), + input: testRuleSet(map[string]albWaf.Mode{ + "911100": albWaf.MODE_MODE_ENABLED, + "920450": albWaf.MODE_MODE_LOG_ONLY, + }), + expected: modes(map[string]string{"911100": "MODE_ENABLED"}), + }, + { + name: "not configured stays null", + state: types.MapNull(types.StringType), + input: testRuleSet(map[string]albWaf.Mode{"911100": albWaf.MODE_MODE_LOG_ONLY}), + expected: types.MapNull(types.StringType), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := &ResourceModel{ + Model: Model{ProjectId: testProjectId, Region: testRegion, Name: testName}, + RuleModes: tt.state, + } + if err := mapResourceFields(context.Background(), tt.input, model, testRegion.ValueString()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff(model.RuleModes, tt.expected); diff != "" { + t.Errorf("rule_modes do not match: %s", diff) + } + }) + } +} + +func modes(m map[string]string) types.Map { + values := map[string]attr.Value{} + for k, v := range m { + values[k] = types.StringValue(v) + } + return types.MapValueMust(types.StringType, values) +} + +// testRuleSet returns a rule set whose rules sit in the group named by their +// first three digits, as in OWASP CRS. +func testRuleSet(rules map[string]albWaf.Mode) *albWaf.GetManagedRuleSetResponse { + groups := map[string]albWaf.MRSRuleGroup{} + for ruleKey, mode := range rules { + groupKey := ruleKey[:3] + group := groups[groupKey] + if group.Rules == nil { + group.Rules = &map[string]albWaf.MRSRule{} + } + (*group.Rules)[ruleKey] = albWaf.MRSRule{Mode: mode} + groups[groupKey] = group + } + return &albWaf.GetManagedRuleSetResponse{ + Groups: &groups, + Name: testName.ValueString(), + Type: albWaf.TYPE_TYPE_OWASP_CRS, + Version: "4.14.0", + } +} + +func TestToPatchPayload(t *testing.T) { + ruleSet := testRuleSet(map[string]albWaf.Mode{ + "911100": albWaf.MODE_MODE_ENABLED, + "920450": albWaf.MODE_MODE_ENABLED, + "921140": albWaf.MODE_MODE_LOG_ONLY, + }) + logOnly := albWaf.MODE_MODE_LOG_ONLY + enabled := albWaf.MODE_MODE_ENABLED + disabled := albWaf.MODE_MODE_DISABLED + tests := []struct { + name string + wanted types.Map + previous types.Map + expected map[string]albWaf.PatchMRSRuleGroup + isValid bool + }{ + { + name: "set on create", + wanted: modes(map[string]string{"911100": "MODE_LOG_ONLY", "920450": "MODE_LOG_ONLY"}), + previous: types.MapNull(types.StringType), + expected: map[string]albWaf.PatchMRSRuleGroup{ + "911": {Rules: map[string]albWaf.PatchMRSRule{"911100": {Mode: &logOnly}}}, + "920": {Rules: map[string]albWaf.PatchMRSRule{"920450": {Mode: &logOnly}}}, + }, + isValid: true, + }, + { + name: "removed rule goes back to enabled, changed rule takes its new mode", + wanted: modes(map[string]string{"920450": "MODE_DISABLED"}), + previous: modes(map[string]string{"911100": "MODE_LOG_ONLY", "920450": "MODE_LOG_ONLY"}), + expected: map[string]albWaf.PatchMRSRuleGroup{ + "911": {Rules: map[string]albWaf.PatchMRSRule{"911100": {Mode: &enabled}}}, + "920": {Rules: map[string]albWaf.PatchMRSRule{"920450": {Mode: &disabled}}}, + }, + isValid: true, + }, + { + name: "attribute removed entirely", + wanted: types.MapNull(types.StringType), + previous: modes(map[string]string{"921140": "MODE_LOG_ONLY"}), + expected: map[string]albWaf.PatchMRSRuleGroup{ + "921": {Rules: map[string]albWaf.PatchMRSRule{"921140": {Mode: &enabled}}}, + }, + isValid: true, + }, + { + name: "nothing to do", + wanted: types.MapNull(types.StringType), + previous: types.MapNull(types.StringType), + expected: map[string]albWaf.PatchMRSRuleGroup{}, + isValid: true, + }, + { + name: "unknown rule", + wanted: modes(map[string]string{"123456": "MODE_LOG_ONLY"}), + previous: types.MapNull(types.StringType), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toPatchPayload(context.Background(), ruleSet, tt.wanted, tt.previous) + if (err == nil) != tt.isValid { + t.Fatalf("toPatchPayload() error = %v, isValid %v", err, tt.isValid) + } + if tt.isValid { + if diff := cmp.Diff(got.Groups, tt.expected); diff != "" { + t.Errorf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/albwaf/testdata/managed-rule-set-rule-modes.tf b/stackit/internal/services/albwaf/testdata/managed-rule-set-rule-modes.tf new file mode 100644 index 000000000..5eb600adc --- /dev/null +++ b/stackit/internal/services/albwaf/testdata/managed-rule-set-rule-modes.tf @@ -0,0 +1,13 @@ +variable "project_id" {} +variable "type" {} +variable "name" {} +variable "rule_modes" { + type = map(string) +} + +resource "stackit_alb_waf_managed_rule_set" "managed_rule_set" { + project_id = var.project_id + type = var.type + name = var.name + rule_modes = var.rule_modes +}