From 243265e537dacc41a5be782e74af751583079685 Mon Sep 17 00:00:00 2001 From: Omar Silva Date: Fri, 2 Oct 2026 16:28:54 +0200 Subject: [PATCH 1/3] feat(albwaf): set managed rule modes with rule_modes stackit_alb_waf_managed_rule_set exposed the mode of every rule only as a read-only attribute, and Update returned "not updatable", so a CRS rule could be set to log-only or disabled only through the API, and was lost whenever the rule set was recreated. rule_modes is an optional map from rule ID to mode. It is applied with PatchManagedRuleSet after create and on update, in place; a rule removed from the map goes back to MODE_ENABLED. Read refreshes only the listed rules, so a mode changed outside Terraform shows as drift. On update the plan carries the groups inventory from state with the planned modes applied, rather than marking the whole inventory unknown. The data source keeps its schema: the resource uses ResourceModel, which embeds the shared Model and adds rule_modes. relates to #1794 Co-Authored-By: Claude Opus 5.5 --- docs/resources/alb_waf_managed_rule_set.md | 14 + .../resource.tf | 13 + .../services/albwaf/albwaf_acc_test.go | 57 ++++ .../albwaf/managed_rule_set/resource.go | 281 +++++++++++++++++- .../albwaf/managed_rule_set/resource_test.go | 142 +++++++++ .../testdata/managed-rule-set-rule-modes.tf | 13 + 6 files changed, 509 insertions(+), 11 deletions(-) create mode 100644 stackit/internal/services/albwaf/testdata/managed-rule-set-rule-modes.tf 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..64ff04c7e 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" @@ -44,6 +45,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,6 +119,16 @@ 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 = []string{ + string(albWaf.MODE_MODE_ENABLED), + string(albWaf.MODE_MODE_DISABLED), + string(albWaf.MODE_MODE_LOG_ONLY), } func (r *managedRuleSetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { @@ -167,6 +185,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 +236,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 +245,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 +257,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() && !stateModel.Groups.IsNull() && !stateModel.Groups.IsUnknown() && !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 +302,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 +317,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 +340,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 +368,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 +457,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 +472,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 +512,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 !previous.IsNull() && !previous.IsUnknown() { + for ruleKey := range previous.Elements() { + modes[ruleKey] = string(albWaf.MODE_MODE_ENABLED) + } + } + if !wanted.IsNull() && !wanted.IsUnknown() { + for ruleKey, value := range wanted.Elements() { + mode, ok := value.(types.String) + if !ok || mode.IsNull() || mode.IsUnknown() { + 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 +683,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 !model.RuleModes.IsNull() && !model.RuleModes.IsUnknown() { + 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 +} From 8a949908aa97e82eba9e277e24522257fba51f63 Mon Sep 17 00:00:00 2001 From: Omar Silva Date: Wed, 28 Oct 2026 23:29:11 +0100 Subject: [PATCH 2/3] refactor(albwaf): use IsUndefined helper --- .../services/albwaf/managed_rule_set/resource.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/albwaf/managed_rule_set/resource.go b/stackit/internal/services/albwaf/managed_rule_set/resource.go index 64ff04c7e..d146553e7 100644 --- a/stackit/internal/services/albwaf/managed_rule_set/resource.go +++ b/stackit/internal/services/albwaf/managed_rule_set/resource.go @@ -265,7 +265,7 @@ func (r *managedRuleSetResource) ModifyPlan(ctx context.Context, req resource.Mo // 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() && !stateModel.Groups.IsNull() && !stateModel.Groups.IsUnknown() && !planModel.RuleModes.IsUnknown() { + 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)) @@ -585,15 +585,15 @@ func toPatchPayload(_ context.Context, managedRuleSet *albWaf.GetManagedRuleSetR } modes := map[string]string{} - if !previous.IsNull() && !previous.IsUnknown() { + if !tfutils.IsUndefined(previous) { for ruleKey := range previous.Elements() { modes[ruleKey] = string(albWaf.MODE_MODE_ENABLED) } } - if !wanted.IsNull() && !wanted.IsUnknown() { + if !tfutils.IsUndefined(wanted) { for ruleKey, value := range wanted.Elements() { mode, ok := value.(types.String) - if !ok || mode.IsNull() || mode.IsUnknown() { + if !ok || tfutils.IsUndefined(mode) { return nil, fmt.Errorf("rule %q: mode is not a known string", ruleKey) } modes[ruleKey] = mode.ValueString() @@ -698,7 +698,7 @@ func mapResourceFields(ctx context.Context, managedRuleSet *albWaf.GetManagedRul // 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 !model.RuleModes.IsNull() && !model.RuleModes.IsUnknown() { + if !tfutils.IsUndefined(model.RuleModes) { live := map[string]string{} if groups, ok := managedRuleSet.GetGroupsOk(); ok { for _, group := range *groups { From b1b6377051841aedb84a143771aec71e7f2d8d97 Mon Sep 17 00:00:00 2001 From: Omar Silva Date: Wed, 28 Oct 2026 23:33:20 +0100 Subject: [PATCH 3/3] refactor(albwaf): use sdkUtils for rule mode values --- .../internal/services/albwaf/managed_rule_set/resource.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/stackit/internal/services/albwaf/managed_rule_set/resource.go b/stackit/internal/services/albwaf/managed_rule_set/resource.go index d146553e7..b84317f77 100644 --- a/stackit/internal/services/albwaf/managed_rule_set/resource.go +++ b/stackit/internal/services/albwaf/managed_rule_set/resource.go @@ -20,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" @@ -125,11 +126,7 @@ var descriptions = map[string]string{ } // ruleModeValues are the modes a rule can be set to through rule_modes. -var ruleModeValues = []string{ - string(albWaf.MODE_MODE_ENABLED), - string(albWaf.MODE_MODE_DISABLED), - string(albWaf.MODE_MODE_LOG_ONLY), -} +var ruleModeValues = sdkUtils.EnumSliceToStringSlice(albWaf.AllowedModeEnumValues) func (r *managedRuleSetResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{