From 20e3f0a9fe530e3390d0996460b7a18c40bef0a0 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Wed, 11 Mar 2026 07:15:47 +0000 Subject: [PATCH 01/16] refactor: tuples: accept plain strings in generation On-behalf-of: @SAP --- internal/subroutine/account_tuples.go | 10 ++- internal/subroutine/workspace_initializer.go | 5 +- pkg/fga/tuple_manager_test.go | 22 +++++- pkg/fga/tuples.go | 39 +++++----- pkg/fga/tuples_test.go | 80 +++----------------- 5 files changed, 59 insertions(+), 97 deletions(-) diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index dcf3bf5d..183737f6 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -73,7 +73,13 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti } // Ensure the necessary tuples in OpenFGA. - tuples, err := fga.InitialTuplesForAccount(acc, ai, s.creatorRelation, s.parentRelation, s.objectType) + if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("account creator is nil or empty"), true, true) + } + tuples, err := fga.InitialTuplesForAccount(*acc.Spec.Creator, + ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name, + ai.Spec.ParentAccount.OriginClusterId, ai.Spec.ParentAccount.Name, + s.creatorRelation, s.parentRelation, s.objectType) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for account: %w", err), true, true) } @@ -94,7 +100,7 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim // List tuples that reference the account. tm := fga.NewTupleManager(s.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)) - accountReferenceTuples, err := tm.ListWithKey(ctx, fga.ReferencingAccountTupleKey(s.objectType, ai)) + accountReferenceTuples, err := tm.ListWithKey(ctx, fga.ReferencingAccountTupleKey(s.objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name)) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("listing tuples referencing Account: %w", err), true, true) } diff --git a/internal/subroutine/workspace_initializer.go b/internal/subroutine/workspace_initializer.go index dba9b64c..1d40bd02 100644 --- a/internal/subroutine/workspace_initializer.go +++ b/internal/subroutine/workspace_initializer.go @@ -119,7 +119,10 @@ func (w *workspaceInitializer) Initialize(ctx context.Context, instance runtimeo ObjectMeta: metav1.ObjectMeta{Name: generateStoreName(lc)}, } - tuples, err := fga.TuplesForOrganization(acc, ai, w.creatorRelation, w.objectType) + if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("account creator is nil or empty"), true, true) + } + tuples, err := fga.TuplesForOrganization(*acc.Spec.Creator, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name, w.creatorRelation, w.objectType) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for organization: %w", err), true, true) } diff --git a/pkg/fga/tuple_manager_test.go b/pkg/fga/tuple_manager_test.go index 1250f79b..6d85dca4 100644 --- a/pkg/fga/tuple_manager_test.go +++ b/pkg/fga/tuple_manager_test.go @@ -182,7 +182,7 @@ func TestTupleManager_Delete_verifies_tuple_contents(t *testing.T) { func TestIsTupleOfAccountFilter_returnsFalseForAllTuplesWhenGeneratedClusterIdEmpty(t *testing.T) { _, ai := testAccountAndInfo("test-account", "") - filter := IsTupleOfAccountFilter(ai) + filter := IsTupleOfAccountFilter(ai.Spec.Account.GeneratedClusterId) // Any tuple should be rejected when GeneratedClusterId is empty tuples := []v1alpha1.Tuple{ @@ -198,12 +198,26 @@ func TestIsTupleOfAccountFilter_returnsFalseForAllTuplesWhenGeneratedClusterIdEm func TestIsTupleOfAccountFilter_deleteRemovesGeneratedTuples(t *testing.T) { // Use distinct GeneratedClusterIds so the filter matches only one account's tuples acc, ai := testAccountAndInfo("test-account", "1mj722nrt4jo3ggn") - accountTuples, err := InitialTuplesForAccount(acc, ai, "creator", "parent", "account") + var creator string + if acc.Spec.Creator != nil { + creator = *acc.Spec.Creator + } + accountTuples, err := InitialTuplesForAccount(creator, + ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name, + ai.Spec.ParentAccount.OriginClusterId, ai.Spec.ParentAccount.Name, + "creator", "parent", "account") require.NoError(t, err) // Tuples for a second account (should NOT be deleted when we delete test-account's tuples) acc2, ai2 := testAccountAndInfo("other-account", "1yrj2fwqtxcxbm1v") - otherTuples, err := InitialTuplesForAccount(acc2, ai2, "creator", "parent", "account") + var creator2 string + if acc2.Spec.Creator != nil { + creator2 = *acc2.Spec.Creator + } + otherTuples, err := InitialTuplesForAccount(creator2, + ai2.Spec.Account.OriginClusterId, ai2.Spec.Account.Name, + ai2.Spec.ParentAccount.OriginClusterId, ai2.Spec.ParentAccount.Name, + "creator", "parent", "account") require.NoError(t, err) // allTuples: database managed by mocks (Write appends/deletes, Read returns current state) @@ -242,7 +256,7 @@ func TestIsTupleOfAccountFilter_deleteRemovesGeneratedTuples(t *testing.T) { require.Len(t, allTuples, len(tuplesToApply), "database should contain all applied tuples") // 2. ListWithFilter: should return only account tuples - filtered, err := mgr.ListWithFilter(context.Background(), IsTupleOfAccountFilter(ai)) + filtered, err := mgr.ListWithFilter(context.Background(), IsTupleOfAccountFilter(ai.Spec.Account.GeneratedClusterId)) require.NoError(t, err) require.Len(t, filtered, len(accountTuples), "filter should return only account tuples") diff --git a/pkg/fga/tuples.go b/pkg/fga/tuples.go index 19b5195f..7c8572cb 100644 --- a/pkg/fga/tuples.go +++ b/pkg/fga/tuples.go @@ -6,34 +6,32 @@ import ( "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" - accountv1alpha1 "github.com/platform-mesh/account-operator/api/v1alpha1" "github.com/platform-mesh/security-operator/api/v1alpha1" ) // InitialTuplesForAccount returns FGA tuples for an account not of type // organization. -func InitialTuplesForAccount(acc accountv1alpha1.Account, ai accountv1alpha1.AccountInfo, creatorRelation, parentRelation, objectType string) ([]v1alpha1.Tuple, error) { - base, err := baseTuples(acc, ai, creatorRelation, objectType) +func InitialTuplesForAccount(creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, creatorRelation, parentRelation, objectType string) ([]v1alpha1.Tuple, error) { + base, err := baseTuples(creator, accountOriginClusterID, accountName, creatorRelation, objectType) if err != nil { return nil, err } tuples := append(base, v1alpha1.Tuple{ - User: renderAccountEntity(objectType, ai.Spec.ParentAccount.OriginClusterId, ai.Spec.ParentAccount.Name), + User: renderAccountEntity(objectType, parentOriginClusterID, parentName), Relation: parentRelation, - Object: renderAccountEntity(objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Object: renderAccountEntity(objectType, accountOriginClusterID, accountName), }) return tuples, nil } // TuplesForOrganization returns FGA tuples for an Account of type organization. -func TuplesForOrganization(acc accountv1alpha1.Account, ai accountv1alpha1.AccountInfo, creatorRelation, objectType string) ([]v1alpha1.Tuple, error) { - return baseTuples(acc, ai, creatorRelation, objectType) +func TuplesForOrganization(creator, accountOriginClusterID, accountName, creatorRelation, objectType string) ([]v1alpha1.Tuple, error) { + return baseTuples(creator, accountOriginClusterID, accountName, creatorRelation, objectType) } // IsTupleOfAccountFilter returns a filter determining whether a tuple is tied // to the given account, i.e. contains its cluster id. -func IsTupleOfAccountFilter(ai accountv1alpha1.AccountInfo) TupleFilter { - generatedClusterID := ai.Spec.Account.GeneratedClusterId +func IsTupleOfAccountFilter(generatedClusterID string) TupleFilter { return func(t v1alpha1.Tuple) bool { return generatedClusterID != "" && (strings.Contains(t.Object, generatedClusterID) || strings.Contains(t.User, generatedClusterID)) } @@ -41,34 +39,35 @@ func IsTupleOfAccountFilter(ai accountv1alpha1.AccountInfo) TupleFilter { // ReferencingAccountTupleKey returns a key that can be used to List tuples that // reference a given account. -func ReferencingAccountTupleKey(objectType string, ai accountv1alpha1.AccountInfo) *openfgav1.ReadRequestTupleKey { +func ReferencingAccountTupleKey(objectType, accountOriginClusterID, accountName string) *openfgav1.ReadRequestTupleKey { return &openfgav1.ReadRequestTupleKey{ - Object: renderAccountEntity(objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Object: renderAccountEntity(objectType, accountOriginClusterID, accountName), } } // ReferencingOwnerRoleTupleKey returns a key that can be used to List tuples // that reference the owner role of a given account. -func ReferencingOwnerRoleTupleKey(objectType string, ai accountv1alpha1.AccountInfo) *openfgav1.ReadRequestTupleKey { +func ReferencingOwnerRoleTupleKey(objectType, accountOriginClusterID, accountName string) *openfgav1.ReadRequestTupleKey { return &openfgav1.ReadRequestTupleKey{ - Object: renderOwnerRole(objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Object: renderOwnerRole(objectType, accountOriginClusterID, accountName), } } -func baseTuples(acc accountv1alpha1.Account, ai accountv1alpha1.AccountInfo, creatorRelation, objectType string) ([]v1alpha1.Tuple, error) { - if acc.Spec.Creator == nil { - return nil, errors.New("account creator is nil") + +func baseTuples(creator, accountOriginClusterID, accountName, creatorRelation, objectType string) ([]v1alpha1.Tuple, error) { + if creator == "" { + return nil, errors.New("account creator is empty") } return []v1alpha1.Tuple{ { - User: renderCreatorUser(*acc.Spec.Creator), + User: renderCreatorUser(creator), Relation: "assignee", - Object: renderOwnerRole(objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Object: renderOwnerRole(objectType, accountOriginClusterID, accountName), }, { - User: renderOwnerRoleAssigneeGroup(objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + User: renderOwnerRoleAssigneeGroup(objectType, accountOriginClusterID, accountName), Relation: creatorRelation, - Object: renderAccountEntity(objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name), + Object: renderAccountEntity(objectType, accountOriginClusterID, accountName), }, }, nil } diff --git a/pkg/fga/tuples_test.go b/pkg/fga/tuples_test.go index 8c0722ea..e4ef0ac2 100644 --- a/pkg/fga/tuples_test.go +++ b/pkg/fga/tuples_test.go @@ -3,17 +3,13 @@ package fga import ( "testing" - accountv1alpha1 "github.com/platform-mesh/account-operator/api/v1alpha1" "github.com/platform-mesh/security-operator/api/v1alpha1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( accountName = "one" - accountInfoName = "account" parentAccountName = "default" generatedClusterID = "1mj722nrt4jo3ggn" originClusterID = "14uc34987epvgggc" @@ -24,29 +20,9 @@ const ( ) func TestInitialTuplesForAccount(t *testing.T) { - creatorVal := creator - acc := accountv1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: accountName}, - Spec: accountv1alpha1.AccountSpec{ - Creator: &creatorVal, - }, - } - ai := accountv1alpha1.AccountInfo{ - ObjectMeta: metav1.ObjectMeta{Name: accountInfoName}, - Spec: accountv1alpha1.AccountInfoSpec{ - Account: accountv1alpha1.AccountLocation{ - Name: accountName, - GeneratedClusterId: generatedClusterID, - OriginClusterId: originClusterID, - }, - ParentAccount: &accountv1alpha1.AccountLocation{ - Name: parentAccountName, - OriginClusterId: originClusterID, - }, - }, - } - - tuples, err := InitialTuplesForAccount(acc, ai, creatorRelation, parentRelation, objectType) + tuples, err := InitialTuplesForAccount(creator, + originClusterID, accountName, originClusterID, parentAccountName, + creatorRelation, parentRelation, objectType) require.NoError(t, err) require.Len(t, tuples, 3) @@ -74,28 +50,9 @@ func TestInitialTuplesForAccount(t *testing.T) { func TestInitialTuplesForAccount_formatUser(t *testing.T) { creator := "system:serviceaccount:ns:name" - acc := accountv1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: accountName}, - Spec: accountv1alpha1.AccountSpec{ - Creator: &creator, - }, - } - ai := accountv1alpha1.AccountInfo{ - ObjectMeta: metav1.ObjectMeta{Name: accountInfoName}, - Spec: accountv1alpha1.AccountInfoSpec{ - Account: accountv1alpha1.AccountLocation{ - Name: accountName, - GeneratedClusterId: generatedClusterID, - OriginClusterId: originClusterID, - }, - ParentAccount: &accountv1alpha1.AccountLocation{ - Name: parentAccountName, - OriginClusterId: originClusterID, - }, - }, - } - - tuples, err := InitialTuplesForAccount(acc, ai, creatorRelation, parentRelation, objectType) + tuples, err := InitialTuplesForAccount(creator, + originClusterID, accountName, originClusterID, parentAccountName, + creatorRelation, parentRelation, objectType) require.NoError(t, err) require.Len(t, tuples, 3) @@ -103,26 +60,9 @@ func TestInitialTuplesForAccount_formatUser(t *testing.T) { } func TestInitialTuplesForAccount_nilCreator(t *testing.T) { - acc := accountv1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: accountName}, - Spec: accountv1alpha1.AccountSpec{}, - } - ai := accountv1alpha1.AccountInfo{ - ObjectMeta: metav1.ObjectMeta{Name: accountInfoName}, - Spec: accountv1alpha1.AccountInfoSpec{ - Account: accountv1alpha1.AccountLocation{ - Name: accountName, - GeneratedClusterId: generatedClusterID, - OriginClusterId: originClusterID, - }, - ParentAccount: &accountv1alpha1.AccountLocation{ - Name: parentAccountName, - OriginClusterId: originClusterID, - }, - }, - } - - _, err := InitialTuplesForAccount(acc, ai, creatorRelation, parentRelation, objectType) + _, err := InitialTuplesForAccount("", + originClusterID, accountName, originClusterID, parentAccountName, + creatorRelation, parentRelation, objectType) assert.Error(t, err) - assert.Contains(t, err.Error(), "creator is nil") + assert.Contains(t, err.Error(), "creator is empty") } From accbbb8025993685a941d32dcf7040bc136d6a27 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Wed, 11 Mar 2026 07:40:11 +0000 Subject: [PATCH 02/16] refactor: fga to internal package On-behalf-of: @SAP --- {pkg => internal}/fga/tuple_manager.go | 0 {pkg => internal}/fga/tuple_manager_test.go | 0 {pkg => internal}/fga/tuples.go | 0 {pkg => internal}/fga/tuples_test.go | 0 internal/subroutine/account_tuples.go | 2 +- internal/subroutine/tuples.go | 2 +- internal/subroutine/workspace_initializer.go | 2 +- 7 files changed, 3 insertions(+), 3 deletions(-) rename {pkg => internal}/fga/tuple_manager.go (100%) rename {pkg => internal}/fga/tuple_manager_test.go (100%) rename {pkg => internal}/fga/tuples.go (100%) rename {pkg => internal}/fga/tuples_test.go (100%) diff --git a/pkg/fga/tuple_manager.go b/internal/fga/tuple_manager.go similarity index 100% rename from pkg/fga/tuple_manager.go rename to internal/fga/tuple_manager.go diff --git a/pkg/fga/tuple_manager_test.go b/internal/fga/tuple_manager_test.go similarity index 100% rename from pkg/fga/tuple_manager_test.go rename to internal/fga/tuple_manager_test.go diff --git a/pkg/fga/tuples.go b/internal/fga/tuples.go similarity index 100% rename from pkg/fga/tuples.go rename to internal/fga/tuples.go diff --git a/pkg/fga/tuples_test.go b/internal/fga/tuples_test.go similarity index 100% rename from pkg/fga/tuples_test.go rename to internal/fga/tuples_test.go diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index 183737f6..ec9181c4 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -13,7 +13,7 @@ import ( "github.com/platform-mesh/golang-commons/logger" "github.com/platform-mesh/security-operator/api/v1alpha1" iclient "github.com/platform-mesh/security-operator/internal/client" - "github.com/platform-mesh/security-operator/pkg/fga" + "github.com/platform-mesh/security-operator/internal/fga" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" diff --git a/internal/subroutine/tuples.go b/internal/subroutine/tuples.go index 4b18b6d1..fe73672c 100644 --- a/internal/subroutine/tuples.go +++ b/internal/subroutine/tuples.go @@ -11,7 +11,7 @@ import ( "github.com/platform-mesh/golang-commons/errors" "github.com/platform-mesh/golang-commons/logger" securityv1alpha1 "github.com/platform-mesh/security-operator/api/v1alpha1" - "github.com/platform-mesh/security-operator/pkg/fga" + "github.com/platform-mesh/security-operator/internal/fga" ctrl "sigs.k8s.io/controller-runtime" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" diff --git a/internal/subroutine/workspace_initializer.go b/internal/subroutine/workspace_initializer.go index 1d40bd02..9b544136 100644 --- a/internal/subroutine/workspace_initializer.go +++ b/internal/subroutine/workspace_initializer.go @@ -14,7 +14,7 @@ import ( "github.com/platform-mesh/security-operator/api/v1alpha1" iclient "github.com/platform-mesh/security-operator/internal/client" "github.com/platform-mesh/security-operator/internal/config" - "github.com/platform-mesh/security-operator/pkg/fga" + "github.com/platform-mesh/security-operator/internal/fga" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" From bc5d16e1481c089ba277cd1d17d28d3681a334b5 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Wed, 11 Mar 2026 10:19:16 +0000 Subject: [PATCH 03/16] feat: add caching storeID getter On-behalf-of: @SAP --- internal/fga/storeid_getter.go | 79 +++++++++++++++++++ internal/fga/storeid_getter_test.go | 118 ++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 internal/fga/storeid_getter.go create mode 100644 internal/fga/storeid_getter_test.go diff --git a/internal/fga/storeid_getter.go b/internal/fga/storeid_getter.go new file mode 100644 index 00000000..25a03ee3 --- /dev/null +++ b/internal/fga/storeid_getter.go @@ -0,0 +1,79 @@ +package fga + +import ( + "context" + "fmt" + "sync" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// StoreIDGetter should return the OpenFGA store ID for a store name. +type StoreIDGetter interface { + Get(ctx context.Context, storeName string) (string, error) +} + +// CachingStoreIDGetter maps store names to IDs by listing stores in OpenFGA but keeps +// a local cache to avoid frequent list calls. +type CachingStoreIDGetter struct { + mu sync.RWMutex + stores map[string]string + fga openfgav1.OpenFGAServiceClient +} + +func NewCachingStoreIDGetter(fga openfgav1.OpenFGAServiceClient) *CachingStoreIDGetter { + return &CachingStoreIDGetter{ + stores: make(map[string]string), + fga: fga, + } +} + +// Get returns the store ID for the given store name. +func (m *CachingStoreIDGetter) Get(ctx context.Context, storeName string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if id, ok := m.stores[storeName]; ok { + return id, nil + } + + if err := m.syncFromOpenFGA(ctx); err != nil { + return "", fmt.Errorf("syncing stores: %w", err) + } + + if id, ok := m.stores[storeName]; ok { + return id, nil + } + + return "", fmt.Errorf("store %q not found", storeName) +} + +func (m *CachingStoreIDGetter) syncFromOpenFGA(ctx context.Context) error { + stores := make(map[string]string) + var continuationToken string + + for { + resp, err := m.fga.ListStores(ctx, &openfgav1.ListStoresRequest{ + PageSize: wrapperspb.Int32(100), + ContinuationToken: continuationToken, + }) + if err != nil { + return err + } + + for _, store := range resp.GetStores() { + stores[store.GetName()] = store.GetId() + } + + continuationToken = resp.GetContinuationToken() + if continuationToken == "" { + break + } + } + + m.stores = stores + return nil +} + +var _ StoreIDGetter = (*CachingStoreIDGetter)(nil) diff --git a/internal/fga/storeid_getter_test.go b/internal/fga/storeid_getter_test.go new file mode 100644 index 00000000..c2b64606 --- /dev/null +++ b/internal/fga/storeid_getter_test.go @@ -0,0 +1,118 @@ +package fga + +import ( + "context" + "errors" + "testing" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/platform-mesh/security-operator/internal/subroutine/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func TestCachingStoreIDGetter_Get(t *testing.T) { + t.Run("returns store ID from OpenFGA on cache miss", func(t *testing.T) { + client := mocks.NewMockOpenFGAServiceClient(t) + client.EXPECT().ListStores(mock.Anything, mock.Anything).Return(&openfgav1.ListStoresResponse{ + Stores: []*openfgav1.Store{ + {Name: "foo", Id: "DEADBEEF"}, + }, + }, nil).Once() + + getter := NewCachingStoreIDGetter(client) + + id, err := getter.Get(context.Background(), "foo") + require.NoError(t, err) + assert.Equal(t, "DEADBEEF", id) + }) + + t.Run("returns cached value on subsequent calls without calling OpenFGA", func(t *testing.T) { + client := mocks.NewMockOpenFGAServiceClient(t) + client.EXPECT().ListStores(mock.Anything, mock.Anything).Return(&openfgav1.ListStoresResponse{ + Stores: []*openfgav1.Store{ + {Name: "foo", Id: "DEADBEEF"}, + }, + }, nil).Once() + + getter := NewCachingStoreIDGetter(client) + + id1, err := getter.Get(context.Background(), "foo") + require.NoError(t, err) + assert.Equal(t, "DEADBEEF", id1) + + id2, err := getter.Get(context.Background(), "foo") + require.NoError(t, err) + assert.Equal(t, "DEADBEEF", id2) + + client.AssertExpectations(t) + }) + + t.Run("returns error when store not found in OpenFGA", func(t *testing.T) { + client := mocks.NewMockOpenFGAServiceClient(t) + client.EXPECT().ListStores(mock.Anything, mock.Anything).Return(&openfgav1.ListStoresResponse{ + Stores: []*openfgav1.Store{ + {Name: "other-store", Id: "OTHER-ID"}, + }, + }, nil).Once() + + getter := NewCachingStoreIDGetter(client) + + id, err := getter.Get(context.Background(), "missing-store") + assert.Error(t, err) + assert.Contains(t, err.Error(), "store \"missing-store\" not found") + assert.Empty(t, id) + }) + + t.Run("returns error when ListStores fails", func(t *testing.T) { + client := mocks.NewMockOpenFGAServiceClient(t) + client.EXPECT().ListStores(mock.Anything, mock.Anything).Return(nil, errors.New("connection refused")).Once() + + getter := NewCachingStoreIDGetter(client) + + id, err := getter.Get(context.Background(), "foo") + assert.Error(t, err) + assert.Empty(t, id) + }) + + t.Run("sync removes stores no longer in OpenFGA", func(t *testing.T) { + callCount := 0 + client := mocks.NewMockOpenFGAServiceClient(t) + client.EXPECT().ListStores(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, req *openfgav1.ListStoresRequest, opts ...grpc.CallOption) (*openfgav1.ListStoresResponse, error) { + callCount++ + if callCount == 1 { + // First sync: two stores + return &openfgav1.ListStoresResponse{ + Stores: []*openfgav1.Store{ + {Name: "foo", Id: "DEADBEEF"}, + {Name: "bar", Id: "1337CAFE"}, + }, + }, nil + } + // Second sync: one store deleted + return &openfgav1.ListStoresResponse{ + Stores: []*openfgav1.Store{ + {Name: "bar", Id: "1337CAFE"}, + }, + }, nil + }) + + getter := NewCachingStoreIDGetter(client) + + // First Get: sync loads both stores + id1, err := getter.Get(context.Background(), "foo") + require.NoError(t, err) + assert.Equal(t, "DEADBEEF", id1) + + // Get a non-cached store to trigger rsync on cache-miss + _, err = getter.Get(context.Background(), "hoge") + assert.Error(t, err) + + // DEADBEEF should not be in cache anymore + _, err = getter.Get(context.Background(), "foo") + assert.Error(t, err) + assert.Contains(t, err.Error(), "store \"foo\" not found") + }) +} From 7bf2baac2dd6901a890d9c0cbea1dc114e7e0d8f Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 05:19:49 +0000 Subject: [PATCH 04/16] feat: init/term: get storeID via caching getter On-behalf-of: @SAP --- cmd/initializer.go | 6 ++- cmd/terminator.go | 6 ++- .../accountlogicalcluster_controller.go | 5 ++- internal/subroutine/account_tuples.go | 40 +++++++++++++++---- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/cmd/initializer.go b/cmd/initializer.go index aa960aed..746e5fc7 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -8,6 +8,7 @@ import ( sourcev1 "github.com/fluxcd/source-controller/api/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/platform-mesh/security-operator/internal/controller" + "github.com/platform-mesh/security-operator/internal/fga" "github.com/platform-mesh/security-operator/internal/predicates" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -118,14 +119,15 @@ var initializerCmd = &cobra.Command{ return err } defer func() { _ = conn.Close() }() - fga := openfgav1.NewOpenFGAServiceClient(conn) + fgaClient := openfgav1.NewOpenFGAServiceClient(conn) + storeIDGetter := fga.NewCachingStoreIDGetter(fgaClient) mcc, err := mcclient.New(kcpCfg, client.Options{Scheme: scheme}) if err != nil { log.Error().Err(err).Msg("Failed to create multicluster client") os.Exit(1) } - if err := controller.NewAccountLogicalClusterReconciler(log, initializerCfg, fga, mcc, mgr). + if err := controller.NewAccountLogicalClusterReconciler(log, initializerCfg, fgaClient, storeIDGetter, mcc, mgr). SetupWithManager(mgr, defaultCfg, predicate.Not(predicates.LogicalClusterIsAccountTypeOrg())); err != nil { setupLog.Error(err, "unable to create controller", "controller", "AccountLogicalCluster") os.Exit(1) diff --git a/cmd/terminator.go b/cmd/terminator.go index ba7e05f8..ed9842d2 100644 --- a/cmd/terminator.go +++ b/cmd/terminator.go @@ -7,6 +7,7 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" iclient "github.com/platform-mesh/security-operator/internal/client" "github.com/platform-mesh/security-operator/internal/controller" + "github.com/platform-mesh/security-operator/internal/fga" "github.com/platform-mesh/security-operator/internal/predicates" "github.com/platform-mesh/security-operator/internal/terminatingworkspaces" "github.com/spf13/cobra" @@ -104,9 +105,10 @@ var terminatorCmd = &cobra.Command{ os.Exit(1) } defer func() { _ = conn.Close() }() - fga := openfgav1.NewOpenFGAServiceClient(conn) + fgaClient := openfgav1.NewOpenFGAServiceClient(conn) + storeIDGetter := fga.NewCachingStoreIDGetter(fgaClient) - if err := controller.NewAccountLogicalClusterReconciler(log, terminatorCfg, fga, mcc, mgr). + if err := controller.NewAccountLogicalClusterReconciler(log, terminatorCfg, fgaClient, storeIDGetter, mcc, mgr). SetupWithManager(mgr, defaultCfg, predicate.Not(predicates.LogicalClusterIsAccountTypeOrg())); err != nil { log.Error().Err(err).Msg("Unable to create AccountLogicalClusterTerminator") os.Exit(1) diff --git a/internal/controller/accountlogicalcluster_controller.go b/internal/controller/accountlogicalcluster_controller.go index f9641695..62e87210 100644 --- a/internal/controller/accountlogicalcluster_controller.go +++ b/internal/controller/accountlogicalcluster_controller.go @@ -10,6 +10,7 @@ import ( lifecyclesubroutine "github.com/platform-mesh/golang-commons/controller/lifecycle/subroutine" "github.com/platform-mesh/golang-commons/logger" "github.com/platform-mesh/security-operator/internal/config" + "github.com/platform-mesh/security-operator/internal/fga" "github.com/platform-mesh/security-operator/internal/subroutine" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -28,11 +29,11 @@ type AccountLogicalClusterReconciler struct { mclifecycle *multicluster.LifecycleManager } -func NewAccountLogicalClusterReconciler(log *logger.Logger, cfg config.Config, fga openfgav1.OpenFGAServiceClient, mcc mcclient.ClusterClient, mgr mcmanager.Manager) *AccountLogicalClusterReconciler { +func NewAccountLogicalClusterReconciler(log *logger.Logger, cfg config.Config, fgaClient openfgav1.OpenFGAServiceClient, storeIDGetter fga.StoreIDGetter, mcc mcclient.ClusterClient, mgr mcmanager.Manager) *AccountLogicalClusterReconciler { return &AccountLogicalClusterReconciler{ log: log, mclifecycle: builder.NewBuilder("security", "AccountLogicalClusterReconciler", []lifecyclesubroutine.Subroutine{ - subroutine.NewAccountTuplesSubroutine(mcc, mgr, fga, cfg.FGA.CreatorRelation, cfg.FGA.ParentRelation, cfg.FGA.ObjectType), + subroutine.NewAccountTuplesSubroutine(mcc, mgr, fgaClient, storeIDGetter, cfg.FGA.CreatorRelation, cfg.FGA.ParentRelation, cfg.FGA.ObjectType), }, log). WithReadOnly(). WithStaticThenExponentialRateLimiter(). diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index ec9181c4..5a6664ef 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -33,10 +33,10 @@ const accountTuplesTerminatorFinalizer = "core.platform-mesh.io/account-tuples-t // AccountTuplesSubroutine creates FGA tuples for Accounts not of the // "org"-type when initializing, and deletes them when terminating. type AccountTuplesSubroutine struct { - mgr mcmanager.Manager - mcc mcclient.ClusterClient - fga openfgav1.OpenFGAServiceClient - + mgr mcmanager.Manager + mcc mcclient.ClusterClient + fga openfgav1.OpenFGAServiceClient + storeIDGetter fga.StoreIDGetter objectType string parentRelation string creatorRelation string @@ -83,7 +83,15 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for account: %w", err), true, true) } - if err := fga.NewTupleManager(s.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)).Apply(ctx, tuples); err != nil { + storeName := storeNameFromLogicalCluster(lc) + if storeName == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("logical cluster path annotation not set"), true, true) + } + storeID, err := s.storeIDGetter.Get(ctx, storeName) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) + } + if err := fga.NewTupleManager(s.fga, storeID, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)).Apply(ctx, tuples); err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("applying tuples for Account: %w", err), true, true) } @@ -98,8 +106,17 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim return ctrl.Result{}, opErr } + storeName := storeNameFromLogicalCluster(lc) + if storeName == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("logical cluster path annotation not set"), true, true) + } + storeID, err := s.storeIDGetter.Get(ctx, storeName) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) + } + // List tuples that reference the account. - tm := fga.NewTupleManager(s.fga, ai.Spec.FGA.Store.Id, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)) + tm := fga.NewTupleManager(s.fga, storeID, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)) accountReferenceTuples, err := tm.ListWithKey(ctx, fga.ReferencingAccountTupleKey(s.objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name)) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("listing tuples referencing Account: %w", err), true, true) @@ -158,11 +175,12 @@ func (s *AccountTuplesSubroutine) Finalizers(_ runtimeobject.RuntimeObject) []st // GetName implements lifecycle.Subroutine. func (s *AccountTuplesSubroutine) GetName() string { return "AccountTuplesSubroutine" } -func NewAccountTuplesSubroutine(mcc mcclient.ClusterClient, mgr mcmanager.Manager, fga openfgav1.OpenFGAServiceClient, creatorRelation, parentRelation, objectType string) *AccountTuplesSubroutine { +func NewAccountTuplesSubroutine(mcc mcclient.ClusterClient, mgr mcmanager.Manager, fga openfgav1.OpenFGAServiceClient, storeIDGetter fga.StoreIDGetter, creatorRelation, parentRelation, objectType string) *AccountTuplesSubroutine { return &AccountTuplesSubroutine{ mgr: mgr, mcc: mcc, fga: fga, + storeIDGetter: storeIDGetter, creatorRelation: creatorRelation, parentRelation: parentRelation, objectType: objectType, @@ -175,6 +193,14 @@ var ( _ lifecyclesubroutine.Terminator = &AccountTuplesSubroutine{} ) +func storeNameFromLogicalCluster(lc *kcpcorev1alpha1.LogicalCluster) string { + if path, ok := lc.Annotations[kcpcore.LogicalClusterPathAnnotationKey]; ok { + pathElements := strings.Split(path, ":") + return pathElements[len(pathElements)-1] + } + return "" +} + // AccountAndInfoForLogicalCluster fetches the AccountInfo from the // LogicalCluster and the corresponding Account from the parent account's // workspace. From 90b70694622ab2842e013f0e2b7d14436e5c0ee4 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 06:22:17 +0000 Subject: [PATCH 05/16] feat: dont populate accountinfo with store ID anymore On-behalf-of: @SAP --- internal/subroutine/account_tuples.go | 36 ++++++++++---------- internal/subroutine/workspace_initializer.go | 16 --------- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index 5a6664ef..0878e9f2 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -14,6 +14,7 @@ import ( "github.com/platform-mesh/security-operator/api/v1alpha1" iclient "github.com/platform-mesh/security-operator/internal/client" "github.com/platform-mesh/security-operator/internal/fga" + platformmeshpath "github.com/platform-mesh/security-operator/internal/platformmesh" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -72,6 +73,16 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti } } + accountPath, err := platformmeshpath.NewAccountPathFromLogicalCluster(lc) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting AccountPath from LogicalCluster: %w", err), true, true) + } + + storeID, err := s.storeIDGetter.Get(ctx, storeNameFromAccountPath(accountPath)) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) + } + // Ensure the necessary tuples in OpenFGA. if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("account creator is nil or empty"), true, true) @@ -83,14 +94,6 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for account: %w", err), true, true) } - storeName := storeNameFromLogicalCluster(lc) - if storeName == "" { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("logical cluster path annotation not set"), true, true) - } - storeID, err := s.storeIDGetter.Get(ctx, storeName) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) - } if err := fga.NewTupleManager(s.fga, storeID, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)).Apply(ctx, tuples); err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("applying tuples for Account: %w", err), true, true) } @@ -106,11 +109,12 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim return ctrl.Result{}, opErr } - storeName := storeNameFromLogicalCluster(lc) - if storeName == "" { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("logical cluster path annotation not set"), true, true) + accountPath, err := platformmeshpath.NewAccountPathFromLogicalCluster(lc) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting AccountPath from LogicalCluster: %w", err), true, true) } - storeID, err := s.storeIDGetter.Get(ctx, storeName) + + storeID, err := s.storeIDGetter.Get(ctx, storeNameFromAccountPath(accountPath)) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) } @@ -193,12 +197,8 @@ var ( _ lifecyclesubroutine.Terminator = &AccountTuplesSubroutine{} ) -func storeNameFromLogicalCluster(lc *kcpcorev1alpha1.LogicalCluster) string { - if path, ok := lc.Annotations[kcpcore.LogicalClusterPathAnnotationKey]; ok { - pathElements := strings.Split(path, ":") - return pathElements[len(pathElements)-1] - } - return "" +func storeNameFromAccountPath(ap platformmeshpath.AccountPath) string { + return ap.Org().Base() } // AccountAndInfoForLogicalCluster fetches the AccountInfo from the diff --git a/internal/subroutine/workspace_initializer.go b/internal/subroutine/workspace_initializer.go index 9b544136..9ff19aba 100644 --- a/internal/subroutine/workspace_initializer.go +++ b/internal/subroutine/workspace_initializer.go @@ -165,22 +165,6 @@ func (w *workspaceInitializer) Initialize(ctx context.Context, instance runtimeo return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("store id is empty"), true, false) } - cluster, err := w.mgr.ClusterFromContext(ctx) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to get cluster from context: %w", err), true, false) - } - - accountInfo := accountsv1alpha1.AccountInfo{ - ObjectMeta: metav1.ObjectMeta{Name: "account"}, - } - _, err = controllerutil.CreateOrUpdate(ctx, cluster.GetClient(), &accountInfo, func() error { - accountInfo.Spec.FGA.Store.Id = store.Status.StoreID - return nil - }) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("unable to create/update accountInfo: %w", err), true, true) - } - return ctrl.Result{}, nil } From de2c4c069c4923b3742390a9367c9b86be334275 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 06:50:04 +0000 Subject: [PATCH 06/16] feat: add platformmesh AccountType struct On-behalf-of: @SAP --- internal/platformmesh/account_path.go | 64 +++++++++++ internal/platformmesh/account_path_test.go | 127 +++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/platformmesh/account_path.go create mode 100644 internal/platformmesh/account_path_test.go diff --git a/internal/platformmesh/account_path.go b/internal/platformmesh/account_path.go new file mode 100644 index 00000000..a23652b7 --- /dev/null +++ b/internal/platformmesh/account_path.go @@ -0,0 +1,64 @@ +package platformmeshpath + +import ( + "fmt" + "strings" + + "github.com/kcp-dev/logicalcluster/v3" + kcpcore "github.com/kcp-dev/sdk/apis/core" + kcpcorev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" +) + +const ( + rootWorkspace = "root" + orgsWorkspace = "orgs" + + kcpWorkpaceSeparator = ":" +) + +type AccountPath struct { + logicalcluster.Path +} + +func NewAccountPath(value string) (AccountPath, error) { + if !IsPlatformMeshAccountPath(value) { + return AccountPath{}, fmt.Errorf("%s is not a valid platform mesh path", value) + } + + return AccountPath{ + Path: logicalcluster.NewPath(value), + }, nil +} + +func NewAccountPathFromLogicalCluster(lc *kcpcorev1alpha1.LogicalCluster) (AccountPath, error) { + p, ok := lc.Annotations[kcpcore.LogicalClusterPathAnnotationKey] + if !ok { + return AccountPath{}, fmt.Errorf("LogicalCluster does not contain %s annotation", kcpcore.LogicalClusterPathAnnotationKey) + } + + return NewAccountPath(p) +} + +// IsOrg returns true if the AccountPath is an organisation. +func (a AccountPath) IsOrg() bool { + parts := strings.Split(a.String(), kcpWorkpaceSeparator) + return len(parts) == 3 +} + +// Org returns the AccountPath's parent organisation. +func (a AccountPath) Org() AccountPath { + parts := strings.Split(a.String(), kcpWorkpaceSeparator) + return AccountPath{ + Path: logicalcluster.NewPath(strings.Join(parts[:3], kcpWorkpaceSeparator)), + } +} + +// IsPlatformMeshAccountPath returns whether a value is a platform-mesh account +// path, i.e. a canonical KCP workspace path within the platform-mesh account +// workspace tree "root:orgs". +func IsPlatformMeshAccountPath(value string) bool { + _, valid := logicalcluster.NewValidatedPath(value) + parts := strings.Split(value, kcpWorkpaceSeparator) + + return valid && len(parts) > 2 && parts[0] == rootWorkspace && parts[1] == orgsWorkspace +} diff --git a/internal/platformmesh/account_path_test.go b/internal/platformmesh/account_path_test.go new file mode 100644 index 00000000..67a2a189 --- /dev/null +++ b/internal/platformmesh/account_path_test.go @@ -0,0 +1,127 @@ +package platformmeshpath + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAccountPath(t *testing.T) { + t.Run("returns no error for org path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default") + require.NoError(t, err) + assert.Equal(t, "root:orgs:default", path.String()) + }) + + t.Run("returns no error for account path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount") + require.NoError(t, err) + assert.Equal(t, "root:orgs:default:testaccount", path.String()) + }) + + t.Run("returns no error for subaccount path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount") + require.NoError(t, err) + assert.Equal(t, "root:orgs:default:testaccount", path.String()) + }) + + t.Run("returns error for invalid path", func(t *testing.T) { + _, err := NewAccountPath("invalid-path") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a valid platform mesh path") + }) + + t.Run("returns error for path out of orgs workspace", func(t *testing.T) { + _, err := NewAccountPath("root:platform-mesh-system") + assert.Error(t, err) + }) + + t.Run("returns error for orgs workspace", func(t *testing.T) { + _, err := NewAccountPath("root:orgs") + assert.Error(t, err) + }) +} + +func TestAccountPath_IsOrg(t *testing.T) { + t.Run("returns true for org path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default") + require.NoError(t, err) + assert.True(t, path.IsOrg()) + }) + + t.Run("returns false for workspace path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount") + require.NoError(t, err) + assert.False(t, path.IsOrg()) + }) + + t.Run("returns false for nested workspace path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount:subaccount") + require.NoError(t, err) + assert.False(t, path.IsOrg()) + }) +} + +func TestAccountPath_Org(t *testing.T) { + t.Run("returns self for org path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default") + require.NoError(t, err) + org := path.Org() + assert.Equal(t, "root:orgs:default", org.String()) + }) + + t.Run("returns parent org for workspace path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount") + require.NoError(t, err) + org := path.Org() + assert.Equal(t, "root:orgs:default", org.String()) + }) + + t.Run("returns root org for deeply nested path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount:subaccount") + require.NoError(t, err) + org := path.Org() + assert.Equal(t, "root:orgs:default", org.String()) + }) +} + +func TestIsPlatformMeshAccountPath(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + {"valid org path", "root:orgs:default", true}, + {"valid workspace path", "root:orgs:default:testaccount", true}, + {"invalid - wrong prefix", "root:other:default", false}, + {"invalid - too few segments", "root:orgs", false}, + {"invalid - single segment", "root", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsPlatformMeshAccountPath(tt.path) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestStoreNameFromAccountPath(t *testing.T) { + t.Run("returns name of org for account path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount") + require.NoError(t, err) + assert.Equal(t, "default", StoreNameFromAccountPath(path)) + }) + + t.Run("returns name of org for subaccount path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default:testaccount:sub") + require.NoError(t, err) + assert.Equal(t, "default", StoreNameFromAccountPath(path)) + }) + + t.Run("returns name of org name for org path", func(t *testing.T) { + path, err := NewAccountPath("root:orgs:default") + require.NoError(t, err) + assert.Equal(t, "default", StoreNameFromAccountPath(path)) + }) +} From 730fe5482f388562571f323f45e6be20013903f3 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 06:51:38 +0000 Subject: [PATCH 07/16] fix: remove bogus tests On-behalf-of: @SAP --- internal/platformmesh/account_path_test.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/internal/platformmesh/account_path_test.go b/internal/platformmesh/account_path_test.go index 67a2a189..9d4669f0 100644 --- a/internal/platformmesh/account_path_test.go +++ b/internal/platformmesh/account_path_test.go @@ -105,23 +105,3 @@ func TestIsPlatformMeshAccountPath(t *testing.T) { }) } } - -func TestStoreNameFromAccountPath(t *testing.T) { - t.Run("returns name of org for account path", func(t *testing.T) { - path, err := NewAccountPath("root:orgs:default:testaccount") - require.NoError(t, err) - assert.Equal(t, "default", StoreNameFromAccountPath(path)) - }) - - t.Run("returns name of org for subaccount path", func(t *testing.T) { - path, err := NewAccountPath("root:orgs:default:testaccount:sub") - require.NoError(t, err) - assert.Equal(t, "default", StoreNameFromAccountPath(path)) - }) - - t.Run("returns name of org name for org path", func(t *testing.T) { - path, err := NewAccountPath("root:orgs:default") - require.NoError(t, err) - assert.Equal(t, "default", StoreNameFromAccountPath(path)) - }) -} From 09d8546b125bcfae7de01eeae70368fdbefedc29 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 10:57:05 +0000 Subject: [PATCH 08/16] feat: init/term: gather information for tuples without relying on AccountInfo On-behalf-of: @SAP --- internal/subroutine/account_tuples.go | 145 +++++++++++--------------- 1 file changed, 60 insertions(+), 85 deletions(-) diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index 0878e9f2..acace765 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -17,20 +17,13 @@ import ( platformmeshpath "github.com/platform-mesh/security-operator/internal/platformmesh" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - mccontext "sigs.k8s.io/multicluster-runtime/pkg/context" mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" - kerrors "k8s.io/apimachinery/pkg/api/errors" - "github.com/kcp-dev/logicalcluster/v3" mcclient "github.com/kcp-dev/multicluster-provider/client" - kcpcore "github.com/kcp-dev/sdk/apis/core" kcpcorev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" ) -const accountTuplesTerminatorFinalizer = "core.platform-mesh.io/account-tuples-terminator" - // AccountTuplesSubroutine creates FGA tuples for Accounts not of the // "org"-type when initializing, and deletes them when terminating. type AccountTuplesSubroutine struct { @@ -52,26 +45,6 @@ func (s *AccountTuplesSubroutine) Process(ctx context.Context, instance runtimeo // Initialize implements lifecycle.Initializer. func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runtimeobject.RuntimeObject) (ctrl.Result, errors.OperatorError) { lc := instance.(*kcpcorev1alpha1.LogicalCluster) - acc, ai, opErr := AccountAndInfoForLogicalCluster(ctx, s.mgr, lc) - if opErr != nil { - return ctrl.Result{}, opErr - } - - if updated := controllerutil.AddFinalizer(&ai, accountTuplesTerminatorFinalizer); updated { - lcID, ok := mccontext.ClusterFrom(ctx) - if !ok { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster name not found in context"), true, true) - } - - lcClient, err := iclient.NewForLogicalCluster(s.mgr.GetLocalManager().GetConfig(), s.mgr.GetLocalManager().GetScheme(), logicalcluster.Name(lcID)) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting client: %w", err), true, true) - } - - if err := lcClient.Update(ctx, &ai); err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("updating AccountInfo to set finalizer: %w", err), true, true) - } - } accountPath, err := platformmeshpath.NewAccountPathFromLogicalCluster(lc) if err != nil { @@ -84,13 +57,11 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti } // Ensure the necessary tuples in OpenFGA. - if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("account creator is nil or empty"), true, true) + creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, err := TupleInformationForAccountPath(ctx, s.mgr, accountPath) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("rendering tuple information for Account: %w", err), true, true) } - tuples, err := fga.InitialTuplesForAccount(*acc.Spec.Creator, - ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name, - ai.Spec.ParentAccount.OriginClusterId, ai.Spec.ParentAccount.Name, - s.creatorRelation, s.parentRelation, s.objectType) + tuples, err := fga.InitialTuplesForAccount(creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, s.creatorRelation, s.parentRelation, s.objectType) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for account: %w", err), true, true) } @@ -104,15 +75,23 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti // Terminate implements lifecycle.Terminator. func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtimeobject.RuntimeObject) (ctrl.Result, errors.OperatorError) { lc := instance.(*kcpcorev1alpha1.LogicalCluster) - _, ai, opErr := AccountAndInfoForLogicalCluster(ctx, s.mgr, lc) - if opErr != nil { - return ctrl.Result{}, opErr - } accountPath, err := platformmeshpath.NewAccountPathFromLogicalCluster(lc) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting AccountPath from LogicalCluster: %w", err), true, true) } + parentPath, _ := accountPath.Parent() + + // Retrieve parent LogicalCluster to determine its cluster ID + parentLC, err := LogicalClusterForPath(ctx, s.mgr, parentPath) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster %w", err), true, true) + } + parentClusterID, ok := parentLC.Annotations["kcp.io/cluster"] + if !ok || parentClusterID == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set"), true, true) + + } storeID, err := s.storeIDGetter.Get(ctx, storeNameFromAccountPath(accountPath)) if err != nil { @@ -121,7 +100,7 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim // List tuples that reference the account. tm := fga.NewTupleManager(s.fga, storeID, fga.AuthorizationModelIDLatest, logger.LoadLoggerFromContext(ctx)) - accountReferenceTuples, err := tm.ListWithKey(ctx, fga.ReferencingAccountTupleKey(s.objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name)) + accountReferenceTuples, err := tm.ListWithKey(ctx, fga.ReferencingAccountTupleKey(s.objectType, parentClusterID, accountPath.Base())) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("listing tuples referencing Account: %w", err), true, true) } @@ -129,7 +108,7 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim accountTuples = append(accountTuples, accountReferenceTuples...) // From tuples referencing the account, parse potential roles specific to the account. - rolePrefix := fga.RenderRolePrefix(s.objectType, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name) + rolePrefix := fga.RenderRolePrefix(s.objectType, parentClusterID, accountPath.Base()) for _, t := range accountReferenceTuples { if strings.HasPrefix(t.User, rolePrefix) { role := strings.TrimSuffix(t.User, "#assignee") @@ -146,23 +125,6 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("deleting tuples for Account: %w", err), true, true) } - // Remove finalizer from AccountInfo. - if updated := controllerutil.RemoveFinalizer(&ai, accountTuplesTerminatorFinalizer); updated { - lcID, ok := mccontext.ClusterFrom(ctx) - if !ok { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster name not found in context"), true, true) - } - - lcClient, err := iclient.NewForLogicalCluster(s.mgr.GetLocalManager().GetConfig(), s.mgr.GetLocalManager().GetScheme(), logicalcluster.Name(lcID)) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting client: %w", err), true, true) - } - - if err := lcClient.Update(ctx, &ai); err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("updating AccountInfo to remove finalizer: %w", err), true, true) - } - } - return ctrl.Result{}, nil } @@ -201,45 +163,58 @@ func storeNameFromAccountPath(ap platformmeshpath.AccountPath) string { return ap.Org().Base() } -// AccountAndInfoForLogicalCluster fetches the AccountInfo from the -// LogicalCluster and the corresponding Account from the parent account's -// workspace. -func AccountAndInfoForLogicalCluster(ctx context.Context, mgr mcmanager.Manager, lc *kcpcorev1alpha1.LogicalCluster) (accountsv1alpha1.Account, accountsv1alpha1.AccountInfo, errors.OperatorError) { - if lc.Annotations[kcpcore.LogicalClusterPathAnnotationKey] == "" { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("annotation on LogicalCluster is not set"), true, true) +// returns creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, +func TupleInformationForAccountPath(ctx context.Context, mgr mcmanager.Manager, ap platformmeshpath.AccountPath) (string, string, string, string, string, error) { + // Retrieve the parent's LogicalCluster to determine its cluster ID + parentPath, _ := ap.Parent() + parentAccountLC, err := LogicalClusterForPath(ctx, mgr, parentPath) + if err != nil { + return "", "", "", "", "", fmt.Errorf("getting parent account's LogicalCluster%w", err) } - lcID, ok := mccontext.ClusterFrom(ctx) - if !ok { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("cluster name not found in context"), true, true) + parentAccountClusterID, ok := parentAccountLC.Annotations["kcp.io/cluster"] + if !ok || parentAccountClusterID == "" { + return "", "", "", "", "", fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set") } - // The AccountInfo in the logical cluster belongs to the Account the - // Workspace was created for - lcClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(lcID)) + // Retrieve the grandparent's LogicalCluster to determine its clusterID + grandParentPath, _ := parentPath.Parent() + grandParentAccountLC, err := LogicalClusterForPath(ctx, mgr, grandParentPath) if err != nil { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("getting client: %w", err), true, true) + return "", "", "", "", "", fmt.Errorf("getting parent account's LogicalCluster%w", err) } - var ai accountsv1alpha1.AccountInfo - if err := lcClient.Get(ctx, client.ObjectKey{ - Name: "account", - }, &ai); err != nil && !kerrors.IsNotFound(err) { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("getting AccountInfo for LogicalCluster: %w", err), true, true) - } else if kerrors.IsNotFound(err) { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("AccountInfo not found"), true, true) + grandParentAccountClusterID, ok := grandParentAccountLC.Annotations["kcp.io/cluster"] + if !ok || grandParentAccountClusterID == "" { + return "", "", "", "", "", fmt.Errorf("cluster-annotation on grandparent account's LogicalCluster is not set") } - // The actual Account resource belonging to the Workspace needs to be - // fetched from the parent Account's Workspace - parentAccountClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(ai.Spec.ParentAccount.Path)) - if err != nil { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("getting parent account cluster client: %w", err), true, true) - } + // Retrieve the Account resource out of the parent workspace to determine + // the creator + parentAccountClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(parentPath.String())) var acc accountsv1alpha1.Account if err := parentAccountClient.Get(ctx, client.ObjectKey{ - Name: ai.Spec.Account.Name, + Name: ap.Base(), }, &acc); err != nil { - return accountsv1alpha1.Account{}, accountsv1alpha1.AccountInfo{}, errors.NewOperatorError(fmt.Errorf("getting Account in parent account cluster: %w", err), true, true) + return "", "", "", "", "", fmt.Errorf("getting Account in parent account cluster: %w", err) + } + if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { + return "", "", "", "", "", fmt.Errorf("account creator is nil or empty") + } + + return *acc.Spec.Creator, parentAccountClusterID, ap.Base(), grandParentAccountClusterID, parentPath.Base(), nil +} + +func LogicalClusterForPath(ctx context.Context, mgr mcmanager.Manager, p logicalcluster.Path) (kcpcorev1alpha1.LogicalCluster, error) { + var lc kcpcorev1alpha1.LogicalCluster + + clusterClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(p.String())) + if err != nil { + return lc, fmt.Errorf("getting account cluster client: %w", err) + } + if err := clusterClient.Get(ctx, client.ObjectKey{ + Name: "cluster", + }, &lc); err != nil { + return lc, fmt.Errorf("getting account's LogicalCluster: %w", err) } - return acc, ai, nil + return lc, nil } From 74e4f0d8e26918e4680d6d82e9baffb647a968b6 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 12:53:54 +0000 Subject: [PATCH 09/16] refactor: fga: input structs for tuple generation On-behalf-of: @SAP --- internal/fga/tuple_manager_test.go | 32 +++++++++---- internal/fga/tuples.go | 47 +++++++++++++------ internal/fga/tuples_test.go | 49 ++++++++++++++++---- internal/subroutine/account_tuples.go | 41 ++++++++++------ internal/subroutine/workspace_initializer.go | 10 +++- 5 files changed, 133 insertions(+), 46 deletions(-) diff --git a/internal/fga/tuple_manager_test.go b/internal/fga/tuple_manager_test.go index 6d85dca4..bb9cf3b6 100644 --- a/internal/fga/tuple_manager_test.go +++ b/internal/fga/tuple_manager_test.go @@ -202,10 +202,18 @@ func TestIsTupleOfAccountFilter_deleteRemovesGeneratedTuples(t *testing.T) { if acc.Spec.Creator != nil { creator = *acc.Spec.Creator } - accountTuples, err := InitialTuplesForAccount(creator, - ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name, - ai.Spec.ParentAccount.OriginClusterId, ai.Spec.ParentAccount.Name, - "creator", "parent", "account") + accountTuples, err := InitialTuplesForAccount(InitialTuplesForAccountInput{ + BaseTuplesInput: BaseTuplesInput{ + Creator: creator, + AccountOriginClusterID: ai.Spec.Account.OriginClusterId, + AccountName: ai.Spec.Account.Name, + CreatorRelation: "creator", + ObjectType: "account", + }, + ParentOriginClusterID: ai.Spec.ParentAccount.OriginClusterId, + ParentName: ai.Spec.ParentAccount.Name, + ParentRelation: "parent", + }) require.NoError(t, err) // Tuples for a second account (should NOT be deleted when we delete test-account's tuples) @@ -214,10 +222,18 @@ func TestIsTupleOfAccountFilter_deleteRemovesGeneratedTuples(t *testing.T) { if acc2.Spec.Creator != nil { creator2 = *acc2.Spec.Creator } - otherTuples, err := InitialTuplesForAccount(creator2, - ai2.Spec.Account.OriginClusterId, ai2.Spec.Account.Name, - ai2.Spec.ParentAccount.OriginClusterId, ai2.Spec.ParentAccount.Name, - "creator", "parent", "account") + otherTuples, err := InitialTuplesForAccount(InitialTuplesForAccountInput{ + BaseTuplesInput: BaseTuplesInput{ + Creator: creator2, + AccountOriginClusterID: ai2.Spec.Account.OriginClusterId, + AccountName: ai2.Spec.Account.Name, + CreatorRelation: "creator", + ObjectType: "account", + }, + ParentOriginClusterID: ai2.Spec.ParentAccount.OriginClusterId, + ParentName: ai2.Spec.ParentAccount.Name, + ParentRelation: "parent", + }) require.NoError(t, err) // allTuples: database managed by mocks (Write appends/deletes, Read returns current state) diff --git a/internal/fga/tuples.go b/internal/fga/tuples.go index 7c8572cb..bf4597c8 100644 --- a/internal/fga/tuples.go +++ b/internal/fga/tuples.go @@ -9,24 +9,43 @@ import ( "github.com/platform-mesh/security-operator/api/v1alpha1" ) +type BaseTuplesInput struct { + Creator string + AccountOriginClusterID string + AccountName string + CreatorRelation string + ObjectType string +} + +type TuplesForOrganizationInput struct { + BaseTuplesInput +} + +type InitialTuplesForAccountInput struct { + BaseTuplesInput + ParentOriginClusterID string + ParentName string + ParentRelation string +} + // InitialTuplesForAccount returns FGA tuples for an account not of type // organization. -func InitialTuplesForAccount(creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, creatorRelation, parentRelation, objectType string) ([]v1alpha1.Tuple, error) { - base, err := baseTuples(creator, accountOriginClusterID, accountName, creatorRelation, objectType) +func InitialTuplesForAccount(in InitialTuplesForAccountInput) ([]v1alpha1.Tuple, error) { + base, err := baseTuples(in.BaseTuplesInput) if err != nil { return nil, err } tuples := append(base, v1alpha1.Tuple{ - User: renderAccountEntity(objectType, parentOriginClusterID, parentName), - Relation: parentRelation, - Object: renderAccountEntity(objectType, accountOriginClusterID, accountName), + User: renderAccountEntity(in.ObjectType, in.ParentOriginClusterID, in.ParentName), + Relation: in.ParentRelation, + Object: renderAccountEntity(in.ObjectType, in.AccountOriginClusterID, in.AccountName), }) return tuples, nil } // TuplesForOrganization returns FGA tuples for an Account of type organization. -func TuplesForOrganization(creator, accountOriginClusterID, accountName, creatorRelation, objectType string) ([]v1alpha1.Tuple, error) { - return baseTuples(creator, accountOriginClusterID, accountName, creatorRelation, objectType) +func TuplesForOrganization(in TuplesForOrganizationInput) ([]v1alpha1.Tuple, error) { + return baseTuples(in.BaseTuplesInput) } // IsTupleOfAccountFilter returns a filter determining whether a tuple is tied @@ -53,21 +72,21 @@ func ReferencingOwnerRoleTupleKey(objectType, accountOriginClusterID, accountNam } } -func baseTuples(creator, accountOriginClusterID, accountName, creatorRelation, objectType string) ([]v1alpha1.Tuple, error) { - if creator == "" { +func baseTuples(in BaseTuplesInput) ([]v1alpha1.Tuple, error) { + if in.Creator == "" { return nil, errors.New("account creator is empty") } return []v1alpha1.Tuple{ { - User: renderCreatorUser(creator), + User: renderCreatorUser(in.Creator), Relation: "assignee", - Object: renderOwnerRole(objectType, accountOriginClusterID, accountName), + Object: renderOwnerRole(in.ObjectType, in.AccountOriginClusterID, in.AccountName), }, { - User: renderOwnerRoleAssigneeGroup(objectType, accountOriginClusterID, accountName), - Relation: creatorRelation, - Object: renderAccountEntity(objectType, accountOriginClusterID, accountName), + User: renderOwnerRoleAssigneeGroup(in.ObjectType, in.AccountOriginClusterID, in.AccountName), + Relation: in.CreatorRelation, + Object: renderAccountEntity(in.ObjectType, in.AccountOriginClusterID, in.AccountName), }, }, nil } diff --git a/internal/fga/tuples_test.go b/internal/fga/tuples_test.go index e4ef0ac2..4983cf19 100644 --- a/internal/fga/tuples_test.go +++ b/internal/fga/tuples_test.go @@ -20,9 +20,19 @@ const ( ) func TestInitialTuplesForAccount(t *testing.T) { - tuples, err := InitialTuplesForAccount(creator, - originClusterID, accountName, originClusterID, parentAccountName, - creatorRelation, parentRelation, objectType) + in := InitialTuplesForAccountInput{ + BaseTuplesInput: BaseTuplesInput{ + Creator: creator, + AccountOriginClusterID: originClusterID, + AccountName: accountName, + CreatorRelation: creatorRelation, + ObjectType: objectType, + }, + ParentOriginClusterID: originClusterID, + ParentName: parentAccountName, + ParentRelation: parentRelation, + } + tuples, err := InitialTuplesForAccount(in) require.NoError(t, err) require.Len(t, tuples, 3) @@ -49,10 +59,19 @@ func TestInitialTuplesForAccount(t *testing.T) { } func TestInitialTuplesForAccount_formatUser(t *testing.T) { - creator := "system:serviceaccount:ns:name" - tuples, err := InitialTuplesForAccount(creator, - originClusterID, accountName, originClusterID, parentAccountName, - creatorRelation, parentRelation, objectType) + in := InitialTuplesForAccountInput{ + BaseTuplesInput: BaseTuplesInput{ + Creator: "system:serviceaccount:ns:name", + AccountOriginClusterID: originClusterID, + AccountName: accountName, + CreatorRelation: creatorRelation, + ObjectType: objectType, + }, + ParentOriginClusterID: originClusterID, + ParentName: parentAccountName, + ParentRelation: parentRelation, + } + tuples, err := InitialTuplesForAccount(in) require.NoError(t, err) require.Len(t, tuples, 3) @@ -60,9 +79,19 @@ func TestInitialTuplesForAccount_formatUser(t *testing.T) { } func TestInitialTuplesForAccount_nilCreator(t *testing.T) { - _, err := InitialTuplesForAccount("", - originClusterID, accountName, originClusterID, parentAccountName, - creatorRelation, parentRelation, objectType) + in := InitialTuplesForAccountInput{ + BaseTuplesInput: BaseTuplesInput{ + Creator: "", + AccountOriginClusterID: originClusterID, + AccountName: accountName, + CreatorRelation: creatorRelation, + ObjectType: objectType, + }, + ParentOriginClusterID: originClusterID, + ParentName: parentAccountName, + ParentRelation: parentRelation, + } + _, err := InitialTuplesForAccount(in) assert.Error(t, err) assert.Contains(t, err.Error(), "creator is empty") } diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index acace765..50e6aba1 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -57,11 +57,11 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti } // Ensure the necessary tuples in OpenFGA. - creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, err := TupleInformationForAccountPath(ctx, s.mgr, accountPath) + in, err := TupleInformationForAccountPath(ctx, s.mgr, accountPath, s.creatorRelation, s.parentRelation, s.objectType) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("rendering tuple information for Account: %w", err), true, true) } - tuples, err := fga.InitialTuplesForAccount(creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, s.creatorRelation, s.parentRelation, s.objectType) + tuples, err := fga.InitialTuplesForAccount(in) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for account: %w", err), true, true) } @@ -163,44 +163,59 @@ func storeNameFromAccountPath(ap platformmeshpath.AccountPath) string { return ap.Org().Base() } -// returns creator, accountOriginClusterID, accountName, parentOriginClusterID, parentName, -func TupleInformationForAccountPath(ctx context.Context, mgr mcmanager.Manager, ap platformmeshpath.AccountPath) (string, string, string, string, string, error) { +// TupleInformationForAccountPath gathers information needed to render FGA +// tuples for a given account and returns a complete InitialTuplesForAccountInput. +func TupleInformationForAccountPath(ctx context.Context, mgr mcmanager.Manager, ap platformmeshpath.AccountPath, creatorRelation, parentRelation, objectType string) (fga.InitialTuplesForAccountInput, error) { // Retrieve the parent's LogicalCluster to determine its cluster ID parentPath, _ := ap.Parent() parentAccountLC, err := LogicalClusterForPath(ctx, mgr, parentPath) if err != nil { - return "", "", "", "", "", fmt.Errorf("getting parent account's LogicalCluster%w", err) + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting parent account's LogicalCluster: %w", err) } parentAccountClusterID, ok := parentAccountLC.Annotations["kcp.io/cluster"] if !ok || parentAccountClusterID == "" { - return "", "", "", "", "", fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set") + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set") } // Retrieve the grandparent's LogicalCluster to determine its clusterID grandParentPath, _ := parentPath.Parent() grandParentAccountLC, err := LogicalClusterForPath(ctx, mgr, grandParentPath) if err != nil { - return "", "", "", "", "", fmt.Errorf("getting parent account's LogicalCluster%w", err) + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting parent account's LogicalCluster: %w", err) } grandParentAccountClusterID, ok := grandParentAccountLC.Annotations["kcp.io/cluster"] if !ok || grandParentAccountClusterID == "" { - return "", "", "", "", "", fmt.Errorf("cluster-annotation on grandparent account's LogicalCluster is not set") + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("cluster-annotation on grandparent account's LogicalCluster is not set") } // Retrieve the Account resource out of the parent workspace to determine // the creator parentAccountClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(parentPath.String())) + if err != nil { + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting client for parent account cluster: %w", err) + } var acc accountsv1alpha1.Account if err := parentAccountClient.Get(ctx, client.ObjectKey{ Name: ap.Base(), }, &acc); err != nil { - return "", "", "", "", "", fmt.Errorf("getting Account in parent account cluster: %w", err) + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting Account in parent account cluster: %w", err) } if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { - return "", "", "", "", "", fmt.Errorf("account creator is nil or empty") - } - - return *acc.Spec.Creator, parentAccountClusterID, ap.Base(), grandParentAccountClusterID, parentPath.Base(), nil + return fga.InitialTuplesForAccountInput{}, fmt.Errorf("account creator is nil or empty") + } + + return fga.InitialTuplesForAccountInput{ + BaseTuplesInput: fga.BaseTuplesInput{ + Creator: *acc.Spec.Creator, + AccountOriginClusterID: parentAccountClusterID, + AccountName: ap.Base(), + CreatorRelation: creatorRelation, + ObjectType: objectType, + }, + ParentOriginClusterID: grandParentAccountClusterID, + ParentName: parentPath.Base(), + ParentRelation: parentRelation, + }, nil } func LogicalClusterForPath(ctx context.Context, mgr mcmanager.Manager, p logicalcluster.Path) (kcpcorev1alpha1.LogicalCluster, error) { diff --git a/internal/subroutine/workspace_initializer.go b/internal/subroutine/workspace_initializer.go index 9ff19aba..b0adfcc5 100644 --- a/internal/subroutine/workspace_initializer.go +++ b/internal/subroutine/workspace_initializer.go @@ -122,7 +122,15 @@ func (w *workspaceInitializer) Initialize(ctx context.Context, instance runtimeo if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("account creator is nil or empty"), true, true) } - tuples, err := fga.TuplesForOrganization(*acc.Spec.Creator, ai.Spec.Account.OriginClusterId, ai.Spec.Account.Name, w.creatorRelation, w.objectType) + tuples, err := fga.TuplesForOrganization(fga.TuplesForOrganizationInput{ + BaseTuplesInput: fga.BaseTuplesInput{ + Creator: *acc.Spec.Creator, + AccountOriginClusterID: ai.Spec.Account.OriginClusterId, + AccountName: ai.Spec.Account.Name, + CreatorRelation: w.creatorRelation, + ObjectType: w.objectType, + }, + }) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for organization: %w", err), true, true) } From a481781d010a0f3b232fc2e5fe4973838d9aab9b Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 12:59:29 +0000 Subject: [PATCH 10/16] refactor: remove non-reused function On-behalf-of: @SAP --- internal/subroutine/account_tuples.go | 107 ++++++++++++-------------- 1 file changed, 48 insertions(+), 59 deletions(-) diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index 50e6aba1..55b7acf2 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -56,12 +56,56 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) } - // Ensure the necessary tuples in OpenFGA. - in, err := TupleInformationForAccountPath(ctx, s.mgr, accountPath, s.creatorRelation, s.parentRelation, s.objectType) + // Retrieve the parent's LogicalCluster to determine its cluster ID + parentPath, _ := accountPath.Parent() + parentAccountLC, err := LogicalClusterForPath(ctx, s.mgr, parentPath) if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("rendering tuple information for Account: %w", err), true, true) + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster: %w", err), true, true) } - tuples, err := fga.InitialTuplesForAccount(in) + parentAccountClusterID, ok := parentAccountLC.Annotations["kcp.io/cluster"] + if !ok || parentAccountClusterID == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set"), true, true) + } + + // Retrieve the grandparent's LogicalCluster to determine its clusterID + grandParentPath, _ := parentPath.Parent() + grandParentAccountLC, err := LogicalClusterForPath(ctx, s.mgr, grandParentPath) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster: %w", err), true, true) + } + grandParentAccountClusterID, ok := grandParentAccountLC.Annotations["kcp.io/cluster"] + if !ok || grandParentAccountClusterID == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster-annotation on grandparent account's LogicalCluster is not set"), true, true) + } + + // Retrieve the Account resource out of the parent workspace to determine + // the creator + parentAccountClient, err := iclient.NewForLogicalCluster(s.mgr.GetLocalManager().GetConfig(), s.mgr.GetLocalManager().GetScheme(), logicalcluster.Name(parentPath.String())) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting client for parent account cluster: %w", err), true, true) + } + var acc accountsv1alpha1.Account + if err := parentAccountClient.Get(ctx, client.ObjectKey{ + Name: accountPath.Base(), + }, &acc); err != nil { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting Account in parent account cluster: %w", err), true, true) + } + if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("account creator is nil or empty"), true, true) + } + + tuples, err := fga.InitialTuplesForAccount(fga.InitialTuplesForAccountInput{ + BaseTuplesInput: fga.BaseTuplesInput{ + Creator: *acc.Spec.Creator, + AccountOriginClusterID: parentAccountClusterID, + AccountName: accountPath.Base(), + CreatorRelation: s.creatorRelation, + ObjectType: s.objectType, + }, + ParentOriginClusterID: grandParentAccountClusterID, + ParentName: parentPath.Base(), + ParentRelation: s.parentRelation, + }) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("building tuples for account: %w", err), true, true) } @@ -163,61 +207,6 @@ func storeNameFromAccountPath(ap platformmeshpath.AccountPath) string { return ap.Org().Base() } -// TupleInformationForAccountPath gathers information needed to render FGA -// tuples for a given account and returns a complete InitialTuplesForAccountInput. -func TupleInformationForAccountPath(ctx context.Context, mgr mcmanager.Manager, ap platformmeshpath.AccountPath, creatorRelation, parentRelation, objectType string) (fga.InitialTuplesForAccountInput, error) { - // Retrieve the parent's LogicalCluster to determine its cluster ID - parentPath, _ := ap.Parent() - parentAccountLC, err := LogicalClusterForPath(ctx, mgr, parentPath) - if err != nil { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting parent account's LogicalCluster: %w", err) - } - parentAccountClusterID, ok := parentAccountLC.Annotations["kcp.io/cluster"] - if !ok || parentAccountClusterID == "" { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set") - } - - // Retrieve the grandparent's LogicalCluster to determine its clusterID - grandParentPath, _ := parentPath.Parent() - grandParentAccountLC, err := LogicalClusterForPath(ctx, mgr, grandParentPath) - if err != nil { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting parent account's LogicalCluster: %w", err) - } - grandParentAccountClusterID, ok := grandParentAccountLC.Annotations["kcp.io/cluster"] - if !ok || grandParentAccountClusterID == "" { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("cluster-annotation on grandparent account's LogicalCluster is not set") - } - - // Retrieve the Account resource out of the parent workspace to determine - // the creator - parentAccountClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(parentPath.String())) - if err != nil { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting client for parent account cluster: %w", err) - } - var acc accountsv1alpha1.Account - if err := parentAccountClient.Get(ctx, client.ObjectKey{ - Name: ap.Base(), - }, &acc); err != nil { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("getting Account in parent account cluster: %w", err) - } - if acc.Spec.Creator == nil || *acc.Spec.Creator == "" { - return fga.InitialTuplesForAccountInput{}, fmt.Errorf("account creator is nil or empty") - } - - return fga.InitialTuplesForAccountInput{ - BaseTuplesInput: fga.BaseTuplesInput{ - Creator: *acc.Spec.Creator, - AccountOriginClusterID: parentAccountClusterID, - AccountName: ap.Base(), - CreatorRelation: creatorRelation, - ObjectType: objectType, - }, - ParentOriginClusterID: grandParentAccountClusterID, - ParentName: parentPath.Base(), - ParentRelation: parentRelation, - }, nil -} - func LogicalClusterForPath(ctx context.Context, mgr mcmanager.Manager, p logicalcluster.Path) (kcpcorev1alpha1.LogicalCluster, error) { var lc kcpcorev1alpha1.LogicalCluster From 183759e9d91e0c7433f5d4938e86b1afc361e7c6 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Thu, 12 Mar 2026 13:08:20 +0000 Subject: [PATCH 11/16] refactor: account-tuples: DRY getting cluster ID On-behalf-of: @SAP --- internal/subroutine/account_tuples.go | 40 +++++++++++---------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index 55b7acf2..8234260a 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -56,27 +56,19 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) } - // Retrieve the parent's LogicalCluster to determine its cluster ID + // Determine the parent's LogicalCluster ID parentPath, _ := accountPath.Parent() - parentAccountLC, err := LogicalClusterForPath(ctx, s.mgr, parentPath) + parentAccountClusterID, err := clusterIDFromLogicalClusterForPath(ctx, s.mgr, parentPath) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster: %w", err), true, true) } - parentAccountClusterID, ok := parentAccountLC.Annotations["kcp.io/cluster"] - if !ok || parentAccountClusterID == "" { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set"), true, true) - } - // Retrieve the grandparent's LogicalCluster to determine its clusterID + // Determine the grandparent's LogicalClusterID grandParentPath, _ := parentPath.Parent() - grandParentAccountLC, err := LogicalClusterForPath(ctx, s.mgr, grandParentPath) + grandParentAccountClusterID, err := clusterIDFromLogicalClusterForPath(ctx, s.mgr, grandParentPath) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster: %w", err), true, true) } - grandParentAccountClusterID, ok := grandParentAccountLC.Annotations["kcp.io/cluster"] - if !ok || grandParentAccountClusterID == "" { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster-annotation on grandparent account's LogicalCluster is not set"), true, true) - } // Retrieve the Account resource out of the parent workspace to determine // the creator @@ -126,15 +118,10 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim } parentPath, _ := accountPath.Parent() - // Retrieve parent LogicalCluster to determine its cluster ID - parentLC, err := LogicalClusterForPath(ctx, s.mgr, parentPath) + // Determine the parent's LogicalClusterID + parentClusterID, err := clusterIDFromLogicalClusterForPath(ctx, s.mgr, parentPath) if err != nil { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster %w", err), true, true) - } - parentClusterID, ok := parentLC.Annotations["kcp.io/cluster"] - if !ok || parentClusterID == "" { - return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("cluster-annotation on parent account's LogicalCluster is not set"), true, true) - + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster: %w", err), true, true) } storeID, err := s.storeIDGetter.Get(ctx, storeNameFromAccountPath(accountPath)) @@ -207,18 +194,23 @@ func storeNameFromAccountPath(ap platformmeshpath.AccountPath) string { return ap.Org().Base() } -func LogicalClusterForPath(ctx context.Context, mgr mcmanager.Manager, p logicalcluster.Path) (kcpcorev1alpha1.LogicalCluster, error) { +func clusterIDFromLogicalClusterForPath(ctx context.Context, mgr mcmanager.Manager, p logicalcluster.Path) (string, error) { var lc kcpcorev1alpha1.LogicalCluster clusterClient, err := iclient.NewForLogicalCluster(mgr.GetLocalManager().GetConfig(), mgr.GetLocalManager().GetScheme(), logicalcluster.Name(p.String())) if err != nil { - return lc, fmt.Errorf("getting account cluster client: %w", err) + return "", fmt.Errorf("getting account cluster client: %w", err) } if err := clusterClient.Get(ctx, client.ObjectKey{ Name: "cluster", }, &lc); err != nil { - return lc, fmt.Errorf("getting account's LogicalCluster: %w", err) + return "", fmt.Errorf("getting account's LogicalCluster: %w", err) + } + + clusterID, ok := lc.Annotations["kcp.io/cluster"] + if !ok || clusterID == "" { + return "", fmt.Errorf("cluster-annotation kcp.io/cluster on LogicalCluster is not set") } - return lc, nil + return clusterID, nil } From 3737876201efe64e47c861cb0d99335a9d4832fa Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Fri, 13 Mar 2026 05:24:02 +0000 Subject: [PATCH 12/16] test: update kcp On-behalf-of: @SAP --- Taskfile.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 54447cd4..284da704 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -6,8 +6,8 @@ vars: ENVTEST_K8S_VERSION: "1.31.0" ENVTEST_VERSION: release-0.19 CRD_DIRECTORY: config/crd/bases - KCP_APIGEN_VERSION: v0.29.0 - KCP_VERSION: 0.29.0 + KCP_APIGEN_VERSION: v0.30.1 + KCP_VERSION: 0.30.1 GOLANGCI_LINT_VERSION: v2.8.0 GOARCH: sh: go env GOARCH From 9ca48035bfe41890e3078b915e7ca871b9a7cad1 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Fri, 13 Mar 2026 05:38:08 +0000 Subject: [PATCH 13/16] feat: remove unnecessary function and clarify in comments On-behalf-of: @SAP --- internal/platformmesh/account_path.go | 23 +++++++++++++---------- internal/subroutine/account_tuples.go | 10 ++++------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/internal/platformmesh/account_path.go b/internal/platformmesh/account_path.go index a23652b7..b70f3787 100644 --- a/internal/platformmesh/account_path.go +++ b/internal/platformmesh/account_path.go @@ -16,6 +16,19 @@ const ( kcpWorkpaceSeparator = ":" ) +// IsPlatformMeshAccountPath returns whether a value is a platform-mesh account +// path, i.e. a canonical KCP workspace path child to the platform-mesh account +// workspace tree "root:orgs". +func IsPlatformMeshAccountPath(value string) bool { + _, valid := logicalcluster.NewValidatedPath(value) + parts := strings.Split(value, kcpWorkpaceSeparator) + + return valid && len(parts) > 2 && parts[0] == rootWorkspace && parts[1] == orgsWorkspace +} + +// AccountPath represents a logicalcluster.Path that is assumed to be the path +// of a platform-mesh Account, i.e. conforms to the conditions of the +// IsPlatformMeshAccountPath function. type AccountPath struct { logicalcluster.Path } @@ -52,13 +65,3 @@ func (a AccountPath) Org() AccountPath { Path: logicalcluster.NewPath(strings.Join(parts[:3], kcpWorkpaceSeparator)), } } - -// IsPlatformMeshAccountPath returns whether a value is a platform-mesh account -// path, i.e. a canonical KCP workspace path within the platform-mesh account -// workspace tree "root:orgs". -func IsPlatformMeshAccountPath(value string) bool { - _, valid := logicalcluster.NewValidatedPath(value) - parts := strings.Split(value, kcpWorkpaceSeparator) - - return valid && len(parts) > 2 && parts[0] == rootWorkspace && parts[1] == orgsWorkspace -} diff --git a/internal/subroutine/account_tuples.go b/internal/subroutine/account_tuples.go index 8234260a..fc3a8829 100644 --- a/internal/subroutine/account_tuples.go +++ b/internal/subroutine/account_tuples.go @@ -51,7 +51,7 @@ func (s *AccountTuplesSubroutine) Initialize(ctx context.Context, instance runti return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting AccountPath from LogicalCluster: %w", err), true, true) } - storeID, err := s.storeIDGetter.Get(ctx, storeNameFromAccountPath(accountPath)) + storeID, err := s.storeIDGetter.Get(ctx, accountPath.Org().Base()) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) } @@ -124,7 +124,7 @@ func (s *AccountTuplesSubroutine) Terminate(ctx context.Context, instance runtim return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting parent account's LogicalCluster: %w", err), true, true) } - storeID, err := s.storeIDGetter.Get(ctx, storeNameFromAccountPath(accountPath)) + storeID, err := s.storeIDGetter.Get(ctx, accountPath.Org().Base()) if err != nil { return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("getting store ID: %w", err), true, true) } @@ -190,10 +190,8 @@ var ( _ lifecyclesubroutine.Terminator = &AccountTuplesSubroutine{} ) -func storeNameFromAccountPath(ap platformmeshpath.AccountPath) string { - return ap.Org().Base() -} - +// clusterIDFromLogicalClusterForPath retrieves the LogicalCluster of a given +// path and returns its cluster ID. func clusterIDFromLogicalClusterForPath(ctx context.Context, mgr mcmanager.Manager, p logicalcluster.Path) (string, error) { var lc kcpcorev1alpha1.LogicalCluster From 2b69e7b9329c2be5b37c90b1c40b857d8a585dcb Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Fri, 13 Mar 2026 09:49:32 +0000 Subject: [PATCH 14/16] feat: implement storeid getter with ttlcache package On-behalf-of: @SAP --- cmd/initializer.go | 6 ++- cmd/terminator.go | 6 ++- go.mod | 11 ++++- go.sum | 49 +++++++++++++++++---- internal/config/config.go | 4 ++ internal/fga/storeid_getter.go | 66 ++++++++++++++++++----------- internal/fga/storeid_getter_test.go | 52 ++++------------------- 7 files changed, 114 insertions(+), 80 deletions(-) diff --git a/cmd/initializer.go b/cmd/initializer.go index 746e5fc7..18dde05b 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -120,7 +120,11 @@ var initializerCmd = &cobra.Command{ } defer func() { _ = conn.Close() }() fgaClient := openfgav1.NewOpenFGAServiceClient(conn) - storeIDGetter := fga.NewCachingStoreIDGetter(fgaClient) + storeIDGetter := fga.NewCachingStoreIDGetter( + fgaClient, + initializerCfg.FGA.StoreIDCacheTTL, + cmd.Context(), + ) mcc, err := mcclient.New(kcpCfg, client.Options{Scheme: scheme}) if err != nil { diff --git a/cmd/terminator.go b/cmd/terminator.go index ed9842d2..7e143cfe 100644 --- a/cmd/terminator.go +++ b/cmd/terminator.go @@ -106,7 +106,11 @@ var terminatorCmd = &cobra.Command{ } defer func() { _ = conn.Close() }() fgaClient := openfgav1.NewOpenFGAServiceClient(conn) - storeIDGetter := fga.NewCachingStoreIDGetter(fgaClient) + storeIDGetter := fga.NewCachingStoreIDGetter( + fgaClient, + terminatorCfg.FGA.StoreIDCacheTTL, + cmd.Context(), + ) if err := controller.NewAccountLogicalClusterReconciler(log, terminatorCfg, fgaClient, storeIDGetter, mcc, mgr). SetupWithManager(mgr, defaultCfg, predicate.Not(predicates.LogicalClusterIsAccountTypeOrg())); err != nil { diff --git a/go.mod b/go.mod index eb9d6d8d..e820e703 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,21 @@ module github.com/platform-mesh/security-operator go 1.25.7 +replace ( + k8s.io/api => k8s.io/api v0.34.4 + k8s.io/apiserver => k8s.io/apiserver v0.34.4 + k8s.io/client-go => k8s.io/client-go v0.34.4 + k8s.io/component-base => k8s.io/component-base v0.34.4 +) + require ( github.com/coreos/go-oidc v2.5.0+incompatible + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/fluxcd/helm-controller/api v1.5.1 github.com/fluxcd/source-controller/api v1.8.0 github.com/go-logr/logr v1.4.3 github.com/google/gnostic-models v0.7.1 + github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kcp-dev/logicalcluster/v3 v3.0.5 github.com/kcp-dev/multicluster-provider v0.5.1 github.com/kcp-dev/sdk v0.30.0 @@ -39,7 +48,6 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -67,6 +75,7 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 8051a2b3..aed2de80 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -134,6 +136,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kcp-dev/apimachinery/v2 v2.30.0 h1:bj7lVVPJj5UnQFCWhXVAKC+eNaIMKGGxpq+fE5edRU0= @@ -144,6 +148,8 @@ github.com/kcp-dev/multicluster-provider v0.5.1 h1:2qMZzLPvzClT2ks+AMyE97PD+2lrD github.com/kcp-dev/multicluster-provider v0.5.1/go.mod h1:eJohrSXqLmpjfTSFBbZMoq4Osr57UKg9ZokvhCPNmHc= github.com/kcp-dev/sdk v0.30.0 h1:BdDiKJ7SeVfzLIxueQwbADTrH7bfZ7b5ACYSrx6P93Y= github.com/kcp-dev/sdk v0.30.0/go.mod h1:H3PkpM33QqwPMgGOOw3dfqbQ8dF2gu4NeIsufSlS5KE= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -237,6 +243,8 @@ github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJra github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= @@ -267,18 +275,33 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -286,12 +309,22 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -316,18 +349,18 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= -k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= +k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0= +k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o= k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.2 h1:rb52v0CZGEL0FkhjS+I6jHflAp7fZ4MIaKcEHX7wmDk= -k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= -k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= -k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= -k8s.io/component-base v0.35.2 h1:btgR+qNrpWuRSuvWSnQYsZy88yf5gVwemvz0yw79pGc= -k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= +k8s.io/apiserver v0.34.4 h1:QmMakuCjlFBJpsXKIUom8OUE7+PhZk7hyNiLqlyDH58= +k8s.io/apiserver v0.34.4/go.mod h1:4dM2Pfd+VQQA/4pLVPorZJbIadaTLcvgQn2GYYcA6Ic= +k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= +k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8= +k8s.io/component-base v0.34.4 h1:jP4XqR48YelfXIlRpOHQgms5GebU23zSE6xcvTwpXDE= +k8s.io/component-base v0.34.4/go.mod h1:uujRfLNOwNiFWz47eBjNZEj/Swn2cdhqI7lW2MeFdrU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= diff --git a/internal/config/config.go b/internal/config/config.go index 823449de..caf0e90a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "time" "github.com/spf13/pflag" ) @@ -30,6 +31,7 @@ type FGAConfig struct { ObjectType string ParentRelation string CreatorRelation string + StoreIDCacheTTL time.Duration } type KCPConfig struct { @@ -85,6 +87,7 @@ func NewConfig() Config { ObjectType: "core_platform-mesh_io_account", ParentRelation: "parent", CreatorRelation: "owner", + StoreIDCacheTTL: 24 * time.Hour, }, KCP: KCPConfig{ Kubeconfig: "/api-kubeconfig/kubeconfig", @@ -119,6 +122,7 @@ func NewConfig() Config { func (c *Config) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&c.FGA.Target, "fga-target", c.FGA.Target, "Set the OpenFGA API target") + fs.DurationVar(&c.FGA.StoreIDCacheTTL, "fga-store-id-cache-ttl", c.FGA.StoreIDCacheTTL, "TTL for the OpenFGA store ID cache (e.g. 5m, 1h)") fs.StringVar(&c.FGA.ObjectType, "fga-object-type", c.FGA.ObjectType, "Set the OpenFGA object type for account tuples") fs.StringVar(&c.FGA.ParentRelation, "fga-parent-relation", c.FGA.ParentRelation, "Set the OpenFGA parent relation name") fs.StringVar(&c.FGA.CreatorRelation, "fga-creator-relation", c.FGA.CreatorRelation, "Set the OpenFGA creator relation name") diff --git a/internal/fga/storeid_getter.go b/internal/fga/storeid_getter.go index 25a03ee3..d284d663 100644 --- a/internal/fga/storeid_getter.go +++ b/internal/fga/storeid_getter.go @@ -3,8 +3,9 @@ package fga import ( "context" "fmt" - "sync" + "time" + "github.com/jellydator/ttlcache/v3" openfgav1 "github.com/openfga/api/proto/openfga/v1" "google.golang.org/protobuf/types/known/wrapperspb" ) @@ -17,53 +18,62 @@ type StoreIDGetter interface { // CachingStoreIDGetter maps store names to IDs by listing stores in OpenFGA but keeps // a local cache to avoid frequent list calls. type CachingStoreIDGetter struct { - mu sync.RWMutex - stores map[string]string - fga openfgav1.OpenFGAServiceClient + cache *ttlcache.Cache[string, string] + loader *storeIDLoader } -func NewCachingStoreIDGetter(fga openfgav1.OpenFGAServiceClient) *CachingStoreIDGetter { +func NewCachingStoreIDGetter(fga openfgav1.OpenFGAServiceClient, ttl time.Duration, loadCtx context.Context) *CachingStoreIDGetter { + loader := &storeIDLoader{fga: fga, loadCtx: loadCtx} return &CachingStoreIDGetter{ - stores: make(map[string]string), - fga: fga, + cache: ttlcache.New( + ttlcache.WithTTL[string, string](ttl), + ttlcache.WithLoader(loader), + ), + loader: loader, } } // Get returns the store ID for the given store name. func (m *CachingStoreIDGetter) Get(ctx context.Context, storeName string) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if id, ok := m.stores[storeName]; ok { - return id, nil - } - - if err := m.syncFromOpenFGA(ctx); err != nil { - return "", fmt.Errorf("syncing stores: %w", err) + item := m.cache.Get(storeName) + if err := m.loader.Err(); err != nil { + return "", fmt.Errorf("populating cache: %w", err) } - if id, ok := m.stores[storeName]; ok { - return id, nil + if item != nil { + return item.Value(), nil } return "", fmt.Errorf("store %q not found", storeName) } -func (m *CachingStoreIDGetter) syncFromOpenFGA(ctx context.Context) error { - stores := make(map[string]string) +type storeIDLoader struct { + fga openfgav1.OpenFGAServiceClient + loadErrer error + loadCtx context.Context +} + +// Load lists all stores from OpenFGA, adds them to the cache, and returns the +// requested store's item or nil if not found. Caller is supposed to check +// Err(). Implements ttlcache.Loader. +func (l *storeIDLoader) Load(c *ttlcache.Cache[string, string], storeName string) *ttlcache.Item[string, string] { var continuationToken string + var wantedItem *ttlcache.Item[string, string] for { - resp, err := m.fga.ListStores(ctx, &openfgav1.ListStoresRequest{ + resp, err := l.fga.ListStores(l.loadCtx, &openfgav1.ListStoresRequest{ PageSize: wrapperspb.Int32(100), ContinuationToken: continuationToken, }) if err != nil { - return err + l.loadErrer = fmt.Errorf("listing Stores in OpenFGA: %w", err) + return nil } for _, store := range resp.GetStores() { - stores[store.GetName()] = store.GetId() + if item := c.Set(store.GetName(), store.GetId(), ttlcache.DefaultTTL); item.Key() == storeName { + wantedItem = item + } } continuationToken = resp.GetContinuationToken() @@ -72,8 +82,14 @@ func (m *CachingStoreIDGetter) syncFromOpenFGA(ctx context.Context) error { } } - m.stores = stores - return nil + return wantedItem +} + +// Err returns the last error occured during Load. See [0] for why it works like +// this. +// [0] https://github.com/jellydator/ttlcache/issues/74#issuecomment-1133012806 +func (l *storeIDLoader) Err() error { + return l.loadErrer } var _ StoreIDGetter = (*CachingStoreIDGetter)(nil) diff --git a/internal/fga/storeid_getter_test.go b/internal/fga/storeid_getter_test.go index c2b64606..3bc8e884 100644 --- a/internal/fga/storeid_getter_test.go +++ b/internal/fga/storeid_getter_test.go @@ -4,13 +4,13 @@ import ( "context" "errors" "testing" + "time" openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/platform-mesh/security-operator/internal/subroutine/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "google.golang.org/grpc" ) func TestCachingStoreIDGetter_Get(t *testing.T) { @@ -22,7 +22,7 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { }, }, nil).Once() - getter := NewCachingStoreIDGetter(client) + getter := NewCachingStoreIDGetter(client, 5*time.Minute, context.Background()) id, err := getter.Get(context.Background(), "foo") require.NoError(t, err) @@ -37,7 +37,8 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { }, }, nil).Once() - getter := NewCachingStoreIDGetter(client) + loadCtx := context.Background() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx) id1, err := getter.Get(context.Background(), "foo") require.NoError(t, err) @@ -58,7 +59,8 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { }, }, nil).Once() - getter := NewCachingStoreIDGetter(client) + loadCtx := context.Background() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx) id, err := getter.Get(context.Background(), "missing-store") assert.Error(t, err) @@ -70,49 +72,11 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { client := mocks.NewMockOpenFGAServiceClient(t) client.EXPECT().ListStores(mock.Anything, mock.Anything).Return(nil, errors.New("connection refused")).Once() - getter := NewCachingStoreIDGetter(client) + loadCtx := context.Background() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx) id, err := getter.Get(context.Background(), "foo") assert.Error(t, err) assert.Empty(t, id) }) - - t.Run("sync removes stores no longer in OpenFGA", func(t *testing.T) { - callCount := 0 - client := mocks.NewMockOpenFGAServiceClient(t) - client.EXPECT().ListStores(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, req *openfgav1.ListStoresRequest, opts ...grpc.CallOption) (*openfgav1.ListStoresResponse, error) { - callCount++ - if callCount == 1 { - // First sync: two stores - return &openfgav1.ListStoresResponse{ - Stores: []*openfgav1.Store{ - {Name: "foo", Id: "DEADBEEF"}, - {Name: "bar", Id: "1337CAFE"}, - }, - }, nil - } - // Second sync: one store deleted - return &openfgav1.ListStoresResponse{ - Stores: []*openfgav1.Store{ - {Name: "bar", Id: "1337CAFE"}, - }, - }, nil - }) - - getter := NewCachingStoreIDGetter(client) - - // First Get: sync loads both stores - id1, err := getter.Get(context.Background(), "foo") - require.NoError(t, err) - assert.Equal(t, "DEADBEEF", id1) - - // Get a non-cached store to trigger rsync on cache-miss - _, err = getter.Get(context.Background(), "hoge") - assert.Error(t, err) - - // DEADBEEF should not be in cache anymore - _, err = getter.Get(context.Background(), "foo") - assert.Error(t, err) - assert.Contains(t, err.Error(), "store \"foo\" not found") - }) } From 1b4560ab3a1d392d734ccdc557cacd631bced3dc Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Fri, 13 Mar 2026 10:15:54 +0000 Subject: [PATCH 15/16] feat: add informational logging to cache in storeidgetter On-behalf-of: @SAP --- cmd/initializer.go | 1 + cmd/terminator.go | 1 + internal/fga/storeid_getter.go | 35 ++++++++++++++++++++++++----- internal/fga/storeid_getter_test.go | 13 +++++++---- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/cmd/initializer.go b/cmd/initializer.go index 18dde05b..bcdac21e 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -124,6 +124,7 @@ var initializerCmd = &cobra.Command{ fgaClient, initializerCfg.FGA.StoreIDCacheTTL, cmd.Context(), + log, ) mcc, err := mcclient.New(kcpCfg, client.Options{Scheme: scheme}) diff --git a/cmd/terminator.go b/cmd/terminator.go index 7e143cfe..cd062628 100644 --- a/cmd/terminator.go +++ b/cmd/terminator.go @@ -110,6 +110,7 @@ var terminatorCmd = &cobra.Command{ fgaClient, terminatorCfg.FGA.StoreIDCacheTTL, cmd.Context(), + log, ) if err := controller.NewAccountLogicalClusterReconciler(log, terminatorCfg, fgaClient, storeIDGetter, mcc, mgr). diff --git a/internal/fga/storeid_getter.go b/internal/fga/storeid_getter.go index d284d663..f2b86ab8 100644 --- a/internal/fga/storeid_getter.go +++ b/internal/fga/storeid_getter.go @@ -7,6 +7,7 @@ import ( "github.com/jellydator/ttlcache/v3" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/platform-mesh/golang-commons/logger" "google.golang.org/protobuf/types/known/wrapperspb" ) @@ -20,16 +21,40 @@ type StoreIDGetter interface { type CachingStoreIDGetter struct { cache *ttlcache.Cache[string, string] loader *storeIDLoader + logger *logger.Logger } -func NewCachingStoreIDGetter(fga openfgav1.OpenFGAServiceClient, ttl time.Duration, loadCtx context.Context) *CachingStoreIDGetter { +func NewCachingStoreIDGetter(fga openfgav1.OpenFGAServiceClient, ttl time.Duration, loadCtx context.Context, log *logger.Logger) *CachingStoreIDGetter { loader := &storeIDLoader{fga: fga, loadCtx: loadCtx} + + cache := ttlcache.New( + ttlcache.WithTTL[string, string](ttl), + ttlcache.WithLoader(loader), + ) + cache.OnInsertion(func(_ context.Context, item *ttlcache.Item[string, string]) { + log.Debug(). + Str("store", item.Key()). + Str("id", item.Value()). + Msg("StoreID cache inserted item") + }) + cache.OnUpdate(func(_ context.Context, item *ttlcache.Item[string, string]) { + log.Debug(). + Str("store", item.Key()). + Str("id", item.Value()). + Msg("StoreID cache updated item") + }) + cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, string]) { + log.Debug(). + Str("store", item.Key()). + Str("id", item.Value()). + Str("reason", fmt.Sprint(reason)). + Msg("StoreID cache evicted item") + }) + return &CachingStoreIDGetter{ - cache: ttlcache.New( - ttlcache.WithTTL[string, string](ttl), - ttlcache.WithLoader(loader), - ), + cache: cache, loader: loader, + logger: log, } } diff --git a/internal/fga/storeid_getter_test.go b/internal/fga/storeid_getter_test.go index 3bc8e884..0cb65fad 100644 --- a/internal/fga/storeid_getter_test.go +++ b/internal/fga/storeid_getter_test.go @@ -7,6 +7,7 @@ import ( "time" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/platform-mesh/golang-commons/logger/testlogger" "github.com/platform-mesh/security-operator/internal/subroutine/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -22,7 +23,8 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { }, }, nil).Once() - getter := NewCachingStoreIDGetter(client, 5*time.Minute, context.Background()) + log := testlogger.New() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, context.Background(), log.Logger) id, err := getter.Get(context.Background(), "foo") require.NoError(t, err) @@ -38,7 +40,8 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { }, nil).Once() loadCtx := context.Background() - getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx) + log := testlogger.New() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx, log.Logger) id1, err := getter.Get(context.Background(), "foo") require.NoError(t, err) @@ -60,7 +63,8 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { }, nil).Once() loadCtx := context.Background() - getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx) + log := testlogger.New() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx, log.Logger) id, err := getter.Get(context.Background(), "missing-store") assert.Error(t, err) @@ -73,7 +77,8 @@ func TestCachingStoreIDGetter_Get(t *testing.T) { client.EXPECT().ListStores(mock.Anything, mock.Anything).Return(nil, errors.New("connection refused")).Once() loadCtx := context.Background() - getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx) + log := testlogger.New() + getter := NewCachingStoreIDGetter(client, 5*time.Minute, loadCtx, log.Logger) id, err := getter.Get(context.Background(), "foo") assert.Error(t, err) From deb5e401f56ed47e406be0b2018a4b63aa50a8f4 Mon Sep 17 00:00:00 2001 From: Simon Tesar Date: Mon, 16 Mar 2026 08:18:01 +0000 Subject: [PATCH 16/16] chore: remove replacements and update go On-behalf-of: @SAP --- go.mod | 10 +--------- go.sum | 47 ++++++++--------------------------------------- 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/go.mod b/go.mod index b0aacb2d..40f141cf 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,6 @@ module github.com/platform-mesh/security-operator -go 1.25.7 - -replace ( - k8s.io/api => k8s.io/api v0.34.4 - k8s.io/apiserver => k8s.io/apiserver v0.34.4 - k8s.io/client-go => k8s.io/client-go v0.34.4 - k8s.io/component-base => k8s.io/component-base v0.34.4 -) +go 1.26 require ( github.com/coreos/go-oidc v2.5.0+incompatible @@ -75,7 +68,6 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 86796b75..60944748 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -148,8 +146,6 @@ github.com/kcp-dev/multicluster-provider v0.5.1 h1:2qMZzLPvzClT2ks+AMyE97PD+2lrD github.com/kcp-dev/multicluster-provider v0.5.1/go.mod h1:eJohrSXqLmpjfTSFBbZMoq4Osr57UKg9ZokvhCPNmHc= github.com/kcp-dev/sdk v0.30.0 h1:BdDiKJ7SeVfzLIxueQwbADTrH7bfZ7b5ACYSrx6P93Y= github.com/kcp-dev/sdk v0.30.0/go.mod h1:H3PkpM33QqwPMgGOOw3dfqbQ8dF2gu4NeIsufSlS5KE= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -243,8 +239,6 @@ github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJra github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= @@ -275,33 +269,18 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -309,22 +288,12 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -349,18 +318,18 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.4 h1:Z5hsoQcZ2yBjelb9j5JKzCVo9qv9XLkVm5llnqS4h+0= -k8s.io/api v0.34.4/go.mod h1:6SaGYuGPkMqqCgg8rPG/OQoCrhgSEV+wWn9v21fDP3o= +k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= +k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.34.4 h1:QmMakuCjlFBJpsXKIUom8OUE7+PhZk7hyNiLqlyDH58= -k8s.io/apiserver v0.34.4/go.mod h1:4dM2Pfd+VQQA/4pLVPorZJbIadaTLcvgQn2GYYcA6Ic= -k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= -k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8= -k8s.io/component-base v0.34.4 h1:jP4XqR48YelfXIlRpOHQgms5GebU23zSE6xcvTwpXDE= -k8s.io/component-base v0.34.4/go.mod h1:uujRfLNOwNiFWz47eBjNZEj/Swn2cdhqI7lW2MeFdrU= +k8s.io/apiserver v0.35.2 h1:rb52v0CZGEL0FkhjS+I6jHflAp7fZ4MIaKcEHX7wmDk= +k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= +k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= +k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= +k8s.io/component-base v0.35.2 h1:btgR+qNrpWuRSuvWSnQYsZy88yf5gVwemvz0yw79pGc= +k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY=