diff --git a/.changes/unreleased/operator-Added-20251201-122706.yaml b/.changes/unreleased/operator-Added-20251201-122706.yaml new file mode 100644 index 000000000..abf07de93 --- /dev/null +++ b/.changes/unreleased/operator-Added-20251201-122706.yaml @@ -0,0 +1,5 @@ +project: operator +kind: Added +body: |- + Added `status.managedPrincipals` field to RedpandaRole CRD to track whether the operator is managing role membership. The operator now properly reconciles membership changes when spec.principals is updated, including adding, removing, or clearing all principals. +time: 2025-12-01T12:27:06.000000Z diff --git a/acceptance/features/role-crds.feature b/acceptance/features/role-crds.feature new file mode 100644 index 000000000..b8dfc3c05 --- /dev/null +++ b/acceptance/features/role-crds.feature @@ -0,0 +1,286 @@ +@cluster:sasl @variant:vectorized +Feature: Role CRDs + Background: Cluster available + Given cluster "sasl" is available + + @skip:gke @skip:aks @skip:eks + Scenario: Manage roles + Given there is no role "admin-role" in cluster "sasl" + And there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | alice | password | SCRAM-SHA-256 | + | bob | password | SCRAM-SHA-256 | + When I apply Kubernetes manifest: + """ +# tag::manage-roles-with-principals[] + # In this example manifest, a role called "admin-role" is created in a cluster called "sasl". + # The role includes two principals (alice and bob) who will inherit the role's permissions. + --- + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: admin-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:alice + - User:bob +# end::manage-roles-with-principals[] + """ + And role "admin-role" is successfully synced + Then role "admin-role" should exist in cluster "sasl" + And role "admin-role" should have members "alice and bob" in cluster "sasl" + + @skip:gke @skip:aks @skip:eks + Scenario: Manage roles with authorization + Given there is no role "read-only-role" in cluster "sasl" + And there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | charlie | password | SCRAM-SHA-256 | + When I create topic "public-test" in cluster "sasl" + And I apply Kubernetes manifest: + """ +# tag::manage-roles-with-authorization[] + # In this example manifest, a role called "read-only-role" is created in a cluster called "sasl". + # The role includes authorization rules that allow reading from topics with names starting with "public-". + --- + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: read-only-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:charlie + authorization: + acls: + - type: allow + resource: + type: topic + name: public- + patternType: prefixed + operations: [Read, Describe] +# end::manage-roles-with-authorization[] + """ + And role "read-only-role" is successfully synced + Then role "read-only-role" should exist in cluster "sasl" + And role "read-only-role" should have ACLs for topic pattern "public-" in cluster "sasl" + And "charlie" should be able to read from topic "public-test" in cluster "sasl" + + @skip:gke @skip:aks @skip:eks + Scenario: Manage authorization-only roles + Given there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | travis | password | SCRAM-SHA-256 | + And there is a pre-existing role "travis-role" in cluster "sasl" + When I apply Kubernetes manifest: + """ +# tag::manage-authz-only-roles[] + # In this example manifest, a role CRD called "travis-role" manages ACLs for an existing role. + # The role includes authorization rules that allow reading from topics with names starting with "some-topic". + # This example assumes that you already have a role called "travis-role" in your cluster. + # Note: When the CRD is deleted, the operator will remove both the role and ACLs since it takes full ownership. + --- + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: travis-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:travis + authorization: + acls: + - type: allow + resource: + type: topic + name: some-topic + patternType: prefixed + operations: [Read] +# end::manage-authz-only-roles[] + """ + And role "travis-role" is successfully synced + And I delete the CRD role "travis-role" + Then there should be no role "travis-role" in cluster "sasl" + + @skip:gke @skip:aks @skip:eks + Scenario: Add managed principals to the role + Given there is no role "team-role" in cluster "sasl" + And there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | user1 | password | SCRAM-SHA-256 | + | user2 | password | SCRAM-SHA-256 | + | user3 | password | SCRAM-SHA-256 | + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: team-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:user1 + - User:user2 + """ + And role "team-role" is successfully synced + Then role "team-role" should exist in cluster "sasl" + And role "team-role" should have members "user1 and user2" in cluster "sasl" + And RedpandaRole "team-role" should have status field "managedPrincipals" set to "true" + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: team-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:user1 + - User:user2 + - User:user3 + """ + And role "team-role" is successfully synced + Then role "team-role" should have members "user1 and user2 and user3" in cluster "sasl" + And RedpandaRole "team-role" should have status field "managedPrincipals" set to "true" + + @skip:gke @skip:aks @skip:eks + Scenario: Remove managed principals from the role + Given there is no role "shrinking-role" in cluster "sasl" + And there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | dev1 | password | SCRAM-SHA-256 | + | dev2 | password | SCRAM-SHA-256 | + | dev3 | password | SCRAM-SHA-256 | + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: shrinking-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:dev1 + - User:dev2 + - User:dev3 + """ + And role "shrinking-role" is successfully synced + Then role "shrinking-role" should have members "dev1 and dev2 and dev3" in cluster "sasl" + And RedpandaRole "shrinking-role" should have status field "managedPrincipals" set to "true" + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: shrinking-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:dev1 + """ + And role "shrinking-role" is successfully synced + Then role "shrinking-role" should have members "dev1" in cluster "sasl" + And role "shrinking-role" should not have member "dev2" in cluster "sasl" + And role "shrinking-role" should not have member "dev3" in cluster "sasl" + And RedpandaRole "shrinking-role" should have status field "managedPrincipals" set to "true" + + @skip:gke @skip:aks @skip:eks + Scenario: Stop managing principals + Given there is no role "clearing-role" in cluster "sasl" + And there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | temp1 | password | SCRAM-SHA-256 | + | temp2 | password | SCRAM-SHA-256 | + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: clearing-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:temp1 + - User:temp2 + """ + And role "clearing-role" is successfully synced + Then role "clearing-role" should have members "temp1 and temp2" in cluster "sasl" + And RedpandaRole "clearing-role" should have status field "managedPrincipals" set to "true" + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: clearing-role + spec: + cluster: + clusterRef: + name: sasl + principals: [] + """ + And role "clearing-role" is successfully synced + Then RedpandaRole "clearing-role" should have no members in cluster "sasl" + And RedpandaRole "clearing-role" should have status field "managedPrincipals" set to "false" + + @skip:gke @skip:aks @skip:eks + Scenario: Replace all managed principals + Given there is no role "swap-role" in cluster "sasl" + And there are the following pre-existing users in cluster "sasl" + | name | password | mechanism | + | olduser1| password | SCRAM-SHA-256 | + | olduser2| password | SCRAM-SHA-256 | + | newuser1| password | SCRAM-SHA-256 | + | newuser2| password | SCRAM-SHA-256 | + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: swap-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:olduser1 + - User:olduser2 + """ + And role "swap-role" is successfully synced + Then role "swap-role" should have members "olduser1 and olduser2" in cluster "sasl" + And RedpandaRole "swap-role" should have status field "managedPrincipals" set to "true" + When I apply Kubernetes manifest: + """ + apiVersion: cluster.redpanda.com/v1alpha2 + kind: RedpandaRole + metadata: + name: swap-role + spec: + cluster: + clusterRef: + name: sasl + principals: + - User:newuser1 + - User:newuser2 + """ + And role "swap-role" is successfully synced + Then role "swap-role" should have members "newuser1 and newuser2" in cluster "sasl" + And role "swap-role" should not have member "olduser1" in cluster "sasl" + And role "swap-role" should not have member "olduser2" in cluster "sasl" + And RedpandaRole "swap-role" should have status field "managedPrincipals" set to "true" diff --git a/acceptance/steps/register.go b/acceptance/steps/register.go index 9ed217af3..fb87e5f88 100644 --- a/acceptance/steps/register.go +++ b/acceptance/steps/register.go @@ -33,6 +33,7 @@ func init() { // User scenario steps framework.RegisterStep(`^user "([^"]*)" is successfully synced$`, userIsSuccessfullySynced) +<<<<<<< HEAD framework.RegisterStep(`^there is no user "([^"]*)" in cluster "([^"]*)"$`, thereIsNoUser) framework.RegisterStep(`^there are already the following ACLs in cluster "([^"]*)":$`, thereAreAlreadyTheFollowingACLsInCluster) framework.RegisterStep(`^there are the following pre-existing users in cluster "([^"]*)"$`, thereAreTheFollowingPreexistingUsersInCluster) @@ -43,6 +44,23 @@ func init() { framework.RegisterStep(`^"([^"]*)" should exist and be able to authenticate to the "([^"]*)" cluster$`, shouldExistAndBeAbleToAuthenticateToTheCluster) framework.RegisterStep(`^"([^"]*)" should be able to authenticate to the "([^"]*)" cluster with password "([^"]*)" and mechanism "([^"]*)"$`, shouldBeAbleToAuthenticateToTheClusterWithPasswordAndMechanism) framework.RegisterStep(`^there should be ACLs in the cluster "([^"]*)" for user "([^"]*)"$`, thereShouldBeACLsInTheClusterForUser) +======= + // Role scenario steps + framework.RegisterStep(`^role "([^"]*)" is successfully synced$`, roleIsSuccessfullySynced) + framework.RegisterStep(`^I delete the CRD role "([^"]*)"$`, iDeleteTheCRDRole) + framework.RegisterStep(`^there is no role "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, thereIsNoRole) + framework.RegisterStep(`^role "([^"]*)" should exist in( vectorized)? cluster "([^"]*)"$`, roleShouldExistInCluster) + framework.RegisterStep(`^there should be no role "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, thereShouldBeNoRoleInCluster) + framework.RegisterStep(`^role "([^"]*)" should not have member "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, roleShouldNotHaveMemberInCluster) + framework.RegisterStep(`^role "([^"]*)" should have ACLs for topic pattern "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, roleShouldHaveACLsForTopicPatternInCluster) + framework.RegisterStep(`^role "([^"]*)" should have no managed ACLs in( vectorized)? cluster "([^"]*)"$`, roleShouldHaveNoManagedACLsInCluster) + framework.RegisterStep(`^there should be no ACLs for role "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, thereShouldBeNoACLsForRoleInCluster) + framework.RegisterStep(`^role "([^"]*)" should have members "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, roleShouldHaveMembersAndInCluster) + framework.RegisterStep(`^there is a pre-existing role "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, thereIsAPreExistingRole) + framework.RegisterStep(`^there should still be role "([^"]*)" in( vectorized)? cluster "([^"]*)"$`, thereShouldStillBeRole) + framework.RegisterStep(`^RedpandaRole "([^"]*)" should have no members in( vectorized)? cluster "([^"]*)"$`, roleShouldHaveNoMembersInCluster) + framework.RegisterStep(`^RedpandaRole "([^"]*)" should have status field "([^"]*)" set to "([^"]*)"$`, redpandaRoleShouldHaveStatusFieldSetTo) +>>>>>>> 7b1c1df1 (feat(operator): implement role membership reconciliation (#1192)) // Metrics scenario steps framework.RegisterStep(`^the operator is running$`, operatorIsRunning) diff --git a/acceptance/steps/roles.go b/acceptance/steps/roles.go new file mode 100644 index 000000000..5bb6a98d8 --- /dev/null +++ b/acceptance/steps/roles.go @@ -0,0 +1,298 @@ +// Copyright 2025 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package steps + +import ( + "context" + "strings" + "time" + + "github.com/redpanda-data/common-go/rpadmin" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + framework "github.com/redpanda-data/redpanda-operator/harpoon" + redpandav1alpha2 "github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2" +) + +func roleIsSuccessfullySynced(ctx context.Context, t framework.TestingT, role string) { + var roleObject redpandav1alpha2.RedpandaRole + require.NoError(t, t.Get(ctx, t.ResourceKey(role), &roleObject)) + + // make sure the resource is stable + checkStableResource(ctx, t, &roleObject) + + // make sure it's synchronized + t.RequireCondition(metav1.Condition{ + Type: redpandav1alpha2.ResourceConditionTypeSynced, + Status: metav1.ConditionTrue, + Reason: redpandav1alpha2.ResourceConditionReasonSynced, + }, roleObject.Status.Conditions) + + t.Cleanup(func(ctx context.Context) { + t.Logf("Deleting role %q", role) + err := t.Get(ctx, t.ResourceKey(role), &roleObject) + if err != nil { + if apierrors.IsNotFound(err) { + return + } + t.Fatalf("Error deleting role %q: %v", role, err) + } + require.NoError(t, t.Delete(ctx, &roleObject)) + }) +} + +func iDeleteTheCRDRole(ctx context.Context, t framework.TestingT, role string) { + var roleObject redpandav1alpha2.RedpandaRole + + t.Logf("Deleting role %q", role) + err := t.Get(ctx, t.ResourceKey(role), &roleObject) + if err != nil { + if apierrors.IsNotFound(err) { + t.Logf("Role %q already deleted", role) + return + } + t.Fatalf("Error deleting role %q: %v", role, err) + } + + t.Logf("Found role %q, deleting it", role) + require.NoError(t, t.Delete(ctx, &roleObject)) + t.Logf("Successfully deleted role %q CRD", role) +} + +func roleShouldExistInCluster(ctx context.Context, t framework.TestingT, role, version, cluster string) { + versionedClientsForCluster(ctx, version, cluster).ExpectRole(ctx, role) +} + +func thereShouldBeNoRoleInCluster(ctx context.Context, t framework.TestingT, role, version, cluster string) { + t.Logf("Checking that role %q does not exist in cluster %q", role, cluster) + + // Add retry logic for role deletion timing issues + require.Eventually(t, func() bool { + defer func() { + if r := recover(); r != nil { + t.Logf("Recovered from panic during role check: %v", r) + } + }() + versionedClientsForCluster(ctx, version, cluster).ExpectNoRole(ctx, role) + return true + }, 60*time.Second, 5*time.Second, "Role %q should be deleted from cluster %q", role, cluster) +} + +func roleShouldHaveMembersAndInCluster(ctx context.Context, t framework.TestingT, role string, members string, version, cluster string) { + clients := versionedClientsForCluster(ctx, version, cluster) + adminClient := clients.RedpandaAdmin(ctx) + defer adminClient.Close() + + // Parse the members string (e.g., "alice" or "alice" and "bob") + var expectedMembers []string + if strings.Contains(members, " and ") { + // Handle "alice" and "bob" format + parts := strings.Split(members, " and ") + for _, part := range parts { + part = strings.Trim(part, `"`) + if part != "" { + expectedMembers = append(expectedMembers, part) + } + } + } else { + // Handle single member + expectedMembers = []string{strings.Trim(members, `"`)} + } + + // Get role members from Redpanda + membersResp, err := adminClient.RoleMembers(ctx, role) + if err != nil { + t.Fatalf("Failed to get members for role %q: %v", role, err) + } + + // Check that expected members are present + actualMembers := make(map[string]bool) + for _, member := range membersResp.Members { + actualMembers[member.Name] = true + } + + // Verify all expected members are present + for _, expectedMember := range expectedMembers { + require.True(t, actualMembers[expectedMember], + "Expected member %q not found in role %q", expectedMember, role) + } + + // Verify we have exactly the expected number of members + require.Equal(t, len(expectedMembers), len(actualMembers), + "Role %q should have exactly %d members, got %d", role, len(expectedMembers), len(actualMembers)) +} + +func roleShouldNotHaveMemberInCluster(ctx context.Context, t framework.TestingT, role, member, version, cluster string) { + clients := versionedClientsForCluster(ctx, version, cluster) + adminClient := clients.RedpandaAdmin(ctx) + defer adminClient.Close() + + // Get role members from Redpanda + membersResp, err := adminClient.RoleMembers(ctx, role) + if err != nil { + t.Fatalf("Failed to get members for role %q: %v", role, err) + } + + // Check that the member is not present + for _, m := range membersResp.Members { + require.NotEqual(t, member, m.Name, + "Member %q should not be in role %q", member, role) + } +} + +func roleShouldHaveACLsForTopicPatternInCluster(ctx context.Context, t framework.TestingT, role, pattern, version, cluster string) { + t.Logf("Checking ACLs for role %q in cluster %q", role, cluster) + + // Add a small delay to ensure cluster is fully ready + time.Sleep(5 * time.Second) + + clients := versionedClientsForCluster(ctx, version, cluster) + t.Logf("Created cluster clients for %q", cluster) + + aclClient := clients.ACLs(ctx) + defer aclClient.Close() + t.Logf("Created ACL client for cluster %q", cluster) + + // Create a role object for ACL checking + roleObj := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{Name: role}, + } + + // List ACLs for the role + t.Logf("Listing ACLs for role %q principal %q", role, roleObj.GetPrincipal()) + rules, err := aclClient.ListACLs(ctx, roleObj.GetPrincipal()) + if err != nil { + t.Fatalf("Failed to list ACLs for role %q: %v", role, err) + } + require.NotEmpty(t, rules, "Role %q should have ACLs", role) + + // Check for topic pattern ACL + found := false + for _, rule := range rules { + if rule.Resource.Type == redpandav1alpha2.ResourceTypeTopic && + rule.Resource.Name == pattern && + ptr.Deref(rule.Resource.PatternType, redpandav1alpha2.PatternTypeLiteral) == redpandav1alpha2.PatternTypePrefixed { + found = true + break + } + } + require.True(t, found, "Role %q should have ACL for topic pattern %q", role, pattern) +} + +func roleShouldHaveNoManagedACLsInCluster(ctx context.Context, t framework.TestingT, role, version, cluster string) { + clients := versionedClientsForCluster(ctx, version, cluster) + aclClient := clients.ACLs(ctx) + defer aclClient.Close() + + // Create a role object for ACL checking + roleObj := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{Name: role}, + } + + // List ACLs for the role + rules, err := aclClient.ListACLs(ctx, roleObj.GetPrincipal()) + if err != nil { + t.Fatalf("Failed to list ACLs for role %q: %v", role, err) + } + require.Empty(t, rules, "Role %q should have no managed ACLs", role) +} + +func thereShouldBeNoACLsForRoleInCluster(ctx context.Context, t framework.TestingT, role, version, cluster string) { + clients := versionedClientsForCluster(ctx, version, cluster) + aclClient := clients.ACLs(ctx) + defer aclClient.Close() + + // Create a role object for ACL checking + roleObj := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{Name: role}, + } + + // List ACLs for the role + rules, err := aclClient.ListACLs(ctx, roleObj.GetPrincipal()) + if err != nil { + t.Fatalf("Failed to list ACLs for role %q: %v", role, err) + } + require.Empty(t, rules, "There should be no ACLs for role %q", role) +} + +func thereIsNoRole(ctx context.Context, role, version, cluster string) { + versionedClientsForCluster(ctx, version, cluster).ExpectNoRole(ctx, role) +} + +func thereIsAPreExistingRole(ctx context.Context, role, version, cluster string) { + t := framework.T(ctx) + + t.Logf("Creating pre-existing role %q in cluster %q", role, cluster) + adminClient := versionedClientsForCluster(ctx, version, cluster).RedpandaAdmin(ctx) + defer adminClient.Close() + + // Create the role first + _, err := adminClient.CreateRole(ctx, role) + if err != nil { + t.Fatalf("Failed to create pre-existing role %q: %v", role, err) + } + + // Then assign the travis user to the role + // TODO: Add role membership assignment if needed for the test + _, err = adminClient.AssignRole(ctx, role, []rpadmin.RoleMember{ + { + Name: "travis", + PrincipalType: "User", + }, + }) + if err != nil { + t.Fatalf("Failed to create pre-existing role %q: %v", role, err) + } + t.Logf("Successfully created pre-existing role %q", role) +} + +func thereShouldStillBeRole(ctx context.Context, role, version, cluster string) { + versionedClientsForCluster(ctx, version, cluster).ExpectRole(ctx, role) +} + +func roleShouldHaveNoMembersInCluster(ctx context.Context, t framework.TestingT, role, version, cluster string) { + clients := versionedClientsForCluster(ctx, version, cluster) + adminClient := clients.RedpandaAdmin(ctx) + defer adminClient.Close() + + // Get role members from Redpanda + membersResp, err := adminClient.RoleMembers(ctx, role) + if err != nil { + t.Fatalf("Failed to get members for role %q: %v", role, err) + } + + // Check that there are no members + require.Empty(t, membersResp.Members, "Role %q should have no members", role) +} + +func redpandaRoleShouldHaveStatusFieldSetTo(ctx context.Context, t framework.TestingT, roleName, field, value string) { + var roleObject redpandav1alpha2.RedpandaRole + require.NoError(t, t.Get(ctx, t.ResourceKey(roleName), &roleObject)) + + switch field { + case "managedPrincipals": + expectedValue := value == "true" + require.Equal(t, expectedValue, roleObject.Status.ManagedPrincipals, + "RedpandaRole %q status.managedPrincipals should be %v", roleName, expectedValue) + case "managedRole": + expectedValue := value == "true" + require.Equal(t, expectedValue, roleObject.Status.ManagedRole, + "RedpandaRole %q status.managedRole should be %v", roleName, expectedValue) + case "managedAcls": + expectedValue := value == "true" + require.Equal(t, expectedValue, roleObject.Status.ManagedACLs, + "RedpandaRole %q status.managedAcls should be %v", roleName, expectedValue) + default: + t.Fatalf("Unknown status field %q", field) + } +} diff --git a/operator/api/applyconfiguration/redpanda/v1alpha2/rolestatus.go b/operator/api/applyconfiguration/redpanda/v1alpha2/rolestatus.go new file mode 100644 index 000000000..52f67a22e --- /dev/null +++ b/operator/api/applyconfiguration/redpanda/v1alpha2/rolestatus.go @@ -0,0 +1,77 @@ +// Copyright 2025 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// RoleStatusApplyConfiguration represents a declarative configuration of the RoleStatus type for use +// with apply. +type RoleStatusApplyConfiguration struct { + ObservedGeneration *int64 `json:"observedGeneration,omitempty"` + Conditions []v1.ConditionApplyConfiguration `json:"conditions,omitempty"` + ManagedACLs *bool `json:"managedAcls,omitempty"` + ManagedRole *bool `json:"managedRole,omitempty"` + ManagedPrincipals *bool `json:"managedPrincipals,omitempty"` +} + +// RoleStatusApplyConfiguration constructs a declarative configuration of the RoleStatus type for use with +// apply. +func RoleStatus() *RoleStatusApplyConfiguration { + return &RoleStatusApplyConfiguration{} +} + +// WithObservedGeneration sets the ObservedGeneration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ObservedGeneration field is set to the value of the last call. +func (b *RoleStatusApplyConfiguration) WithObservedGeneration(value int64) *RoleStatusApplyConfiguration { + b.ObservedGeneration = &value + return b +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *RoleStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *RoleStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.Conditions = append(b.Conditions, *values[i]) + } + return b +} + +// WithManagedACLs sets the ManagedACLs field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ManagedACLs field is set to the value of the last call. +func (b *RoleStatusApplyConfiguration) WithManagedACLs(value bool) *RoleStatusApplyConfiguration { + b.ManagedACLs = &value + return b +} + +// WithManagedRole sets the ManagedRole field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ManagedRole field is set to the value of the last call. +func (b *RoleStatusApplyConfiguration) WithManagedRole(value bool) *RoleStatusApplyConfiguration { + b.ManagedRole = &value + return b +} + +// WithManagedPrincipals sets the ManagedPrincipals field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ManagedPrincipals field is set to the value of the last call. +func (b *RoleStatusApplyConfiguration) WithManagedPrincipals(value bool) *RoleStatusApplyConfiguration { + b.ManagedPrincipals = &value + return b +} diff --git a/operator/api/redpanda/v1alpha2/role_types.go b/operator/api/redpanda/v1alpha2/role_types.go new file mode 100644 index 000000000..4abf5befb --- /dev/null +++ b/operator/api/redpanda/v1alpha2/role_types.go @@ -0,0 +1,137 @@ +// Copyright 2025 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "github.com/redpanda-data/redpanda-operator/operator/pkg/functional" +) + +// RedpandaRole defines the CRD for a Redpanda role. +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=rpr +// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=`.status.conditions[?(@.type=="Synced")].status` +// +kubebuilder:printcolumn:name="Managing ACLs",type="boolean",JSONPath=`.status.managedAcls` +// +kubebuilder:storageversion +type RedpandaRole struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Defines the desired state of the Redpanda role. + Spec RoleSpec `json:"spec"` + // Represents the current status of the Redpanda role. + // +kubebuilder:default={conditions: {{type: "Synced", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}}} + Status RoleStatus `json:"status,omitempty"` +} + +var ( + _ ClusterReferencingObject = (*RedpandaRole)(nil) + _ AuthorizedObject = (*RedpandaRole)(nil) +) + +// GetPrincipal constructs the principal of a Role for defining ACLs. +func (r *RedpandaRole) GetPrincipal() string { + return "RedpandaRole:" + r.Name +} + +func (r *RedpandaRole) GetACLs() []ACLRule { + if r.Spec.Authorization == nil { + return nil + } + return r.Spec.Authorization.ACLs +} + +func (r *RedpandaRole) GetClusterSource() *ClusterSource { + return r.Spec.ClusterSource +} + +func (r *RedpandaRole) ShouldManageACLs() bool { + return r.Spec.Authorization != nil +} + +func (r *RedpandaRole) HasManagedACLs() bool { + return r.Status.ManagedACLs +} + +func (r *RedpandaRole) ShouldManageRole() bool { + // Always manage the role if it has a spec (similar to how users work) + return true +} + +func (r *RedpandaRole) HasManagedRole() bool { + return r.Status.ManagedRole +} + +func (r *RedpandaRole) ShouldManagePrincipals() bool { + return len(r.Spec.Principals) > 0 +} + +func (r *RedpandaRole) HasManagedPrincipals() bool { + return r.Status.ManagedPrincipals +} + +// RoleSpec defines the configuration of a Redpanda role. +type RoleSpec struct { + // ClusterSource is a reference to the cluster where the role should be created. + // It is used in constructing the client created to configure a cluster. + // +kubebuilder:validation:XValidation:message="spec.cluster.staticConfiguration.admin: required value",rule=`!has(self.staticConfiguration) || has(self.staticConfiguration.admin)` + // +kubebuilder:validation:XValidation:message="spec.cluster.staticConfiguration.kafka: required value",rule=`!has(self.staticConfiguration) || has(self.staticConfiguration.kafka)` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ClusterSource is immutable" + // +required + ClusterSource *ClusterSource `json:"cluster"` + // Principals defines the list of users assigned to this role. + // Format: Type:Name (e.g., User:john, User:jane). If type is omitted, defaults to User. + // +kubebuilder:validation:MaxItems=1024 + Principals []string `json:"principals,omitempty"` + // Authorization rules defined for this role. If specified, the operator will manage ACLs for this role. + // If omitted, ACLs should be managed separately using Redpanda's ACL management. + Authorization *RoleAuthorizationSpec `json:"authorization,omitempty"` +} + +// RoleAuthorizationSpec defines authorization rules for this role. +type RoleAuthorizationSpec struct { + // List of ACL rules which should be applied to this role. + // +kubebuilder:validation:MaxItems=1024 + ACLs []ACLRule `json:"acls,omitempty"` +} + +// RoleStatus defines the observed state of a Redpanda role +type RoleStatus struct { + // Specifies the last observed generation. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Conditions holds the conditions for the Redpanda role. + Conditions []metav1.Condition `json:"conditions,omitempty"` + // ManagedACLs returns whether the role has managed ACLs that need + // to be cleaned up. + ManagedACLs bool `json:"managedAcls,omitempty"` + // ManagedRole returns whether the role has been created in Redpanda and needs + // to be cleaned up. + ManagedRole bool `json:"managedRole,omitempty"` + // ManagedPrincipals returns whether the role has managed principals (membership) + // that are being reconciled by the operator. + ManagedPrincipals bool `json:"managedPrincipals,omitempty"` +} + +// RedpandaRoleList contains a list of Redpanda role objects. +// +kubebuilder:object:root=true +type RedpandaRoleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + // Specifies a list of Redpanda role resources. + Items []RedpandaRole `json:"items"` +} + +func (r *RedpandaRoleList) GetItems() []*RedpandaRole { + return functional.MapFn(ptr.To, r.Items) +} diff --git a/operator/api/redpanda/v1alpha2/testdata/crd-docs.adoc b/operator/api/redpanda/v1alpha2/testdata/crd-docs.adoc index 61a3d4acb..a2f9e1f5e 100644 --- a/operator/api/redpanda/v1alpha2/testdata/crd-docs.adoc +++ b/operator/api/redpanda/v1alpha2/testdata/crd-docs.adoc @@ -2329,6 +2329,86 @@ and `Requests`. |=== +<<<<<<< HEAD +======= +[id="{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-roleauthorizationspec"] +==== RoleAuthorizationSpec + + + +RoleAuthorizationSpec defines authorization rules for this role. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-rolespec[$$RoleSpec$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`acls`* __xref:{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-aclrule[$$ACLRule$$] array__ | List of ACL rules which should be applied to this role. + | | MaxItems: 1024 + + +|=== + + +[id="{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-rolespec"] +==== RoleSpec + + + +RoleSpec defines the configuration of a Redpanda role. + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-redpandarole[$$RedpandaRole$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`cluster`* __xref:{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-clustersource[$$ClusterSource$$]__ | ClusterSource is a reference to the cluster where the role should be created. + +It is used in constructing the client created to configure a cluster. + | | +| *`principals`* __string array__ | Principals defines the list of users assigned to this role. + +Format: Type:Name (e.g., User:john, User:jane). If type is omitted, defaults to User. + | | MaxItems: 1024 + + +| *`authorization`* __xref:{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-roleauthorizationspec[$$RoleAuthorizationSpec$$]__ | Authorization rules defined for this role. If specified, the operator will manage ACLs for this role. + +If omitted, ACLs should be managed separately using Redpanda's ACL management. + | | +|=== + + +[id="{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-rolestatus"] +==== RoleStatus + + + +RoleStatus defines the observed state of a Redpanda role + + + +.Appears In: +**** +- xref:{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-redpandarole[$$RedpandaRole$$] +**** + +[cols="20a,50a,15a,15a", options="header"] +|=== +| Field | Description | Default | Validation +| *`observedGeneration`* __integer__ | Specifies the last observed generation. + | | +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#condition-v1-meta[$$Condition$$] array__ | Conditions holds the conditions for the Redpanda role. + | | +| *`managedAcls`* __boolean__ | ManagedACLs returns whether the role has managed ACLs that need + +to be cleaned up. + | | +| *`managedRole`* __boolean__ | ManagedRole returns whether the role has been created in Redpanda and needs + +to be cleaned up. + | | +| *`managedPrincipals`* __boolean__ | ManagedPrincipals returns whether the role has managed principals (membership) + +that are being reconciled by the operator. + | | +|=== + + +>>>>>>> 7b1c1df1 (feat(operator): implement role membership reconciliation (#1192)) [id="{anchor_prefix}-github-com-redpanda-data-redpanda-operator-operator-api-redpanda-v1alpha2-sasl"] ==== SASL diff --git a/operator/config/crd/bases/cluster.redpanda.com_redpandaroles.yaml b/operator/config/crd/bases/cluster.redpanda.com_redpandaroles.yaml new file mode 100644 index 000000000..52bc9a056 --- /dev/null +++ b/operator/config/crd/bases/cluster.redpanda.com_redpandaroles.yaml @@ -0,0 +1,2575 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: redpandaroles.cluster.redpanda.com +spec: + group: cluster.redpanda.com + names: + kind: RedpandaRole + listKind: RedpandaRoleList + plural: redpandaroles + shortNames: + - rpr + singular: redpandarole + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Synced")].status + name: Synced + type: string + - jsonPath: .status.managedAcls + name: Managing ACLs + type: boolean + name: v1alpha2 + schema: + openAPIV3Schema: + description: RedpandaRole defines the CRD for a Redpanda role. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Defines the desired state of the Redpanda role. + properties: + authorization: + description: |- + Authorization rules defined for this role. If specified, the operator will manage ACLs for this role. + If omitted, ACLs should be managed separately using Redpanda's ACL management. + properties: + acls: + description: List of ACL rules which should be applied to this + role. + items: + description: |- + ACLRule defines an ACL rule applied to the given user. + + Validations taken from https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=75978240 + properties: + host: + default: '*' + description: |- + The host from which the action described in the ACL rule is allowed or denied. + If not set, it defaults to *, allowing or denying the action from any host. + type: string + operations: + description: |- + List of operations which will be allowed or denied. Valid values are resource type dependent, but include: + - Read + - Write + - Delete + - Alter + - Describe + - IdempotentWrite + - ClusterAction + - Create + - AlterConfigs + - DescribeConfigs + items: + description: ACLOperation specifies the type of operation + for an ACL. + type: string + maxItems: 11 + minItems: 1 + type: array + resource: + description: Indicates the resource for which given ACL + rule applies. + properties: + name: + description: |- + Name of resource for which given ACL rule applies. If using type `cluster` this must not be specified. + Can be combined with patternType field to use prefix pattern. + type: string + patternType: + default: literal + description: |- + Describes the pattern used in the resource field. The supported types are literal + and prefixed. With literal pattern type, the resource field will be used as a definition + of a full topic name. With prefix pattern type, the resource name will be used only as + a prefix. Prefixed patterns can only be specified when using types `topic`, `group`, or + `transactionalId`. Default value is literal. Valid values: + - literal + - prefixed + enum: + - literal + - prefixed + type: string + type: + description: |- + Type specifies the type of resource an ACL is applied to. Valid values: + - topic + - group + - cluster + - transactionalId + enum: + - topic + - group + - cluster + - transactionalId + type: string + required: + - name + - type + type: object + x-kubernetes-validations: + - message: prefixed pattern type only supported for ['group', + 'topic', 'transactionalId'] + rule: 'self.type in [''group'', ''topic'', ''transactionalId''] + ? true : !has(self.patternType) || self.patternType + != ''prefixed''' + - message: name must not be specified for type ['cluster'] + rule: 'self.type == "cluster" ? (self.name == "") : true' + - message: acl rules on non-cluster resources must specify + a name + rule: 'self.type == "cluster" ? true : (self.name != "")' + type: + description: |- + Type specifies the type of ACL rule to create. Valid values are: + - allow + - deny + enum: + - allow + - deny + type: string + required: + - operations + - resource + - type + type: object + x-kubernetes-validations: + - message: supported topic operations are ['Alter', 'AlterConfigs', + 'Create', 'Delete', 'Describe', 'DescribeConfigs', 'Read', + 'Write'] + rule: 'self.resource.type == ''topic'' ? self.operations.all(o, + o in [''Alter'', ''AlterConfigs'', ''Create'', ''Delete'', + ''Describe'', ''DescribeConfigs'', ''Read'', ''Write'']) + : true' + - message: supported group operations are ['Delete', 'Describe', + 'Read'] + rule: 'self.resource.type == ''group'' ? self.operations.all(o, + o in [''Delete'', ''Describe'', ''Read'']) : true' + - message: supported transactionalId operations are ['Describe', + 'Write'] + rule: 'self.resource.type == ''transactionalId'' ? self.operations.all(o, + o in [''Describe'', ''Write'']) : true' + - message: supported cluster operations are ['Alter', 'AlterConfigs', + 'ClusterAction', 'Create', 'Describe', 'DescribeConfigs', + 'IdempotentWrite'] + rule: 'self.resource.type == ''cluster'' ? self.operations.all(o, + o in [''Alter'', ''AlterConfigs'', ''ClusterAction'', ''Create'', + ''Describe'', ''DescribeConfigs'', ''IdempotentWrite'']) + : true' + maxItems: 1024 + type: array + type: object + cluster: + description: |- + ClusterSource is a reference to the cluster where the role should be created. + It is used in constructing the client created to configure a cluster. + properties: + clusterRef: + description: |- + ClusterRef is a reference to the cluster where the object should be created. + It is used in constructing the client created to configure a cluster. + This takes precedence over StaticConfigurationSource. + properties: + group: + description: |- + Group is used to override the object group that this reference points to. + If unspecified, defaults to "cluster.redpanda.com". + type: string + kind: + description: |- + Kind is used to override the object kind that this reference points to. + If unspecified, defaults to "Redpanda". + type: string + name: + description: Name specifies the name of the cluster being + referenced. + type: string + required: + - name + type: object + staticConfiguration: + description: StaticConfiguration holds connection parameters to + Kafka and Admin APIs. + properties: + admin: + description: |- + AdminAPISpec is the configuration information for communicating with the Admin + API of a Redpanda cluster where the object should be created. + properties: + sasl: + description: Defines authentication configuration settings + for Redpanda clusters that have authentication enabled. + properties: + authToken: + description: Specifies token for token-based authentication + (only used if no username/password are provided). + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + mechanism: + description: Specifies the SASL/SCRAM authentication + mechanism. + type: string + password: + description: Specifies the password. + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + passwordSecretRef: + description: 'Deprecated: use `password` instead' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + token: + description: 'Deprecated: use `authToken` instead' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + username: + description: Specifies the username. + type: string + required: + - mechanism + type: object + tls: + description: Defines TLS configuration settings for Redpanda + clusters that have TLS enabled. + properties: + caCert: + description: CaCert is the reference for certificate + authority used to establish TLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + caCertSecretRef: + description: 'Deprecated: replaced by "caCert".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + cert: + description: Cert is the reference for client public + certificate to establish mTLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + certSecretRef: + description: 'Deprecated: replaced by "cert".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + enabled: + description: |- + Enabled tells any connections derived from this configuration to leverage TLS even if no + certificate configuration is specified. It *only* is relevant if no other field is specified + in the TLS configuration block, as, for backwards compatibility reasons, any CA/Cert/Key-specification + results in attempting to create a connection using TLS - specifying "false" in such a case does + *not* disable TLS from being used. Leveraging this option is to support the use-case where a + connection is served by publically issued TLS certificates that don't require any additional certificate + specification. + type: boolean + insecureSkipTlsVerify: + description: InsecureSkipTLSVerify can skip verifying + Redpanda self-signed certificate when establish + TLS connection to Redpanda + type: boolean + key: + description: Key is the reference for client private + certificate to establish mTLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + keySecretRef: + description: 'Deprecated: replaced by "key".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + type: object + urls: + description: Specifies a list of broker addresses in the + format : + items: + type: string + type: array + required: + - urls + type: object + kafka: + description: |- + Kafka is the configuration information for communicating with the Kafka + API of a Redpanda cluster where the object should be created. + properties: + brokers: + description: Specifies a list of broker addresses in the + format : + items: + type: string + minItems: 1 + type: array + sasl: + description: Defines authentication configuration settings + for Redpanda clusters that have authentication enabled. + properties: + awsMskIam: + description: |- + KafkaSASLAWSMskIam is the config for AWS IAM SASL mechanism, + see: https://docs.aws.amazon.com/msk/latest/developerguide/iam-access-control.html + properties: + accessKey: + type: string + secretKey: + description: ValueSource represents where a value + can be pulled from + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can + be set + rule: '!has(self.inline) || (has(self.inline) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other + field can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field + can be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other + field can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + secretKeySecretRef: + description: 'Deprecated: use `secretKey` instead' + properties: + key: + description: Key in Secret data to get value + from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + sessionToken: + description: |- + SessionToken, if non-empty, is a session / security token to use for authentication. + See: https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can + be set + rule: '!has(self.inline) || (has(self.inline) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other + field can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field + can be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other + field can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + sessionTokenSecretRef: + description: 'Deprecated: use `sessionToken` instead' + properties: + key: + description: Key in Secret data to get value + from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + userAgent: + description: |- + UserAgent is the user agent to for the client to use when connecting + to Kafka, overriding the default "franz-go//". + + Setting a UserAgent allows authorizing based on the aws:UserAgent + condition key; see the following link for more details: + https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-useragent + type: string + required: + - accessKey + - userAgent + type: object + gssapi: + description: KafkaSASLGSSAPI represents the Kafka + Kerberos config. + properties: + authType: + type: string + enableFast: + description: |- + EnableFAST enables FAST, which is a pre-authentication framework for Kerberos. + It includes a mechanism for tunneling pre-authentication exchanges using armored KDC messages. + FAST provides increased resistance to passive password guessing attacks. + type: boolean + kerberosConfigPath: + type: string + keyTabPath: + type: string + password: + description: ValueSource represents where a value + can be pulled from + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can + be set + rule: '!has(self.inline) || (has(self.inline) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other + field can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field + can be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other + field can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + passwordSecretRef: + description: 'Deprecated: use `password` instead' + properties: + key: + description: Key in Secret data to get value + from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + realm: + type: string + serviceName: + type: string + username: + type: string + required: + - authType + - enableFast + - kerberosConfigPath + - keyTabPath + - realm + - serviceName + - username + type: object + mechanism: + description: Specifies the SASL/SCRAM authentication + mechanism. + type: string + oauth: + description: KafkaSASLOAuthBearer is the config struct + for the SASL OAuthBearer mechanism + properties: + token: + description: ValueSource represents where a value + can be pulled from + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can + be set + rule: '!has(self.inline) || (has(self.inline) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other + field can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field + can be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other + field can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + tokenSecretRef: + description: 'Deprecated: use `token` instead' + properties: + key: + description: Key in Secret data to get value + from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + type: object + password: + description: Specifies the password. + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + passwordSecretRef: + description: 'Deprecated: use `password` instead' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + username: + description: Specifies the username. + type: string + required: + - mechanism + type: object + x-kubernetes-validations: + - message: username and password must be set when mechanism + is plain + rule: self.mechanism.lowerAscii() != 'plain' || (self.username + != "" && (has(self.passwordSecretRef) || has(self.password))) + - message: username and password must be set when mechanism + is sha-256 + rule: self.mechanism.lowerAscii() != 'scram-sha-256' + || (self.username != "" && (has(self.passwordSecretRef) + || has(self.password))) + - message: username and password must be set when mechanism + is sha-512 + rule: self.mechanism.lowerAscii() != 'scram-sha-512' + || (self.username != "" && (has(self.passwordSecretRef) + || has(self.password))) + - message: oauth must be set when mechanism is oauth + rule: self.mechanism.lowerAscii() != 'oauthbearer' || + has(self.oauth) + - message: gssapi must be set when mechanism is gssapi + rule: self.mechanism.lowerAscii() != 'gssapi' || has(self.gssapi) + - message: awsMskIam must be set when mechanism is aws_msk_iam + rule: self.mechanism.lowerAscii() != 'aws_msk_iam' || + has(self.awsMskIam) + tls: + description: Defines TLS configuration settings for Redpanda + clusters that have TLS enabled. + properties: + caCert: + description: CaCert is the reference for certificate + authority used to establish TLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + caCertSecretRef: + description: 'Deprecated: replaced by "caCert".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + cert: + description: Cert is the reference for client public + certificate to establish mTLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + certSecretRef: + description: 'Deprecated: replaced by "cert".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + enabled: + description: |- + Enabled tells any connections derived from this configuration to leverage TLS even if no + certificate configuration is specified. It *only* is relevant if no other field is specified + in the TLS configuration block, as, for backwards compatibility reasons, any CA/Cert/Key-specification + results in attempting to create a connection using TLS - specifying "false" in such a case does + *not* disable TLS from being used. Leveraging this option is to support the use-case where a + connection is served by publically issued TLS certificates that don't require any additional certificate + specification. + type: boolean + insecureSkipTlsVerify: + description: InsecureSkipTLSVerify can skip verifying + Redpanda self-signed certificate when establish + TLS connection to Redpanda + type: boolean + key: + description: Key is the reference for client private + certificate to establish mTLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + keySecretRef: + description: 'Deprecated: replaced by "key".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + type: object + required: + - brokers + type: object + schemaRegistry: + description: |- + SchemaRegistry is the configuration information for communicating with the Schema Registry + API of a Redpanda cluster where the object should be created. + properties: + sasl: + description: Defines authentication configuration settings + for Redpanda clusters that have authentication enabled. + properties: + authToken: + description: ValueSource represents where a value + can be pulled from + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + mechanism: + description: Specifies the SASL/SCRAM authentication + mechanism. + type: string + password: + description: Specifies the password. + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + passwordSecretRef: + description: 'Deprecated: use `password` instead' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + token: + description: 'Deprecated: use `authToken` instead' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + username: + description: Specifies the username. + type: string + required: + - mechanism + type: object + tls: + description: Defines TLS configuration settings for Redpanda + clusters that have TLS enabled. + properties: + caCert: + description: CaCert is the reference for certificate + authority used to establish TLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + caCertSecretRef: + description: 'Deprecated: replaced by "caCert".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + cert: + description: Cert is the reference for client public + certificate to establish mTLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + certSecretRef: + description: 'Deprecated: replaced by "cert".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + enabled: + description: |- + Enabled tells any connections derived from this configuration to leverage TLS even if no + certificate configuration is specified. It *only* is relevant if no other field is specified + in the TLS configuration block, as, for backwards compatibility reasons, any CA/Cert/Key-specification + results in attempting to create a connection using TLS - specifying "false" in such a case does + *not* disable TLS from being used. Leveraging this option is to support the use-case where a + connection is served by publically issued TLS certificates that don't require any additional certificate + specification. + type: boolean + insecureSkipTlsVerify: + description: InsecureSkipTLSVerify can skip verifying + Redpanda self-signed certificate when establish + TLS connection to Redpanda + type: boolean + key: + description: Key is the reference for client private + certificate to establish mTLS connection to Redpanda + properties: + configMapKeyRef: + description: |- + If the value is supplied by a kubernetes object reference, coordinates are embedded here. + For target values, the string value fetched from the source will be treated as + a raw string. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + externalSecretRef: + description: |- + If the value is supplied by an external source, coordinates are embedded here. + Note: we interpret all fetched external secrets as raw string values + properties: + name: + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + inline: + description: Inline is the raw value specified + inline. + type: string + secretKeyRef: + description: |- + Should the value be contained in a k8s secret rather than configmap, we can refer + to it here. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: one of inline, configMapKeyRef, secretKeyRef, + or externalSecretRef must be set + rule: has(self.inline) || has(self.configMapKeyRef) + || has(self.secretKeyRef) || has(self.externalSecretRef) + - message: if inline is set no other field can be + set + rule: '!has(self.inline) || (has(self.inline) && + !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if configMapKeyRef is set no other field + can be set + rule: '!has(self.configMapKeyRef) || (has(self.configMapKeyRef) + && !(has(self.inline) || has(self.secretKeyRef) + || has(self.externalSecretRef)))' + - message: if secretKeyRef is set no other field can + be set + rule: '!has(self.secretKeyRef) || (has(self.secretKeyRef) + && !(has(self.configMapKeyRef) || has(self.inline) + || has(self.externalSecretRef)))' + - message: if externalSecretRef is set no other field + can be set + rule: '!has(self.externalSecretRef) || (has(self.externalSecretRef) + && !(has(self.configMapKeyRef) || has(self.secretKeyRef) + || has(self.inline)))' + keySecretRef: + description: 'Deprecated: replaced by "key".' + properties: + key: + description: Key in Secret data to get value from + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object + type: object + urls: + description: Specifies a list of broker addresses in the + format : + items: + type: string + type: array + required: + - urls + type: object + type: object + type: object + x-kubernetes-validations: + - message: 'spec.cluster.staticConfiguration.admin: required value' + rule: '!has(self.staticConfiguration) || has(self.staticConfiguration.admin)' + - message: 'spec.cluster.staticConfiguration.kafka: required value' + rule: '!has(self.staticConfiguration) || has(self.staticConfiguration.kafka)' + - message: ClusterSource is immutable + rule: self == oldSelf + - message: either clusterRef or staticConfiguration must be set + rule: has(self.clusterRef) || has(self.staticConfiguration) + principals: + description: |- + Principals defines the list of users assigned to this role. + Format: Type:Name (e.g., User:john, User:jane). If type is omitted, defaults to User. + items: + type: string + maxItems: 1024 + type: array + required: + - cluster + type: object + status: + default: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for controller + reason: Pending + status: Unknown + type: Synced + description: Represents the current status of the Redpanda role. + properties: + conditions: + description: Conditions holds the conditions for the Redpanda role. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + managedAcls: + description: |- + ManagedACLs returns whether the role has managed ACLs that need + to be cleaned up. + type: boolean + managedPrincipals: + description: |- + ManagedPrincipals returns whether the role has managed principals (membership) + that are being reconciled by the operator. + type: boolean + managedRole: + description: |- + ManagedRole returns whether the role has been created in Redpanda and needs + to be cleaned up. + type: boolean + observedGeneration: + description: Specifies the last observed generation. + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/operator/internal/controller/redpanda/role_controller.go b/operator/internal/controller/redpanda/role_controller.go new file mode 100644 index 000000000..42640aee8 --- /dev/null +++ b/operator/internal/controller/redpanda/role_controller.go @@ -0,0 +1,217 @@ +// Copyright 2025 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +// Package redpanda reconciles resources that comes from Redpanda dictionary like Topic, ACL and more. +package redpanda + +import ( + "context" + "time" + + "github.com/twmb/franz-go/pkg/kgo" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + redpandav1alpha2ac "github.com/redpanda-data/redpanda-operator/operator/api/applyconfiguration/redpanda/v1alpha2" + redpandav1alpha2 "github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2" + vectorizedv1alpha1 "github.com/redpanda-data/redpanda-operator/operator/api/vectorized/v1alpha1" + "github.com/redpanda-data/redpanda-operator/operator/internal/controller" + internalclient "github.com/redpanda-data/redpanda-operator/operator/pkg/client" + "github.com/redpanda-data/redpanda-operator/operator/pkg/client/acls" + "github.com/redpanda-data/redpanda-operator/operator/pkg/client/kubernetes" + "github.com/redpanda-data/redpanda-operator/operator/pkg/client/roles" + "github.com/redpanda-data/redpanda-operator/operator/pkg/utils" + "github.com/redpanda-data/redpanda-operator/pkg/secrets" +) + +//+kubebuilder:rbac:groups=cluster.redpanda.com,resources=redpandaroles,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=cluster.redpanda.com,resources=redpandaroles/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=cluster.redpanda.com,resources=redpandaroles/finalizers,verbs=update + +// RoleReconciler reconciles a Role object +type RoleReconciler struct { + // extraOptions can be overridden in tests + // to change the way the underlying clients + // function, i.e. setting low timeouts + extraOptions []kgo.Opt +} + +func (r *RoleReconciler) FinalizerPatch(request ResourceRequest[*redpandav1alpha2.RedpandaRole]) client.Patch { + role := request.object + config := redpandav1alpha2ac.RedpandaRole(role.Name, role.Namespace) + return kubernetes.ApplyPatch(config.WithFinalizers(FinalizerKey)) +} + +func (r *RoleReconciler) SyncResource(ctx context.Context, request ResourceRequest[*redpandav1alpha2.RedpandaRole]) (client.Patch, error) { + role := request.object + hasManagedACLs, hasManagedRole, hasManagedPrincipals := role.HasManagedACLs(), role.HasManagedRole(), role.HasManagedPrincipals() + shouldManageACLs, shouldManageRole, shouldManagePrincipals := role.ShouldManageACLs(), role.ShouldManageRole(), role.ShouldManagePrincipals() + + createPatch := func(err error) (client.Patch, error) { + var syncCondition metav1.Condition + config := redpandav1alpha2ac.RedpandaRole(role.Name, role.Namespace) + + if err != nil { + syncCondition, err = handleResourceSyncErrors(err) + } else { + syncCondition = redpandav1alpha2.ResourceSyncedCondition(role.Name) + } + + return kubernetes.ApplyPatch(config.WithStatus(redpandav1alpha2ac.RoleStatus(). + WithObservedGeneration(role.Generation). + WithManagedRole(hasManagedRole). + WithManagedACLs(hasManagedACLs). + WithManagedPrincipals(hasManagedPrincipals). + WithConditions(utils.StatusConditionConfigs(role.Status.Conditions, role.Generation, []metav1.Condition{ + syncCondition, + })...))), err + } + + rolesClient, syncer, hasRole, err := r.roleAndACLClients(ctx, request) + if err != nil { + return createPatch(err) + } + defer rolesClient.Close() + defer syncer.Close() + + if !hasRole && shouldManageRole { + if err := rolesClient.Create(ctx, role); err != nil { + return createPatch(err) + } + hasManagedRole = true + hasManagedPrincipals = shouldManagePrincipals + } + + if hasRole && shouldManageRole { + // Update principals if we should manage them + if shouldManagePrincipals { + if err := rolesClient.Update(ctx, role); err != nil { + return createPatch(err) + } + hasManagedPrincipals = true + } else if hasManagedPrincipals { + // If we were managing principals but shouldn't anymore, clear them + if err := rolesClient.ClearPrincipals(ctx, role); err != nil { + return createPatch(err) + } + hasManagedPrincipals = false + } + // Always claim ownership when managing a role + hasManagedRole = true + } + + if hasRole && !shouldManageRole { + if err := rolesClient.Delete(ctx, role); err != nil { + return createPatch(err) + } + hasManagedRole = false + hasManagedPrincipals = false + } + + if shouldManageACLs { + if err := syncer.Sync(ctx, role); err != nil { + return createPatch(err) + } + hasManagedACLs = true + } + + if !shouldManageACLs && hasManagedACLs { + if err := syncer.DeleteAll(ctx, role); err != nil { + return createPatch(err) + } + hasManagedACLs = false + } + + return createPatch(nil) +} + +func (r *RoleReconciler) DeleteResource(ctx context.Context, request ResourceRequest[*redpandav1alpha2.RedpandaRole]) error { + request.logger.V(2).Info("Deleting role data from cluster") + + role := request.object + hasManagedACLs, hasManagedRole := role.HasManagedACLs(), role.HasManagedRole() + + rolesClient, syncer, hasRole, err := r.roleAndACLClients(ctx, request) + if err != nil { + return ignoreAllConnectionErrors(request.logger, err) + } + defer rolesClient.Close() + defer syncer.Close() + + if hasRole && hasManagedRole { + request.logger.V(2).Info("Deleting managed role") + if err := rolesClient.Delete(ctx, role); err != nil { + return ignoreAllConnectionErrors(request.logger, err) + } + } + + if hasManagedACLs { + request.logger.V(2).Info("Deleting managed ACLs") + if err := syncer.DeleteAll(ctx, role); err != nil { + return ignoreAllConnectionErrors(request.logger, err) + } + } + + return nil +} + +func (r *RoleReconciler) roleAndACLClients(ctx context.Context, request ResourceRequest[*redpandav1alpha2.RedpandaRole]) (*roles.Client, *acls.Syncer, bool, error) { + role := request.object + rolesClient, err := request.factory.Roles(ctx, role) + if err != nil { + return nil, nil, false, err + } + + syncer, err := request.factory.ACLs(ctx, role, r.extraOptions...) + if err != nil { + return nil, nil, false, err + } + + hasRole, err := rolesClient.Has(ctx, role) + if err != nil { + return nil, nil, false, err + } + + return rolesClient, syncer, hasRole, nil +} + +func SetupRoleController(ctx context.Context, mgr ctrl.Manager, expander *secrets.CloudExpander, includeV1, includeV2 bool) error { + c := mgr.GetClient() + config := mgr.GetConfig() + factory := internalclient.NewFactory(config, c, expander) + + builder := ctrl.NewControllerManagedBy(mgr). + For(&redpandav1alpha2.RedpandaRole{}). + Owns(&corev1.Secret{}) + + if includeV1 { + enqueueV1Role, err := controller.RegisterV1ClusterSourceIndex(ctx, mgr, "role_v1", &redpandav1alpha2.RedpandaRole{}, &redpandav1alpha2.RedpandaRoleList{}) + if err != nil { + return err + } + builder.Watches(&vectorizedv1alpha1.Cluster{}, enqueueV1Role) + } + + if includeV2 { + enqueueV2Role, err := controller.RegisterClusterSourceIndex(ctx, mgr, "role", &redpandav1alpha2.RedpandaRole{}, &redpandav1alpha2.RedpandaRoleList{}) + if err != nil { + return err + } + builder.Watches(&redpandav1alpha2.Redpanda{}, enqueueV2Role) + } + + controller := NewResourceController(c, factory, &RoleReconciler{}, "RoleReconciler") + + // Every 5 minutes try and check to make sure no manual modifications + // happened on the resource synced to the cluster and attempt to correct + // any drift. + return builder.Complete(controller.PeriodicallyReconcile(5 * time.Minute)) +} diff --git a/operator/internal/controller/redpanda/role_controller_test.go b/operator/internal/controller/redpanda/role_controller_test.go new file mode 100644 index 000000000..18406fde2 --- /dev/null +++ b/operator/internal/controller/redpanda/role_controller_test.go @@ -0,0 +1,703 @@ +// Copyright 2025 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package redpanda + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kgo" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + redpandav1alpha2 "github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2" +) + +func TestRoleReconcile(t *testing.T) { // nolint:funlen // These tests have clear subtests. + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2) + defer cancel() + + timeoutOption := kgo.RetryTimeout(1 * time.Millisecond) + environment := InitializeResourceReconcilerTest(t, ctx, &RoleReconciler{ + extraOptions: []kgo.Opt{timeoutOption}, + }) + + authorizationSpec := &redpandav1alpha2.RoleAuthorizationSpec{ + ACLs: []redpandav1alpha2.ACLRule{{ + Type: redpandav1alpha2.ACLTypeAllow, + Resource: redpandav1alpha2.ACLResourceSpec{ + Type: redpandav1alpha2.ResourceTypeGroup, + Name: "group", + }, + Operations: []redpandav1alpha2.ACLOperation{ + redpandav1alpha2.ACLOperationDescribe, + }, + }}, + } + + baseRole := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + }, + Spec: redpandav1alpha2.RoleSpec{ + ClusterSource: environment.ClusterSourceValid, + Principals: []string{"User:testuser1", "User:testuser2"}, + Authorization: authorizationSpec, + }, + } + + for name, tt := range map[string]struct { + mutate func(role *redpandav1alpha2.RedpandaRole) + expectedCondition metav1.Condition + onlyCheckDeletion bool + }{ + "success - role and authorization": { + expectedCondition: environment.SyncedCondition, + }, + "success - role and authorization deletion cleanup": { + expectedCondition: environment.SyncedCondition, + onlyCheckDeletion: true, + }, + "success - role only (no authorization)": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.Authorization = nil + }, + expectedCondition: environment.SyncedCondition, + }, + "success - role only deletion cleanup": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.Authorization = nil + }, + expectedCondition: environment.SyncedCondition, + onlyCheckDeletion: true, + }, + "success - authorization only (no principals)": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.Principals = nil + }, + expectedCondition: environment.SyncedCondition, + onlyCheckDeletion: true, + }, + "success - authorization only deletion cleanup": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.Principals = nil + }, + expectedCondition: environment.SyncedCondition, + onlyCheckDeletion: true, + }, + "error - invalid cluster ref": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.ClusterSource = environment.ClusterSourceInvalidRef + }, + expectedCondition: environment.InvalidClusterRefCondition, + }, + "error - client error no SASL": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.ClusterSource = environment.ClusterSourceNoSASL + }, + expectedCondition: environment.ClientErrorCondition, + }, + "error - client error invalid credentials": { + mutate: func(role *redpandav1alpha2.RedpandaRole) { + role.Spec.ClusterSource = environment.ClusterSourceBadPassword + }, + expectedCondition: environment.ClientErrorCondition, + }, + } { + t.Run(name, func(t *testing.T) { + role := baseRole.DeepCopy() + role.Name = "role" + strconv.Itoa(int(time.Now().UnixNano())) + + if tt.mutate != nil { + tt.mutate(role) + } + + key := client.ObjectKeyFromObject(role) + req := ctrl.Request{NamespacedName: key} + + require.NoError(t, environment.Factory.Create(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.Equal(t, []string{FinalizerKey}, role.Finalizers) + require.Len(t, role.Status.Conditions, 1) + require.Equal(t, tt.expectedCondition.Type, role.Status.Conditions[0].Type) + require.Equal(t, tt.expectedCondition.Status, role.Status.Conditions[0].Status) + require.Equal(t, tt.expectedCondition.Reason, role.Status.Conditions[0].Reason) + + if tt.expectedCondition.Status == metav1.ConditionTrue { //nolint:nestif // ignore + syncer, err := environment.Factory.ACLs(ctx, role) + require.NoError(t, err) + defer syncer.Close() + + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + // if we're supposed to have synced, then check to make sure we properly + // set the management flags + require.Equal(t, role.ShouldManageACLs(), role.Status.ManagedACLs) + require.Equal(t, role.ShouldManageRole(), role.Status.ManagedRole) + require.Equal(t, role.ShouldManagePrincipals(), role.Status.ManagedPrincipals) + + if role.ShouldManageRole() { + // make sure we actually have a role + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole) + } + + if role.ShouldManageACLs() { + // make sure we actually have ACLs + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 1) + } + + if !tt.onlyCheckDeletion { + if role.ShouldManageRole() { + // Test role updates by changing principals + role.Spec.Principals = []string{"User:newuser1", "User:newuser2"} + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err = environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + + } + + if role.ShouldManageACLs() { + // now clear out any managed ACLs and re-check + role.Spec.Authorization = nil + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err = environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.False(t, role.Status.ManagedACLs) + } + + // make sure we no longer have acls + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 0) + } + + // clean up and make sure we properly delete everything + require.NoError(t, environment.Factory.Delete(ctx, role)) + _, err = environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.True(t, apierrors.IsNotFound(environment.Factory.Get(ctx, key, role))) + + // make sure we no longer have a role + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.False(t, hasRole) + + // make sure we no longer have acls + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 0) + + return + } + + // clean up and make sure we properly delete everything + require.NoError(t, environment.Factory.Delete(ctx, role)) + _, err = environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.True(t, apierrors.IsNotFound(environment.Factory.Get(ctx, key, role))) + }) + } +} + +func TestRolePrincipalsAndACLs(t *testing.T) { // nolint:funlen // Comprehensive test coverage + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2) + defer cancel() + + timeoutOption := kgo.RetryTimeout(1 * time.Millisecond) + environment := InitializeResourceReconcilerTest(t, ctx, &RoleReconciler{ + extraOptions: []kgo.Opt{timeoutOption}, + }) + + // Test different role configurations + testCases := []struct { + name string + principals []string + authorization *redpandav1alpha2.RoleAuthorizationSpec + expectedACLs int + shouldManageRole bool + shouldManageACLs bool + description string + }{ + { + name: "principals-only-mode", + principals: []string{"User:alice", "User:bob"}, + authorization: nil, + expectedACLs: 0, + shouldManageRole: true, + shouldManageACLs: false, + description: "Role with principals only, no ACLs", + }, + { + name: "acls-only-mode", + principals: nil, + authorization: &redpandav1alpha2.RoleAuthorizationSpec{ + ACLs: []redpandav1alpha2.ACLRule{{ + Type: redpandav1alpha2.ACLTypeAllow, + Resource: redpandav1alpha2.ACLResourceSpec{ + Type: redpandav1alpha2.ResourceTypeTopic, + Name: "test-topic", + }, + Operations: []redpandav1alpha2.ACLOperation{ + redpandav1alpha2.ACLOperationRead, + }, + }}, + }, + expectedACLs: 1, + shouldManageRole: true, + shouldManageACLs: true, + description: "Role with ACLs only, no principals", + }, + { + name: "combined-mode", + principals: []string{"User:charlie", "User:dave"}, + authorization: &redpandav1alpha2.RoleAuthorizationSpec{ + ACLs: []redpandav1alpha2.ACLRule{ + { + Type: redpandav1alpha2.ACLTypeAllow, + Resource: redpandav1alpha2.ACLResourceSpec{ + Type: redpandav1alpha2.ResourceTypeTopic, + Name: "team-topic", + }, + Operations: []redpandav1alpha2.ACLOperation{ + redpandav1alpha2.ACLOperationRead, + redpandav1alpha2.ACLOperationWrite, + }, + }, + { + Type: redpandav1alpha2.ACLTypeAllow, + Resource: redpandav1alpha2.ACLResourceSpec{ + Type: redpandav1alpha2.ResourceTypeGroup, + Name: "team-group", + }, + Operations: []redpandav1alpha2.ACLOperation{ + redpandav1alpha2.ACLOperationRead, + }, + }, + }, + }, + expectedACLs: 3, // 2 topic + 1 group + shouldManageRole: true, + shouldManageACLs: true, + description: "Role with both principals and ACLs", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + role := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "test-role-" + strconv.Itoa(int(time.Now().UnixNano())), + }, + Spec: redpandav1alpha2.RoleSpec{ + ClusterSource: environment.ClusterSourceValid, + Principals: tt.principals, + Authorization: tt.authorization, + }, + } + + key := client.ObjectKeyFromObject(role) + req := ctrl.Request{NamespacedName: key} + + // Create and reconcile + require.NoError(t, environment.Factory.Create(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + // Verify status + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.Equal(t, []string{FinalizerKey}, role.Finalizers) + require.Len(t, role.Status.Conditions, 1) + require.Equal(t, environment.SyncedCondition.Status, role.Status.Conditions[0].Status) + + // Verify management flags + require.Equal(t, tt.shouldManageRole, role.ShouldManageRole(), tt.description) + require.Equal(t, tt.shouldManageACLs, role.ShouldManageACLs(), tt.description) + require.Equal(t, tt.shouldManageRole, role.Status.ManagedRole, tt.description) + require.Equal(t, tt.shouldManageACLs, role.Status.ManagedACLs, tt.description) + require.Equal(t, len(tt.principals) > 0, role.Status.ManagedPrincipals, tt.description) + + // Verify role exists if managed + if tt.shouldManageRole { + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole, "Role should exist in Redpanda") + } + + // Verify ACLs if managed + if tt.shouldManageACLs { + syncer, err := environment.Factory.ACLs(ctx, role) + require.NoError(t, err) + defer syncer.Close() + + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, tt.expectedACLs, tt.description) + } + + // Clean up + require.NoError(t, environment.Factory.Delete(ctx, role)) + _, err = environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.True(t, apierrors.IsNotFound(environment.Factory.Get(ctx, key, role))) + }) + } +} + +func TestRoleLifecycleTransitions(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*3) + defer cancel() + + timeoutOption := kgo.RetryTimeout(1 * time.Millisecond) + environment := InitializeResourceReconcilerTest(t, ctx, &RoleReconciler{ + extraOptions: []kgo.Opt{timeoutOption}, + }) + + role := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "lifecycle-role-" + strconv.Itoa(int(time.Now().UnixNano())), + }, + Spec: redpandav1alpha2.RoleSpec{ + ClusterSource: environment.ClusterSourceValid, + Principals: []string{"User:lifecycle-user"}, + // Start in principals-only mode + }, + } + + key := client.ObjectKeyFromObject(role) + req := ctrl.Request{NamespacedName: key} + + // Phase 1: Create in principals-only mode + t.Run("create_principals_only", func(t *testing.T) { + require.NoError(t, environment.Factory.Create(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.ShouldManageRole()) + require.False(t, role.ShouldManageACLs()) + require.True(t, role.ShouldManagePrincipals()) + require.True(t, role.Status.ManagedRole) + require.False(t, role.Status.ManagedACLs) + require.True(t, role.Status.ManagedPrincipals) + + // Verify role exists but no ACLs + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole) + + syncer, err := environment.Factory.ACLs(ctx, role) + require.NoError(t, err) + defer syncer.Close() + + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 0) + }) + + // Phase 2: Transition to combined mode + t.Run("add_authorization", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Authorization = &redpandav1alpha2.RoleAuthorizationSpec{ + ACLs: []redpandav1alpha2.ACLRule{{ + Type: redpandav1alpha2.ACLTypeAllow, + Resource: redpandav1alpha2.ACLResourceSpec{ + Type: redpandav1alpha2.ResourceTypeTopic, + Name: "lifecycle-topic", + }, + Operations: []redpandav1alpha2.ACLOperation{ + redpandav1alpha2.ACLOperationRead, + }, + }}, + } + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.ShouldManageRole()) + require.True(t, role.ShouldManageACLs()) + require.True(t, role.ShouldManagePrincipals()) + require.True(t, role.Status.ManagedRole) + require.True(t, role.Status.ManagedACLs) + require.True(t, role.Status.ManagedPrincipals) + + // Verify both role and ACLs exist + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole) + + syncer, err := environment.Factory.ACLs(ctx, role) + require.NoError(t, err) + defer syncer.Close() + + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 1) + }) + + // Phase 3: Update principals + t.Run("update_principals", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Principals = []string{"User:lifecycle-user", "User:additional-user"} + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + require.True(t, role.Status.ManagedACLs) + require.True(t, role.Status.ManagedPrincipals) + require.Equal(t, []string{"User:lifecycle-user", "User:additional-user"}, role.Spec.Principals) + + // Verify role still exists with updated principals and ACLs remain + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole) + + syncer, err := environment.Factory.ACLs(ctx, role) + require.NoError(t, err) + defer syncer.Close() + + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 1) + }) + + // Phase 4: Remove authorization (back to principals-only) + t.Run("remove_authorization", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Authorization = nil + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.ShouldManageRole()) + require.False(t, role.ShouldManageACLs()) + require.True(t, role.ShouldManagePrincipals()) + require.True(t, role.Status.ManagedRole) + require.False(t, role.Status.ManagedACLs) + require.True(t, role.Status.ManagedPrincipals) + + // Verify role still exists but ACLs are removed + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole) + + syncer, err := environment.Factory.ACLs(ctx, role) + require.NoError(t, err) + defer syncer.Close() + + acls, err := syncer.ListACLs(ctx, role.GetPrincipal()) + require.NoError(t, err) + require.Len(t, acls, 0) + }) + + // Phase 5: Clean up + t.Run("cleanup", func(t *testing.T) { + require.NoError(t, environment.Factory.Delete(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.True(t, apierrors.IsNotFound(environment.Factory.Get(ctx, key, role))) + }) +} + +func TestRoleMembershipReconciliation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2) + defer cancel() + + timeoutOption := kgo.RetryTimeout(1 * time.Millisecond) + environment := InitializeResourceReconcilerTest(t, ctx, &RoleReconciler{ + extraOptions: []kgo.Opt{timeoutOption}, + }) + + // Create a role with initial members + role := &redpandav1alpha2.RedpandaRole{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "membership-role-" + strconv.Itoa(int(time.Now().UnixNano())), + }, + Spec: redpandav1alpha2.RoleSpec{ + ClusterSource: environment.ClusterSourceValid, + Principals: []string{"User:alice", "User:bob"}, + }, + } + + key := client.ObjectKeyFromObject(role) + req := ctrl.Request{NamespacedName: key} + + // Initial creation + t.Run("initial_creation", func(t *testing.T) { + require.NoError(t, environment.Factory.Create(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + require.True(t, role.Status.ManagedPrincipals) + require.Equal(t, []string{"User:alice", "User:bob"}, role.Spec.Principals) + + // Verify role exists with correct members + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole) + }) + + // Test 1: Add a new member + t.Run("add_member", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Principals = []string{"User:alice", "User:bob", "User:charlie"} + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + require.True(t, role.Status.ManagedPrincipals) + require.Equal(t, []string{"User:alice", "User:bob", "User:charlie"}, role.Spec.Principals) + + // Verify the role still exists and reconciliation was triggered + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole, "Role should still exist after membership update") + }) + + // Test 2: Remove a member + t.Run("remove_member", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Principals = []string{"User:alice", "User:charlie"} + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + require.True(t, role.Status.ManagedPrincipals) + require.Equal(t, []string{"User:alice", "User:charlie"}, role.Spec.Principals) + + // Verify the role still exists after removing a member + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole, "Role should still exist after member removal") + }) + + // Test 3: Replace all members + t.Run("replace_all_members", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Principals = []string{"User:dave", "User:eve"} + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + require.True(t, role.Status.ManagedPrincipals) + require.Equal(t, []string{"User:dave", "User:eve"}, role.Spec.Principals) + + // Verify the role still exists after replacing all members + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole, "Role should still exist after member replacement") + }) + + // Test 4: Remove all members (empty principals list) + t.Run("remove_all_members", func(t *testing.T) { + require.NoError(t, environment.Factory.Get(ctx, key, role)) + role.Spec.Principals = nil + + require.NoError(t, environment.Factory.Update(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + + require.NoError(t, environment.Factory.Get(ctx, key, role)) + require.True(t, role.Status.ManagedRole) + require.False(t, role.Status.ManagedPrincipals) // No longer managing principals + require.Empty(t, role.Spec.Principals) + + // Verify the role still exists even with no members + rolesClient, err := environment.Factory.Roles(ctx, role) + require.NoError(t, err) + defer rolesClient.Close() + + hasRole, err := rolesClient.Has(ctx, role) + require.NoError(t, err) + require.True(t, hasRole, "Role should still exist with empty membership") + }) + + // Cleanup + t.Run("cleanup", func(t *testing.T) { + require.NoError(t, environment.Factory.Delete(ctx, role)) + _, err := environment.Reconciler.Reconcile(ctx, req) + require.NoError(t, err) + require.True(t, apierrors.IsNotFound(environment.Factory.Get(ctx, key, role))) + }) +} diff --git a/operator/pkg/client/roles/client.go b/operator/pkg/client/roles/client.go new file mode 100644 index 000000000..359537057 --- /dev/null +++ b/operator/pkg/client/roles/client.go @@ -0,0 +1,281 @@ +// Copyright 2025 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package roles + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/redpanda-data/common-go/rpadmin" + + redpandav1alpha2 "github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2" +) + +// Client is a high-level client for managing roles in a Redpanda cluster. +type Client struct { + adminClient *rpadmin.AdminAPI +} + +// NewClient returns a high-level client that is able to manage roles in a Redpanda cluster. +func NewClient(ctx context.Context, adminClient *rpadmin.AdminAPI) (*Client, error) { + // Verify admin client connectivity (similar to how users client verifies API versions) + _, err := adminClient.Brokers(ctx) + if err != nil { + return nil, fmt.Errorf("failed to verify admin client connectivity: %w", err) + } + + return &Client{ + adminClient: adminClient, + }, nil +} + +// Close closes the underlying client connections. +func (c *Client) Close() { + c.adminClient.Close() +} + +// Has checks if a role exists in the Redpanda cluster. +func (c *Client) Has(ctx context.Context, role *redpandav1alpha2.RedpandaRole) (bool, error) { + if role == nil || role.Name == "" { + return false, fmt.Errorf("role is nil or has empty name") + } + + _, err := c.adminClient.Role(ctx, role.Name) + if err != nil { + // Check if error indicates role doesn't exist using proper error unwrapping + if isNotFoundError(err) { + return false, nil + } + // Return the error if it's not a "not found" error + return false, fmt.Errorf("checking if role %s exists: %w", role.Name, err) + } + return true, nil +} + +// Create creates a role in the Redpanda cluster. +func (c *Client) Create(ctx context.Context, role *redpandav1alpha2.RedpandaRole) error { + if role == nil || role.Name == "" { + return fmt.Errorf("role is nil or has empty name") + } + + // Check if role already exists + exists, err := c.Has(ctx, role) + if err != nil { + return fmt.Errorf("checking if role %s already exists: %w", role.Name, err) + } + if exists { + return fmt.Errorf("role %s already exists", role.Name) + } + + // Create the role + _, err = c.adminClient.CreateRole(ctx, role.Name) + if err != nil { + return fmt.Errorf("creating role %s: %w", role.Name, err) + } + + // Assign principals to the role if specified + if len(role.Spec.Principals) > 0 { + err = c.updateRoleMembers(ctx, role.Name, role.Spec.Principals, nil) + if err != nil { + // Try to clean up the role if principal assignment fails + _ = c.adminClient.DeleteRole(ctx, role.Name, true) + return fmt.Errorf("assigning principals to role %s: %w", role.Name, err) + } + } + + return nil +} + +// Delete removes a role from the Redpanda cluster. +func (c *Client) Delete(ctx context.Context, role *redpandav1alpha2.RedpandaRole) error { + if role == nil || role.Name == "" { + return fmt.Errorf("role is nil or has empty name") + } + + // Delete role and its associated ACLs + err := c.adminClient.DeleteRole(ctx, role.Name, true) + if err != nil { + // Check if role already doesn't exist (404) - this is not an error for deletion + if isNotFoundError(err) { + // Role already doesn't exist, consider deletion successful + return nil + } + return fmt.Errorf("deleting role %s: %w", role.Name, err) + } + return nil +} + +// Update updates an existing role in the Redpanda cluster. +func (c *Client) Update(ctx context.Context, role *redpandav1alpha2.RedpandaRole) error { + if role == nil || role.Name == "" { + return fmt.Errorf("role is nil or has empty name") + } + + // Check if role exists + exists, err := c.Has(ctx, role) + if err != nil { + return fmt.Errorf("checking if role %s exists: %w", role.Name, err) + } + if !exists { + return fmt.Errorf("role %s does not exist", role.Name) + } + + // Get current role members + currentMembersResp, err := c.adminClient.RoleMembers(ctx, role.Name) + if err != nil { + return fmt.Errorf("getting current role members for %s: %w", role.Name, err) + } + + // Convert current members to string slice for comparison + currentPrincipalNames := make([]string, len(currentMembersResp.Members)) + for i, member := range currentMembersResp.Members { + // Reconstruct principal format: "Type:Name" + currentPrincipalNames[i] = member.PrincipalType + ":" + member.Name + } + + // Calculate members to add and remove + toAdd, toRemove := calculateMembershipChanges(currentPrincipalNames, role.Spec.Principals) + + // Update membership if there are changes + if len(toAdd) > 0 || len(toRemove) > 0 { + err = c.updateRoleMembers(ctx, role.Name, toAdd, toRemove) + if err != nil { + return fmt.Errorf("updating role membership for %s: %w", role.Name, err) + } + } + + return nil +} + +// ClearPrincipals removes all principals from a role, used when transitioning +// from managed to unmanaged principals. +func (c *Client) ClearPrincipals(ctx context.Context, role *redpandav1alpha2.RedpandaRole) error { + if role == nil || role.Name == "" { + return fmt.Errorf("role is nil or has empty name") + } + + // Get current role members + currentMembersResp, err := c.adminClient.RoleMembers(ctx, role.Name) + if err != nil { + return fmt.Errorf("getting current role members for %s: %w", role.Name, err) + } + + // If there are no members, nothing to clear + if len(currentMembersResp.Members) == 0 { + return nil + } + + // Convert all current members to string slice for removal + currentPrincipalNames := make([]string, len(currentMembersResp.Members)) + for i, member := range currentMembersResp.Members { + currentPrincipalNames[i] = member.PrincipalType + ":" + member.Name + } + + // Remove all current members + err = c.updateRoleMembers(ctx, role.Name, nil, currentPrincipalNames) + if err != nil { + return fmt.Errorf("clearing principals for role %s: %w", role.Name, err) + } + + return nil +} + +// updateRoleMembers updates role membership by adding and removing principals +func (c *Client) updateRoleMembers(ctx context.Context, roleName string, toAdd, toRemove []string) error { + if roleName == "" { + return fmt.Errorf("role name cannot be empty") + } + + // Convert to RoleMember structs - extract username from "User:username" format + addMembers := make([]rpadmin.RoleMember, len(toAdd)) + removeMembers := make([]rpadmin.RoleMember, len(toRemove)) + + for i, principal := range toAdd { + if principal == "" { + return fmt.Errorf("principal at index %d is empty", i) + } + // Parse principal to extract username + name := parsePrincipal(principal) + addMembers[i] = rpadmin.RoleMember{ + Name: name, + PrincipalType: principalTypeUser, + } + } + + for i, principal := range toRemove { + if principal == "" { + return fmt.Errorf("principal at index %d is empty", i) + } + // Parse principal to extract username + name := parsePrincipal(principal) + removeMembers[i] = rpadmin.RoleMember{ + Name: name, + PrincipalType: principalTypeUser, + } + } + + // Use bulk update for efficiency + if len(addMembers) > 0 || len(removeMembers) > 0 { + _, err := c.adminClient.UpdateRoleMembership(ctx, roleName, addMembers, removeMembers, false) + if err != nil { + return fmt.Errorf("updating role membership for role %s: %w", roleName, err) + } + } + + return nil +} + +// calculateMembershipChanges determines which principals to add and remove +func calculateMembershipChanges(current, desired []string) (toAdd, toRemove []string) { + // Find principals to add (in desired but not in current) + for _, principal := range desired { + if !slices.Contains(current, principal) { + toAdd = append(toAdd, principal) + } + } + + // Find principals to remove (in current but not in desired) + for _, principal := range current { + if !slices.Contains(desired, principal) { + toRemove = append(toRemove, principal) + } + } + + return toAdd, toRemove +} + +// isNotFoundError checks if the error is a 404 Not Found HTTP error +func isNotFoundError(err error) bool { + var httpErr *rpadmin.HTTPResponseError + if errors.As(err, &httpErr) { + return httpErr.Response.StatusCode == http.StatusNotFound + } + return false +} + +const ( + userPrefix = "User:" + principalTypeUser = "User" +) + +// parsePrincipal extracts the username from a principal string. +// Handles "User:username" format and defaults to treating the whole string as username if no prefix. +// Currently only supports User principals. +func parsePrincipal(p string) string { + if name, found := strings.CutPrefix(p, userPrefix); found { + return name + } + // Default to treating the whole string as username + return p +}